diff --git a/.claude/settings.local.json b/.claude/settings.local.json index d62d903..49a4a0f 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -26,7 +26,8 @@ "Bash(sed:*)", "Bash(./clean-build.sh:*)", "Bash(git add:*)", - "Bash(scp:*)" + "Bash(scp:*)", + "Bash(ssh:*)" ] }, "enableAllProjectMcpServers": false diff --git a/.dockerignore b/.dockerignore index f3254c2..c43bb32 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,13 +1,13 @@ -bin/ -obj/ -.git/ -.gitignore -*.md -README.md -tests/ -docs/ -.vs/ -.vscode/ -**/.DS_Store -**/Thumbs.db - +bin/ +obj/ +.git/ +.gitignore +*.md +README.md +tests/ +docs/ +.vs/ +.vscode/ +**/.DS_Store +**/Thumbs.db + diff --git a/.gitea/workflows/pr-validation.yml.backup b/.gitea/workflows/pr-validation.yml.backup index 0110e0e..00647f3 100644 --- a/.gitea/workflows/pr-validation.yml.backup +++ b/.gitea/workflows/pr-validation.yml.backup @@ -1,111 +1,111 @@ -name: PR Validation for Release - -on: - pull_request: - branches: - - 'Release/*' - types: [opened, synchronize, reopened, ready_for_review] - -env: - REGISTRY: registry.redecarneir.us - IMAGE_NAME: bcards - MONGODB_HOST: 192.168.0.100:27017 - -jobs: - validate-pr: - name: Validate Pull Request - runs-on: ubuntu-latest - if: github.event.pull_request.draft == false - - steps: - - name: PR Info - run: | - echo "🔍 Validando PR #${{ github.event.number }}" - echo "📂 Source: ${{ github.head_ref }}" - echo "🎯 Target: ${{ github.base_ref }}" - echo "👤 Author: ${{ github.event.pull_request.user.login }}" - echo "📝 Title: ${{ github.event.pull_request.title }}" - - - name: Checkout PR code - uses: actions/checkout@v4 - with: - ref: ${{ github.event.pull_request.head.sha }} - - - name: Setup .NET 8 - uses: actions/setup-dotnet@v4 - with: - dotnet-version: '8.0.x' - - - name: Restore dependencies - run: dotnet restore - - - name: Build solution - run: dotnet build --no-restore --configuration Release - - - name: Run tests - if: ${{ vars.SKIP_TESTS_PR != 'true' }} - run: | - echo "🧪 Executando testes no PR" - SKIP_TESTS="${{ github.event.inputs.skip_tests || vars.SKIP_TESTS }}" - - if [ "$SKIP_TESTS" == "true" ]; then - echo "⚠️ Testes PULADOS" - echo "TESTS_SKIPPED=true" >> $GITHUB_ENV - else - echo "✅ Executando testes" - dotnet test --no-build --configuration Release --verbosity normal - echo "TESTS_SKIPPED=false" >> $GITHUB_ENV - fi - - - name: Build Docker image (test only) - run: | - echo "🐳 Testando build da imagem Docker..." - - # Extrair versão da branch de destino - TARGET_BRANCH="${{ github.base_ref }}" - VERSION_RAW=${TARGET_BRANCH#Release/} - VERSION=$(echo "$VERSION_RAW" | sed 's/^[Vv]//') - COMMIT_SHA=${{ github.event.pull_request.head.sha }} - SHORT_COMMIT=${COMMIT_SHA:0:7} - - echo "📦 Version: $VERSION" - echo "🔑 Commit: $SHORT_COMMIT" - - # Build apenas para teste (sem push) - docker buildx build \ - --platform linux/amd64 \ - --file Dockerfile.release \ - --build-arg VERSION=$VERSION \ - --build-arg COMMIT=$SHORT_COMMIT \ - --tag $REGISTRY/$IMAGE_NAME:pr-${{ github.event.number }}-$SHORT_COMMIT \ - --output type=docker \ - . - - - name: Security scan (opcional) - run: | - echo "🔒 Executando verificações de segurança..." - # Adicione suas verificações de segurança aqui - - - name: PR Status Summary - run: | - echo "✅ Pull Request Validation Summary" - echo "🎯 Target Branch: ${{ github.base_ref }}" - echo "📂 Source Branch: ${{ github.head_ref }}" - echo "🧪 Tests: ${{ vars.SKIP_TESTS_PR == 'true' && 'SKIPPED' || 'PASSED' }}" - echo "🐳 Docker Build: PASSED" - echo "🔒 Security Scan: PASSED" - echo "" - echo "✨ PR está pronto para merge!" - - # Job que só executa se a validação passou - ready-for-merge: - name: Ready for Merge - runs-on: ubuntu-latest - needs: [validate-pr] - if: success() - - steps: - - name: Merge readiness - run: | - echo "🎉 Pull Request #${{ github.event.number }} passou em todas as validações!" +name: PR Validation for Release + +on: + pull_request: + branches: + - 'Release/*' + types: [opened, synchronize, reopened, ready_for_review] + +env: + REGISTRY: registry.redecarneir.us + IMAGE_NAME: bcards + MONGODB_HOST: 192.168.0.100:27017 + +jobs: + validate-pr: + name: Validate Pull Request + runs-on: ubuntu-latest + if: github.event.pull_request.draft == false + + steps: + - name: PR Info + run: | + echo "🔍 Validando PR #${{ github.event.number }}" + echo "📂 Source: ${{ github.head_ref }}" + echo "🎯 Target: ${{ github.base_ref }}" + echo "👤 Author: ${{ github.event.pull_request.user.login }}" + echo "📝 Title: ${{ github.event.pull_request.title }}" + + - name: Checkout PR code + uses: actions/checkout@v4 + with: + ref: ${{ github.event.pull_request.head.sha }} + + - name: Setup .NET 8 + uses: actions/setup-dotnet@v4 + with: + dotnet-version: '8.0.x' + + - name: Restore dependencies + run: dotnet restore + + - name: Build solution + run: dotnet build --no-restore --configuration Release + + - name: Run tests + if: ${{ vars.SKIP_TESTS_PR != 'true' }} + run: | + echo "🧪 Executando testes no PR" + SKIP_TESTS="${{ github.event.inputs.skip_tests || vars.SKIP_TESTS }}" + + if [ "$SKIP_TESTS" == "true" ]; then + echo "⚠️ Testes PULADOS" + echo "TESTS_SKIPPED=true" >> $GITHUB_ENV + else + echo "✅ Executando testes" + dotnet test --no-build --configuration Release --verbosity normal + echo "TESTS_SKIPPED=false" >> $GITHUB_ENV + fi + + - name: Build Docker image (test only) + run: | + echo "🐳 Testando build da imagem Docker..." + + # Extrair versão da branch de destino + TARGET_BRANCH="${{ github.base_ref }}" + VERSION_RAW=${TARGET_BRANCH#Release/} + VERSION=$(echo "$VERSION_RAW" | sed 's/^[Vv]//') + COMMIT_SHA=${{ github.event.pull_request.head.sha }} + SHORT_COMMIT=${COMMIT_SHA:0:7} + + echo "📦 Version: $VERSION" + echo "🔑 Commit: $SHORT_COMMIT" + + # Build apenas para teste (sem push) + docker buildx build \ + --platform linux/amd64 \ + --file Dockerfile.release \ + --build-arg VERSION=$VERSION \ + --build-arg COMMIT=$SHORT_COMMIT \ + --tag $REGISTRY/$IMAGE_NAME:pr-${{ github.event.number }}-$SHORT_COMMIT \ + --output type=docker \ + . + + - name: Security scan (opcional) + run: | + echo "🔒 Executando verificações de segurança..." + # Adicione suas verificações de segurança aqui + + - name: PR Status Summary + run: | + echo "✅ Pull Request Validation Summary" + echo "🎯 Target Branch: ${{ github.base_ref }}" + echo "📂 Source Branch: ${{ github.head_ref }}" + echo "🧪 Tests: ${{ vars.SKIP_TESTS_PR == 'true' && 'SKIPPED' || 'PASSED' }}" + echo "🐳 Docker Build: PASSED" + echo "🔒 Security Scan: PASSED" + echo "" + echo "✨ PR está pronto para merge!" + + # Job que só executa se a validação passou + ready-for-merge: + name: Ready for Merge + runs-on: ubuntu-latest + needs: [validate-pr] + if: success() + + steps: + - name: Merge readiness + run: | + echo "🎉 Pull Request #${{ github.event.number }} passou em todas as validações!" echo "✅ Pode ser feito o merge com segurança" \ No newline at end of file diff --git a/.gitea/workflows/release-deploy.yml.backup b/.gitea/workflows/release-deploy.yml.backup index e04c8a4..f00931b 100644 --- a/.gitea/workflows/release-deploy.yml.backup +++ b/.gitea/workflows/release-deploy.yml.backup @@ -1,120 +1,120 @@ -name: Release Deployment Pipeline - -on: - push: - branches: - - 'Release/*' - -env: - REGISTRY: registry.redecarneir.us - IMAGE_NAME: bcards - MONGODB_HOST: 192.168.0.100:27017 - -jobs: - test: - name: Run Tests - runs-on: ubuntu-latest - - steps: - - name: Check if tests should run - run: | - # Prioridade: manual input > variável do repo - SKIP_TESTS="${{ github.event.inputs.skip_tests || vars.SKIP_TESTS }}" - - if [ "$SKIP_TESTS" == "true" ]; then - echo "⚠️ Testes PULADOS" - echo "TESTS_SKIPPED=true" >> $GITHUB_ENV - else - echo "✅ Executando testes" - echo "TESTS_SKIPPED=false" >> $GITHUB_ENV - fi - - echo "🎯 Trigger: ${{ github.event_name }}" - echo "📂 Branch: ${{ github.ref_name }}" - - - name: Checkout code - if: env.TESTS_SKIPPED == 'false' - uses: actions/checkout@v4 - - - name: Setup .NET 8 - if: env.TESTS_SKIPPED == 'false' - uses: actions/setup-dotnet@v4 - with: - dotnet-version: '8.0.x' - - - name: Restore dependencies - if: env.TESTS_SKIPPED == 'false' - run: dotnet restore - - - name: Build solution - if: env.TESTS_SKIPPED == 'false' - run: dotnet build --no-restore --configuration Release - - - name: Run unit tests - if: env.TESTS_SKIPPED == 'false' - run: dotnet test --no-build --configuration Release --verbosity normal - - build-and-deploy: - name: Build and Deploy - runs-on: ubuntu-latest - needs: [test] - if: always() && (needs.test.result == 'success' || needs.test.result == 'failure') - - steps: - - name: Deployment info - run: | - echo "🚀 Iniciando deployment para ${{ github.ref_name }}" - echo "🧪 Tests: ${{ vars.SKIP_TESTS == 'true' && 'SKIPPED' || 'EXECUTED' }}" - - - name: Checkout code - uses: actions/checkout@v4 - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 - with: - platforms: linux/amd64,linux/arm64 - - - name: Extract version info - id: version - run: | - BRANCH_NAME="${{ github.ref_name }}" - VERSION_RAW=${BRANCH_NAME#Release/} - VERSION=$(echo "$VERSION_RAW" | sed 's/^[Vv]//') - - if [ -z "$VERSION" ]; then - VERSION="0.0.1" - fi - - COMMIT_SHA=${{ github.sha }} - SHORT_COMMIT=${COMMIT_SHA:0:7} - - echo "version=$VERSION" >> $GITHUB_OUTPUT - echo "commit=$SHORT_COMMIT" >> $GITHUB_OUTPUT - echo "tag=$VERSION-$SHORT_COMMIT" >> $GITHUB_OUTPUT - - echo "📦 Version: $VERSION" - echo "🔑 Commit: $SHORT_COMMIT" - echo "🏷️ Tag: $VERSION-$SHORT_COMMIT" - - - name: Build and push multi-arch image - run: | - echo "🏗️ Building multi-arch image..." - - docker buildx build \ - --platform linux/amd64,linux/arm64 \ - --file Dockerfile.release \ - --tag $REGISTRY/$IMAGE_NAME:${{ steps.version.outputs.tag }} \ - --tag $REGISTRY/$IMAGE_NAME:${{ steps.version.outputs.version }}-latest \ - --tag $REGISTRY/$IMAGE_NAME:release-latest \ - --push \ - --build-arg VERSION=${{ steps.version.outputs.version }} \ - --build-arg COMMIT=${{ steps.version.outputs.commit }} \ - --progress=plain - - # Resto do deployment... - - name: Deploy notification - run: | - echo "✅ Deployment concluído!" - echo "📦 Image: $REGISTRY/$IMAGE_NAME:${{ steps.version.outputs.tag }}" - echo "🎯 Trigger: ${{ github.event_name }}" +name: Release Deployment Pipeline + +on: + push: + branches: + - 'Release/*' + +env: + REGISTRY: registry.redecarneir.us + IMAGE_NAME: bcards + MONGODB_HOST: 192.168.0.100:27017 + +jobs: + test: + name: Run Tests + runs-on: ubuntu-latest + + steps: + - name: Check if tests should run + run: | + # Prioridade: manual input > variável do repo + SKIP_TESTS="${{ github.event.inputs.skip_tests || vars.SKIP_TESTS }}" + + if [ "$SKIP_TESTS" == "true" ]; then + echo "⚠️ Testes PULADOS" + echo "TESTS_SKIPPED=true" >> $GITHUB_ENV + else + echo "✅ Executando testes" + echo "TESTS_SKIPPED=false" >> $GITHUB_ENV + fi + + echo "🎯 Trigger: ${{ github.event_name }}" + echo "📂 Branch: ${{ github.ref_name }}" + + - name: Checkout code + if: env.TESTS_SKIPPED == 'false' + uses: actions/checkout@v4 + + - name: Setup .NET 8 + if: env.TESTS_SKIPPED == 'false' + uses: actions/setup-dotnet@v4 + with: + dotnet-version: '8.0.x' + + - name: Restore dependencies + if: env.TESTS_SKIPPED == 'false' + run: dotnet restore + + - name: Build solution + if: env.TESTS_SKIPPED == 'false' + run: dotnet build --no-restore --configuration Release + + - name: Run unit tests + if: env.TESTS_SKIPPED == 'false' + run: dotnet test --no-build --configuration Release --verbosity normal + + build-and-deploy: + name: Build and Deploy + runs-on: ubuntu-latest + needs: [test] + if: always() && (needs.test.result == 'success' || needs.test.result == 'failure') + + steps: + - name: Deployment info + run: | + echo "🚀 Iniciando deployment para ${{ github.ref_name }}" + echo "🧪 Tests: ${{ vars.SKIP_TESTS == 'true' && 'SKIPPED' || 'EXECUTED' }}" + + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + with: + platforms: linux/amd64,linux/arm64 + + - name: Extract version info + id: version + run: | + BRANCH_NAME="${{ github.ref_name }}" + VERSION_RAW=${BRANCH_NAME#Release/} + VERSION=$(echo "$VERSION_RAW" | sed 's/^[Vv]//') + + if [ -z "$VERSION" ]; then + VERSION="0.0.1" + fi + + COMMIT_SHA=${{ github.sha }} + SHORT_COMMIT=${COMMIT_SHA:0:7} + + echo "version=$VERSION" >> $GITHUB_OUTPUT + echo "commit=$SHORT_COMMIT" >> $GITHUB_OUTPUT + echo "tag=$VERSION-$SHORT_COMMIT" >> $GITHUB_OUTPUT + + echo "📦 Version: $VERSION" + echo "🔑 Commit: $SHORT_COMMIT" + echo "🏷️ Tag: $VERSION-$SHORT_COMMIT" + + - name: Build and push multi-arch image + run: | + echo "🏗️ Building multi-arch image..." + + docker buildx build \ + --platform linux/amd64,linux/arm64 \ + --file Dockerfile.release \ + --tag $REGISTRY/$IMAGE_NAME:${{ steps.version.outputs.tag }} \ + --tag $REGISTRY/$IMAGE_NAME:${{ steps.version.outputs.version }}-latest \ + --tag $REGISTRY/$IMAGE_NAME:release-latest \ + --push \ + --build-arg VERSION=${{ steps.version.outputs.version }} \ + --build-arg COMMIT=${{ steps.version.outputs.commit }} \ + --progress=plain + + # Resto do deployment... + - name: Deploy notification + run: | + echo "✅ Deployment concluído!" + echo "📦 Image: $REGISTRY/$IMAGE_NAME:${{ steps.version.outputs.tag }}" + echo "🎯 Trigger: ${{ github.event_name }}" echo "📂 Branch: ${{ github.ref_name }}" \ No newline at end of file diff --git a/BCards.sln b/BCards.sln index 6548584..bec54d6 100644 --- a/BCards.sln +++ b/BCards.sln @@ -1,48 +1,48 @@ -Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio Version 17 -VisualStudioVersion = 17.0.31903.59 -MinimumVisualStudioVersion = 10.0.40219.1 -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "BCards.Web", "src\BCards.Web\BCards.Web.csproj", "{2E8F4B5C-9B3A-4F8E-8C7D-1A2B3C4D5E6F}" -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "BCards.IntegrationTests", "src\BCards.IntegrationTests\BCards.IntegrationTests.csproj", "{8F9E4C7D-2A3B-4E5F-9C8D-1B2A3E4F5C6D}" -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Scripts", "Scripts", "{02EA681E-C7D8-13C7-8484-4AC65E1B71E8}" - ProjectSection(SolutionItems) = preProject - scripts\deploy-release.sh = scripts\deploy-release.sh - scripts\init-mongo.js = scripts\init-mongo.js - scripts\test-mongodb-connection.sh = scripts\test-mongodb-connection.sh - EndProjectSection -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Pipeline", "Pipeline", "{3F3DEEDF-9E0A-434D-8130-1FBAC43FD1F7}" - ProjectSection(SolutionItems) = preProject - .gitea\workflows\deploy-bcards.yml = .gitea\workflows\deploy-bcards.yml - EndProjectSection -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{8EC462FD-D22E-90A8-E5CE-7E832BA40C5D}" - ProjectSection(SolutionItems) = preProject - Conexoes.txt = Conexoes.txt - README.md = README.md - EndProjectSection -EndProject -Global - GlobalSection(SolutionConfigurationPlatforms) = preSolution - Debug|Any CPU = Debug|Any CPU - Release|Any CPU = Release|Any CPU - EndGlobalSection - GlobalSection(ProjectConfigurationPlatforms) = postSolution - {2E8F4B5C-9B3A-4F8E-8C7D-1A2B3C4D5E6F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {2E8F4B5C-9B3A-4F8E-8C7D-1A2B3C4D5E6F}.Debug|Any CPU.Build.0 = Debug|Any CPU - {2E8F4B5C-9B3A-4F8E-8C7D-1A2B3C4D5E6F}.Release|Any CPU.ActiveCfg = Release|Any CPU - {2E8F4B5C-9B3A-4F8E-8C7D-1A2B3C4D5E6F}.Release|Any CPU.Build.0 = Release|Any CPU - {8F9E4C7D-2A3B-4E5F-9C8D-1B2A3E4F5C6D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {8F9E4C7D-2A3B-4E5F-9C8D-1B2A3E4F5C6D}.Debug|Any CPU.Build.0 = Debug|Any CPU - {8F9E4C7D-2A3B-4E5F-9C8D-1B2A3E4F5C6D}.Release|Any CPU.ActiveCfg = Release|Any CPU - {8F9E4C7D-2A3B-4E5F-9C8D-1B2A3E4F5C6D}.Release|Any CPU.Build.0 = Release|Any CPU - EndGlobalSection - GlobalSection(SolutionProperties) = preSolution - HideSolutionNode = FALSE - EndGlobalSection - GlobalSection(ExtensibilityGlobals) = postSolution - SolutionGuid = {3DA87F09-8B78-450D-9EF8-A0C0E02F0E04} - EndGlobalSection -EndGlobal +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.0.31903.59 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "BCards.Web", "src\BCards.Web\BCards.Web.csproj", "{2E8F4B5C-9B3A-4F8E-8C7D-1A2B3C4D5E6F}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "BCards.IntegrationTests", "src\BCards.IntegrationTests\BCards.IntegrationTests.csproj", "{8F9E4C7D-2A3B-4E5F-9C8D-1B2A3E4F5C6D}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Scripts", "Scripts", "{02EA681E-C7D8-13C7-8484-4AC65E1B71E8}" + ProjectSection(SolutionItems) = preProject + scripts\deploy-release.sh = scripts\deploy-release.sh + scripts\init-mongo.js = scripts\init-mongo.js + scripts\test-mongodb-connection.sh = scripts\test-mongodb-connection.sh + EndProjectSection +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Pipeline", "Pipeline", "{3F3DEEDF-9E0A-434D-8130-1FBAC43FD1F7}" + ProjectSection(SolutionItems) = preProject + .gitea\workflows\deploy-bcards.yml = .gitea\workflows\deploy-bcards.yml + EndProjectSection +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{8EC462FD-D22E-90A8-E5CE-7E832BA40C5D}" + ProjectSection(SolutionItems) = preProject + Conexoes.txt = Conexoes.txt + README.md = README.md + EndProjectSection +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {2E8F4B5C-9B3A-4F8E-8C7D-1A2B3C4D5E6F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {2E8F4B5C-9B3A-4F8E-8C7D-1A2B3C4D5E6F}.Debug|Any CPU.Build.0 = Debug|Any CPU + {2E8F4B5C-9B3A-4F8E-8C7D-1A2B3C4D5E6F}.Release|Any CPU.ActiveCfg = Release|Any CPU + {2E8F4B5C-9B3A-4F8E-8C7D-1A2B3C4D5E6F}.Release|Any CPU.Build.0 = Release|Any CPU + {8F9E4C7D-2A3B-4E5F-9C8D-1B2A3E4F5C6D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {8F9E4C7D-2A3B-4E5F-9C8D-1B2A3E4F5C6D}.Debug|Any CPU.Build.0 = Debug|Any CPU + {8F9E4C7D-2A3B-4E5F-9C8D-1B2A3E4F5C6D}.Release|Any CPU.ActiveCfg = Release|Any CPU + {8F9E4C7D-2A3B-4E5F-9C8D-1B2A3E4F5C6D}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {3DA87F09-8B78-450D-9EF8-A0C0E02F0E04} + EndGlobalSection +EndGlobal diff --git a/CLAUDE.md b/CLAUDE.md index 5fb06df..384ee7c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,246 +1,246 @@ -# CLAUDE.md - -This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. - -## Project Overview - -BCards is a professional LinkTree clone built in ASP.NET Core MVC, targeting Brazilian and Spanish markets. It provides hierarchical URLs organized by business categories (/page/{category}/{slug}), integrated Stripe payments, and a sophisticated moderation system with preview tokens. - -## Development Commands - -### Build & Run -```bash -# Quick clean build (RECOMMENDED after VS 2022 updates) -./clean-build.sh - -# Manual process: -dotnet restore -dotnet build - -# Run development server -cd src/BCards.Web -dotnet run -# Access: https://localhost:49178 - -# Run with Docker -docker-compose up -d -# Access: http://localhost:8080 -``` - -### 🚨 Known Issues After VS 2022 Updates - -**Problem**: After VS 2022 updates, build cache gets corrupted causing: -- OAuth login failures (especially Google in Edge browser) -- Need for constant clean/rebuild cycles -- NuGet package resolution errors - -**Solution**: Use the automated cleanup script: -```bash -./clean-build.sh -``` - -**Google OAuth Edge Issue**: If Google login shows "browser not secure" error: -1. Clear browser data for localhost:49178 and accounts.google.com -2. Test in incognito/private mode -3. Use Vivaldi or Chrome (Edge has known compatibility issues) - -### Testing -```bash -# Run all tests -dotnet test - -# Run tests with coverage -dotnet test --collect:"XPlat Code Coverage" - -# Run specific test -dotnet test --filter "TestClassName" -``` - -## Architecture Overview - -### Technology Stack -- **Framework**: ASP.NET Core MVC (.NET 8) -- **Database**: MongoDB 7.0 with MongoDB.Driver 2.25.0 -- **Authentication**: OAuth 2.0 (Google + Microsoft) -- **Payments**: Stripe.NET 44.7.0 -- **Frontend**: Bootstrap 5.3.2, jQuery 3.7.1, vanilla JavaScript -- **Email**: SendGrid 9.29.3 -- **Containerization**: Docker + Docker Compose - -### Core Architecture Patterns -- **MVC Pattern**: Controllers handle HTTP requests, Views render UI, Models represent data -- **Repository Pattern**: Data access abstraction via IUserPageRepository, ICategoryRepository, etc. -- **Service Layer**: Business logic encapsulation (IUserPageService, IModerationService, etc.) -- **Dependency Injection**: Built-in ASP.NET Core DI container -- **Domain-Driven Design**: Rich domain models with business logic - -### Key Business Logic - -#### Page Status System -Pages follow this lifecycle with explicit numeric enum values: -- `Creating = 6`: Development phase, requires preview tokens -- `PendingModeration = 4`: Submitted for approval, requires preview tokens -- `Rejected = 5`: Failed moderation, requires preview tokens -- `Active = 0`: Live and publicly accessible -- `Inactive = 3`: Paused by user -- `Expired = 1`: Trial expired, redirects to pricing -- `PendingPayment = 2`: Payment overdue, shows warning - -#### Moderation System -- Content approval workflow with attempts tracking -- Preview tokens with 4-hour expiration for non-Active pages -- Email notifications for status changes -- Automatic status transitions based on user actions - -#### Pricing Strategy -Three-tier system with psychological pricing (decoy effect): -- Basic (R$ 9.90/mês): 5 links, basic themes -- Professional (R$ 24.90/mês): 15 links, all themes - *DECOY* -- Premium (R$ 29.90/mês): Unlimited links, custom themes - -### Project Structure -``` -src/BCards.Web/ -├── Controllers/ # MVC Controllers (9 total) -│ ├── AdminController # User dashboard and page management -│ ├── AuthController # OAuth authentication -│ ├── HomeController # Public pages and landing -│ ├── PaymentController # Stripe integration -│ ├── ModerationController # Content approval system -│ └── UserPageController # Public page display -├── Models/ # Domain entities (12 total) -│ ├── User # Authentication and subscriptions -│ ├── UserPage # Main business card entity -│ ├── LinkItem # Individual links with analytics -│ └── Category # Business categories -├── Services/ # Business logic (20 services) -│ ├── IUserPageService # Core page operations -│ ├── IModerationService # Content approval -│ ├── IAuthService # Authentication -│ └── IPaymentService # Stripe integration -├── Repositories/ # Data access (8 repositories) -├── ViewModels/ # View-specific models -├── Middleware/ # Custom middleware (4 pieces) -│ ├── PageStatusMiddleware # Handles page access by status -│ ├── ModerationAuthMiddleware # Admin access control -│ └── PreviewTokenMiddleware # Preview token validation -└── Views/ # Razor templates with Bootstrap 5 -``` - -### Database Design (MongoDB) - -#### Core Collections -- `users`: Authentication, subscription status, Stripe customer data -- `userpages`: Business cards with status, links, themes, moderation history -- `categories`: Business categories with SEO metadata -- `subscriptions`: Stripe subscription tracking - -#### Important Indexes -- Compound: `{category: 1, slug: 1}` for page lookups -- User pages: `{userId: 1, status: 1}` for dashboard filtering -- Active pages: `{status: 1, category: 1}` for public listings - -### Key Features Implementation - -#### Preview Token System -Non-Active pages require preview tokens for access: -```csharp -// Generate fresh token (4-hour expiry) -POST /Admin/GeneratePreviewToken/{id} - -// Access page with token -GET /page/{category}/{slug}?preview={token} -``` - -#### OAuth Integration -Supports Google and Microsoft OAuth with automatic user creation and session management. - -#### Stripe Payment Flow -Complete subscription lifecycle: -1. Checkout session creation -2. Webhook handling for events -3. Subscription status updates -4. Plan limitation enforcement - -#### Dynamic Theming -CSS generation system with customizable colors, backgrounds, and layouts based on user's plan limitations. - -## Configuration - -### Required Environment Variables -```json -{ - "MongoDb": { - "ConnectionString": "mongodb://localhost:27017", - "DatabaseName": "BCardsDB" - }, - "Stripe": { - "PublishableKey": "pk_test_...", - "SecretKey": "sk_test_...", - "WebhookSecret": "whsec_..." - }, - "Authentication": { - "Google": { "ClientId": "...", "ClientSecret": "..." }, - "Microsoft": { "ClientId": "...", "ClientSecret": "..." } - } -} -``` - -### Development Setup -1. Install .NET 8 SDK, MongoDB 4.4+ -2. Configure OAuth credentials (Google Cloud Console, Azure Portal) -3. Set up Stripe account with test keys -4. Configure webhook endpoints for `/webhook/stripe` - -## Important Implementation Notes - -### Page Status Middleware -`PageStatusMiddleware` intercepts all `/page/{category}/{slug}` requests and enforces access rules based on page status. Non-Active pages require valid preview tokens. - -### Moderation Workflow -1. Pages start as `Creating` status -2. Users click "Submit for Moderation" → `PendingModeration` -3. Moderators approve/reject → `Active` or `Rejected` -4. Rejected pages can be edited and resubmitted - -### Preview Token Security -- Tokens expire after 4 hours -- Generated on-demand via AJAX calls -- Required for Creating, PendingModeration, and Rejected pages -- Validated by middleware before page access - -### Plan Limitations -Enforced throughout the application: -- Link count limits per plan -- Theme availability restrictions -- Analytics access control -- Page creation limits - -## Common Development Patterns - -### Repository Usage -```csharp -var page = await _userPageService.GetPageByIdAsync(id); -await _userPageService.UpdatePageAsync(page); -``` - -### Service Layer Pattern -Business logic resides in services, not controllers: -```csharp -public class UserPageService : IUserPageService -{ - private readonly IUserPageRepository _repository; - // Implementation with business rules -} -``` - -### Status-Based Logic -Always check page status before operations: -```csharp -if (page.Status == PageStatus.Creating || page.Status == PageStatus.Rejected) -{ - // Allow editing -} -``` - +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +BCards is a professional LinkTree clone built in ASP.NET Core MVC, targeting Brazilian and Spanish markets. It provides hierarchical URLs organized by business categories (/page/{category}/{slug}), integrated Stripe payments, and a sophisticated moderation system with preview tokens. + +## Development Commands + +### Build & Run +```bash +# Quick clean build (RECOMMENDED after VS 2022 updates) +./clean-build.sh + +# Manual process: +dotnet restore +dotnet build + +# Run development server +cd src/BCards.Web +dotnet run +# Access: https://localhost:49178 + +# Run with Docker +docker-compose up -d +# Access: http://localhost:8080 +``` + +### 🚨 Known Issues After VS 2022 Updates + +**Problem**: After VS 2022 updates, build cache gets corrupted causing: +- OAuth login failures (especially Google in Edge browser) +- Need for constant clean/rebuild cycles +- NuGet package resolution errors + +**Solution**: Use the automated cleanup script: +```bash +./clean-build.sh +``` + +**Google OAuth Edge Issue**: If Google login shows "browser not secure" error: +1. Clear browser data for localhost:49178 and accounts.google.com +2. Test in incognito/private mode +3. Use Vivaldi or Chrome (Edge has known compatibility issues) + +### Testing +```bash +# Run all tests +dotnet test + +# Run tests with coverage +dotnet test --collect:"XPlat Code Coverage" + +# Run specific test +dotnet test --filter "TestClassName" +``` + +## Architecture Overview + +### Technology Stack +- **Framework**: ASP.NET Core MVC (.NET 8) +- **Database**: MongoDB 7.0 with MongoDB.Driver 2.25.0 +- **Authentication**: OAuth 2.0 (Google + Microsoft) +- **Payments**: Stripe.NET 44.7.0 +- **Frontend**: Bootstrap 5.3.2, jQuery 3.7.1, vanilla JavaScript +- **Email**: SendGrid 9.29.3 +- **Containerization**: Docker + Docker Compose + +### Core Architecture Patterns +- **MVC Pattern**: Controllers handle HTTP requests, Views render UI, Models represent data +- **Repository Pattern**: Data access abstraction via IUserPageRepository, ICategoryRepository, etc. +- **Service Layer**: Business logic encapsulation (IUserPageService, IModerationService, etc.) +- **Dependency Injection**: Built-in ASP.NET Core DI container +- **Domain-Driven Design**: Rich domain models with business logic + +### Key Business Logic + +#### Page Status System +Pages follow this lifecycle with explicit numeric enum values: +- `Creating = 6`: Development phase, requires preview tokens +- `PendingModeration = 4`: Submitted for approval, requires preview tokens +- `Rejected = 5`: Failed moderation, requires preview tokens +- `Active = 0`: Live and publicly accessible +- `Inactive = 3`: Paused by user +- `Expired = 1`: Trial expired, redirects to pricing +- `PendingPayment = 2`: Payment overdue, shows warning + +#### Moderation System +- Content approval workflow with attempts tracking +- Preview tokens with 4-hour expiration for non-Active pages +- Email notifications for status changes +- Automatic status transitions based on user actions + +#### Pricing Strategy +Three-tier system with psychological pricing (decoy effect): +- Basic (R$ 9.90/mês): 5 links, basic themes +- Professional (R$ 24.90/mês): 15 links, all themes - *DECOY* +- Premium (R$ 29.90/mês): Unlimited links, custom themes + +### Project Structure +``` +src/BCards.Web/ +├── Controllers/ # MVC Controllers (9 total) +│ ├── AdminController # User dashboard and page management +│ ├── AuthController # OAuth authentication +│ ├── HomeController # Public pages and landing +│ ├── PaymentController # Stripe integration +│ ├── ModerationController # Content approval system +│ └── UserPageController # Public page display +├── Models/ # Domain entities (12 total) +│ ├── User # Authentication and subscriptions +│ ├── UserPage # Main business card entity +│ ├── LinkItem # Individual links with analytics +│ └── Category # Business categories +├── Services/ # Business logic (20 services) +│ ├── IUserPageService # Core page operations +│ ├── IModerationService # Content approval +│ ├── IAuthService # Authentication +│ └── IPaymentService # Stripe integration +├── Repositories/ # Data access (8 repositories) +├── ViewModels/ # View-specific models +├── Middleware/ # Custom middleware (4 pieces) +│ ├── PageStatusMiddleware # Handles page access by status +│ ├── ModerationAuthMiddleware # Admin access control +│ └── PreviewTokenMiddleware # Preview token validation +└── Views/ # Razor templates with Bootstrap 5 +``` + +### Database Design (MongoDB) + +#### Core Collections +- `users`: Authentication, subscription status, Stripe customer data +- `userpages`: Business cards with status, links, themes, moderation history +- `categories`: Business categories with SEO metadata +- `subscriptions`: Stripe subscription tracking + +#### Important Indexes +- Compound: `{category: 1, slug: 1}` for page lookups +- User pages: `{userId: 1, status: 1}` for dashboard filtering +- Active pages: `{status: 1, category: 1}` for public listings + +### Key Features Implementation + +#### Preview Token System +Non-Active pages require preview tokens for access: +```csharp +// Generate fresh token (4-hour expiry) +POST /Admin/GeneratePreviewToken/{id} + +// Access page with token +GET /page/{category}/{slug}?preview={token} +``` + +#### OAuth Integration +Supports Google and Microsoft OAuth with automatic user creation and session management. + +#### Stripe Payment Flow +Complete subscription lifecycle: +1. Checkout session creation +2. Webhook handling for events +3. Subscription status updates +4. Plan limitation enforcement + +#### Dynamic Theming +CSS generation system with customizable colors, backgrounds, and layouts based on user's plan limitations. + +## Configuration + +### Required Environment Variables +```json +{ + "MongoDb": { + "ConnectionString": "mongodb://localhost:27017", + "DatabaseName": "BCardsDB" + }, + "Stripe": { + "PublishableKey": "pk_test_...", + "SecretKey": "sk_test_...", + "WebhookSecret": "whsec_..." + }, + "Authentication": { + "Google": { "ClientId": "...", "ClientSecret": "..." }, + "Microsoft": { "ClientId": "...", "ClientSecret": "..." } + } +} +``` + +### Development Setup +1. Install .NET 8 SDK, MongoDB 4.4+ +2. Configure OAuth credentials (Google Cloud Console, Azure Portal) +3. Set up Stripe account with test keys +4. Configure webhook endpoints for `/webhook/stripe` + +## Important Implementation Notes + +### Page Status Middleware +`PageStatusMiddleware` intercepts all `/page/{category}/{slug}` requests and enforces access rules based on page status. Non-Active pages require valid preview tokens. + +### Moderation Workflow +1. Pages start as `Creating` status +2. Users click "Submit for Moderation" → `PendingModeration` +3. Moderators approve/reject → `Active` or `Rejected` +4. Rejected pages can be edited and resubmitted + +### Preview Token Security +- Tokens expire after 4 hours +- Generated on-demand via AJAX calls +- Required for Creating, PendingModeration, and Rejected pages +- Validated by middleware before page access + +### Plan Limitations +Enforced throughout the application: +- Link count limits per plan +- Theme availability restrictions +- Analytics access control +- Page creation limits + +## Common Development Patterns + +### Repository Usage +```csharp +var page = await _userPageService.GetPageByIdAsync(id); +await _userPageService.UpdatePageAsync(page); +``` + +### Service Layer Pattern +Business logic resides in services, not controllers: +```csharp +public class UserPageService : IUserPageService +{ + private readonly IUserPageRepository _repository; + // Implementation with business rules +} +``` + +### Status-Based Logic +Always check page status before operations: +```csharp +if (page.Status == PageStatus.Creating || page.Status == PageStatus.Rejected) +{ + // Allow editing +} +``` + This architecture supports a production-ready SaaS application with complex business rules, payment integration, and content moderation workflows. \ No newline at end of file diff --git a/Conexoes.txt b/Conexoes.txt index 5af174f..eacb007 100644 --- a/Conexoes.txt +++ b/Conexoes.txt @@ -1,4 +1,4 @@ -bcards -ssh ubuntu@141.148.162.114 -convert-it -ssh ubuntu@129.146.116.218 +bcards +ssh ubuntu@141.148.162.114 +convert-it +ssh ubuntu@129.146.116.218 diff --git a/Dockerfile b/Dockerfile index 506ed6f..f34ff92 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,39 +1,39 @@ -# Dockerfile - Production build (similar to QRRapido structure) -FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS base -WORKDIR /app -EXPOSE 8080 -EXPOSE 8443 - -FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build -WORKDIR /src -COPY ["src/BCards.Web/BCards.Web.csproj", "src/BCards.Web/"] -RUN dotnet restore "src/BCards.Web/BCards.Web.csproj" -COPY . . -WORKDIR "/src/src/BCards.Web" -RUN dotnet build "BCards.Web.csproj" -c Release -o /app/build - -FROM build AS publish -RUN dotnet publish "BCards.Web.csproj" -c Release -o /app/publish /p:UseAppHost=false - -FROM base AS final -WORKDIR /app -COPY --from=publish /app/publish . - -# Create uploads directory -RUN mkdir -p /app/uploads && chmod 755 /app/uploads - -# Install dependencies for image processing -RUN apt-get update && apt-get install -y \ - libgdiplus \ - curl \ - && rm -rf /var/lib/apt/lists/* - -# Environment variables -ENV ASPNETCORE_URLS=http://+:8080 -ENV DOTNET_RUNNING_IN_CONTAINER=true - -# Health check -HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \ - CMD curl -f http://localhost:8080/health || exit 1 - +# Dockerfile - Production build (similar to QRRapido structure) +FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS base +WORKDIR /app +EXPOSE 8080 +EXPOSE 8443 + +FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build +WORKDIR /src +COPY ["src/BCards.Web/BCards.Web.csproj", "src/BCards.Web/"] +RUN dotnet restore "src/BCards.Web/BCards.Web.csproj" +COPY . . +WORKDIR "/src/src/BCards.Web" +RUN dotnet build "BCards.Web.csproj" -c Release -o /app/build + +FROM build AS publish +RUN dotnet publish "BCards.Web.csproj" -c Release -o /app/publish /p:UseAppHost=false + +FROM base AS final +WORKDIR /app +COPY --from=publish /app/publish . + +# Create uploads directory +RUN mkdir -p /app/uploads && chmod 755 /app/uploads + +# Install dependencies for image processing +RUN apt-get update && apt-get install -y \ + libgdiplus \ + curl \ + && rm -rf /var/lib/apt/lists/* + +# Environment variables +ENV ASPNETCORE_URLS=http://+:8080 +ENV DOTNET_RUNNING_IN_CONTAINER=true + +# Health check +HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \ + CMD curl -f http://localhost:8080/health || exit 1 + ENTRYPOINT ["dotnet", "BCards.Web.dll"] \ No newline at end of file diff --git a/Dockerfile.release b/Dockerfile.release index 334c100..b347256 100644 --- a/Dockerfile.release +++ b/Dockerfile.release @@ -1,120 +1,120 @@ -# Dockerfile.release - Multi-architecture build for Release environment -# Supports: linux/amd64, linux/arm64 -ARG BUILDPLATFORM=linux/amd64 -ARG TARGETPLATFORM -ARG VERSION=0.0.1 -ARG COMMIT=unknown - -# Base runtime image with multi-arch support -FROM --platform=$TARGETPLATFORM mcr.microsoft.com/dotnet/aspnet:8.0 AS base -WORKDIR /app -EXPOSE 8080 -EXPOSE 8443 - -# Install dependencies based on target platform -RUN apt-get update && \ - apt-get install -y --no-install-recommends \ - libgdiplus \ - curl \ - ca-certificates \ - && rm -rf /var/lib/apt/lists/* \ - && apt-get clean - -# Create application directories -RUN mkdir -p /app/uploads /app/logs \ - && chmod 755 /app/uploads /app/logs - -# Build stage - restore and publish -FROM --platform=$BUILDPLATFORM mcr.microsoft.com/dotnet/sdk:8.0 AS build -ARG TARGETPLATFORM -ARG VERSION -ARG COMMIT - -WORKDIR /src - -# Copy project file and restore dependencies -COPY ["src/BCards.Web/BCards.Web.csproj", "src/BCards.Web/"] - -# Map platform to .NET runtime identifier and restore -RUN case "$TARGETPLATFORM" in \ - "linux/amd64") RID="linux-x64" ;; \ - "linux/arm64") RID="linux-arm64" ;; \ - *) echo "Unsupported platform: $TARGETPLATFORM" && exit 1 ;; \ - esac && \ - echo "🔧 Restoring for RID: $RID" && \ - dotnet restore "src/BCards.Web/BCards.Web.csproj" --runtime $RID - -# Copy source code -COPY . . -WORKDIR "/src/src/BCards.Web" - -# Publish diretamente (build + publish em um comando) -RUN case "$TARGETPLATFORM" in \ - "linux/amd64") RID="linux-x64" ;; \ - "linux/arm64") RID="linux-arm64" ;; \ - *) echo "Unsupported platform: $TARGETPLATFORM" && exit 1 ;; \ - esac && \ - echo "📦 Publishing for RID: $RID" && \ - dotnet publish "BCards.Web.csproj" \ - -c Release \ - -o /app/publish \ - --no-restore \ - --runtime $RID \ - --self-contained false \ - -p:PublishReadyToRun=false \ - -p:PublishSingleFile=false \ - -p:UseAppHost=false \ - -p:Version=$VERSION \ - -p:InformationalVersion=$COMMIT - -# Final stage - runtime optimized for Release environment -FROM base AS final -ARG VERSION=0.0.1 -ARG COMMIT=unknown -ARG TARGETPLATFORM - -# Metadata labels -LABEL maintainer="BCards Team" -LABEL version=$VERSION -LABEL commit=$COMMIT -LABEL platform=$TARGETPLATFORM -LABEL environment="release" - -WORKDIR /app - -# Copy published application -COPY --from=build /app/publish . - -# Create non-root user for security -RUN groupadd -r bcards && useradd -r -g bcards bcards \ - && chown -R bcards:bcards /app - -# Environment variables for Release -ENV ASPNETCORE_ENVIRONMENT=Release -ENV ASPNETCORE_URLS=http://+:8080 -ENV DOTNET_RUNNING_IN_CONTAINER=true -ENV DOTNET_EnableDiagnostics=0 -ENV DOTNET_USE_POLLING_FILE_WATCHER=true -ENV DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=false - -# Platform-specific optimizations -RUN if [ "$TARGETPLATFORM" = "linux/arm64" ]; then \ - echo "🔧 Applying ARM64 optimizations..." && \ - echo 'export DOTNET_TieredPGO=1' >> /etc/environment && \ - echo 'export DOTNET_TC_QuickJitForLoops=1' >> /etc/environment && \ - echo 'export DOTNET_ReadyToRun=0' >> /etc/environment; \ - else \ - echo "🔧 Applying AMD64 optimizations..." && \ - echo 'export DOTNET_TieredPGO=1' >> /etc/environment && \ - echo 'export DOTNET_ReadyToRun=1' >> /etc/environment; \ - fi - -# Health check endpoint -HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \ - CMD curl -f http://localhost:8080/health || exit 1 - -# Switch to non-root user -USER bcards - -# Entry point with optimized runtime settings -ENTRYPOINT ["dotnet", "BCards.Web.dll"] +# Dockerfile.release - Multi-architecture build for Release environment +# Supports: linux/amd64, linux/arm64 +ARG BUILDPLATFORM=linux/amd64 +ARG TARGETPLATFORM +ARG VERSION=0.0.1 +ARG COMMIT=unknown + +# Base runtime image with multi-arch support +FROM --platform=$TARGETPLATFORM mcr.microsoft.com/dotnet/aspnet:8.0 AS base +WORKDIR /app +EXPOSE 8080 +EXPOSE 8443 + +# Install dependencies based on target platform +RUN apt-get update && \ + apt-get install -y --no-install-recommends \ + libgdiplus \ + curl \ + ca-certificates \ + && rm -rf /var/lib/apt/lists/* \ + && apt-get clean + +# Create application directories +RUN mkdir -p /app/uploads /app/logs \ + && chmod 755 /app/uploads /app/logs + +# Build stage - restore and publish +FROM --platform=$BUILDPLATFORM mcr.microsoft.com/dotnet/sdk:8.0 AS build +ARG TARGETPLATFORM +ARG VERSION +ARG COMMIT + +WORKDIR /src + +# Copy project file and restore dependencies +COPY ["src/BCards.Web/BCards.Web.csproj", "src/BCards.Web/"] + +# Map platform to .NET runtime identifier and restore +RUN case "$TARGETPLATFORM" in \ + "linux/amd64") RID="linux-x64" ;; \ + "linux/arm64") RID="linux-arm64" ;; \ + *) echo "Unsupported platform: $TARGETPLATFORM" && exit 1 ;; \ + esac && \ + echo "🔧 Restoring for RID: $RID" && \ + dotnet restore "src/BCards.Web/BCards.Web.csproj" --runtime $RID + +# Copy source code +COPY . . +WORKDIR "/src/src/BCards.Web" + +# Publish diretamente (build + publish em um comando) +RUN case "$TARGETPLATFORM" in \ + "linux/amd64") RID="linux-x64" ;; \ + "linux/arm64") RID="linux-arm64" ;; \ + *) echo "Unsupported platform: $TARGETPLATFORM" && exit 1 ;; \ + esac && \ + echo "📦 Publishing for RID: $RID" && \ + dotnet publish "BCards.Web.csproj" \ + -c Release \ + -o /app/publish \ + --no-restore \ + --runtime $RID \ + --self-contained false \ + -p:PublishReadyToRun=false \ + -p:PublishSingleFile=false \ + -p:UseAppHost=false \ + -p:Version=$VERSION \ + -p:InformationalVersion=$COMMIT + +# Final stage - runtime optimized for Release environment +FROM base AS final +ARG VERSION=0.0.1 +ARG COMMIT=unknown +ARG TARGETPLATFORM + +# Metadata labels +LABEL maintainer="BCards Team" +LABEL version=$VERSION +LABEL commit=$COMMIT +LABEL platform=$TARGETPLATFORM +LABEL environment="release" + +WORKDIR /app + +# Copy published application +COPY --from=build /app/publish . + +# Create non-root user for security +RUN groupadd -r bcards && useradd -r -g bcards bcards \ + && chown -R bcards:bcards /app + +# Environment variables for Release +ENV ASPNETCORE_ENVIRONMENT=Release +ENV ASPNETCORE_URLS=http://+:8080 +ENV DOTNET_RUNNING_IN_CONTAINER=true +ENV DOTNET_EnableDiagnostics=0 +ENV DOTNET_USE_POLLING_FILE_WATCHER=true +ENV DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=false + +# Platform-specific optimizations +RUN if [ "$TARGETPLATFORM" = "linux/arm64" ]; then \ + echo "🔧 Applying ARM64 optimizations..." && \ + echo 'export DOTNET_TieredPGO=1' >> /etc/environment && \ + echo 'export DOTNET_TC_QuickJitForLoops=1' >> /etc/environment && \ + echo 'export DOTNET_ReadyToRun=0' >> /etc/environment; \ + else \ + echo "🔧 Applying AMD64 optimizations..." && \ + echo 'export DOTNET_TieredPGO=1' >> /etc/environment && \ + echo 'export DOTNET_ReadyToRun=1' >> /etc/environment; \ + fi + +# Health check endpoint +HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \ + CMD curl -f http://localhost:8080/health || exit 1 + +# Switch to non-root user +USER bcards + +# Entry point with optimized runtime settings +ENTRYPOINT ["dotnet", "BCards.Web.dll"] diff --git a/GITEA-VARIABLES-SETUP.md b/GITEA-VARIABLES-SETUP.md index 85f9c57..1d76ee7 100644 --- a/GITEA-VARIABLES-SETUP.md +++ b/GITEA-VARIABLES-SETUP.md @@ -1,178 +1,178 @@ -# 🔧 **CONFIGURAÇÃO DE VARIÁVEIS NO GITEA** - -## 📋 **VISÃO GERAL** - -Agora o deploy é **100% automatizado**! O YAML cria o `appsettings.Production.json` dinamicamente usando variáveis do Gitea. - ---- - -## ⚙️ **COMO CONFIGURAR NO GITEA** - -### **1. Acessar Configurações** -```bash -1. Vá para: https://seu-gitea.com/seu-usuario/vcart.me.novo -2. Clique em: Settings (Configurações) -3. Na sidebar: Secrets and variables → Actions -``` - -### **2. Criar SECRETS (dados sensíveis)** -Clique em **"New repository secret"** para cada um: - -```bash -# STRIPE (obrigatório) -STRIPE_SECRET_KEY = sk_test_51RjUmIBMIadsOxJVeqsMFxnZ8ePR7d8IbnaF4sAwBVJv9rrfODPEQ2C9fF3beoABpITdfzEk0ZDzGTTQfvKv63xI00PeZoABGO - -STRIPE_WEBHOOK_SECRET = whsec_SEU_WEBHOOK_SECRET_AQUI - -# OAUTH (obrigatório) -GOOGLE_CLIENT_SECRET = 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 -MICROSOFT_CLIENT_SECRET = T0.8Q~an.51iW1H0DVjL2i1bmSK_qTgVQOuEmapK - -# SENDGRID (obrigatório) -SENDGRID_API_KEY = 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 -``` - -### **3. Criar VARIABLES (dados não-sensíveis)** -Clique em **"New repository variable"** para cada um: - -```bash -# STRIPE -STRIPE_PUBLISHABLE_KEY = pk_test_51RjUmIBMIadsOxJVP4bWc54pHEOSf5km1hpOkOBSoGVoKxI46N4KSWtevpXCSq68OjFazBuXmPJGBwZ1KDN5MNJy003lj1YmAS -STRIPE_ENVIRONMENT = test - -# OAUTH -GOOGLE_CLIENT_ID = 472850008574-nmeepbdt4hunsk5c8krpbdmd3olc4jv6.apps.googleusercontent.com -MICROSOFT_CLIENT_ID = b411606a-e574-4f59-b7cd-10dd941b9fa3 - -# SENDGRID -SENDGRID_FROM_EMAIL = ricardo.carneiro@jobmaker.com.br -SENDGRID_FROM_NAME = Ricardo Carneiro - -# MODERAÇÃO -MODERATOR_EMAIL = ricardo.carneiro@jobmaker.com.br -MODERATOR_EMAIL_1 = rrcgoncalves@gmail.com -MODERATOR_EMAIL_2 = rirocarneiro@gmail.com -``` - ---- - -## 🎯 **PASSO-A-PASSO COMPLETO** - -### **FASE 1: TESTE ONLINE (ATUAL)** - -#### **1.1 Configure Webhook no Stripe** -1. **Stripe Dashboard** → **Developers** → **Webhooks** -2. **Add endpoint**: `https://bcards.site/api/stripe/webhook` -3. **Select events**: - - `customer.subscription.created` - - `customer.subscription.updated` - - `customer.subscription.deleted` - - `invoice.payment_succeeded` - - `invoice.payment_failed` -4. **Copiar**: Signing secret (`whsec_...`) - -#### **1.2 Desativar Modo Restrito no Stripe** -1. **Stripe Dashboard** → **Settings** → **Account settings** -2. **Business settings** → **"Restricted mode"** → **DESATIVAR** ✅ - -#### **1.3 Configurar Variáveis no Gitea** -Use os valores atuais (modo teste): - -```bash -# Para TESTE ONLINE -STRIPE_ENVIRONMENT = test -STRIPE_PUBLISHABLE_KEY = pk_test_... (sua chave atual) -STRIPE_SECRET_KEY = sk_test_... (sua chave atual) -STRIPE_WEBHOOK_SECRET = whsec_... (do webhook configurado) -``` - -#### **1.4 Commit e Deploy** -```bash -git add . -git commit -m "feat: deploy automatizado com configuração dinâmica via Gitea - -- YAML cria appsettings.Production.json automaticamente -- Configuração via variáveis/secrets do Gitea -- Mount do arquivo no container Docker -- Suporte para teste online e produção - -🔧 Generated with [Claude Code](https://claude.ai/code) - -Co-Authored-By: Claude " - -git push origin main -``` - ---- - -### **FASE 2: MIGRAÇÃO PARA LIVE (QUANDO PRONTO)** - -#### **2.1 Obter Chaves Live** -```bash -1. Stripe Dashboard → Toggle "Test mode" OFF -2. Developers → API Keys -3. Copiar: pk_live_... e sk_live_... -``` - -#### **2.2 Configurar Webhook Live** -```bash -1. Stripe Dashboard (Live) → Webhooks -2. Add endpoint: https://bcards.site/api/stripe/webhook -3. Copiar: whsec_live_... -``` - -#### **2.3 Criar Produtos Live** -```bash -# No Stripe Dashboard (Live mode): -Products → Create products para cada plano -Copiar todos os price_live_xxx IDs -``` - -#### **2.4 Atualizar Variáveis no Gitea** -```bash -# Mudar apenas estas variáveis: -STRIPE_ENVIRONMENT = live -STRIPE_PUBLISHABLE_KEY = pk_live_... (nova) -STRIPE_SECRET_KEY = sk_live_... (nova) -STRIPE_WEBHOOK_SECRET = whsec_live_... (nova) -``` - -#### **2.5 Atualizar Price IDs** -Editar `appsettings.json` com os novos price IDs live e fazer commit. - ---- - -## ✅ **VANTAGENS DESTA ABORDAGEM** - -### **🔒 Segurança:** -- ✅ Secrets nunca vão para o git -- ✅ Variables ficam na interface do Gitea -- ✅ Deploy totalmente automatizado - -### **🚀 Produtividade:** -- ✅ Mudança de ambiente: apenas update de variáveis -- ✅ Sem arquivos manuais nos servidores -- ✅ Rollback fácil -- ✅ Configuração versionada via interface - -### **🔧 Manutenibilidade:** -- ✅ Uma única fonte de verdade -- ✅ Fácil adicionar novos ambientes -- ✅ Logs claros no deploy -- ✅ Validação automática - ---- - -## 📊 **FLUXO FINAL** - -```mermaid -graph TD - A[git push main] --> B[Gitea Actions] - B --> C[Create appsettings.Production.json] - C --> D[Build Docker Image] - D --> E[Upload Config to Servers] - E --> F[Deploy with Volume Mount] - F --> G[✅ Site Online] -``` - +# 🔧 **CONFIGURAÇÃO DE VARIÁVEIS NO GITEA** + +## 📋 **VISÃO GERAL** + +Agora o deploy é **100% automatizado**! O YAML cria o `appsettings.Production.json` dinamicamente usando variáveis do Gitea. + +--- + +## ⚙️ **COMO CONFIGURAR NO GITEA** + +### **1. Acessar Configurações** +```bash +1. Vá para: https://seu-gitea.com/seu-usuario/vcart.me.novo +2. Clique em: Settings (Configurações) +3. Na sidebar: Secrets and variables → Actions +``` + +### **2. Criar SECRETS (dados sensíveis)** +Clique em **"New repository secret"** para cada um: + +```bash +# STRIPE (obrigatório) +STRIPE_SECRET_KEY = sk_test_51RjUmIBMIadsOxJVeqsMFxnZ8ePR7d8IbnaF4sAwBVJv9rrfODPEQ2C9fF3beoABpITdfzEk0ZDzGTTQfvKv63xI00PeZoABGO + +STRIPE_WEBHOOK_SECRET = whsec_SEU_WEBHOOK_SECRET_AQUI + +# OAUTH (obrigatório) +GOOGLE_CLIENT_SECRET = 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 +MICROSOFT_CLIENT_SECRET = T0.8Q~an.51iW1H0DVjL2i1bmSK_qTgVQOuEmapK + +# SENDGRID (obrigatório) +SENDGRID_API_KEY = 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 +``` + +### **3. Criar VARIABLES (dados não-sensíveis)** +Clique em **"New repository variable"** para cada um: + +```bash +# STRIPE +STRIPE_PUBLISHABLE_KEY = pk_test_51RjUmIBMIadsOxJVP4bWc54pHEOSf5km1hpOkOBSoGVoKxI46N4KSWtevpXCSq68OjFazBuXmPJGBwZ1KDN5MNJy003lj1YmAS +STRIPE_ENVIRONMENT = test + +# OAUTH +GOOGLE_CLIENT_ID = 472850008574-nmeepbdt4hunsk5c8krpbdmd3olc4jv6.apps.googleusercontent.com +MICROSOFT_CLIENT_ID = b411606a-e574-4f59-b7cd-10dd941b9fa3 + +# SENDGRID +SENDGRID_FROM_EMAIL = ricardo.carneiro@jobmaker.com.br +SENDGRID_FROM_NAME = Ricardo Carneiro + +# MODERAÇÃO +MODERATOR_EMAIL = ricardo.carneiro@jobmaker.com.br +MODERATOR_EMAIL_1 = rrcgoncalves@gmail.com +MODERATOR_EMAIL_2 = rirocarneiro@gmail.com +``` + +--- + +## 🎯 **PASSO-A-PASSO COMPLETO** + +### **FASE 1: TESTE ONLINE (ATUAL)** + +#### **1.1 Configure Webhook no Stripe** +1. **Stripe Dashboard** → **Developers** → **Webhooks** +2. **Add endpoint**: `https://bcards.site/api/stripe/webhook` +3. **Select events**: + - `customer.subscription.created` + - `customer.subscription.updated` + - `customer.subscription.deleted` + - `invoice.payment_succeeded` + - `invoice.payment_failed` +4. **Copiar**: Signing secret (`whsec_...`) + +#### **1.2 Desativar Modo Restrito no Stripe** +1. **Stripe Dashboard** → **Settings** → **Account settings** +2. **Business settings** → **"Restricted mode"** → **DESATIVAR** ✅ + +#### **1.3 Configurar Variáveis no Gitea** +Use os valores atuais (modo teste): + +```bash +# Para TESTE ONLINE +STRIPE_ENVIRONMENT = test +STRIPE_PUBLISHABLE_KEY = pk_test_... (sua chave atual) +STRIPE_SECRET_KEY = sk_test_... (sua chave atual) +STRIPE_WEBHOOK_SECRET = whsec_... (do webhook configurado) +``` + +#### **1.4 Commit e Deploy** +```bash +git add . +git commit -m "feat: deploy automatizado com configuração dinâmica via Gitea + +- YAML cria appsettings.Production.json automaticamente +- Configuração via variáveis/secrets do Gitea +- Mount do arquivo no container Docker +- Suporte para teste online e produção + +🔧 Generated with [Claude Code](https://claude.ai/code) + +Co-Authored-By: Claude " + +git push origin main +``` + +--- + +### **FASE 2: MIGRAÇÃO PARA LIVE (QUANDO PRONTO)** + +#### **2.1 Obter Chaves Live** +```bash +1. Stripe Dashboard → Toggle "Test mode" OFF +2. Developers → API Keys +3. Copiar: pk_live_... e sk_live_... +``` + +#### **2.2 Configurar Webhook Live** +```bash +1. Stripe Dashboard (Live) → Webhooks +2. Add endpoint: https://bcards.site/api/stripe/webhook +3. Copiar: whsec_live_... +``` + +#### **2.3 Criar Produtos Live** +```bash +# No Stripe Dashboard (Live mode): +Products → Create products para cada plano +Copiar todos os price_live_xxx IDs +``` + +#### **2.4 Atualizar Variáveis no Gitea** +```bash +# Mudar apenas estas variáveis: +STRIPE_ENVIRONMENT = live +STRIPE_PUBLISHABLE_KEY = pk_live_... (nova) +STRIPE_SECRET_KEY = sk_live_... (nova) +STRIPE_WEBHOOK_SECRET = whsec_live_... (nova) +``` + +#### **2.5 Atualizar Price IDs** +Editar `appsettings.json` com os novos price IDs live e fazer commit. + +--- + +## ✅ **VANTAGENS DESTA ABORDAGEM** + +### **🔒 Segurança:** +- ✅ Secrets nunca vão para o git +- ✅ Variables ficam na interface do Gitea +- ✅ Deploy totalmente automatizado + +### **🚀 Produtividade:** +- ✅ Mudança de ambiente: apenas update de variáveis +- ✅ Sem arquivos manuais nos servidores +- ✅ Rollback fácil +- ✅ Configuração versionada via interface + +### **🔧 Manutenibilidade:** +- ✅ Uma única fonte de verdade +- ✅ Fácil adicionar novos ambientes +- ✅ Logs claros no deploy +- ✅ Validação automática + +--- + +## 📊 **FLUXO FINAL** + +```mermaid +graph TD + A[git push main] --> B[Gitea Actions] + B --> C[Create appsettings.Production.json] + C --> D[Build Docker Image] + D --> E[Upload Config to Servers] + E --> F[Deploy with Volume Mount] + F --> G[✅ Site Online] +``` + **Resultado**: Configuração 100% automatizada, segura e versionada! 🎉 \ No newline at end of file diff --git a/STRIPE-SETUP-GUIDE.md b/STRIPE-SETUP-GUIDE.md index 8a5c515..0a55935 100644 --- a/STRIPE-SETUP-GUIDE.md +++ b/STRIPE-SETUP-GUIDE.md @@ -1,219 +1,219 @@ -# 🎯 **GUIA COMPLETO: STRIPE TESTE → PRODUÇÃO** - -## 📋 **DIFERENÇAS IMPORTANTES** - -### **MODO DE TESTE vs MODO RESTRITO** - -| Aspecto | **Modo de Teste** | **Modo Restrito** | -|---------|-------------------|-------------------| -| 🎯 **Propósito** | Simular pagamentos | Limitar acesso público | -| 💳 **Cartões** | Apenas cartões de teste (`4242...`) | Cartões reais OU teste | -| 💰 **Dinheiro** | Não cobra dinheiro real | Pode cobrar (se live mode) | -| 🌐 **Acesso Online** | ✅ Funciona perfeitamente | ❌ Bloqueia usuários não autorizados | -| 🧪 **Para Testes** | ✅ **IDEAL** | ❌ Impede testes públicos | - -### **🚨 PROBLEMA ATUAL** -Seu site provavelmente está em **Modo Restrito**, impedindo pagamentos online mesmo com cartões de teste. - ---- - -## 🛠️ **PASSO-A-PASSO COMPLETO** - -### **📝 FASE 1: PREPARAÇÃO LOCAL** - -#### **1.1 Criar appsettings.Production.json** -```bash -# No seu ambiente local, crie: -cp appsettings.Production.example.json src/BCards.Web/appsettings.Production.json -``` - -#### **1.2 Para TESTE ONLINE (Recomendado primeiro)** -Edite `appsettings.Production.json`: -```json -{ - "Stripe": { - "PublishableKey": "pk_test_51RjUmIBMIadsOxJVP4bWc54pHEOSf5km1hpOkOBSoGVoKxI46N4KSWtevpXCSq68OjFazBuXmPJGBwZ1KDN5MNJy003lj1YmAS", - "SecretKey": "sk_test_51RjUmIBMIadsOxJVeqsMFxnZ8ePR7d8IbnaF4sAwBVJv9rrfODPEQ2C9fF3beoABpITdfzEk0ZDzGTTQfvKv63xI00PeZoABGO", - "WebhookSecret": "SEU_WEBHOOK_PRODUÇÃO_AQUI", - "Environment": "test" - } -} -``` - ---- - -### **🔧 FASE 2: CONFIGURAÇÃO DO STRIPE** - -#### **2.1 Desativar Modo Restrito (CRÍTICO)** -1. **Acesse**: https://dashboard.stripe.com -2. **Certifique-se**: Test mode **LIGADO** (toggle azul no topo) -3. **Settings** → **Account settings** -4. **Business settings** → **Restrictions** -5. **"Restricted mode"** → **DESATIVAR** ✅ - -#### **2.2 Configurar Webhook de Produção** -1. **Stripe Dashboard** → **Developers** → **Webhooks** -2. **Add endpoint**: `https://bcards.site/api/stripe/webhook` -3. **Select events**: - - `customer.subscription.created` - - `customer.subscription.updated` - - `customer.subscription.deleted` - - `invoice.payment_succeeded` - - `invoice.payment_failed` -4. **Copiar**: Signing secret → `whsec_...` -5. **Colar**: no `WebhookSecret` do seu `appsettings.Production.json` - ---- - -### **📤 FASE 3: COMMIT E DEPLOY** - -#### **3.1 Commit (SEGURO)** -```bash -# Seus arquivos de configuração agora estão seguros: -git add . -git commit -m "feat: configuração segura do Stripe para teste online - -- appsettings.json: chaves removidas (seguro para git) -- appsettings.Development.json: chaves teste para desenvolvimento -- appsettings.Production.json: ignorado pelo git (chaves produção) -- Validação de ambiente Stripe adicionada -- Endpoint /stripe-info para debugging - -🔧 Generated with [Claude Code](https://claude.ai/code) - -Co-Authored-By: Claude " - -# Push para main (deploy automático) -git push origin main -``` - -#### **3.2 Verificar Deploy** -```bash -# Aguardar deploy completar, então testar: -curl https://bcards.site/health -curl https://bcards.site/stripe-info -``` - ---- - -### **🧪 FASE 4: TESTES ONLINE** - -#### **4.1 Testar Informações do Stripe** -```bash -# Verificar configuração: -https://bcards.site/stripe-info - -# Deve mostrar: -{ - "Environment": "test", - "IsTestMode": true, - "WebhookConfigured": true -} -``` - -#### **4.2 Testar Pagamento** -1. **Cadastre-se** no site online -2. **Escolha um plano** -3. **Use cartão teste**: `4242 4242 4242 4242` -4. **Data**: Qualquer data futura (ex: 12/25) -5. **CVC**: Qualquer 3 dígitos (ex: 123) - -#### **4.3 Verificar Logs** -```bash -# Monitorar webhooks no Stripe Dashboard -# Developers → Webhooks → Seu endpoint → Recent deliveries -``` - ---- - -### **🚀 FASE 5: MIGRAÇÃO PARA LIVE (QUANDO PRONTO)** - -#### **5.1 Obter Chaves Live** -1. **Stripe Dashboard** → Toggle "Test mode" **OFF** -2. **Developers** → **API Keys** -3. **Copiar**: `pk_live_...` e `sk_live_...` - -#### **5.2 Criar Produtos Live** -```bash -# No Stripe Dashboard (Live mode): -# Products → Create products para cada plano -# Copiar todos os price_live_xxx -``` - -#### **5.3 Atualizar Produção** -```json -// appsettings.Production.json -{ - "Stripe": { - "PublishableKey": "pk_live_...", - "SecretKey": "sk_live_...", - "WebhookSecret": "whsec_live_...", - "Environment": "live" - }, - "Plans": { - "Basic": { - "PriceId": "price_live_basic_xxx" // ← ATUALIZAR TODOS - } - // ... outros planos - } -} -``` - ---- - -## 🔍 **DEBUGGING & MONITORAMENTO** - -### **Endpoints Úteis:** -- `https://bcards.site/health` - Status da aplicação -- `https://bcards.site/stripe-info` - Configuração Stripe (apenas logados) - -### **Logs Importantes:** -```bash -# Ao iniciar a aplicação, você verá: -🔧 Stripe Environment: TEST | Test Mode: True -⚠️ STRIPE TEST MODE ENABLED - Only test payments will work -``` - -### **Cartões de Teste:** -- **Sucesso**: `4242 4242 4242 4242` -- **Falha**: `4000 0000 0000 0002` -- **3D Secure**: `4000 0025 0000 3155` - ---- - -## ✅ **CHECKLIST FINAL** - -### **Para Teste Online:** -```bash -□ Modo Restrito DESATIVADO no Stripe -□ Webhook configurado para bcards.site -□ appsettings.Production.json criado (não no git) -□ Commit realizado (configurações seguras) -□ Deploy executado com sucesso -□ /stripe-info mostra Environment: "test" -□ Pagamento teste funciona online -``` - -### **Para Produção (Depois):** -```bash -□ Chaves LIVE obtidas -□ Produtos LIVE criados no Stripe -□ Price IDs atualizados -□ Webhook LIVE configurado -□ appsettings.Production.json atualizado -□ Environment: "live" configurado -□ Testes com cartões reais (pequenos valores) -□ Monitoramento ativo -``` - ---- - -## 🎯 **RESULTADO ESPERADO** - -**Agora** você pode: -- ✅ **Testar online** com cartões de teste -- ✅ **Manter segurança** (chaves fora do git) -- ✅ **Monitorar** facilmente via endpoints -- ✅ **Migrar para live** quando pronto - +# 🎯 **GUIA COMPLETO: STRIPE TESTE → PRODUÇÃO** + +## 📋 **DIFERENÇAS IMPORTANTES** + +### **MODO DE TESTE vs MODO RESTRITO** + +| Aspecto | **Modo de Teste** | **Modo Restrito** | +|---------|-------------------|-------------------| +| 🎯 **Propósito** | Simular pagamentos | Limitar acesso público | +| 💳 **Cartões** | Apenas cartões de teste (`4242...`) | Cartões reais OU teste | +| 💰 **Dinheiro** | Não cobra dinheiro real | Pode cobrar (se live mode) | +| 🌐 **Acesso Online** | ✅ Funciona perfeitamente | ❌ Bloqueia usuários não autorizados | +| 🧪 **Para Testes** | ✅ **IDEAL** | ❌ Impede testes públicos | + +### **🚨 PROBLEMA ATUAL** +Seu site provavelmente está em **Modo Restrito**, impedindo pagamentos online mesmo com cartões de teste. + +--- + +## 🛠️ **PASSO-A-PASSO COMPLETO** + +### **📝 FASE 1: PREPARAÇÃO LOCAL** + +#### **1.1 Criar appsettings.Production.json** +```bash +# No seu ambiente local, crie: +cp appsettings.Production.example.json src/BCards.Web/appsettings.Production.json +``` + +#### **1.2 Para TESTE ONLINE (Recomendado primeiro)** +Edite `appsettings.Production.json`: +```json +{ + "Stripe": { + "PublishableKey": "pk_test_51RjUmIBMIadsOxJVP4bWc54pHEOSf5km1hpOkOBSoGVoKxI46N4KSWtevpXCSq68OjFazBuXmPJGBwZ1KDN5MNJy003lj1YmAS", + "SecretKey": "sk_test_51RjUmIBMIadsOxJVeqsMFxnZ8ePR7d8IbnaF4sAwBVJv9rrfODPEQ2C9fF3beoABpITdfzEk0ZDzGTTQfvKv63xI00PeZoABGO", + "WebhookSecret": "SEU_WEBHOOK_PRODUÇÃO_AQUI", + "Environment": "test" + } +} +``` + +--- + +### **🔧 FASE 2: CONFIGURAÇÃO DO STRIPE** + +#### **2.1 Desativar Modo Restrito (CRÍTICO)** +1. **Acesse**: https://dashboard.stripe.com +2. **Certifique-se**: Test mode **LIGADO** (toggle azul no topo) +3. **Settings** → **Account settings** +4. **Business settings** → **Restrictions** +5. **"Restricted mode"** → **DESATIVAR** ✅ + +#### **2.2 Configurar Webhook de Produção** +1. **Stripe Dashboard** → **Developers** → **Webhooks** +2. **Add endpoint**: `https://bcards.site/api/stripe/webhook` +3. **Select events**: + - `customer.subscription.created` + - `customer.subscription.updated` + - `customer.subscription.deleted` + - `invoice.payment_succeeded` + - `invoice.payment_failed` +4. **Copiar**: Signing secret → `whsec_...` +5. **Colar**: no `WebhookSecret` do seu `appsettings.Production.json` + +--- + +### **📤 FASE 3: COMMIT E DEPLOY** + +#### **3.1 Commit (SEGURO)** +```bash +# Seus arquivos de configuração agora estão seguros: +git add . +git commit -m "feat: configuração segura do Stripe para teste online + +- appsettings.json: chaves removidas (seguro para git) +- appsettings.Development.json: chaves teste para desenvolvimento +- appsettings.Production.json: ignorado pelo git (chaves produção) +- Validação de ambiente Stripe adicionada +- Endpoint /stripe-info para debugging + +🔧 Generated with [Claude Code](https://claude.ai/code) + +Co-Authored-By: Claude " + +# Push para main (deploy automático) +git push origin main +``` + +#### **3.2 Verificar Deploy** +```bash +# Aguardar deploy completar, então testar: +curl https://bcards.site/health +curl https://bcards.site/stripe-info +``` + +--- + +### **🧪 FASE 4: TESTES ONLINE** + +#### **4.1 Testar Informações do Stripe** +```bash +# Verificar configuração: +https://bcards.site/stripe-info + +# Deve mostrar: +{ + "Environment": "test", + "IsTestMode": true, + "WebhookConfigured": true +} +``` + +#### **4.2 Testar Pagamento** +1. **Cadastre-se** no site online +2. **Escolha um plano** +3. **Use cartão teste**: `4242 4242 4242 4242` +4. **Data**: Qualquer data futura (ex: 12/25) +5. **CVC**: Qualquer 3 dígitos (ex: 123) + +#### **4.3 Verificar Logs** +```bash +# Monitorar webhooks no Stripe Dashboard +# Developers → Webhooks → Seu endpoint → Recent deliveries +``` + +--- + +### **🚀 FASE 5: MIGRAÇÃO PARA LIVE (QUANDO PRONTO)** + +#### **5.1 Obter Chaves Live** +1. **Stripe Dashboard** → Toggle "Test mode" **OFF** +2. **Developers** → **API Keys** +3. **Copiar**: `pk_live_...` e `sk_live_...` + +#### **5.2 Criar Produtos Live** +```bash +# No Stripe Dashboard (Live mode): +# Products → Create products para cada plano +# Copiar todos os price_live_xxx +``` + +#### **5.3 Atualizar Produção** +```json +// appsettings.Production.json +{ + "Stripe": { + "PublishableKey": "pk_live_...", + "SecretKey": "sk_live_...", + "WebhookSecret": "whsec_live_...", + "Environment": "live" + }, + "Plans": { + "Basic": { + "PriceId": "price_live_basic_xxx" // ← ATUALIZAR TODOS + } + // ... outros planos + } +} +``` + +--- + +## 🔍 **DEBUGGING & MONITORAMENTO** + +### **Endpoints Úteis:** +- `https://bcards.site/health` - Status da aplicação +- `https://bcards.site/stripe-info` - Configuração Stripe (apenas logados) + +### **Logs Importantes:** +```bash +# Ao iniciar a aplicação, você verá: +🔧 Stripe Environment: TEST | Test Mode: True +⚠️ STRIPE TEST MODE ENABLED - Only test payments will work +``` + +### **Cartões de Teste:** +- **Sucesso**: `4242 4242 4242 4242` +- **Falha**: `4000 0000 0000 0002` +- **3D Secure**: `4000 0025 0000 3155` + +--- + +## ✅ **CHECKLIST FINAL** + +### **Para Teste Online:** +```bash +□ Modo Restrito DESATIVADO no Stripe +□ Webhook configurado para bcards.site +□ appsettings.Production.json criado (não no git) +□ Commit realizado (configurações seguras) +□ Deploy executado com sucesso +□ /stripe-info mostra Environment: "test" +□ Pagamento teste funciona online +``` + +### **Para Produção (Depois):** +```bash +□ Chaves LIVE obtidas +□ Produtos LIVE criados no Stripe +□ Price IDs atualizados +□ Webhook LIVE configurado +□ appsettings.Production.json atualizado +□ Environment: "live" configurado +□ Testes com cartões reais (pequenos valores) +□ Monitoramento ativo +``` + +--- + +## 🎯 **RESULTADO ESPERADO** + +**Agora** você pode: +- ✅ **Testar online** com cartões de teste +- ✅ **Manter segurança** (chaves fora do git) +- ✅ **Monitorar** facilmente via endpoints +- ✅ **Migrar para live** quando pronto + **O site funcionará online em modo teste, permitindo que qualquer pessoa teste com cartões fake!** 🎉 \ No newline at end of file diff --git a/appsettings.Production.example.json b/appsettings.Production.example.json index d59dbd0..be539e3 100644 --- a/appsettings.Production.example.json +++ b/appsettings.Production.example.json @@ -1,27 +1,27 @@ -{ - "// EXEMPLO - RENOMEAR PARA appsettings.Production.json": "Este arquivo mostra como configurar produção", - - "Stripe": { - "// PARA TESTE ONLINE": { - "PublishableKey": "pk_test_SUA_CHAVE_PUBLICA_AQUI", - "SecretKey": "sk_test_SUA_CHAVE_SECRETA_AQUI", - "WebhookSecret": "whsec_WEBHOOK_DO_SEU_SITE_AQUI", - "Environment": "test" - }, - - "// PARA PRODUÇÃO REAL": { - "PublishableKey": "pk_live_SUA_CHAVE_LIVE_AQUI", - "SecretKey": "sk_live_SUA_CHAVE_LIVE_AQUI", - "WebhookSecret": "whsec_WEBHOOK_LIVE_AQUI", - "Environment": "live" - } - }, - - "// INSTRUCOES": [ - "1. Copie este arquivo para appsettings.Production.json", - "2. Para TESTE ONLINE: use chaves pk_test_ e sk_test_", - "3. Para PRODUÇÃO REAL: use chaves pk_live_ e sk_live_", - "4. Configure webhook para https://bcards.site/api/stripe/webhook", - "5. Modo Restrito no Stripe: desativar para permitir pagamentos públicos" - ] +{ + "// EXEMPLO - RENOMEAR PARA appsettings.Production.json": "Este arquivo mostra como configurar produção", + + "Stripe": { + "// PARA TESTE ONLINE": { + "PublishableKey": "pk_test_SUA_CHAVE_PUBLICA_AQUI", + "SecretKey": "sk_test_SUA_CHAVE_SECRETA_AQUI", + "WebhookSecret": "whsec_WEBHOOK_DO_SEU_SITE_AQUI", + "Environment": "test" + }, + + "// PARA PRODUÇÃO REAL": { + "PublishableKey": "pk_live_SUA_CHAVE_LIVE_AQUI", + "SecretKey": "sk_live_SUA_CHAVE_LIVE_AQUI", + "WebhookSecret": "whsec_WEBHOOK_LIVE_AQUI", + "Environment": "live" + } + }, + + "// INSTRUCOES": [ + "1. Copie este arquivo para appsettings.Production.json", + "2. Para TESTE ONLINE: use chaves pk_test_ e sk_test_", + "3. Para PRODUÇÃO REAL: use chaves pk_live_ e sk_live_", + "4. Configure webhook para https://bcards.site/api/stripe/webhook", + "5. Modo Restrito no Stripe: desativar para permitir pagamentos públicos" + ] } \ No newline at end of file diff --git a/categorias.json b/categorias.json index 3e6c700..a98748b 100644 --- a/categorias.json +++ b/categorias.json @@ -1,452 +1,452 @@ -[ - { - "name": "Artesanato", - "slug": "artesanato", - "icon": "🎨", - "seoKeywords": [ - "artesanato", - "artesão", - "feito à mão", - "personalizado", - "criativo", - "decoração" - ], - "description": "Artesãos e criadores de produtos feitos à mão, decoração e arte personalizada", - "isActive": true - }, - { - "name": "Papelaria", - "slug": "papelaria", - "icon": "📝", - "seoKeywords": [ - "papelaria", - "escritório", - "material escolar", - "impressão", - "convites", - "personalização" - ], - "description": "Lojas de papelaria, material de escritório, impressão e produtos personalizados", - "isActive": true - }, - { - "name": "Coaching", - "slug": "coaching", - "icon": "🎯", - "seoKeywords": [ - "coaching", - "mentoria", - "desenvolvimento pessoal", - "life coach", - "business coach", - "liderança" - ], - "description": "Coaches, mentores e profissionais de desenvolvimento pessoal e empresarial", - "isActive": true - }, - { - "name": "Fitness", - "slug": "fitness", - "icon": "💪", - "seoKeywords": [ - "fitness", - "academia", - "personal trainer", - "musculação", - "treinamento", - "exercícios" - ], - "description": "Personal trainers, academias, estúdios de pilates e profissionais fitness", - "isActive": true - }, - { - "name": "Psicologia", - "slug": "psicologia", - "icon": "🧠", - "seoKeywords": [ - "psicólogo", - "terapia", - "psicologia", - "saúde mental", - "consultório", - "atendimento" - ], - "description": "Psicólogos, terapeutas e profissionais de saúde mental", - "isActive": true - }, - { - "name": "Nutrição", - "slug": "nutricao", - "icon": "🥗", - "seoKeywords": [ - "nutricionista", - "dieta", - "nutrição", - "alimentação saudável", - "consultoria nutricional", - "emagrecimento" - ], - "description": "Nutricionistas, consultores em alimentação e profissionais da nutrição", - "isActive": true - }, - { - "name": "Moda e Vestuário", - "slug": "moda", - "icon": "👗", - "seoKeywords": [ - "moda", - "vestuário", - "roupas", - "fashion", - "estilista", - "costureira" - ], - "description": "Lojas de roupas, estilistas, costureiras e profissionais da moda", - "isActive": true - }, - { - "name": "Fotografia", - "slug": "fotografia", - "icon": "📸", - "seoKeywords": [ - "fotógrafo", - "fotografia", - "ensaio", - "casamento", - "eventos", - "retratos" - ], - "description": "Fotógrafos profissionais, estúdios fotográficos e serviços de fotografia", - "isActive": true - }, - { - "name": "Marketing Digital", - "slug": "marketing-digital", - "icon": "📱", - "seoKeywords": [ - "marketing digital", - "social media", - "publicidade", - "SEO", - "gestão de redes", - "digital" - ], - "description": "Agências de marketing digital, gestores de redes sociais e consultores digitais", - "isActive": true - }, - { - "name": "Contabilidade", - "slug": "contabilidade", - "icon": "📊", - "seoKeywords": [ - "contador", - "contabilidade", - "fiscal", - "imposto de renda", - "MEI", - "consultoria contábil" - ], - "description": "Contadores, escritórios contábeis e consultoria fiscal", - "isActive": true - }, - { - "name": "Design", - "slug": "design", - "icon": "🎨", - "seoKeywords": [ - "designer", - "design gráfico", - "identidade visual", - "logo", - "criativo", - "branding" - ], - "description": "Designers gráficos, criativos e profissionais de identidade visual", - "isActive": true - }, - { - "name": "Consultoria", - "slug": "consultoria", - "icon": "🤝", - "seoKeywords": [ - "consultor", - "consultoria", - "assessoria", - "especialista", - "negócios", - "estratégia" - ], - "description": "Consultores especializados, assessoria empresarial e serviços de consultoria", - "isActive": true - }, - { - "name": "Pets", - "slug": "pets", - "icon": "🐕", - "seoKeywords": [ - "veterinário", - "pet shop", - "animais", - "cuidados", - "petshop", - "adestramento" - ], - "description": "Veterinários, pet shops, adestradores e serviços para animais", - "isActive": true - }, - { - "name": "Casa e Jardim", - "slug": "casa-jardim", - "icon": "🏡", - "seoKeywords": [ - "paisagismo", - "jardinagem", - "decoração", - "casa", - "jardim", - "plantas" - ], - "description": "Paisagistas, jardineiros, decoradores e serviços para casa e jardim", - "isActive": true - }, - { - "name": "Automóveis", - "slug": "automoveis", - "icon": "🚗", - "seoKeywords": [ - "mecânico", - "automóveis", - "carros", - "oficina", - "manutenção", - "peças" - ], - "description": "Mecânicos, oficinas, lojas de peças e serviços automotivos", - "isActive": true - }, - { - "name": "Turismo", - "slug": "turismo", - "icon": "✈️", - "seoKeywords": [ - "turismo", - "viagem", - "agência", - "guia turístico", - "passeios", - "hospedagem" - ], - "description": "Agências de turismo, guias, pousadas e prestadores de serviços turísticos", - "isActive": true - }, - { - "name": "Música", - "slug": "musica", - "icon": "🎵", - "seoKeywords": [ - "músico", - "professor de música", - "instrumentos", - "aulas", - "banda", - "eventos musicais" - ], - "description": "Músicos, professores de música, bandas e profissionais do entretenimento", - "isActive": true - }, - { - "name": "Idiomas", - "slug": "idiomas", - "icon": "🗣️", - "seoKeywords": [ - "professor de idiomas", - "inglês", - "espanhol", - "tradutor", - "aulas particulares", - "curso de idiomas" - ], - "description": "Professores de idiomas, tradutores e escolas de línguas", - "isActive": true - }, - { - "name": "Limpeza", - "slug": "limpeza", - "icon": "🧽", - "seoKeywords": [ - "limpeza", - "faxina", - "diarista", - "higienização", - "empresa de limpeza", - "doméstica" - ], - "description": "Empresas de limpeza, diaristas e serviços de higienização", - "isActive": true - }, - { - "name": "Segurança", - "slug": "seguranca", - "icon": "🛡️", - "seoKeywords": [ - "segurança", - "vigilante", - "porteiro", - "alarmes", - "monitoramento", - "proteção" - ], - "description": "Empresas de segurança, vigilantes e serviços de proteção", - "isActive": true - }, - { - "name": "Eventos", - "slug": "eventos", - "icon": "🎉", - "seoKeywords": [ - "eventos", - "festa", - "casamento", - "buffet", - "decoração de festas", - "cerimonial" - ], - "description": "Organizadores de eventos, buffets, decoração e cerimonial", - "isActive": true - }, - { - "name": "Transporte", - "slug": "transporte", - "icon": "🚐", - "seoKeywords": [ - "transporte", - "frete", - "mudança", - "delivery", - "motorista", - "logística" - ], - "description": "Empresas de transporte, fretes, mudanças e serviços de entrega", - "isActive": true - }, - { - "name": "Construção", - "slug": "construcao", - "icon": "🔨", - "seoKeywords": [ - "construção", - "pedreiro", - "pintor", - "eletricista", - "encanador", - "reforma" - ], - "description": "Profissionais da construção civil, reformas e manutenção predial", - "isActive": true - }, - { - "name": "Joias e Acessórios", - "slug": "joias", - "icon": "💎", - "seoKeywords": [ - "joias", - "bijuterias", - "acessórios", - "ourives", - "relógios", - "semijoias" - ], - "description": "Joalherias, bijuterias, ourives e lojas de acessórios", - "isActive": true - }, - { - "name": "Odontologia", - "slug": "odontologia", - "icon": "🦷", - "seoKeywords": [ - "dentista", - "odontologia", - "clínica dentária", - "ortodontia", - "implante", - "oral" - ], - "description": "Dentistas, clínicas odontológicas e profissionais da área bucal", - "isActive": true - }, - { - "name": "Fisioterapia", - "slug": "fisioterapia", - "icon": "🏥", - "seoKeywords": [ - "fisioterapeuta", - "fisioterapia", - "reabilitação", - "RPG", - "massagem", - "terapia" - ], - "description": "Fisioterapeutas, clínicas de reabilitação e terapias corporais", - "isActive": true - }, - { - "name": "Livraria", - "slug": "livraria", - "icon": "📚", - "seoKeywords": [ - "livraria", - "livros", - "sebo", - "literatura", - "editora", - "publicação" - ], - "description": "Livrarias, sebos, editoras e comércio de livros e publicações", - "isActive": true - }, - { - "name": "Floricultura", - "slug": "floricultura", - "icon": "🌸", - "seoKeywords": [ - "floricultura", - "flores", - "buquê", - "plantas", - "arranjos", - "casamento" - ], - "description": "Floriculturas, arranjos florais e comércio de plantas ornamentais", - "isActive": true - }, - { - "name": "Farmácia", - "slug": "farmacia", - "icon": "💊", - "seoKeywords": [ - "farmácia", - "farmacêutico", - "medicamentos", - "drogaria", - "manipulação", - "remédios" - ], - "description": "Farmácias, drogarias e farmacêuticos especializados", - "isActive": true - }, - { - "name": "Delivery", - "slug": "delivery", - "icon": "🛵", - "seoKeywords": [ - "delivery", - "entrega", - "motoboy", - "comida", - "aplicativo", - "rápido" - ], - "description": "Serviços de delivery, entregadores e aplicativos de entrega", - "isActive": true - } +[ + { + "name": "Artesanato", + "slug": "artesanato", + "icon": "🎨", + "seoKeywords": [ + "artesanato", + "artesão", + "feito à mão", + "personalizado", + "criativo", + "decoração" + ], + "description": "Artesãos e criadores de produtos feitos à mão, decoração e arte personalizada", + "isActive": true + }, + { + "name": "Papelaria", + "slug": "papelaria", + "icon": "📝", + "seoKeywords": [ + "papelaria", + "escritório", + "material escolar", + "impressão", + "convites", + "personalização" + ], + "description": "Lojas de papelaria, material de escritório, impressão e produtos personalizados", + "isActive": true + }, + { + "name": "Coaching", + "slug": "coaching", + "icon": "🎯", + "seoKeywords": [ + "coaching", + "mentoria", + "desenvolvimento pessoal", + "life coach", + "business coach", + "liderança" + ], + "description": "Coaches, mentores e profissionais de desenvolvimento pessoal e empresarial", + "isActive": true + }, + { + "name": "Fitness", + "slug": "fitness", + "icon": "💪", + "seoKeywords": [ + "fitness", + "academia", + "personal trainer", + "musculação", + "treinamento", + "exercícios" + ], + "description": "Personal trainers, academias, estúdios de pilates e profissionais fitness", + "isActive": true + }, + { + "name": "Psicologia", + "slug": "psicologia", + "icon": "🧠", + "seoKeywords": [ + "psicólogo", + "terapia", + "psicologia", + "saúde mental", + "consultório", + "atendimento" + ], + "description": "Psicólogos, terapeutas e profissionais de saúde mental", + "isActive": true + }, + { + "name": "Nutrição", + "slug": "nutricao", + "icon": "🥗", + "seoKeywords": [ + "nutricionista", + "dieta", + "nutrição", + "alimentação saudável", + "consultoria nutricional", + "emagrecimento" + ], + "description": "Nutricionistas, consultores em alimentação e profissionais da nutrição", + "isActive": true + }, + { + "name": "Moda e Vestuário", + "slug": "moda", + "icon": "👗", + "seoKeywords": [ + "moda", + "vestuário", + "roupas", + "fashion", + "estilista", + "costureira" + ], + "description": "Lojas de roupas, estilistas, costureiras e profissionais da moda", + "isActive": true + }, + { + "name": "Fotografia", + "slug": "fotografia", + "icon": "📸", + "seoKeywords": [ + "fotógrafo", + "fotografia", + "ensaio", + "casamento", + "eventos", + "retratos" + ], + "description": "Fotógrafos profissionais, estúdios fotográficos e serviços de fotografia", + "isActive": true + }, + { + "name": "Marketing Digital", + "slug": "marketing-digital", + "icon": "📱", + "seoKeywords": [ + "marketing digital", + "social media", + "publicidade", + "SEO", + "gestão de redes", + "digital" + ], + "description": "Agências de marketing digital, gestores de redes sociais e consultores digitais", + "isActive": true + }, + { + "name": "Contabilidade", + "slug": "contabilidade", + "icon": "📊", + "seoKeywords": [ + "contador", + "contabilidade", + "fiscal", + "imposto de renda", + "MEI", + "consultoria contábil" + ], + "description": "Contadores, escritórios contábeis e consultoria fiscal", + "isActive": true + }, + { + "name": "Design", + "slug": "design", + "icon": "🎨", + "seoKeywords": [ + "designer", + "design gráfico", + "identidade visual", + "logo", + "criativo", + "branding" + ], + "description": "Designers gráficos, criativos e profissionais de identidade visual", + "isActive": true + }, + { + "name": "Consultoria", + "slug": "consultoria", + "icon": "🤝", + "seoKeywords": [ + "consultor", + "consultoria", + "assessoria", + "especialista", + "negócios", + "estratégia" + ], + "description": "Consultores especializados, assessoria empresarial e serviços de consultoria", + "isActive": true + }, + { + "name": "Pets", + "slug": "pets", + "icon": "🐕", + "seoKeywords": [ + "veterinário", + "pet shop", + "animais", + "cuidados", + "petshop", + "adestramento" + ], + "description": "Veterinários, pet shops, adestradores e serviços para animais", + "isActive": true + }, + { + "name": "Casa e Jardim", + "slug": "casa-jardim", + "icon": "🏡", + "seoKeywords": [ + "paisagismo", + "jardinagem", + "decoração", + "casa", + "jardim", + "plantas" + ], + "description": "Paisagistas, jardineiros, decoradores e serviços para casa e jardim", + "isActive": true + }, + { + "name": "Automóveis", + "slug": "automoveis", + "icon": "🚗", + "seoKeywords": [ + "mecânico", + "automóveis", + "carros", + "oficina", + "manutenção", + "peças" + ], + "description": "Mecânicos, oficinas, lojas de peças e serviços automotivos", + "isActive": true + }, + { + "name": "Turismo", + "slug": "turismo", + "icon": "✈️", + "seoKeywords": [ + "turismo", + "viagem", + "agência", + "guia turístico", + "passeios", + "hospedagem" + ], + "description": "Agências de turismo, guias, pousadas e prestadores de serviços turísticos", + "isActive": true + }, + { + "name": "Música", + "slug": "musica", + "icon": "🎵", + "seoKeywords": [ + "músico", + "professor de música", + "instrumentos", + "aulas", + "banda", + "eventos musicais" + ], + "description": "Músicos, professores de música, bandas e profissionais do entretenimento", + "isActive": true + }, + { + "name": "Idiomas", + "slug": "idiomas", + "icon": "🗣️", + "seoKeywords": [ + "professor de idiomas", + "inglês", + "espanhol", + "tradutor", + "aulas particulares", + "curso de idiomas" + ], + "description": "Professores de idiomas, tradutores e escolas de línguas", + "isActive": true + }, + { + "name": "Limpeza", + "slug": "limpeza", + "icon": "🧽", + "seoKeywords": [ + "limpeza", + "faxina", + "diarista", + "higienização", + "empresa de limpeza", + "doméstica" + ], + "description": "Empresas de limpeza, diaristas e serviços de higienização", + "isActive": true + }, + { + "name": "Segurança", + "slug": "seguranca", + "icon": "🛡️", + "seoKeywords": [ + "segurança", + "vigilante", + "porteiro", + "alarmes", + "monitoramento", + "proteção" + ], + "description": "Empresas de segurança, vigilantes e serviços de proteção", + "isActive": true + }, + { + "name": "Eventos", + "slug": "eventos", + "icon": "🎉", + "seoKeywords": [ + "eventos", + "festa", + "casamento", + "buffet", + "decoração de festas", + "cerimonial" + ], + "description": "Organizadores de eventos, buffets, decoração e cerimonial", + "isActive": true + }, + { + "name": "Transporte", + "slug": "transporte", + "icon": "🚐", + "seoKeywords": [ + "transporte", + "frete", + "mudança", + "delivery", + "motorista", + "logística" + ], + "description": "Empresas de transporte, fretes, mudanças e serviços de entrega", + "isActive": true + }, + { + "name": "Construção", + "slug": "construcao", + "icon": "🔨", + "seoKeywords": [ + "construção", + "pedreiro", + "pintor", + "eletricista", + "encanador", + "reforma" + ], + "description": "Profissionais da construção civil, reformas e manutenção predial", + "isActive": true + }, + { + "name": "Joias e Acessórios", + "slug": "joias", + "icon": "💎", + "seoKeywords": [ + "joias", + "bijuterias", + "acessórios", + "ourives", + "relógios", + "semijoias" + ], + "description": "Joalherias, bijuterias, ourives e lojas de acessórios", + "isActive": true + }, + { + "name": "Odontologia", + "slug": "odontologia", + "icon": "🦷", + "seoKeywords": [ + "dentista", + "odontologia", + "clínica dentária", + "ortodontia", + "implante", + "oral" + ], + "description": "Dentistas, clínicas odontológicas e profissionais da área bucal", + "isActive": true + }, + { + "name": "Fisioterapia", + "slug": "fisioterapia", + "icon": "🏥", + "seoKeywords": [ + "fisioterapeuta", + "fisioterapia", + "reabilitação", + "RPG", + "massagem", + "terapia" + ], + "description": "Fisioterapeutas, clínicas de reabilitação e terapias corporais", + "isActive": true + }, + { + "name": "Livraria", + "slug": "livraria", + "icon": "📚", + "seoKeywords": [ + "livraria", + "livros", + "sebo", + "literatura", + "editora", + "publicação" + ], + "description": "Livrarias, sebos, editoras e comércio de livros e publicações", + "isActive": true + }, + { + "name": "Floricultura", + "slug": "floricultura", + "icon": "🌸", + "seoKeywords": [ + "floricultura", + "flores", + "buquê", + "plantas", + "arranjos", + "casamento" + ], + "description": "Floriculturas, arranjos florais e comércio de plantas ornamentais", + "isActive": true + }, + { + "name": "Farmácia", + "slug": "farmacia", + "icon": "💊", + "seoKeywords": [ + "farmácia", + "farmacêutico", + "medicamentos", + "drogaria", + "manipulação", + "remédios" + ], + "description": "Farmácias, drogarias e farmacêuticos especializados", + "isActive": true + }, + { + "name": "Delivery", + "slug": "delivery", + "icon": "🛵", + "seoKeywords": [ + "delivery", + "entrega", + "motoboy", + "comida", + "aplicativo", + "rápido" + ], + "description": "Serviços de delivery, entregadores e aplicativos de entrega", + "isActive": true + } ] \ No newline at end of file diff --git a/deploy-manual.ps1 b/deploy-manual.ps1 index 0f6f834..e6fc186 100644 --- a/deploy-manual.ps1 +++ b/deploy-manual.ps1 @@ -1,68 +1,68 @@ -# Deploy manual corrigido (sem problemas de quebra de linha) -param( - [string]$Tag = "latest-manual" -) - -Write-Host "🏗️ Building and deploying BCards..." -ForegroundColor Green - -try { - # 1. Build da imagem - Write-Host "📦 Building Docker image..." -ForegroundColor Yellow - - # Build e push da imagem - docker buildx build --platform linux/arm64 --tag "registry.redecarneir.us/bcards:$Tag" --tag "registry.redecarneir.us/bcards:latest" --push --no-cache . - - if ($LASTEXITCODE -ne 0) { - throw "Build failed" - } - - Write-Host "✅ Image built and pushed successfully!" -ForegroundColor Green - - # 2. Deploy no servidor 1 - Write-Host "🚀 Deploying to server 1 (141.148.162.114)..." -ForegroundColor Yellow - - # Usar script inline mais simples - $deployScript1 = @' -echo "🔄 Updating server 1..." -docker stop bcards-prod || true -docker rm bcards-prod || true -docker rmi registry.redecarneir.us/bcards:latest || true -docker pull registry.redecarneir.us/bcards:latest -docker run -d --name bcards-prod --restart unless-stopped --network host -e ASPNETCORE_ENVIRONMENT=Production -e ASPNETCORE_URLS=http://+:8080 -e "MongoDb__ConnectionString=mongodb://admin:c4rn31r0@129.146.116.218:27017,141.148.162.114:27017/BCardsDB?replicaSet=rs0&authSource=admin" -e MongoDb__DatabaseName=BCardsDB -e Logging__LogLevel__Default=Debug -e Serilog__SeqUrl=http://localhost:5343 registry.redecarneir.us/bcards:latest -echo "✅ Server 1 updated" -'@ - - $deployScript1 | ssh ubuntu@141.148.162.114 'bash -s' - - # 3. Deploy no servidor 2 - Write-Host "🚀 Deploying to server 2 (129.146.116.218)..." -ForegroundColor Yellow - - $deployScript2 = @' -echo "🔄 Updating server 2..." -docker stop bcards-prod || true -docker rm bcards-prod || true -docker rmi registry.redecarneir.us/bcards:latest || true -docker pull registry.redecarneir.us/bcards:latest -docker run -d --name bcards-prod --restart unless-stopped --network host -e ASPNETCORE_ENVIRONMENT=Production -e ASPNETCORE_URLS=http://+:8080 -e "MongoDb__ConnectionString=mongodb://admin:c4rn31r0@129.146.116.218:27017,141.148.162.114:27017/BCardsDB?replicaSet=rs0&authSource=admin" -e MongoDb__DatabaseName=BCardsDB -e Logging__LogLevel__Default=Debug -e Serilog__SeqUrl=http://localhost:5342 registry.redecarneir.us/bcards:latest -echo "✅ Server 2 updated" -'@ - - $deployScript2 | ssh ubuntu@129.146.116.218 'bash -s' - - # 4. Health check - Write-Host "🏥 Running health checks..." -ForegroundColor Yellow - Start-Sleep 30 - - Write-Host "Testing server 1..." -ForegroundColor Cyan - ssh ubuntu@141.148.162.114 'curl -f http://localhost:8080/health || echo "Server 1 health check failed"' - - Write-Host "Testing server 2..." -ForegroundColor Cyan - ssh ubuntu@129.146.116.218 'curl -f http://localhost:8080/health || echo "Server 2 health check failed"' - - Write-Host "✅ Manual deploy completed successfully!" -ForegroundColor Green - Write-Host "🌐 Test your CSS changes now!" -ForegroundColor Magenta - -} catch { - Write-Host "❌ Deploy failed: $_" -ForegroundColor Red - exit 1 +# Deploy manual corrigido (sem problemas de quebra de linha) +param( + [string]$Tag = "latest-manual" +) + +Write-Host "🏗️ Building and deploying BCards..." -ForegroundColor Green + +try { + # 1. Build da imagem + Write-Host "📦 Building Docker image..." -ForegroundColor Yellow + + # Build e push da imagem + docker buildx build --platform linux/arm64 --tag "registry.redecarneir.us/bcards:$Tag" --tag "registry.redecarneir.us/bcards:latest" --push --no-cache . + + if ($LASTEXITCODE -ne 0) { + throw "Build failed" + } + + Write-Host "✅ Image built and pushed successfully!" -ForegroundColor Green + + # 2. Deploy no servidor 1 + Write-Host "🚀 Deploying to server 1 (141.148.162.114)..." -ForegroundColor Yellow + + # Usar script inline mais simples + $deployScript1 = @' +echo "🔄 Updating server 1..." +docker stop bcards-prod || true +docker rm bcards-prod || true +docker rmi registry.redecarneir.us/bcards:latest || true +docker pull registry.redecarneir.us/bcards:latest +docker run -d --name bcards-prod --restart unless-stopped --network host -e ASPNETCORE_ENVIRONMENT=Production -e ASPNETCORE_URLS=http://+:8080 -e "MongoDb__ConnectionString=mongodb://admin:c4rn31r0@129.146.116.218:27017,141.148.162.114:27017/BCardsDB?replicaSet=rs0&authSource=admin" -e MongoDb__DatabaseName=BCardsDB -e Logging__LogLevel__Default=Debug -e Serilog__SeqUrl=http://localhost:5343 registry.redecarneir.us/bcards:latest +echo "✅ Server 1 updated" +'@ + + $deployScript1 | ssh ubuntu@141.148.162.114 'bash -s' + + # 3. Deploy no servidor 2 + Write-Host "🚀 Deploying to server 2 (129.146.116.218)..." -ForegroundColor Yellow + + $deployScript2 = @' +echo "🔄 Updating server 2..." +docker stop bcards-prod || true +docker rm bcards-prod || true +docker rmi registry.redecarneir.us/bcards:latest || true +docker pull registry.redecarneir.us/bcards:latest +docker run -d --name bcards-prod --restart unless-stopped --network host -e ASPNETCORE_ENVIRONMENT=Production -e ASPNETCORE_URLS=http://+:8080 -e "MongoDb__ConnectionString=mongodb://admin:c4rn31r0@129.146.116.218:27017,141.148.162.114:27017/BCardsDB?replicaSet=rs0&authSource=admin" -e MongoDb__DatabaseName=BCardsDB -e Logging__LogLevel__Default=Debug -e Serilog__SeqUrl=http://localhost:5342 registry.redecarneir.us/bcards:latest +echo "✅ Server 2 updated" +'@ + + $deployScript2 | ssh ubuntu@129.146.116.218 'bash -s' + + # 4. Health check + Write-Host "🏥 Running health checks..." -ForegroundColor Yellow + Start-Sleep 30 + + Write-Host "Testing server 1..." -ForegroundColor Cyan + ssh ubuntu@141.148.162.114 'curl -f http://localhost:8080/health || echo "Server 1 health check failed"' + + Write-Host "Testing server 2..." -ForegroundColor Cyan + ssh ubuntu@129.146.116.218 'curl -f http://localhost:8080/health || echo "Server 2 health check failed"' + + Write-Host "✅ Manual deploy completed successfully!" -ForegroundColor Green + Write-Host "🌐 Test your CSS changes now!" -ForegroundColor Magenta + +} catch { + Write-Host "❌ Deploy failed: $_" -ForegroundColor Red + exit 1 } \ No newline at end of file diff --git a/docker-compose.staging.yml b/docker-compose.staging.yml index e3e7d07..f341ccc 100644 --- a/docker-compose.staging.yml +++ b/docker-compose.staging.yml @@ -1,160 +1,160 @@ -version: '3.8' - -services: - bcards-web: - image: ${REGISTRY:-registry.redecarneir.us}/bcards:${IMAGE_TAG:-release-latest} - container_name: bcards-staging - restart: unless-stopped - ports: - - "8090:8080" - - "8453:8443" - environment: - # Core ASP.NET Configuration - - ASPNETCORE_ENVIRONMENT=Release - - ASPNETCORE_URLS=http://+:8080 - - ASPNETCORE_FORWARDEDHEADERS_ENABLED=true - - # MongoDB Configuration - - MongoDb__ConnectionString=${MONGODB_CONNECTION_STRING:-mongodb://192.168.0.100:27017/BCardsDB} - - MongoDb__DatabaseName=BCardsDB - - # Application Settings - - AppSettings__Environment=Staging - - AppSettings__Version=${IMAGE_TAG:-unknown} - - AppSettings__AllowedHosts=* - - # Logging Configuration - - Logging__LogLevel__Default=Information - - Logging__LogLevel__Microsoft.AspNetCore=Warning - - Logging__LogLevel__BCards=Debug - - # Performance Optimizations - - DOTNET_RUNNING_IN_CONTAINER=true - - DOTNET_EnableDiagnostics=0 - - DOTNET_USE_POLLING_FILE_WATCHER=true - - DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=false - - DOTNET_TieredPGO=1 - - DOTNET_TC_QuickJitForLoops=1 - - # Security Headers - - ASPNETCORE_HTTPS_PORT=8443 - - ASPNETCORE_Kestrel__Certificates__Default__Path=/app/certs/cert.pfx - - ASPNETCORE_Kestrel__Certificates__Default__Password=${CERT_PASSWORD:-} - - # Redis Configuration (if needed) - - Redis__ConnectionString=localhost:6379 - - volumes: - # Application logs - - ./logs:/app/logs:rw - - # File uploads (if needed) - - ./uploads:/app/uploads:rw - - # SSL certificates (if using HTTPS) - # - ./certs:/app/certs:ro - - networks: - - bcards-staging-network - - # Health check configuration - healthcheck: - test: ["CMD", "curl", "-f", "http://localhost:8080/health"] - interval: 30s - timeout: 10s - retries: 3 - start_period: 60s - - # Resource limits for staging environment - deploy: - resources: - limits: - memory: 1G - cpus: '1.0' - reservations: - memory: 512M - cpus: '0.5' - - # Logging configuration - logging: - driver: "json-file" - options: - max-size: "100m" - max-file: "5" - - # Platform specification (will use the appropriate arch from multi-arch image) - # platform: linux/amd64 # Uncomment if forcing specific architecture - - # Security options - security_opt: - - no-new-privileges:true - read_only: false # Set to true for extra security, but may need volume mounts for temp files - - # Process limits - ulimits: - nproc: 65535 - nofile: - soft: 65535 - hard: 65535 - - # Optional: Redis for caching (if application uses it) - redis: - image: redis:7-alpine - container_name: bcards-redis-staging - restart: unless-stopped - ports: - - "6379:6379" - command: redis-server --appendonly yes --maxmemory 256mb --maxmemory-policy allkeys-lru - volumes: - - redis_staging_data:/data - networks: - - bcards-staging-network - deploy: - resources: - limits: - memory: 256M - cpus: '0.5' - logging: - driver: "json-file" - options: - max-size: "50m" - max-file: "3" - - # Optional: Nginx reverse proxy for additional features - nginx: - image: nginx:alpine - container_name: bcards-nginx-staging - restart: unless-stopped - ports: - - "8091:80" - - "8454:443" - volumes: - - ./nginx/staging.conf:/etc/nginx/conf.d/default.conf:ro - - ./nginx/ssl:/etc/ssl/certs:ro - - ./logs/nginx:/var/log/nginx:rw - depends_on: - - bcards-web - networks: - - bcards-staging-network - deploy: - resources: - limits: - memory: 128M - cpus: '0.25' - -# Named volumes for persistent data -volumes: - redis_staging_data: - driver: local - driver_opts: - type: none - o: bind - device: ./data/redis - -# Network for staging environment -networks: - bcards-staging-network: - driver: bridge - ipam: - config: +version: '3.8' + +services: + bcards-web: + image: ${REGISTRY:-registry.redecarneir.us}/bcards:${IMAGE_TAG:-release-latest} + container_name: bcards-staging + restart: unless-stopped + ports: + - "8090:8080" + - "8453:8443" + environment: + # Core ASP.NET Configuration + - ASPNETCORE_ENVIRONMENT=Release + - ASPNETCORE_URLS=http://+:8080 + - ASPNETCORE_FORWARDEDHEADERS_ENABLED=true + + # MongoDB Configuration + - MongoDb__ConnectionString=${MONGODB_CONNECTION_STRING:-mongodb://192.168.0.100:27017/BCardsDB} + - MongoDb__DatabaseName=BCardsDB + + # Application Settings + - AppSettings__Environment=Staging + - AppSettings__Version=${IMAGE_TAG:-unknown} + - AppSettings__AllowedHosts=* + + # Logging Configuration + - Logging__LogLevel__Default=Information + - Logging__LogLevel__Microsoft.AspNetCore=Warning + - Logging__LogLevel__BCards=Debug + + # Performance Optimizations + - DOTNET_RUNNING_IN_CONTAINER=true + - DOTNET_EnableDiagnostics=0 + - DOTNET_USE_POLLING_FILE_WATCHER=true + - DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=false + - DOTNET_TieredPGO=1 + - DOTNET_TC_QuickJitForLoops=1 + + # Security Headers + - ASPNETCORE_HTTPS_PORT=8443 + - ASPNETCORE_Kestrel__Certificates__Default__Path=/app/certs/cert.pfx + - ASPNETCORE_Kestrel__Certificates__Default__Password=${CERT_PASSWORD:-} + + # Redis Configuration (if needed) + - Redis__ConnectionString=localhost:6379 + + volumes: + # Application logs + - ./logs:/app/logs:rw + + # File uploads (if needed) + - ./uploads:/app/uploads:rw + + # SSL certificates (if using HTTPS) + # - ./certs:/app/certs:ro + + networks: + - bcards-staging-network + + # Health check configuration + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8080/health"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 60s + + # Resource limits for staging environment + deploy: + resources: + limits: + memory: 1G + cpus: '1.0' + reservations: + memory: 512M + cpus: '0.5' + + # Logging configuration + logging: + driver: "json-file" + options: + max-size: "100m" + max-file: "5" + + # Platform specification (will use the appropriate arch from multi-arch image) + # platform: linux/amd64 # Uncomment if forcing specific architecture + + # Security options + security_opt: + - no-new-privileges:true + read_only: false # Set to true for extra security, but may need volume mounts for temp files + + # Process limits + ulimits: + nproc: 65535 + nofile: + soft: 65535 + hard: 65535 + + # Optional: Redis for caching (if application uses it) + redis: + image: redis:7-alpine + container_name: bcards-redis-staging + restart: unless-stopped + ports: + - "6379:6379" + command: redis-server --appendonly yes --maxmemory 256mb --maxmemory-policy allkeys-lru + volumes: + - redis_staging_data:/data + networks: + - bcards-staging-network + deploy: + resources: + limits: + memory: 256M + cpus: '0.5' + logging: + driver: "json-file" + options: + max-size: "50m" + max-file: "3" + + # Optional: Nginx reverse proxy for additional features + nginx: + image: nginx:alpine + container_name: bcards-nginx-staging + restart: unless-stopped + ports: + - "8091:80" + - "8454:443" + volumes: + - ./nginx/staging.conf:/etc/nginx/conf.d/default.conf:ro + - ./nginx/ssl:/etc/ssl/certs:ro + - ./logs/nginx:/var/log/nginx:rw + depends_on: + - bcards-web + networks: + - bcards-staging-network + deploy: + resources: + limits: + memory: 128M + cpus: '0.25' + +# Named volumes for persistent data +volumes: + redis_staging_data: + driver: local + driver_opts: + type: none + o: bind + device: ./data/redis + +# Network for staging environment +networks: + bcards-staging-network: + driver: bridge + ipam: + config: - subnet: 172.20.0.0/16 \ No newline at end of file diff --git a/scripts/deploy-release.sh b/scripts/deploy-release.sh index b0b052e..b11206e 100644 --- a/scripts/deploy-release.sh +++ b/scripts/deploy-release.sh @@ -1,369 +1,369 @@ -#!/bin/bash - -# Deploy script for Release environment with multi-architecture support -# Usage: ./deploy-release.sh - -set -euo pipefail - -# Configuration -readonly SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -readonly PROJECT_ROOT="$(dirname "$SCRIPT_DIR")" -readonly DEPLOY_DIR="/opt/bcards-staging" -readonly DOCKER_COMPOSE_FILE="docker-compose.staging.yml" -readonly CONTAINER_NAME="bcards-staging" -readonly HEALTH_CHECK_URL="http://localhost:8090/health" -readonly MAX_HEALTH_CHECK_ATTEMPTS=10 -readonly HEALTH_CHECK_INTERVAL=10 -readonly ROLLBACK_TIMEOUT=300 - -# Colors for output -readonly RED='\033[0;31m' -readonly GREEN='\033[0;32m' -readonly YELLOW='\033[1;33m' -readonly BLUE='\033[0;34m' -readonly NC='\033[0m' # No Color - -# Logging functions -log_info() { - echo -e "${BLUE}[INFO]${NC} $1" -} - -log_success() { - echo -e "${GREEN}[SUCCESS]${NC} $1" -} - -log_warning() { - echo -e "${YELLOW}[WARNING]${NC} $1" -} - -log_error() { - echo -e "${RED}[ERROR]${NC} $1" -} - -# Cleanup function -cleanup() { - local exit_code=$? - if [ $exit_code -ne 0 ]; then - log_error "Deployment failed with exit code $exit_code" - rollback_deployment - fi - exit $exit_code -} - -# Set trap for cleanup on exit -trap cleanup EXIT - -# Validate input parameters -validate_input() { - if [ $# -ne 1 ]; then - log_error "Usage: $0 " - exit 1 - fi - - local image_tag="$1" - if [[ ! "$image_tag" =~ ^[a-zA-Z0-9._-]+$ ]]; then - log_error "Invalid image tag format: $image_tag" - exit 1 - fi -} - -# Check prerequisites -check_prerequisites() { - log_info "Checking prerequisites..." - - # Check if Docker is running - if ! docker info >/dev/null 2>&1; then - log_error "Docker is not running or not accessible" - exit 1 - fi - - # Check if docker-compose is available - if ! command -v docker-compose >/dev/null 2>&1; then - log_error "docker-compose is not installed" - exit 1 - fi - - # Check if deployment directory exists - if [ ! -d "$DEPLOY_DIR" ]; then - log_info "Creating deployment directory: $DEPLOY_DIR" - mkdir -p "$DEPLOY_DIR" - fi - - log_success "Prerequisites check passed" -} - -# Backup current deployment -backup_current_deployment() { - log_info "Backing up current deployment..." - - local timestamp=$(date +%Y%m%d_%H%M%S) - local backup_dir="$DEPLOY_DIR/backups/$timestamp" - - mkdir -p "$backup_dir" - - # Backup environment file if exists - if [ -f "$DEPLOY_DIR/.env" ]; then - cp "$DEPLOY_DIR/.env" "$backup_dir/.env.backup" - cp "$DEPLOY_DIR/.env" "$DEPLOY_DIR/.env.backup" - log_info "Environment file backed up" - fi - - # Backup docker-compose file if exists - if [ -f "$DEPLOY_DIR/$DOCKER_COMPOSE_FILE" ]; then - cp "$DEPLOY_DIR/$DOCKER_COMPOSE_FILE" "$backup_dir/${DOCKER_COMPOSE_FILE}.backup" - log_info "Docker compose file backed up" - fi - - # Get current container image for potential rollback - if docker ps --format "table {{.Names}}\t{{.Image}}" | grep -q "$CONTAINER_NAME"; then - local current_image=$(docker inspect --format='{{.Config.Image}}' "$CONTAINER_NAME" 2>/dev/null || echo "") - if [ -n "$current_image" ]; then - echo "$current_image" > "$DEPLOY_DIR/.previous_image" - log_info "Current image backed up: $current_image" - fi - fi - - log_success "Backup completed: $backup_dir" -} - -# Test MongoDB connectivity -test_mongodb_connection() { - log_info "Testing MongoDB connectivity..." - - local mongodb_host="192.168.0.100" - local mongodb_port="27017" - - # Test basic connectivity - if timeout 10 bash -c "/dev/null; then - log_success "MongoDB connection test passed" - else - log_error "Cannot connect to MongoDB at $mongodb_host:$mongodb_port" - return 1 - fi - - # Run detailed MongoDB test script if available - if [ -f "$SCRIPT_DIR/test-mongodb-connection.sh" ]; then - log_info "Running detailed MongoDB connection tests..." - bash "$SCRIPT_DIR/test-mongodb-connection.sh" - fi -} - -# Pull new Docker image -pull_docker_image() { - local image_tag="$1" - local full_image="registry.redecarneir.us/bcards:$image_tag" - - log_info "Pulling Docker image: $full_image" - - # Pull the multi-arch image - if docker pull "$full_image"; then - log_success "Image pulled successfully" - else - log_error "Failed to pull image: $full_image" - return 1 - fi - - # Verify image architecture - local image_arch=$(docker inspect --format='{{.Architecture}}' "$full_image" 2>/dev/null || echo "unknown") - local system_arch=$(uname -m) - - log_info "Image architecture: $image_arch" - log_info "System architecture: $system_arch" - - # Convert system arch format to Docker format for comparison - case "$system_arch" in - x86_64) system_arch="amd64" ;; - aarch64) system_arch="arm64" ;; - esac - - if [ "$image_arch" = "$system_arch" ] || [ "$image_arch" = "unknown" ]; then - log_success "Image architecture is compatible" - else - log_warning "Image architecture ($image_arch) may not match system ($system_arch), but multi-arch support should handle this" - fi -} - -# Deploy new version -deploy_new_version() { - local image_tag="$1" - - log_info "Deploying new version with tag: $image_tag" - - # Copy docker-compose file to deployment directory - cp "$PROJECT_ROOT/$DOCKER_COMPOSE_FILE" "$DEPLOY_DIR/" - - # Create/update environment file - cat > "$DEPLOY_DIR/.env" << EOF -IMAGE_TAG=$image_tag -REGISTRY=registry.redecarneir.us -MONGODB_CONNECTION_STRING=mongodb://192.168.0.100:27017/BCardsDB -ASPNETCORE_ENVIRONMENT=Release -CERT_PASSWORD= -EOF - - # Stop existing containers - cd "$DEPLOY_DIR" - if docker-compose -f "$DOCKER_COMPOSE_FILE" ps -q | grep -q .; then - log_info "Stopping existing containers..." - docker-compose -f "$DOCKER_COMPOSE_FILE" down --remove-orphans - fi - - # Start new containers - log_info "Starting new containers..." - docker-compose -f "$DOCKER_COMPOSE_FILE" up -d - - # Wait for containers to start - sleep 15 - - log_success "New version deployed" -} - -# Health check -perform_health_check() { - log_info "Performing health check..." - - local attempt=1 - while [ $attempt -le $MAX_HEALTH_CHECK_ATTEMPTS ]; do - log_info "Health check attempt $attempt/$MAX_HEALTH_CHECK_ATTEMPTS" - - # Check if container is running - if ! docker ps --format "table {{.Names}}" | grep -q "$CONTAINER_NAME"; then - log_warning "Container $CONTAINER_NAME is not running" - else - # Check application health endpoint - if curl -f -s "$HEALTH_CHECK_URL" >/dev/null 2>&1; then - log_success "Health check passed" - return 0 - fi - - # Check if the application is responding on port 80 - if curl -f -s "http://localhost:8090/" >/dev/null 2>&1; then - log_success "Application is responding (health endpoint may not be configured)" - return 0 - fi - fi - - if [ $attempt -eq $MAX_HEALTH_CHECK_ATTEMPTS ]; then - log_error "Health check failed after $MAX_HEALTH_CHECK_ATTEMPTS attempts" - return 1 - fi - - log_info "Waiting $HEALTH_CHECK_INTERVAL seconds before next attempt..." - sleep $HEALTH_CHECK_INTERVAL - ((attempt++)) - done -} - -# Rollback deployment -rollback_deployment() { - log_warning "Initiating rollback..." - - cd "$DEPLOY_DIR" - - # Stop current containers - if [ -f "$DOCKER_COMPOSE_FILE" ]; then - docker-compose -f "$DOCKER_COMPOSE_FILE" down --remove-orphans - fi - - # Restore previous environment if backup exists - if [ -f ".env.backup" ]; then - mv ".env.backup" ".env" - log_info "Previous environment restored" - fi - - # Try to start previous version if image is available - if [ -f ".previous_image" ]; then - local previous_image=$(cat ".previous_image") - log_info "Attempting to restore previous image: $previous_image" - - # Update .env with previous image tag - local previous_tag=$(echo "$previous_image" | cut -d':' -f2) - sed -i "s/IMAGE_TAG=.*/IMAGE_TAG=$previous_tag/" .env 2>/dev/null || true - - # Try to start previous version - if docker-compose -f "$DOCKER_COMPOSE_FILE" up -d; then - log_success "Rollback completed successfully" - else - log_error "Rollback failed - manual intervention required" - fi - else - log_warning "No previous version found for rollback" - fi -} - -# Cleanup old images and containers -cleanup_old_resources() { - log_info "Cleaning up old Docker resources..." - - # Remove dangling images - if docker images -f "dangling=true" -q | head -1 | grep -q .; then - docker rmi $(docker images -f "dangling=true" -q) || true - log_info "Dangling images removed" - fi - - # Remove old backups (keep last 5) - if [ -d "$DEPLOY_DIR/backups" ]; then - find "$DEPLOY_DIR/backups" -maxdepth 1 -type d -name "20*" | sort -r | tail -n +6 | xargs rm -rf || true - log_info "Old backups cleaned up" - fi - - log_success "Cleanup completed" -} - -# Display deployment summary -display_summary() { - local image_tag="$1" - - log_success "Deployment Summary:" - echo "==================================" - echo "🚀 Image Tag: $image_tag" - echo "🌐 Environment: Release (Staging)" - echo "🔗 Application URL: http://localhost:8090" - echo "🔗 Health Check: $HEALTH_CHECK_URL" - echo "🗄️ MongoDB: 192.168.0.100:27017" - echo "📁 Deploy Directory: $DEPLOY_DIR" - echo "🐳 Container: $CONTAINER_NAME" - - # Show container status - echo "" - echo "Container Status:" - docker ps --format "table {{.Names}}\t{{.Status}}\t{{.Ports}}" | grep -E "(NAMES|$CONTAINER_NAME)" || true - - # Show image info - echo "" - echo "Image Information:" - docker images --format "table {{.Repository}}\t{{.Tag}}\t{{.CreatedAt}}\t{{.Size}}" | grep -E "(REPOSITORY|bcards)" | head -5 || true - - echo "==================================" -} - -# Main deployment function -main() { - local image_tag="$1" - - log_info "Starting deployment process for BCards Release environment" - log_info "Target image tag: $image_tag" - log_info "Target architecture: $(uname -m)" - log_info "Deploy directory: $DEPLOY_DIR" - - # Execute deployment steps - validate_input "$@" - check_prerequisites - test_mongodb_connection - backup_current_deployment - pull_docker_image "$image_tag" - deploy_new_version "$image_tag" - - # Perform health check (rollback handled by trap if this fails) - if perform_health_check; then - cleanup_old_resources - display_summary "$image_tag" - log_success "Deployment completed successfully!" - else - log_error "Health check failed - rollback will be triggered" - exit 1 - fi -} - -# Run main function with all arguments +#!/bin/bash + +# Deploy script for Release environment with multi-architecture support +# Usage: ./deploy-release.sh + +set -euo pipefail + +# Configuration +readonly SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +readonly PROJECT_ROOT="$(dirname "$SCRIPT_DIR")" +readonly DEPLOY_DIR="/opt/bcards-staging" +readonly DOCKER_COMPOSE_FILE="docker-compose.staging.yml" +readonly CONTAINER_NAME="bcards-staging" +readonly HEALTH_CHECK_URL="http://localhost:8090/health" +readonly MAX_HEALTH_CHECK_ATTEMPTS=10 +readonly HEALTH_CHECK_INTERVAL=10 +readonly ROLLBACK_TIMEOUT=300 + +# Colors for output +readonly RED='\033[0;31m' +readonly GREEN='\033[0;32m' +readonly YELLOW='\033[1;33m' +readonly BLUE='\033[0;34m' +readonly NC='\033[0m' # No Color + +# Logging functions +log_info() { + echo -e "${BLUE}[INFO]${NC} $1" +} + +log_success() { + echo -e "${GREEN}[SUCCESS]${NC} $1" +} + +log_warning() { + echo -e "${YELLOW}[WARNING]${NC} $1" +} + +log_error() { + echo -e "${RED}[ERROR]${NC} $1" +} + +# Cleanup function +cleanup() { + local exit_code=$? + if [ $exit_code -ne 0 ]; then + log_error "Deployment failed with exit code $exit_code" + rollback_deployment + fi + exit $exit_code +} + +# Set trap for cleanup on exit +trap cleanup EXIT + +# Validate input parameters +validate_input() { + if [ $# -ne 1 ]; then + log_error "Usage: $0 " + exit 1 + fi + + local image_tag="$1" + if [[ ! "$image_tag" =~ ^[a-zA-Z0-9._-]+$ ]]; then + log_error "Invalid image tag format: $image_tag" + exit 1 + fi +} + +# Check prerequisites +check_prerequisites() { + log_info "Checking prerequisites..." + + # Check if Docker is running + if ! docker info >/dev/null 2>&1; then + log_error "Docker is not running or not accessible" + exit 1 + fi + + # Check if docker-compose is available + if ! command -v docker-compose >/dev/null 2>&1; then + log_error "docker-compose is not installed" + exit 1 + fi + + # Check if deployment directory exists + if [ ! -d "$DEPLOY_DIR" ]; then + log_info "Creating deployment directory: $DEPLOY_DIR" + mkdir -p "$DEPLOY_DIR" + fi + + log_success "Prerequisites check passed" +} + +# Backup current deployment +backup_current_deployment() { + log_info "Backing up current deployment..." + + local timestamp=$(date +%Y%m%d_%H%M%S) + local backup_dir="$DEPLOY_DIR/backups/$timestamp" + + mkdir -p "$backup_dir" + + # Backup environment file if exists + if [ -f "$DEPLOY_DIR/.env" ]; then + cp "$DEPLOY_DIR/.env" "$backup_dir/.env.backup" + cp "$DEPLOY_DIR/.env" "$DEPLOY_DIR/.env.backup" + log_info "Environment file backed up" + fi + + # Backup docker-compose file if exists + if [ -f "$DEPLOY_DIR/$DOCKER_COMPOSE_FILE" ]; then + cp "$DEPLOY_DIR/$DOCKER_COMPOSE_FILE" "$backup_dir/${DOCKER_COMPOSE_FILE}.backup" + log_info "Docker compose file backed up" + fi + + # Get current container image for potential rollback + if docker ps --format "table {{.Names}}\t{{.Image}}" | grep -q "$CONTAINER_NAME"; then + local current_image=$(docker inspect --format='{{.Config.Image}}' "$CONTAINER_NAME" 2>/dev/null || echo "") + if [ -n "$current_image" ]; then + echo "$current_image" > "$DEPLOY_DIR/.previous_image" + log_info "Current image backed up: $current_image" + fi + fi + + log_success "Backup completed: $backup_dir" +} + +# Test MongoDB connectivity +test_mongodb_connection() { + log_info "Testing MongoDB connectivity..." + + local mongodb_host="192.168.0.100" + local mongodb_port="27017" + + # Test basic connectivity + if timeout 10 bash -c "/dev/null; then + log_success "MongoDB connection test passed" + else + log_error "Cannot connect to MongoDB at $mongodb_host:$mongodb_port" + return 1 + fi + + # Run detailed MongoDB test script if available + if [ -f "$SCRIPT_DIR/test-mongodb-connection.sh" ]; then + log_info "Running detailed MongoDB connection tests..." + bash "$SCRIPT_DIR/test-mongodb-connection.sh" + fi +} + +# Pull new Docker image +pull_docker_image() { + local image_tag="$1" + local full_image="registry.redecarneir.us/bcards:$image_tag" + + log_info "Pulling Docker image: $full_image" + + # Pull the multi-arch image + if docker pull "$full_image"; then + log_success "Image pulled successfully" + else + log_error "Failed to pull image: $full_image" + return 1 + fi + + # Verify image architecture + local image_arch=$(docker inspect --format='{{.Architecture}}' "$full_image" 2>/dev/null || echo "unknown") + local system_arch=$(uname -m) + + log_info "Image architecture: $image_arch" + log_info "System architecture: $system_arch" + + # Convert system arch format to Docker format for comparison + case "$system_arch" in + x86_64) system_arch="amd64" ;; + aarch64) system_arch="arm64" ;; + esac + + if [ "$image_arch" = "$system_arch" ] || [ "$image_arch" = "unknown" ]; then + log_success "Image architecture is compatible" + else + log_warning "Image architecture ($image_arch) may not match system ($system_arch), but multi-arch support should handle this" + fi +} + +# Deploy new version +deploy_new_version() { + local image_tag="$1" + + log_info "Deploying new version with tag: $image_tag" + + # Copy docker-compose file to deployment directory + cp "$PROJECT_ROOT/$DOCKER_COMPOSE_FILE" "$DEPLOY_DIR/" + + # Create/update environment file + cat > "$DEPLOY_DIR/.env" << EOF +IMAGE_TAG=$image_tag +REGISTRY=registry.redecarneir.us +MONGODB_CONNECTION_STRING=mongodb://192.168.0.100:27017/BCardsDB +ASPNETCORE_ENVIRONMENT=Release +CERT_PASSWORD= +EOF + + # Stop existing containers + cd "$DEPLOY_DIR" + if docker-compose -f "$DOCKER_COMPOSE_FILE" ps -q | grep -q .; then + log_info "Stopping existing containers..." + docker-compose -f "$DOCKER_COMPOSE_FILE" down --remove-orphans + fi + + # Start new containers + log_info "Starting new containers..." + docker-compose -f "$DOCKER_COMPOSE_FILE" up -d + + # Wait for containers to start + sleep 15 + + log_success "New version deployed" +} + +# Health check +perform_health_check() { + log_info "Performing health check..." + + local attempt=1 + while [ $attempt -le $MAX_HEALTH_CHECK_ATTEMPTS ]; do + log_info "Health check attempt $attempt/$MAX_HEALTH_CHECK_ATTEMPTS" + + # Check if container is running + if ! docker ps --format "table {{.Names}}" | grep -q "$CONTAINER_NAME"; then + log_warning "Container $CONTAINER_NAME is not running" + else + # Check application health endpoint + if curl -f -s "$HEALTH_CHECK_URL" >/dev/null 2>&1; then + log_success "Health check passed" + return 0 + fi + + # Check if the application is responding on port 80 + if curl -f -s "http://localhost:8090/" >/dev/null 2>&1; then + log_success "Application is responding (health endpoint may not be configured)" + return 0 + fi + fi + + if [ $attempt -eq $MAX_HEALTH_CHECK_ATTEMPTS ]; then + log_error "Health check failed after $MAX_HEALTH_CHECK_ATTEMPTS attempts" + return 1 + fi + + log_info "Waiting $HEALTH_CHECK_INTERVAL seconds before next attempt..." + sleep $HEALTH_CHECK_INTERVAL + ((attempt++)) + done +} + +# Rollback deployment +rollback_deployment() { + log_warning "Initiating rollback..." + + cd "$DEPLOY_DIR" + + # Stop current containers + if [ -f "$DOCKER_COMPOSE_FILE" ]; then + docker-compose -f "$DOCKER_COMPOSE_FILE" down --remove-orphans + fi + + # Restore previous environment if backup exists + if [ -f ".env.backup" ]; then + mv ".env.backup" ".env" + log_info "Previous environment restored" + fi + + # Try to start previous version if image is available + if [ -f ".previous_image" ]; then + local previous_image=$(cat ".previous_image") + log_info "Attempting to restore previous image: $previous_image" + + # Update .env with previous image tag + local previous_tag=$(echo "$previous_image" | cut -d':' -f2) + sed -i "s/IMAGE_TAG=.*/IMAGE_TAG=$previous_tag/" .env 2>/dev/null || true + + # Try to start previous version + if docker-compose -f "$DOCKER_COMPOSE_FILE" up -d; then + log_success "Rollback completed successfully" + else + log_error "Rollback failed - manual intervention required" + fi + else + log_warning "No previous version found for rollback" + fi +} + +# Cleanup old images and containers +cleanup_old_resources() { + log_info "Cleaning up old Docker resources..." + + # Remove dangling images + if docker images -f "dangling=true" -q | head -1 | grep -q .; then + docker rmi $(docker images -f "dangling=true" -q) || true + log_info "Dangling images removed" + fi + + # Remove old backups (keep last 5) + if [ -d "$DEPLOY_DIR/backups" ]; then + find "$DEPLOY_DIR/backups" -maxdepth 1 -type d -name "20*" | sort -r | tail -n +6 | xargs rm -rf || true + log_info "Old backups cleaned up" + fi + + log_success "Cleanup completed" +} + +# Display deployment summary +display_summary() { + local image_tag="$1" + + log_success "Deployment Summary:" + echo "==================================" + echo "🚀 Image Tag: $image_tag" + echo "🌐 Environment: Release (Staging)" + echo "🔗 Application URL: http://localhost:8090" + echo "🔗 Health Check: $HEALTH_CHECK_URL" + echo "🗄️ MongoDB: 192.168.0.100:27017" + echo "📁 Deploy Directory: $DEPLOY_DIR" + echo "🐳 Container: $CONTAINER_NAME" + + # Show container status + echo "" + echo "Container Status:" + docker ps --format "table {{.Names}}\t{{.Status}}\t{{.Ports}}" | grep -E "(NAMES|$CONTAINER_NAME)" || true + + # Show image info + echo "" + echo "Image Information:" + docker images --format "table {{.Repository}}\t{{.Tag}}\t{{.CreatedAt}}\t{{.Size}}" | grep -E "(REPOSITORY|bcards)" | head -5 || true + + echo "==================================" +} + +# Main deployment function +main() { + local image_tag="$1" + + log_info "Starting deployment process for BCards Release environment" + log_info "Target image tag: $image_tag" + log_info "Target architecture: $(uname -m)" + log_info "Deploy directory: $DEPLOY_DIR" + + # Execute deployment steps + validate_input "$@" + check_prerequisites + test_mongodb_connection + backup_current_deployment + pull_docker_image "$image_tag" + deploy_new_version "$image_tag" + + # Perform health check (rollback handled by trap if this fails) + if perform_health_check; then + cleanup_old_resources + display_summary "$image_tag" + log_success "Deployment completed successfully!" + else + log_error "Health check failed - rollback will be triggered" + exit 1 + fi +} + +# Run main function with all arguments main "$@" \ No newline at end of file diff --git a/scripts/test-mongodb-connection.sh b/scripts/test-mongodb-connection.sh index c1a922e..9603ac7 100644 --- a/scripts/test-mongodb-connection.sh +++ b/scripts/test-mongodb-connection.sh @@ -1,495 +1,495 @@ -#!/bin/bash - -# MongoDB Connection Test Script for Release Environment -# Tests connectivity, database operations, and index validation - -set -euo pipefail - -# Configuration -readonly MONGODB_HOST="${MONGODB_HOST:-192.168.0.100}" -readonly MONGODB_PORT="${MONGODB_PORT:-27017}" -readonly DATABASE_NAME="${DATABASE_NAME:-BCardsDB}" -readonly CONNECTION_STRING="mongodb://${MONGODB_HOST}:${MONGODB_PORT}/${DATABASE_NAME}" -readonly TIMEOUT=30 - -# Colors for output -readonly RED='\033[0;31m' -readonly GREEN='\033[0;32m' -readonly YELLOW='\033[1;33m' -readonly BLUE='\033[0;34m' -readonly NC='\033[0m' # No Color - -# Logging functions -log_info() { - echo -e "${BLUE}[INFO]${NC} $1" -} - -log_success() { - echo -e "${GREEN}[SUCCESS]${NC} $1" -} - -log_warning() { - echo -e "${YELLOW}[WARNING]${NC} $1" -} - -log_error() { - echo -e "${RED}[ERROR]${NC} $1" -} - -# Test basic TCP connectivity -test_tcp_connection() { - log_info "Testing TCP connection to $MONGODB_HOST:$MONGODB_PORT..." - - if timeout $TIMEOUT bash -c "/dev/null; then - log_success "TCP connection successful" - return 0 - else - log_error "TCP connection failed" - return 1 - fi -} - -# Test MongoDB connectivity using mongosh if available -test_mongodb_with_mongosh() { - if ! command -v mongosh >/dev/null 2>&1; then - log_warning "mongosh not available, skipping MongoDB shell tests" - return 1 - fi - - log_info "Testing MongoDB connection with mongosh..." - - # Test basic connection - local test_output=$(timeout $TIMEOUT mongosh "$CONNECTION_STRING" --quiet --eval "db.runCommand({ping: 1})" 2>/dev/null || echo "FAILED") - - if [[ "$test_output" == *"ok"* ]]; then - log_success "MongoDB ping successful" - else - log_error "MongoDB ping failed" - return 1 - fi - - # Test database access - log_info "Testing database operations..." - - local db_test=$(timeout $TIMEOUT mongosh "$CONNECTION_STRING" --quiet --eval " - try { - // Test basic database operations - db.connection_test.insertOne({test: true, timestamp: new Date()}); - var result = db.connection_test.findOne({test: true}); - db.connection_test.deleteOne({test: true}); - print('DATABASE_ACCESS_OK'); - } catch (e) { - print('DATABASE_ACCESS_FAILED: ' + e.message); - } - " 2>/dev/null || echo "DATABASE_ACCESS_FAILED") - - if [[ "$db_test" == *"DATABASE_ACCESS_OK"* ]]; then - log_success "Database operations test passed" - else - log_error "Database operations test failed: $db_test" - return 1 - fi - - return 0 -} - -# Test MongoDB connectivity using Python if available -test_mongodb_with_python() { - if ! command -v python3 >/dev/null 2>&1; then - log_warning "Python3 not available, skipping Python MongoDB tests" - return 1 - fi - - log_info "Testing MongoDB connection with Python..." - - python3 << EOF -import sys -try: - import pymongo - from pymongo import MongoClient - import socket - - # Test connection - client = MongoClient("$CONNECTION_STRING", serverSelectionTimeoutMS=$((TIMEOUT * 1000))) - - # Test ping - client.admin.command('ping') - print("MongoDB ping successful (Python)") - - # Test database access - db = client["$DATABASE_NAME"] - - # Insert test document - test_collection = db.connection_test - result = test_collection.insert_one({"test": True, "source": "python"}) - - # Read test document - doc = test_collection.find_one({"_id": result.inserted_id}) - if doc: - print("Database read/write test passed (Python)") - - # Cleanup - test_collection.delete_one({"_id": result.inserted_id}) - - client.close() - print("PYTHON_TEST_SUCCESS") - -except ImportError: - print("PyMongo not installed, skipping Python tests") - sys.exit(1) -except Exception as e: - print(f"Python MongoDB test failed: {e}") - sys.exit(1) -EOF - - local python_result=$? - if [ $python_result -eq 0 ]; then - log_success "Python MongoDB test passed" - return 0 - else - log_error "Python MongoDB test failed" - return 1 - fi -} - -# Test using Docker MongoDB client -test_mongodb_with_docker() { - if ! command -v docker >/dev/null 2>&1; then - log_warning "Docker not available, skipping Docker MongoDB tests" - return 1 - fi - - log_info "Testing MongoDB connection using Docker MongoDB client..." - - # Use official MongoDB image to test connection - local docker_test=$(timeout $TIMEOUT docker run --rm mongo:7.0 mongosh "$CONNECTION_STRING" --quiet --eval " - try { - db.runCommand({ping: 1}); - db.connection_test.insertOne({test: true, source: 'docker', timestamp: new Date()}); - var doc = db.connection_test.findOne({source: 'docker'}); - db.connection_test.deleteOne({source: 'docker'}); - print('DOCKER_TEST_SUCCESS'); - } catch (e) { - print('DOCKER_TEST_FAILED: ' + e.message); - } - " 2>/dev/null || echo "DOCKER_TEST_FAILED") - - if [[ "$docker_test" == *"DOCKER_TEST_SUCCESS"* ]]; then - log_success "Docker MongoDB test passed" - return 0 - else - log_error "Docker MongoDB test failed: $docker_test" - return 1 - fi -} - -# Test MongoDB from application container -test_from_application_container() { - local container_name="bcards-staging" - - if ! docker ps --format "{{.Names}}" | grep -q "^${container_name}$"; then - log_warning "Application container '$container_name' not running, skipping application test" - return 1 - fi - - log_info "Testing MongoDB connection from application container..." - - # Test connection from the application container - local app_test=$(docker exec "$container_name" timeout 10 bash -c " - # Test TCP connection - if timeout 5 bash -c '/dev/null; then - echo 'APP_TCP_OK' - else - echo 'APP_TCP_FAILED' - exit 1 - fi - - # Test HTTP health endpoint if available - if curl -f -s http://localhost:8080/health >/dev/null 2>&1; then - echo 'APP_HEALTH_OK' - else - echo 'APP_HEALTH_FAILED' - fi - " 2>/dev/null || echo "APP_TEST_FAILED") - - if [[ "$app_test" == *"APP_TCP_OK"* ]]; then - log_success "Application container can connect to MongoDB" - - if [[ "$app_test" == *"APP_HEALTH_OK"* ]]; then - log_success "Application health check passed" - else - log_warning "Application health check failed - app may still be starting" - fi - return 0 - else - log_error "Application container cannot connect to MongoDB" - return 1 - fi -} - -# Check MongoDB server status and version -check_mongodb_status() { - log_info "Checking MongoDB server status..." - - # Try multiple methods to check status - local status_checked=false - - # Method 1: Using mongosh - if command -v mongosh >/dev/null 2>&1; then - local server_status=$(timeout $TIMEOUT mongosh "$CONNECTION_STRING" --quiet --eval " - try { - var status = db.runCommand({serverStatus: 1}); - print('MongoDB Version: ' + status.version); - print('Uptime: ' + status.uptime + ' seconds'); - print('Connections: ' + status.connections.current + '/' + status.connections.available); - print('STATUS_CHECK_OK'); - } catch (e) { - print('STATUS_CHECK_FAILED: ' + e.message); - } - " 2>/dev/null || echo "STATUS_CHECK_FAILED") - - if [[ "$server_status" == *"STATUS_CHECK_OK"* ]]; then - echo "$server_status" | grep -v "STATUS_CHECK_OK" - log_success "MongoDB server status check passed" - status_checked=true - fi - fi - - # Method 2: Using Docker if mongosh failed - if [ "$status_checked" = false ] && command -v docker >/dev/null 2>&1; then - local docker_status=$(timeout $TIMEOUT docker run --rm mongo:7.0 mongosh "$CONNECTION_STRING" --quiet --eval " - try { - var status = db.runCommand({serverStatus: 1}); - print('MongoDB Version: ' + status.version); - print('STATUS_CHECK_OK'); - } catch (e) { - print('STATUS_CHECK_FAILED: ' + e.message); - } - " 2>/dev/null || echo "STATUS_CHECK_FAILED") - - if [[ "$docker_status" == *"STATUS_CHECK_OK"* ]]; then - echo "$docker_status" | grep -v "STATUS_CHECK_OK" - log_success "MongoDB server status check passed (via Docker)" - status_checked=true - fi - fi - - if [ "$status_checked" = false ]; then - log_warning "Could not retrieve MongoDB server status" - return 1 - fi - - return 0 -} - -# Test BCards specific collections and indexes -test_bcards_collections() { - if ! command -v mongosh >/dev/null 2>&1 && ! command -v docker >/dev/null 2>&1; then - log_warning "Cannot test BCards collections - no MongoDB client available" - return 1 - fi - - log_info "Testing BCards specific collections and indexes..." - - local mongo_cmd="mongosh" - local docker_prefix="" - - if ! command -v mongosh >/dev/null 2>&1; then - mongo_cmd="docker run --rm mongo:7.0 mongosh" - docker_prefix="timeout $TIMEOUT " - fi - - local collections_test=$(${docker_prefix}${mongo_cmd} "$CONNECTION_STRING" --quiet --eval " - try { - // Check required collections - var collections = db.listCollectionNames(); - var requiredCollections = ['users', 'userpages', 'categories']; - var missingCollections = []; - - requiredCollections.forEach(function(collection) { - if (collections.indexOf(collection) === -1) { - missingCollections.push(collection); - } - }); - - if (missingCollections.length > 0) { - print('Missing collections: ' + missingCollections.join(', ')); - } else { - print('All required collections exist'); - } - - // Check indexes on userpages collection - if (collections.indexOf('userpages') !== -1) { - var indexes = db.userpages.getIndexes(); - print('UserPages collection has ' + indexes.length + ' indexes'); - - // Check for important compound index - var hasCompoundIndex = indexes.some(function(index) { - return index.key && index.key.category && index.key.slug; - }); - - if (hasCompoundIndex) { - print('Required compound index (category, slug) exists'); - } else { - print('WARNING: Compound index (category, slug) is missing'); - } - } - - print('COLLECTIONS_TEST_OK'); - } catch (e) { - print('COLLECTIONS_TEST_FAILED: ' + e.message); - } - " 2>/dev/null || echo "COLLECTIONS_TEST_FAILED") - - if [[ "$collections_test" == *"COLLECTIONS_TEST_OK"* ]]; then - echo "$collections_test" | grep -v "COLLECTIONS_TEST_OK" - log_success "BCards collections test passed" - return 0 - else - log_warning "BCards collections test had issues: $collections_test" - return 1 - fi -} - -# Performance test -test_mongodb_performance() { - log_info "Running basic performance test..." - - if ! command -v mongosh >/dev/null 2>&1; then - log_warning "mongosh not available, skipping performance test" - return 1 - fi - - local perf_test=$(timeout $TIMEOUT mongosh "$CONNECTION_STRING" --quiet --eval " - try { - var start = new Date(); - - // Insert test documents - var docs = []; - for (var i = 0; i < 100; i++) { - docs.push({test: true, index: i, timestamp: new Date()}); - } - db.performance_test.insertMany(docs); - - // Read test - var count = db.performance_test.countDocuments({test: true}); - - // Update test - db.performance_test.updateMany({test: true}, {\$set: {updated: true}}); - - // Delete test - db.performance_test.deleteMany({test: true}); - - var end = new Date(); - var duration = end - start; - - print('Performance test completed in ' + duration + 'ms'); - print('Operations: 100 inserts, 1 count, 100 updates, 100 deletes'); - - if (duration < 5000) { - print('PERFORMANCE_TEST_OK'); - } else { - print('PERFORMANCE_TEST_SLOW'); - } - } catch (e) { - print('PERFORMANCE_TEST_FAILED: ' + e.message); - } - " 2>/dev/null || echo "PERFORMANCE_TEST_FAILED") - - if [[ "$perf_test" == *"PERFORMANCE_TEST_OK"* ]]; then - echo "$perf_test" | grep -v "PERFORMANCE_TEST_OK" - log_success "Performance test passed" - return 0 - elif [[ "$perf_test" == *"PERFORMANCE_TEST_SLOW"* ]]; then - echo "$perf_test" | grep -v "PERFORMANCE_TEST_SLOW" - log_warning "Performance test completed but was slow" - return 0 - else - log_error "Performance test failed: $perf_test" - return 1 - fi -} - -# Display connection summary -display_summary() { - echo "" - log_info "MongoDB Connection Test Summary" - echo "==================================================" - echo "🏠 Host: $MONGODB_HOST:$MONGODB_PORT" - echo "🗄️ Database: $DATABASE_NAME" - echo "🔗 Connection String: $CONNECTION_STRING" - echo "⏱️ Timeout: ${TIMEOUT}s" - echo "📊 Tests completed: $(date)" - echo "==================================================" -} - -# Main test function -main() { - log_info "Starting MongoDB connection tests for Release environment" - - local test_results=() - local overall_success=true - - # Run all tests - if test_tcp_connection; then - test_results+=("✅ TCP Connection") - else - test_results+=("❌ TCP Connection") - overall_success=false - fi - - if test_mongodb_with_mongosh; then - test_results+=("✅ MongoDB Shell") - elif test_mongodb_with_docker; then - test_results+=("✅ MongoDB Docker") - elif test_mongodb_with_python; then - test_results+=("✅ MongoDB Python") - else - test_results+=("❌ MongoDB Client") - overall_success=false - fi - - if test_from_application_container; then - test_results+=("✅ Application Container") - else - test_results+=("⚠️ Application Container") - fi - - if check_mongodb_status; then - test_results+=("✅ Server Status") - else - test_results+=("⚠️ Server Status") - fi - - if test_bcards_collections; then - test_results+=("✅ BCards Collections") - else - test_results+=("⚠️ BCards Collections") - fi - - if test_mongodb_performance; then - test_results+=("✅ Performance Test") - else - test_results+=("⚠️ Performance Test") - fi - - # Display results - display_summary - echo "" - log_info "Test Results:" - for result in "${test_results[@]}"; do - echo " $result" - done - - echo "" - if [ "$overall_success" = true ]; then - log_success "All critical MongoDB tests passed!" - exit 0 - else - log_error "Some critical MongoDB tests failed!" - exit 1 - fi -} - -# Run main function +#!/bin/bash + +# MongoDB Connection Test Script for Release Environment +# Tests connectivity, database operations, and index validation + +set -euo pipefail + +# Configuration +readonly MONGODB_HOST="${MONGODB_HOST:-192.168.0.100}" +readonly MONGODB_PORT="${MONGODB_PORT:-27017}" +readonly DATABASE_NAME="${DATABASE_NAME:-BCardsDB}" +readonly CONNECTION_STRING="mongodb://${MONGODB_HOST}:${MONGODB_PORT}/${DATABASE_NAME}" +readonly TIMEOUT=30 + +# Colors for output +readonly RED='\033[0;31m' +readonly GREEN='\033[0;32m' +readonly YELLOW='\033[1;33m' +readonly BLUE='\033[0;34m' +readonly NC='\033[0m' # No Color + +# Logging functions +log_info() { + echo -e "${BLUE}[INFO]${NC} $1" +} + +log_success() { + echo -e "${GREEN}[SUCCESS]${NC} $1" +} + +log_warning() { + echo -e "${YELLOW}[WARNING]${NC} $1" +} + +log_error() { + echo -e "${RED}[ERROR]${NC} $1" +} + +# Test basic TCP connectivity +test_tcp_connection() { + log_info "Testing TCP connection to $MONGODB_HOST:$MONGODB_PORT..." + + if timeout $TIMEOUT bash -c "/dev/null; then + log_success "TCP connection successful" + return 0 + else + log_error "TCP connection failed" + return 1 + fi +} + +# Test MongoDB connectivity using mongosh if available +test_mongodb_with_mongosh() { + if ! command -v mongosh >/dev/null 2>&1; then + log_warning "mongosh not available, skipping MongoDB shell tests" + return 1 + fi + + log_info "Testing MongoDB connection with mongosh..." + + # Test basic connection + local test_output=$(timeout $TIMEOUT mongosh "$CONNECTION_STRING" --quiet --eval "db.runCommand({ping: 1})" 2>/dev/null || echo "FAILED") + + if [[ "$test_output" == *"ok"* ]]; then + log_success "MongoDB ping successful" + else + log_error "MongoDB ping failed" + return 1 + fi + + # Test database access + log_info "Testing database operations..." + + local db_test=$(timeout $TIMEOUT mongosh "$CONNECTION_STRING" --quiet --eval " + try { + // Test basic database operations + db.connection_test.insertOne({test: true, timestamp: new Date()}); + var result = db.connection_test.findOne({test: true}); + db.connection_test.deleteOne({test: true}); + print('DATABASE_ACCESS_OK'); + } catch (e) { + print('DATABASE_ACCESS_FAILED: ' + e.message); + } + " 2>/dev/null || echo "DATABASE_ACCESS_FAILED") + + if [[ "$db_test" == *"DATABASE_ACCESS_OK"* ]]; then + log_success "Database operations test passed" + else + log_error "Database operations test failed: $db_test" + return 1 + fi + + return 0 +} + +# Test MongoDB connectivity using Python if available +test_mongodb_with_python() { + if ! command -v python3 >/dev/null 2>&1; then + log_warning "Python3 not available, skipping Python MongoDB tests" + return 1 + fi + + log_info "Testing MongoDB connection with Python..." + + python3 << EOF +import sys +try: + import pymongo + from pymongo import MongoClient + import socket + + # Test connection + client = MongoClient("$CONNECTION_STRING", serverSelectionTimeoutMS=$((TIMEOUT * 1000))) + + # Test ping + client.admin.command('ping') + print("MongoDB ping successful (Python)") + + # Test database access + db = client["$DATABASE_NAME"] + + # Insert test document + test_collection = db.connection_test + result = test_collection.insert_one({"test": True, "source": "python"}) + + # Read test document + doc = test_collection.find_one({"_id": result.inserted_id}) + if doc: + print("Database read/write test passed (Python)") + + # Cleanup + test_collection.delete_one({"_id": result.inserted_id}) + + client.close() + print("PYTHON_TEST_SUCCESS") + +except ImportError: + print("PyMongo not installed, skipping Python tests") + sys.exit(1) +except Exception as e: + print(f"Python MongoDB test failed: {e}") + sys.exit(1) +EOF + + local python_result=$? + if [ $python_result -eq 0 ]; then + log_success "Python MongoDB test passed" + return 0 + else + log_error "Python MongoDB test failed" + return 1 + fi +} + +# Test using Docker MongoDB client +test_mongodb_with_docker() { + if ! command -v docker >/dev/null 2>&1; then + log_warning "Docker not available, skipping Docker MongoDB tests" + return 1 + fi + + log_info "Testing MongoDB connection using Docker MongoDB client..." + + # Use official MongoDB image to test connection + local docker_test=$(timeout $TIMEOUT docker run --rm mongo:7.0 mongosh "$CONNECTION_STRING" --quiet --eval " + try { + db.runCommand({ping: 1}); + db.connection_test.insertOne({test: true, source: 'docker', timestamp: new Date()}); + var doc = db.connection_test.findOne({source: 'docker'}); + db.connection_test.deleteOne({source: 'docker'}); + print('DOCKER_TEST_SUCCESS'); + } catch (e) { + print('DOCKER_TEST_FAILED: ' + e.message); + } + " 2>/dev/null || echo "DOCKER_TEST_FAILED") + + if [[ "$docker_test" == *"DOCKER_TEST_SUCCESS"* ]]; then + log_success "Docker MongoDB test passed" + return 0 + else + log_error "Docker MongoDB test failed: $docker_test" + return 1 + fi +} + +# Test MongoDB from application container +test_from_application_container() { + local container_name="bcards-staging" + + if ! docker ps --format "{{.Names}}" | grep -q "^${container_name}$"; then + log_warning "Application container '$container_name' not running, skipping application test" + return 1 + fi + + log_info "Testing MongoDB connection from application container..." + + # Test connection from the application container + local app_test=$(docker exec "$container_name" timeout 10 bash -c " + # Test TCP connection + if timeout 5 bash -c '/dev/null; then + echo 'APP_TCP_OK' + else + echo 'APP_TCP_FAILED' + exit 1 + fi + + # Test HTTP health endpoint if available + if curl -f -s http://localhost:8080/health >/dev/null 2>&1; then + echo 'APP_HEALTH_OK' + else + echo 'APP_HEALTH_FAILED' + fi + " 2>/dev/null || echo "APP_TEST_FAILED") + + if [[ "$app_test" == *"APP_TCP_OK"* ]]; then + log_success "Application container can connect to MongoDB" + + if [[ "$app_test" == *"APP_HEALTH_OK"* ]]; then + log_success "Application health check passed" + else + log_warning "Application health check failed - app may still be starting" + fi + return 0 + else + log_error "Application container cannot connect to MongoDB" + return 1 + fi +} + +# Check MongoDB server status and version +check_mongodb_status() { + log_info "Checking MongoDB server status..." + + # Try multiple methods to check status + local status_checked=false + + # Method 1: Using mongosh + if command -v mongosh >/dev/null 2>&1; then + local server_status=$(timeout $TIMEOUT mongosh "$CONNECTION_STRING" --quiet --eval " + try { + var status = db.runCommand({serverStatus: 1}); + print('MongoDB Version: ' + status.version); + print('Uptime: ' + status.uptime + ' seconds'); + print('Connections: ' + status.connections.current + '/' + status.connections.available); + print('STATUS_CHECK_OK'); + } catch (e) { + print('STATUS_CHECK_FAILED: ' + e.message); + } + " 2>/dev/null || echo "STATUS_CHECK_FAILED") + + if [[ "$server_status" == *"STATUS_CHECK_OK"* ]]; then + echo "$server_status" | grep -v "STATUS_CHECK_OK" + log_success "MongoDB server status check passed" + status_checked=true + fi + fi + + # Method 2: Using Docker if mongosh failed + if [ "$status_checked" = false ] && command -v docker >/dev/null 2>&1; then + local docker_status=$(timeout $TIMEOUT docker run --rm mongo:7.0 mongosh "$CONNECTION_STRING" --quiet --eval " + try { + var status = db.runCommand({serverStatus: 1}); + print('MongoDB Version: ' + status.version); + print('STATUS_CHECK_OK'); + } catch (e) { + print('STATUS_CHECK_FAILED: ' + e.message); + } + " 2>/dev/null || echo "STATUS_CHECK_FAILED") + + if [[ "$docker_status" == *"STATUS_CHECK_OK"* ]]; then + echo "$docker_status" | grep -v "STATUS_CHECK_OK" + log_success "MongoDB server status check passed (via Docker)" + status_checked=true + fi + fi + + if [ "$status_checked" = false ]; then + log_warning "Could not retrieve MongoDB server status" + return 1 + fi + + return 0 +} + +# Test BCards specific collections and indexes +test_bcards_collections() { + if ! command -v mongosh >/dev/null 2>&1 && ! command -v docker >/dev/null 2>&1; then + log_warning "Cannot test BCards collections - no MongoDB client available" + return 1 + fi + + log_info "Testing BCards specific collections and indexes..." + + local mongo_cmd="mongosh" + local docker_prefix="" + + if ! command -v mongosh >/dev/null 2>&1; then + mongo_cmd="docker run --rm mongo:7.0 mongosh" + docker_prefix="timeout $TIMEOUT " + fi + + local collections_test=$(${docker_prefix}${mongo_cmd} "$CONNECTION_STRING" --quiet --eval " + try { + // Check required collections + var collections = db.listCollectionNames(); + var requiredCollections = ['users', 'userpages', 'categories']; + var missingCollections = []; + + requiredCollections.forEach(function(collection) { + if (collections.indexOf(collection) === -1) { + missingCollections.push(collection); + } + }); + + if (missingCollections.length > 0) { + print('Missing collections: ' + missingCollections.join(', ')); + } else { + print('All required collections exist'); + } + + // Check indexes on userpages collection + if (collections.indexOf('userpages') !== -1) { + var indexes = db.userpages.getIndexes(); + print('UserPages collection has ' + indexes.length + ' indexes'); + + // Check for important compound index + var hasCompoundIndex = indexes.some(function(index) { + return index.key && index.key.category && index.key.slug; + }); + + if (hasCompoundIndex) { + print('Required compound index (category, slug) exists'); + } else { + print('WARNING: Compound index (category, slug) is missing'); + } + } + + print('COLLECTIONS_TEST_OK'); + } catch (e) { + print('COLLECTIONS_TEST_FAILED: ' + e.message); + } + " 2>/dev/null || echo "COLLECTIONS_TEST_FAILED") + + if [[ "$collections_test" == *"COLLECTIONS_TEST_OK"* ]]; then + echo "$collections_test" | grep -v "COLLECTIONS_TEST_OK" + log_success "BCards collections test passed" + return 0 + else + log_warning "BCards collections test had issues: $collections_test" + return 1 + fi +} + +# Performance test +test_mongodb_performance() { + log_info "Running basic performance test..." + + if ! command -v mongosh >/dev/null 2>&1; then + log_warning "mongosh not available, skipping performance test" + return 1 + fi + + local perf_test=$(timeout $TIMEOUT mongosh "$CONNECTION_STRING" --quiet --eval " + try { + var start = new Date(); + + // Insert test documents + var docs = []; + for (var i = 0; i < 100; i++) { + docs.push({test: true, index: i, timestamp: new Date()}); + } + db.performance_test.insertMany(docs); + + // Read test + var count = db.performance_test.countDocuments({test: true}); + + // Update test + db.performance_test.updateMany({test: true}, {\$set: {updated: true}}); + + // Delete test + db.performance_test.deleteMany({test: true}); + + var end = new Date(); + var duration = end - start; + + print('Performance test completed in ' + duration + 'ms'); + print('Operations: 100 inserts, 1 count, 100 updates, 100 deletes'); + + if (duration < 5000) { + print('PERFORMANCE_TEST_OK'); + } else { + print('PERFORMANCE_TEST_SLOW'); + } + } catch (e) { + print('PERFORMANCE_TEST_FAILED: ' + e.message); + } + " 2>/dev/null || echo "PERFORMANCE_TEST_FAILED") + + if [[ "$perf_test" == *"PERFORMANCE_TEST_OK"* ]]; then + echo "$perf_test" | grep -v "PERFORMANCE_TEST_OK" + log_success "Performance test passed" + return 0 + elif [[ "$perf_test" == *"PERFORMANCE_TEST_SLOW"* ]]; then + echo "$perf_test" | grep -v "PERFORMANCE_TEST_SLOW" + log_warning "Performance test completed but was slow" + return 0 + else + log_error "Performance test failed: $perf_test" + return 1 + fi +} + +# Display connection summary +display_summary() { + echo "" + log_info "MongoDB Connection Test Summary" + echo "==================================================" + echo "🏠 Host: $MONGODB_HOST:$MONGODB_PORT" + echo "🗄️ Database: $DATABASE_NAME" + echo "🔗 Connection String: $CONNECTION_STRING" + echo "⏱️ Timeout: ${TIMEOUT}s" + echo "📊 Tests completed: $(date)" + echo "==================================================" +} + +# Main test function +main() { + log_info "Starting MongoDB connection tests for Release environment" + + local test_results=() + local overall_success=true + + # Run all tests + if test_tcp_connection; then + test_results+=("✅ TCP Connection") + else + test_results+=("❌ TCP Connection") + overall_success=false + fi + + if test_mongodb_with_mongosh; then + test_results+=("✅ MongoDB Shell") + elif test_mongodb_with_docker; then + test_results+=("✅ MongoDB Docker") + elif test_mongodb_with_python; then + test_results+=("✅ MongoDB Python") + else + test_results+=("❌ MongoDB Client") + overall_success=false + fi + + if test_from_application_container; then + test_results+=("✅ Application Container") + else + test_results+=("⚠️ Application Container") + fi + + if check_mongodb_status; then + test_results+=("✅ Server Status") + else + test_results+=("⚠️ Server Status") + fi + + if test_bcards_collections; then + test_results+=("✅ BCards Collections") + else + test_results+=("⚠️ BCards Collections") + fi + + if test_mongodb_performance; then + test_results+=("✅ Performance Test") + else + test_results+=("⚠️ Performance Test") + fi + + # Display results + display_summary + echo "" + log_info "Test Results:" + for result in "${test_results[@]}"; do + echo " $result" + done + + echo "" + if [ "$overall_success" = true ]; then + log_success "All critical MongoDB tests passed!" + exit 0 + else + log_error "Some critical MongoDB tests failed!" + exit 1 + fi +} + +# Run main function main "$@" \ No newline at end of file diff --git a/src/BCards.IntegrationTests/BCards.IntegrationTests.csproj b/src/BCards.IntegrationTests/BCards.IntegrationTests.csproj index f8abaeb..52be975 100644 --- a/src/BCards.IntegrationTests/BCards.IntegrationTests.csproj +++ b/src/BCards.IntegrationTests/BCards.IntegrationTests.csproj @@ -1,45 +1,45 @@ - - - - net8.0 - enable - enable - false - true - - - - - - - runtime; build; native; contentfiles; analyzers; buildtransitive - all - - - runtime; build; native; contentfiles; analyzers; buildtransitive - all - - - - - - - - - - - - - - - - - - - - - Always - - - + + + + net8.0 + enable + enable + false + true + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + + + + + + + + + + + + + Always + + + \ No newline at end of file diff --git a/src/BCards.IntegrationTests/Fixtures/BCardsWebApplicationFactory.cs b/src/BCards.IntegrationTests/Fixtures/BCardsWebApplicationFactory.cs index de22b33..c5118b2 100644 --- a/src/BCards.IntegrationTests/Fixtures/BCardsWebApplicationFactory.cs +++ b/src/BCards.IntegrationTests/Fixtures/BCardsWebApplicationFactory.cs @@ -1,133 +1,133 @@ -using Microsoft.AspNetCore.Hosting; -using Microsoft.AspNetCore.Mvc.Testing; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.DependencyInjection.Extensions; -using Microsoft.Extensions.Logging; -using MongoDB.Driver; -using BCards.Web.Configuration; -using BCards.Web.Services; -using Testcontainers.MongoDb; -using Xunit; - -namespace BCards.IntegrationTests.Fixtures; - -public class BCardsWebApplicationFactory : WebApplicationFactory, IAsyncLifetime -{ - private readonly MongoDbContainer _mongoContainer = new MongoDbBuilder() - .WithImage("mongo:7.0") - .WithEnvironment("MONGO_INITDB_DATABASE", "BCardsDB_Test") - .Build(); - - public IMongoDatabase TestDatabase { get; private set; } = null!; - public string TestDatabaseName => $"BCardsDB_Test_{Guid.NewGuid():N}"; - - protected override void ConfigureWebHost(IWebHostBuilder builder) - { - builder.ConfigureAppConfiguration((context, config) => - { - // Remove existing configuration and add test configuration - config.Sources.Clear(); - config.AddJsonFile("appsettings.Testing.json", optional: false, reloadOnChange: false); - config.AddInMemoryCollection(new Dictionary - { - ["MongoDb:ConnectionString"] = _mongoContainer.GetConnectionString(), - ["MongoDb:DatabaseName"] = TestDatabaseName, - ["ASPNETCORE_ENVIRONMENT"] = "Testing" - }); - }); - - builder.ConfigureServices(services => - { - // Remove existing MongoDB services - services.RemoveAll(typeof(IMongoClient)); - services.RemoveAll(typeof(IMongoDatabase)); - - // Add test MongoDB services - services.AddSingleton(serviceProvider => - { - return new MongoClient(_mongoContainer.GetConnectionString()); - }); - - services.AddScoped(serviceProvider => - { - var client = serviceProvider.GetRequiredService(); - TestDatabase = client.GetDatabase(TestDatabaseName); - return TestDatabase; - }); - - // Override Stripe settings for testing - services.Configure(options => - { - options.PublishableKey = "pk_test_51234567890abcdef"; - options.SecretKey = "sk_test_51234567890abcdef"; - options.WebhookSecret = "whsec_test_1234567890abcdef"; - }); - - // Mock external services that we don't want to test - services.RemoveAll(typeof(IEmailService)); - services.AddScoped(); - }); - - builder.UseEnvironment("Testing"); - - // Reduce logging noise during tests - builder.ConfigureLogging(logging => - { - logging.ClearProviders(); - logging.AddConsole(); - logging.SetMinimumLevel(LogLevel.Warning); - logging.AddFilter("BCards", LogLevel.Information); - }); - } - - public async Task InitializeAsync() - { - await _mongoContainer.StartAsync(); - } - - public new async Task DisposeAsync() - { - await _mongoContainer.DisposeAsync(); - await base.DisposeAsync(); - } - - public async Task CleanDatabaseAsync() - { - if (TestDatabase != null) - { - var collections = new[] { "users", "userpages", "categories", "livepages", "subscriptions" }; - - foreach (var collectionName in collections) - { - try - { - await TestDatabase.DropCollectionAsync(collectionName); - } - catch (Exception) - { - // Ignore errors if collection doesn't exist - } - } - } - } -} - -// Mock email service to avoid external dependencies in tests -public class MockEmailService : IEmailService -{ - public Task SendModerationStatusAsync(string userEmail, string userName, string pageTitle, string status, string? reason = null, string? previewUrl = null) - { - return Task.CompletedTask; - } - - public Task SendModeratorNotificationAsync(string pageId, string pageTitle, string planType, string userName) - { - return Task.CompletedTask; - } - - public Task SendEmailAsync(string to, string subject, string htmlContent) - { - return Task.FromResult(true); - } +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Logging; +using MongoDB.Driver; +using BCards.Web.Configuration; +using BCards.Web.Services; +using Testcontainers.MongoDb; +using Xunit; + +namespace BCards.IntegrationTests.Fixtures; + +public class BCardsWebApplicationFactory : WebApplicationFactory, IAsyncLifetime +{ + private readonly MongoDbContainer _mongoContainer = new MongoDbBuilder() + .WithImage("mongo:7.0") + .WithEnvironment("MONGO_INITDB_DATABASE", "BCardsDB_Test") + .Build(); + + public IMongoDatabase TestDatabase { get; private set; } = null!; + public string TestDatabaseName => $"BCardsDB_Test_{Guid.NewGuid():N}"; + + protected override void ConfigureWebHost(IWebHostBuilder builder) + { + builder.ConfigureAppConfiguration((context, config) => + { + // Remove existing configuration and add test configuration + config.Sources.Clear(); + config.AddJsonFile("appsettings.Testing.json", optional: false, reloadOnChange: false); + config.AddInMemoryCollection(new Dictionary + { + ["MongoDb:ConnectionString"] = _mongoContainer.GetConnectionString(), + ["MongoDb:DatabaseName"] = TestDatabaseName, + ["ASPNETCORE_ENVIRONMENT"] = "Testing" + }); + }); + + builder.ConfigureServices(services => + { + // Remove existing MongoDB services + services.RemoveAll(typeof(IMongoClient)); + services.RemoveAll(typeof(IMongoDatabase)); + + // Add test MongoDB services + services.AddSingleton(serviceProvider => + { + return new MongoClient(_mongoContainer.GetConnectionString()); + }); + + services.AddScoped(serviceProvider => + { + var client = serviceProvider.GetRequiredService(); + TestDatabase = client.GetDatabase(TestDatabaseName); + return TestDatabase; + }); + + // Override Stripe settings for testing + services.Configure(options => + { + options.PublishableKey = "pk_test_51234567890abcdef"; + options.SecretKey = "sk_test_51234567890abcdef"; + options.WebhookSecret = "whsec_test_1234567890abcdef"; + }); + + // Mock external services that we don't want to test + services.RemoveAll(typeof(IEmailService)); + services.AddScoped(); + }); + + builder.UseEnvironment("Testing"); + + // Reduce logging noise during tests + builder.ConfigureLogging(logging => + { + logging.ClearProviders(); + logging.AddConsole(); + logging.SetMinimumLevel(LogLevel.Warning); + logging.AddFilter("BCards", LogLevel.Information); + }); + } + + public async Task InitializeAsync() + { + await _mongoContainer.StartAsync(); + } + + public new async Task DisposeAsync() + { + await _mongoContainer.DisposeAsync(); + await base.DisposeAsync(); + } + + public async Task CleanDatabaseAsync() + { + if (TestDatabase != null) + { + var collections = new[] { "users", "userpages", "categories", "livepages", "subscriptions" }; + + foreach (var collectionName in collections) + { + try + { + await TestDatabase.DropCollectionAsync(collectionName); + } + catch (Exception) + { + // Ignore errors if collection doesn't exist + } + } + } + } +} + +// Mock email service to avoid external dependencies in tests +public class MockEmailService : IEmailService +{ + public Task SendModerationStatusAsync(string userEmail, string userName, string pageTitle, string status, string? reason = null, string? previewUrl = null) + { + return Task.CompletedTask; + } + + public Task SendModeratorNotificationAsync(string pageId, string pageTitle, string planType, string userName) + { + return Task.CompletedTask; + } + + public Task SendEmailAsync(string to, string subject, string htmlContent) + { + return Task.FromResult(true); + } } \ No newline at end of file diff --git a/src/BCards.IntegrationTests/Fixtures/MongoDbTestFixture.cs b/src/BCards.IntegrationTests/Fixtures/MongoDbTestFixture.cs index 034c568..aac7b59 100644 --- a/src/BCards.IntegrationTests/Fixtures/MongoDbTestFixture.cs +++ b/src/BCards.IntegrationTests/Fixtures/MongoDbTestFixture.cs @@ -1,182 +1,182 @@ -using MongoDB.Driver; -using BCards.Web.Models; -using BCards.Web.Repositories; -using BCards.Web.ViewModels; - -namespace BCards.IntegrationTests.Fixtures; - -public class MongoDbTestFixture -{ - public IMongoDatabase Database { get; } - public IUserRepository UserRepository { get; } - public IUserPageRepository UserPageRepository { get; } - public ICategoryRepository CategoryRepository { get; } - - public MongoDbTestFixture(IMongoDatabase database) - { - Database = database; - UserRepository = new UserRepository(database); - UserPageRepository = new UserPageRepository(database); - CategoryRepository = new CategoryRepository(database); - } - - public async Task InitializeTestDataAsync() - { - // Initialize test categories - var categories = new List - { - new() { Id = "tecnologia", Name = "Tecnologia", Description = "Empresas e profissionais de tecnologia" }, - new() { Id = "negocios", Name = "Negócios", Description = "Empresas e empreendedores" }, - new() { Id = "pessoal", Name = "Pessoal", Description = "Páginas pessoais e freelancers" }, - new() { Id = "saude", Name = "Saúde", Description = "Profissionais da área da saúde" } - }; - - var existingCategories = await CategoryRepository.GetAllActiveAsync(); - if (!existingCategories.Any()) - { - foreach (var category in categories) - { - await CategoryRepository.CreateAsync(category); - } - } - } - - public async Task CreateTestUserAsync(PlanType planType = PlanType.Basic, string? email = null, string? name = null) - { - var user = new User - { - Id = Guid.NewGuid().ToString(), - Email = email ?? $"test-{Guid.NewGuid():N}@example.com", - Name = name ?? "Test User", - CurrentPlan = planType.ToString(), - CreatedAt = DateTime.UtcNow, - UpdatedAt = DateTime.UtcNow, - IsActive = true - }; - - await UserRepository.CreateAsync(user); - return user; - } - - public async Task CreateTestUserPageAsync( - string userId, - PageStatus status = PageStatus.Creating, - string category = "tecnologia", - int normalLinkCount = 3, - int productLinkCount = 1, - string? slug = null) - { - var pageSlug = slug ?? $"test-page-{Guid.NewGuid():N}"; - - var userPage = new UserPage - { - Id = Guid.NewGuid().ToString(), - UserId = userId, - DisplayName = "Test Page", - Category = category, - Slug = pageSlug, - Bio = "Test page for integration testing", - Status = status, - BusinessType = "individual", - Theme = new PageTheme { Name = "minimalist" }, - Links = new List(), - CreatedAt = DateTime.UtcNow, - UpdatedAt = DateTime.UtcNow, - ModerationAttempts = 0, - ModerationHistory = new List() - }; - - // Generate preview token for non-Active pages - if (status != PageStatus.Active) - { - userPage.PreviewToken = Guid.NewGuid().ToString("N")[..16]; - userPage.PreviewTokenExpiry = DateTime.UtcNow.AddHours(4); - } - - // Add normal links - for (int i = 0; i < normalLinkCount; i++) - { - userPage.Links.Add(new LinkItem - { - Title = $"Test Link {i + 1}", - Url = $"https://example.com/link{i + 1}", - Description = $"Description for test link {i + 1}", - Icon = "fas fa-link", - IsActive = true, - Order = i, - Type = LinkType.Normal - }); - } - - // Add product links - for (int i = 0; i < productLinkCount; i++) - { - userPage.Links.Add(new LinkItem - { - Title = $"Test Product {i + 1}", - Url = $"https://example.com/product{i + 1}", - Description = $"Description for test product {i + 1}", - Icon = "fas fa-shopping-cart", - IsActive = true, - Order = normalLinkCount + i, - Type = LinkType.Product, - ProductTitle = $"Amazing Product {i + 1}", - ProductPrice = "R$ 99,90", - ProductDescription = $"This is an amazing product for testing purposes {i + 1}", - ProductImage = $"https://example.com/images/product{i + 1}.jpg" - }); - } - - await UserPageRepository.CreateAsync(userPage); - return userPage; - } - - public async Task CreateTestUserWithPageAsync( - PlanType planType = PlanType.Basic, - PageStatus pageStatus = PageStatus.Creating, - int normalLinks = 3, - int productLinks = 1) - { - var user = await CreateTestUserAsync(planType); - var page = await CreateTestUserPageAsync(user.Id, pageStatus, "tecnologia", normalLinks, productLinks); - - return user; - } - - public async Task CleanAllDataAsync() - { - var collections = new[] { "users", "userpages", "categories", "livepages", "subscriptions" }; - - foreach (var collectionName in collections) - { - try - { - await Database.DropCollectionAsync(collectionName); - } - catch (Exception) - { - // Ignore errors if collection doesn't exist - } - } - - await InitializeTestDataAsync(); - } - - public async Task> GetUserPagesAsync(string userId) - { - var filter = Builders.Filter.Eq(p => p.UserId, userId); - var pages = await UserPageRepository.GetManyAsync(filter); - return pages.ToList(); - } - - public async Task GetUserPageAsync(string category, string slug) - { - var filter = Builders.Filter.And( - Builders.Filter.Eq(p => p.Category, category), - Builders.Filter.Eq(p => p.Slug, slug) - ); - - var pages = await UserPageRepository.GetManyAsync(filter); - return pages.FirstOrDefault(); - } +using MongoDB.Driver; +using BCards.Web.Models; +using BCards.Web.Repositories; +using BCards.Web.ViewModels; + +namespace BCards.IntegrationTests.Fixtures; + +public class MongoDbTestFixture +{ + public IMongoDatabase Database { get; } + public IUserRepository UserRepository { get; } + public IUserPageRepository UserPageRepository { get; } + public ICategoryRepository CategoryRepository { get; } + + public MongoDbTestFixture(IMongoDatabase database) + { + Database = database; + UserRepository = new UserRepository(database); + UserPageRepository = new UserPageRepository(database); + CategoryRepository = new CategoryRepository(database); + } + + public async Task InitializeTestDataAsync() + { + // Initialize test categories + var categories = new List + { + new() { Id = "tecnologia", Name = "Tecnologia", Description = "Empresas e profissionais de tecnologia" }, + new() { Id = "negocios", Name = "Negócios", Description = "Empresas e empreendedores" }, + new() { Id = "pessoal", Name = "Pessoal", Description = "Páginas pessoais e freelancers" }, + new() { Id = "saude", Name = "Saúde", Description = "Profissionais da área da saúde" } + }; + + var existingCategories = await CategoryRepository.GetAllActiveAsync(); + if (!existingCategories.Any()) + { + foreach (var category in categories) + { + await CategoryRepository.CreateAsync(category); + } + } + } + + public async Task CreateTestUserAsync(PlanType planType = PlanType.Basic, string? email = null, string? name = null) + { + var user = new User + { + Id = Guid.NewGuid().ToString(), + Email = email ?? $"test-{Guid.NewGuid():N}@example.com", + Name = name ?? "Test User", + CurrentPlan = planType.ToString(), + CreatedAt = DateTime.UtcNow, + UpdatedAt = DateTime.UtcNow, + IsActive = true + }; + + await UserRepository.CreateAsync(user); + return user; + } + + public async Task CreateTestUserPageAsync( + string userId, + PageStatus status = PageStatus.Creating, + string category = "tecnologia", + int normalLinkCount = 3, + int productLinkCount = 1, + string? slug = null) + { + var pageSlug = slug ?? $"test-page-{Guid.NewGuid():N}"; + + var userPage = new UserPage + { + Id = Guid.NewGuid().ToString(), + UserId = userId, + DisplayName = "Test Page", + Category = category, + Slug = pageSlug, + Bio = "Test page for integration testing", + Status = status, + BusinessType = "individual", + Theme = new PageTheme { Name = "minimalist" }, + Links = new List(), + CreatedAt = DateTime.UtcNow, + UpdatedAt = DateTime.UtcNow, + ModerationAttempts = 0, + ModerationHistory = new List() + }; + + // Generate preview token for non-Active pages + if (status != PageStatus.Active) + { + userPage.PreviewToken = Guid.NewGuid().ToString("N")[..16]; + userPage.PreviewTokenExpiry = DateTime.UtcNow.AddHours(4); + } + + // Add normal links + for (int i = 0; i < normalLinkCount; i++) + { + userPage.Links.Add(new LinkItem + { + Title = $"Test Link {i + 1}", + Url = $"https://example.com/link{i + 1}", + Description = $"Description for test link {i + 1}", + Icon = "fas fa-link", + IsActive = true, + Order = i, + Type = LinkType.Normal + }); + } + + // Add product links + for (int i = 0; i < productLinkCount; i++) + { + userPage.Links.Add(new LinkItem + { + Title = $"Test Product {i + 1}", + Url = $"https://example.com/product{i + 1}", + Description = $"Description for test product {i + 1}", + Icon = "fas fa-shopping-cart", + IsActive = true, + Order = normalLinkCount + i, + Type = LinkType.Product, + ProductTitle = $"Amazing Product {i + 1}", + ProductPrice = "R$ 99,90", + ProductDescription = $"This is an amazing product for testing purposes {i + 1}", + ProductImage = $"https://example.com/images/product{i + 1}.jpg" + }); + } + + await UserPageRepository.CreateAsync(userPage); + return userPage; + } + + public async Task CreateTestUserWithPageAsync( + PlanType planType = PlanType.Basic, + PageStatus pageStatus = PageStatus.Creating, + int normalLinks = 3, + int productLinks = 1) + { + var user = await CreateTestUserAsync(planType); + var page = await CreateTestUserPageAsync(user.Id, pageStatus, "tecnologia", normalLinks, productLinks); + + return user; + } + + public async Task CleanAllDataAsync() + { + var collections = new[] { "users", "userpages", "categories", "livepages", "subscriptions" }; + + foreach (var collectionName in collections) + { + try + { + await Database.DropCollectionAsync(collectionName); + } + catch (Exception) + { + // Ignore errors if collection doesn't exist + } + } + + await InitializeTestDataAsync(); + } + + public async Task> GetUserPagesAsync(string userId) + { + var filter = Builders.Filter.Eq(p => p.UserId, userId); + var pages = await UserPageRepository.GetManyAsync(filter); + return pages.ToList(); + } + + public async Task GetUserPageAsync(string category, string slug) + { + var filter = Builders.Filter.And( + Builders.Filter.Eq(p => p.Category, category), + Builders.Filter.Eq(p => p.Slug, slug) + ); + + var pages = await UserPageRepository.GetManyAsync(filter); + return pages.FirstOrDefault(); + } } \ No newline at end of file diff --git a/src/BCards.IntegrationTests/Helpers/AuthenticationHelper.cs b/src/BCards.IntegrationTests/Helpers/AuthenticationHelper.cs index de92a43..96ab0f8 100644 --- a/src/BCards.IntegrationTests/Helpers/AuthenticationHelper.cs +++ b/src/BCards.IntegrationTests/Helpers/AuthenticationHelper.cs @@ -1,92 +1,92 @@ -using System.Security.Claims; -using System.Text.Encodings.Web; -using Microsoft.AspNetCore.Authentication; -using Microsoft.AspNetCore.Authentication.Cookies; -using Microsoft.AspNetCore.Mvc.Testing; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Options; -using BCards.Web.Models; - -namespace BCards.IntegrationTests.Helpers; - -public static class AuthenticationHelper -{ - public static Task CreateAuthenticatedClientAsync( - WebApplicationFactory factory, - User testUser) - { - var client = factory.WithWebHostBuilder(builder => - { - builder.ConfigureServices(services => - { - services.AddAuthentication("Test") - .AddScheme( - "Test", options => { }); - }); - }).CreateClient(new WebApplicationFactoryClientOptions - { - AllowAutoRedirect = false - }); - - // Set the test user in headers for the TestAuthenticationHandler - client.DefaultRequestHeaders.Add("TestUserId", testUser.Id); - client.DefaultRequestHeaders.Add("TestUserEmail", testUser.Email); - client.DefaultRequestHeaders.Add("TestUserName", testUser.Name); - - return Task.FromResult(client); - } - - public static ClaimsPrincipal CreateTestClaimsPrincipal(User user) - { - var claims = new List - { - new(ClaimTypes.NameIdentifier, user.Id), - new(ClaimTypes.Email, user.Email), - new(ClaimTypes.Name, user.Name), - new("sub", user.Id), - new("email", user.Email), - new("name", user.Name) - }; - - var identity = new ClaimsIdentity(claims, "Test"); - return new ClaimsPrincipal(identity); - } -} - -public class TestAuthenticationHandler : AuthenticationHandler -{ - public TestAuthenticationHandler(IOptionsMonitor options, - ILoggerFactory logger, UrlEncoder encoder) - : base(options, logger, encoder) - { - } - - protected override Task HandleAuthenticateAsync() - { - var userId = Context.Request.Headers["TestUserId"].FirstOrDefault(); - var userEmail = Context.Request.Headers["TestUserEmail"].FirstOrDefault(); - var userName = Context.Request.Headers["TestUserName"].FirstOrDefault(); - - if (string.IsNullOrEmpty(userId) || string.IsNullOrEmpty(userEmail)) - { - return Task.FromResult(AuthenticateResult.Fail("No test user provided")); - } - - var claims = new List - { - new(ClaimTypes.NameIdentifier, userId), - new(ClaimTypes.Email, userEmail), - new(ClaimTypes.Name, userName ?? "Test User"), - new("sub", userId), - new("email", userEmail), - new("name", userName ?? "Test User") - }; - - var identity = new ClaimsIdentity(claims, "Test"); - var principal = new ClaimsPrincipal(identity); - var ticket = new AuthenticationTicket(principal, "Test"); - - return Task.FromResult(AuthenticateResult.Success(ticket)); - } +using System.Security.Claims; +using System.Text.Encodings.Web; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authentication.Cookies; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using BCards.Web.Models; + +namespace BCards.IntegrationTests.Helpers; + +public static class AuthenticationHelper +{ + public static Task CreateAuthenticatedClientAsync( + WebApplicationFactory factory, + User testUser) + { + var client = factory.WithWebHostBuilder(builder => + { + builder.ConfigureServices(services => + { + services.AddAuthentication("Test") + .AddScheme( + "Test", options => { }); + }); + }).CreateClient(new WebApplicationFactoryClientOptions + { + AllowAutoRedirect = false + }); + + // Set the test user in headers for the TestAuthenticationHandler + client.DefaultRequestHeaders.Add("TestUserId", testUser.Id); + client.DefaultRequestHeaders.Add("TestUserEmail", testUser.Email); + client.DefaultRequestHeaders.Add("TestUserName", testUser.Name); + + return Task.FromResult(client); + } + + public static ClaimsPrincipal CreateTestClaimsPrincipal(User user) + { + var claims = new List + { + new(ClaimTypes.NameIdentifier, user.Id), + new(ClaimTypes.Email, user.Email), + new(ClaimTypes.Name, user.Name), + new("sub", user.Id), + new("email", user.Email), + new("name", user.Name) + }; + + var identity = new ClaimsIdentity(claims, "Test"); + return new ClaimsPrincipal(identity); + } +} + +public class TestAuthenticationHandler : AuthenticationHandler +{ + public TestAuthenticationHandler(IOptionsMonitor options, + ILoggerFactory logger, UrlEncoder encoder) + : base(options, logger, encoder) + { + } + + protected override Task HandleAuthenticateAsync() + { + var userId = Context.Request.Headers["TestUserId"].FirstOrDefault(); + var userEmail = Context.Request.Headers["TestUserEmail"].FirstOrDefault(); + var userName = Context.Request.Headers["TestUserName"].FirstOrDefault(); + + if (string.IsNullOrEmpty(userId) || string.IsNullOrEmpty(userEmail)) + { + return Task.FromResult(AuthenticateResult.Fail("No test user provided")); + } + + var claims = new List + { + new(ClaimTypes.NameIdentifier, userId), + new(ClaimTypes.Email, userEmail), + new(ClaimTypes.Name, userName ?? "Test User"), + new("sub", userId), + new("email", userEmail), + new("name", userName ?? "Test User") + }; + + var identity = new ClaimsIdentity(claims, "Test"); + var principal = new ClaimsPrincipal(identity); + var ticket = new AuthenticationTicket(principal, "Test"); + + return Task.FromResult(AuthenticateResult.Success(ticket)); + } } \ No newline at end of file diff --git a/src/BCards.IntegrationTests/Helpers/PuppeteerTestHelper.cs b/src/BCards.IntegrationTests/Helpers/PuppeteerTestHelper.cs index 3e2649a..41ae853 100644 --- a/src/BCards.IntegrationTests/Helpers/PuppeteerTestHelper.cs +++ b/src/BCards.IntegrationTests/Helpers/PuppeteerTestHelper.cs @@ -1,195 +1,195 @@ -using PuppeteerSharp; -using Microsoft.AspNetCore.Mvc.Testing; - -namespace BCards.IntegrationTests.Helpers; - -public class PuppeteerTestHelper : IAsyncDisposable -{ - private IBrowser? _browser; - private IPage? _page; - private readonly string _baseUrl; - - public PuppeteerTestHelper(WebApplicationFactory factory) - { - _baseUrl = factory.Server.BaseAddress?.ToString() ?? "https://localhost:49178"; - } - - public async Task InitializeAsync() - { - // Download Chrome if not available - await new BrowserFetcher().DownloadAsync(); - - _browser = await Puppeteer.LaunchAsync(new LaunchOptions - { - Headless = true, // Set to false for debugging - Args = new[] - { - "--no-sandbox", - "--disable-setuid-sandbox", - "--disable-dev-shm-usage", - "--disable-web-security", - "--allow-running-insecure-content", - "--ignore-certificate-errors" - } - }); - - _page = await _browser.NewPageAsync(); - - // Set viewport for consistent testing - await _page.SetViewportAsync(new ViewPortOptions - { - Width = 1920, - Height = 1080 - }); - } - - public IPage Page => _page ?? throw new InvalidOperationException("PuppeteerTestHelper not initialized. Call InitializeAsync first."); - - public async Task NavigateToAsync(string relativeUrl) - { - var fullUrl = new Uri(new Uri(_baseUrl), relativeUrl).ToString(); - await Page.GoToAsync(fullUrl, new NavigationOptions - { - WaitUntil = new[] { WaitUntilNavigation.Networkidle0 } - }); - } - - public async Task GetPageContentAsync() - { - return await Page.GetContentAsync(); - } - - public async Task GetPageTitleAsync() - { - return await Page.GetTitleAsync(); - } - - public async Task ElementExistsAsync(string selector) - { - try - { - await Page.WaitForSelectorAsync(selector, new WaitForSelectorOptions - { - Timeout = 5000 - }); - return true; - } - catch (WaitTaskTimeoutException) - { - return false; - } - } - - public async Task ClickAsync(string selector) - { - await Page.WaitForSelectorAsync(selector); - await Page.ClickAsync(selector); - } - - public async Task TypeAsync(string selector, string text) - { - await Page.WaitForSelectorAsync(selector); - await Page.TypeAsync(selector, text); - } - - public async Task FillFormAsync(Dictionary formData) - { - foreach (var kvp in formData) - { - await Page.WaitForSelectorAsync(kvp.Key); - await Page.EvaluateExpressionAsync($"document.querySelector('{kvp.Key}').value = ''"); - await Page.TypeAsync(kvp.Key, kvp.Value); - } - } - - public async Task SubmitFormAsync(string formSelector) - { - await Page.ClickAsync($"{formSelector} button[type='submit'], {formSelector} input[type='submit']"); - } - - public async Task WaitForNavigationAsync() - { - await Page.WaitForNavigationAsync(new NavigationOptions - { - WaitUntil = new[] { WaitUntilNavigation.Networkidle0 } - }); - } - - public async Task WaitForElementAsync(string selector, int timeoutMs = 10000) - { - await Page.WaitForSelectorAsync(selector, new WaitForSelectorOptions - { - Timeout = timeoutMs - }); - } - - public async Task GetElementTextAsync(string selector) - { - await Page.WaitForSelectorAsync(selector); - var element = await Page.QuerySelectorAsync(selector); - var text = await Page.EvaluateFunctionAsync("el => el.textContent", element); - return text?.Trim() ?? string.Empty; - } - - public async Task GetElementValueAsync(string selector) - { - await Page.WaitForSelectorAsync(selector); - var element = await Page.QuerySelectorAsync(selector); - var value = await Page.EvaluateFunctionAsync("el => el.value", element); - return value ?? string.Empty; - } - - public async Task IsElementVisibleAsync(string selector) - { - try - { - await Page.WaitForSelectorAsync(selector, new WaitForSelectorOptions - { - Visible = true, - Timeout = 2000 - }); - return true; - } - catch (WaitTaskTimeoutException) - { - return false; - } - } - - public async Task TakeScreenshotAsync(string fileName) - { - await Page.ScreenshotAsync(fileName); - } - - public Task GetCurrentUrlAsync() - { - return Task.FromResult(Page.Url); - } - - public async Task> GetAllElementTextsAsync(string selector) - { - var elements = await Page.QuerySelectorAllAsync(selector); - var texts = new List(); - - foreach (var element in elements) - { - var text = await Page.EvaluateFunctionAsync("el => el.textContent", element); - texts.Add(text?.Trim() ?? string.Empty); - } - - return texts; - } - - public async ValueTask DisposeAsync() - { - if (_page != null) - { - await _page.CloseAsync(); - } - - if (_browser != null) - { - await _browser.CloseAsync(); - } - } +using PuppeteerSharp; +using Microsoft.AspNetCore.Mvc.Testing; + +namespace BCards.IntegrationTests.Helpers; + +public class PuppeteerTestHelper : IAsyncDisposable +{ + private IBrowser? _browser; + private IPage? _page; + private readonly string _baseUrl; + + public PuppeteerTestHelper(WebApplicationFactory factory) + { + _baseUrl = factory.Server.BaseAddress?.ToString() ?? "https://localhost:49178"; + } + + public async Task InitializeAsync() + { + // Download Chrome if not available + await new BrowserFetcher().DownloadAsync(); + + _browser = await Puppeteer.LaunchAsync(new LaunchOptions + { + Headless = true, // Set to false for debugging + Args = new[] + { + "--no-sandbox", + "--disable-setuid-sandbox", + "--disable-dev-shm-usage", + "--disable-web-security", + "--allow-running-insecure-content", + "--ignore-certificate-errors" + } + }); + + _page = await _browser.NewPageAsync(); + + // Set viewport for consistent testing + await _page.SetViewportAsync(new ViewPortOptions + { + Width = 1920, + Height = 1080 + }); + } + + public IPage Page => _page ?? throw new InvalidOperationException("PuppeteerTestHelper not initialized. Call InitializeAsync first."); + + public async Task NavigateToAsync(string relativeUrl) + { + var fullUrl = new Uri(new Uri(_baseUrl), relativeUrl).ToString(); + await Page.GoToAsync(fullUrl, new NavigationOptions + { + WaitUntil = new[] { WaitUntilNavigation.Networkidle0 } + }); + } + + public async Task GetPageContentAsync() + { + return await Page.GetContentAsync(); + } + + public async Task GetPageTitleAsync() + { + return await Page.GetTitleAsync(); + } + + public async Task ElementExistsAsync(string selector) + { + try + { + await Page.WaitForSelectorAsync(selector, new WaitForSelectorOptions + { + Timeout = 5000 + }); + return true; + } + catch (WaitTaskTimeoutException) + { + return false; + } + } + + public async Task ClickAsync(string selector) + { + await Page.WaitForSelectorAsync(selector); + await Page.ClickAsync(selector); + } + + public async Task TypeAsync(string selector, string text) + { + await Page.WaitForSelectorAsync(selector); + await Page.TypeAsync(selector, text); + } + + public async Task FillFormAsync(Dictionary formData) + { + foreach (var kvp in formData) + { + await Page.WaitForSelectorAsync(kvp.Key); + await Page.EvaluateExpressionAsync($"document.querySelector('{kvp.Key}').value = ''"); + await Page.TypeAsync(kvp.Key, kvp.Value); + } + } + + public async Task SubmitFormAsync(string formSelector) + { + await Page.ClickAsync($"{formSelector} button[type='submit'], {formSelector} input[type='submit']"); + } + + public async Task WaitForNavigationAsync() + { + await Page.WaitForNavigationAsync(new NavigationOptions + { + WaitUntil = new[] { WaitUntilNavigation.Networkidle0 } + }); + } + + public async Task WaitForElementAsync(string selector, int timeoutMs = 10000) + { + await Page.WaitForSelectorAsync(selector, new WaitForSelectorOptions + { + Timeout = timeoutMs + }); + } + + public async Task GetElementTextAsync(string selector) + { + await Page.WaitForSelectorAsync(selector); + var element = await Page.QuerySelectorAsync(selector); + var text = await Page.EvaluateFunctionAsync("el => el.textContent", element); + return text?.Trim() ?? string.Empty; + } + + public async Task GetElementValueAsync(string selector) + { + await Page.WaitForSelectorAsync(selector); + var element = await Page.QuerySelectorAsync(selector); + var value = await Page.EvaluateFunctionAsync("el => el.value", element); + return value ?? string.Empty; + } + + public async Task IsElementVisibleAsync(string selector) + { + try + { + await Page.WaitForSelectorAsync(selector, new WaitForSelectorOptions + { + Visible = true, + Timeout = 2000 + }); + return true; + } + catch (WaitTaskTimeoutException) + { + return false; + } + } + + public async Task TakeScreenshotAsync(string fileName) + { + await Page.ScreenshotAsync(fileName); + } + + public Task GetCurrentUrlAsync() + { + return Task.FromResult(Page.Url); + } + + public async Task> GetAllElementTextsAsync(string selector) + { + var elements = await Page.QuerySelectorAllAsync(selector); + var texts = new List(); + + foreach (var element in elements) + { + var text = await Page.EvaluateFunctionAsync("el => el.textContent", element); + texts.Add(text?.Trim() ?? string.Empty); + } + + return texts; + } + + public async ValueTask DisposeAsync() + { + if (_page != null) + { + await _page.CloseAsync(); + } + + if (_browser != null) + { + await _browser.CloseAsync(); + } + } } \ No newline at end of file diff --git a/src/BCards.IntegrationTests/README.md b/src/BCards.IntegrationTests/README.md index 004947e..e5f4c0d 100644 --- a/src/BCards.IntegrationTests/README.md +++ b/src/BCards.IntegrationTests/README.md @@ -1,157 +1,157 @@ -# BCards Integration Tests - -Este projeto contém testes integrados para o sistema BCards, validando workflows completos desde a criação de páginas até o sistema de moderação. - -## Estrutura dos Testes - -### Fixtures -- **BCardsWebApplicationFactory**: Factory personalizada que configura ambiente de teste com MongoDB container -- **MongoDbTestFixture**: Helper para criar e gerenciar dados de teste no MongoDB -- **StripeTestFixture**: Mock para integração Stripe (futuro) - -### Helpers -- **AuthenticationHelper**: Mock para autenticação OAuth (Google/Microsoft) -- **PuppeteerTestHelper**: Automação de browser para testes E2E -- **TestDataBuilder**: Builders para criar objetos de teste (futuro) - -### Tests -- **PageCreationTests**: Validação de criação de páginas e limites por plano -- **PreviewTokenTests**: Sistema de preview tokens para páginas não-ativas -- **ModerationWorkflowTests**: Workflow completo de moderação -- **PlanLimitationTests**: Validação de limitações por plano (futuro) -- **StripeIntegrationTests**: Testes de upgrade via Stripe (futuro) - -## Cenários Testados - -### Sistema de Páginas -1. **Criação de páginas** respeitando limites dos planos (Trial: 1, Basic: 3, etc.) -2. **Status de páginas**: Creating → PendingModeration → Active/Rejected -3. **Preview tokens**: Acesso a páginas em desenvolvimento (4h de validade) -4. **Validação de limites**: Links normais vs produto por plano - -### Workflow de Moderação -1. **Submissão para moderação**: Creating → PendingModeration -2. **Aprovação**: PendingModeration → Active (page vira pública) -3. **Rejeição**: PendingModeration → Inactive/Rejected -4. **Preview system**: Acesso via token para pages não-Active - -### Plan Limitations (Basic vs Professional) -- **Basic**: 5 links máximo -- **Professional**: 15 links máximo -- **Trial**: 1 página, 3 links + 1 produto - -## Tecnologias Utilizadas - -- **xUnit**: Framework de testes -- **FluentAssertions**: Assertions expressivas -- **WebApplicationFactory**: Testes integrados ASP.NET Core -- **Testcontainers**: MongoDB container para isolamento -- **PuppeteerSharp**: Automação de browser (Chrome) -- **MongoDB.Driver**: Acesso direto ao banco para validações - -## Configuração - -### Pré-requisitos -- .NET 8 SDK -- Docker (para MongoDB container) -- Chrome/Chromium (baixado automaticamente pelo PuppeteerSharp) - -### Executar Testes -```bash -# Todos os testes -dotnet test src/BCards.IntegrationTests/ - -# Testes específicos -dotnet test src/BCards.IntegrationTests/ --filter "PageCreationTests" -dotnet test src/BCards.IntegrationTests/ --filter "PreviewTokenTests" -``` - -### Configuração Manual (MongoDB local) -Se preferir usar MongoDB local em vez do container: - -```json -// appsettings.Testing.json -{ - "MongoDb": { - "ConnectionString": "mongodb://localhost:27017", - "DatabaseName": "BCardsDB_Test" - } -} -``` - -## Estrutura de Dados de Teste - -### User -- **Trial**: 1 página máx, links limitados -- **Basic**: 3 páginas, 5 links por página -- **Professional**: 5 páginas, 15 links por página - -### UserPage -- **Status**: Creating, PendingModeration, Active, Rejected -- **Preview Tokens**: 4h de validade para access não-Active -- **Links**: Normal vs Product (limites diferentes por plano) - -### Categories -- **tecnologia**: Empresas de tech -- **negocios**: Empresas e empreendedores -- **pessoal**: Freelancers e páginas pessoais -- **saude**: Profissionais da área da saúde - -## Padrões de Teste - -### Arrange-Act-Assert -Todos os testes seguem o padrão AAA: -```csharp -// Arrange -var user = await _dbFixture.CreateTestUserAsync(PlanType.Basic); -var page = await _dbFixture.CreateTestUserPageAsync(user.Id, PageStatus.Creating); - -// Act -var response = await client.PostAsync($"/Admin/SubmitForModeration/{page.Id}", null); - -// Assert -response.IsSuccessStatusCode.Should().BeTrue(); -``` - -### Cleanup Automático -- Cada teste usa database isolada (GUID no nome) -- Container MongoDB é destruído após os testes -- Sem interferência entre testes - -### Mocks -- **EmailService**: Mockado para evitar envios reais -- **StripeService**: Mockado para evitar cobrança real -- **OAuth**: Mockado para evitar dependência externa - -## Debug e Troubleshooting - -### PuppeteerSharp -Para debug visual dos testes de browser: -```csharp -_browser = await Puppeteer.LaunchAsync(new LaunchOptions -{ - Headless = false, // Mostra o browser - SlowMo = 100 // Delay entre ações -}); -``` - -### MongoDB -Para inspecionar dados durante testes, conecte no container: -```bash -docker exec -it mongosh BCardsDB_Test -``` - -### Logs -Logs são configurados para mostrar apenas warnings/errors durante testes. -Para debug detalhado, altere em `BCardsWebApplicationFactory`: -```csharp -logging.SetMinimumLevel(LogLevel.Information); -``` - -## Próximos Passos - -1. **PlanLimitationTests**: Validar todas as limitações por plano -2. **StripeIntegrationTests**: Testar upgrades via webhook -3. **PerformanceTests**: Testar carga no sistema de moderação -4. **E2E Tests**: Testes completos com PuppeteerSharp +# BCards Integration Tests + +Este projeto contém testes integrados para o sistema BCards, validando workflows completos desde a criação de páginas até o sistema de moderação. + +## Estrutura dos Testes + +### Fixtures +- **BCardsWebApplicationFactory**: Factory personalizada que configura ambiente de teste com MongoDB container +- **MongoDbTestFixture**: Helper para criar e gerenciar dados de teste no MongoDB +- **StripeTestFixture**: Mock para integração Stripe (futuro) + +### Helpers +- **AuthenticationHelper**: Mock para autenticação OAuth (Google/Microsoft) +- **PuppeteerTestHelper**: Automação de browser para testes E2E +- **TestDataBuilder**: Builders para criar objetos de teste (futuro) + +### Tests +- **PageCreationTests**: Validação de criação de páginas e limites por plano +- **PreviewTokenTests**: Sistema de preview tokens para páginas não-ativas +- **ModerationWorkflowTests**: Workflow completo de moderação +- **PlanLimitationTests**: Validação de limitações por plano (futuro) +- **StripeIntegrationTests**: Testes de upgrade via Stripe (futuro) + +## Cenários Testados + +### Sistema de Páginas +1. **Criação de páginas** respeitando limites dos planos (Trial: 1, Basic: 3, etc.) +2. **Status de páginas**: Creating → PendingModeration → Active/Rejected +3. **Preview tokens**: Acesso a páginas em desenvolvimento (4h de validade) +4. **Validação de limites**: Links normais vs produto por plano + +### Workflow de Moderação +1. **Submissão para moderação**: Creating → PendingModeration +2. **Aprovação**: PendingModeration → Active (page vira pública) +3. **Rejeição**: PendingModeration → Inactive/Rejected +4. **Preview system**: Acesso via token para pages não-Active + +### Plan Limitations (Basic vs Professional) +- **Basic**: 5 links máximo +- **Professional**: 15 links máximo +- **Trial**: 1 página, 3 links + 1 produto + +## Tecnologias Utilizadas + +- **xUnit**: Framework de testes +- **FluentAssertions**: Assertions expressivas +- **WebApplicationFactory**: Testes integrados ASP.NET Core +- **Testcontainers**: MongoDB container para isolamento +- **PuppeteerSharp**: Automação de browser (Chrome) +- **MongoDB.Driver**: Acesso direto ao banco para validações + +## Configuração + +### Pré-requisitos +- .NET 8 SDK +- Docker (para MongoDB container) +- Chrome/Chromium (baixado automaticamente pelo PuppeteerSharp) + +### Executar Testes +```bash +# Todos os testes +dotnet test src/BCards.IntegrationTests/ + +# Testes específicos +dotnet test src/BCards.IntegrationTests/ --filter "PageCreationTests" +dotnet test src/BCards.IntegrationTests/ --filter "PreviewTokenTests" +``` + +### Configuração Manual (MongoDB local) +Se preferir usar MongoDB local em vez do container: + +```json +// appsettings.Testing.json +{ + "MongoDb": { + "ConnectionString": "mongodb://localhost:27017", + "DatabaseName": "BCardsDB_Test" + } +} +``` + +## Estrutura de Dados de Teste + +### User +- **Trial**: 1 página máx, links limitados +- **Basic**: 3 páginas, 5 links por página +- **Professional**: 5 páginas, 15 links por página + +### UserPage +- **Status**: Creating, PendingModeration, Active, Rejected +- **Preview Tokens**: 4h de validade para access não-Active +- **Links**: Normal vs Product (limites diferentes por plano) + +### Categories +- **tecnologia**: Empresas de tech +- **negocios**: Empresas e empreendedores +- **pessoal**: Freelancers e páginas pessoais +- **saude**: Profissionais da área da saúde + +## Padrões de Teste + +### Arrange-Act-Assert +Todos os testes seguem o padrão AAA: +```csharp +// Arrange +var user = await _dbFixture.CreateTestUserAsync(PlanType.Basic); +var page = await _dbFixture.CreateTestUserPageAsync(user.Id, PageStatus.Creating); + +// Act +var response = await client.PostAsync($"/Admin/SubmitForModeration/{page.Id}", null); + +// Assert +response.IsSuccessStatusCode.Should().BeTrue(); +``` + +### Cleanup Automático +- Cada teste usa database isolada (GUID no nome) +- Container MongoDB é destruído após os testes +- Sem interferência entre testes + +### Mocks +- **EmailService**: Mockado para evitar envios reais +- **StripeService**: Mockado para evitar cobrança real +- **OAuth**: Mockado para evitar dependência externa + +## Debug e Troubleshooting + +### PuppeteerSharp +Para debug visual dos testes de browser: +```csharp +_browser = await Puppeteer.LaunchAsync(new LaunchOptions +{ + Headless = false, // Mostra o browser + SlowMo = 100 // Delay entre ações +}); +``` + +### MongoDB +Para inspecionar dados durante testes, conecte no container: +```bash +docker exec -it mongosh BCardsDB_Test +``` + +### Logs +Logs são configurados para mostrar apenas warnings/errors durante testes. +Para debug detalhado, altere em `BCardsWebApplicationFactory`: +```csharp +logging.SetMinimumLevel(LogLevel.Information); +``` + +## Próximos Passos + +1. **PlanLimitationTests**: Validar todas as limitações por plano +2. **StripeIntegrationTests**: Testar upgrades via webhook +3. **PerformanceTests**: Testar carga no sistema de moderação +4. **E2E Tests**: Testes completos com PuppeteerSharp 5. **TrialExpirationTests**: Validar exclusão automática após 7 dias \ No newline at end of file diff --git a/src/BCards.IntegrationTests/Tests/ModerationWorkflowTests.cs b/src/BCards.IntegrationTests/Tests/ModerationWorkflowTests.cs index 44ecd07..89f1ede 100644 --- a/src/BCards.IntegrationTests/Tests/ModerationWorkflowTests.cs +++ b/src/BCards.IntegrationTests/Tests/ModerationWorkflowTests.cs @@ -1,204 +1,204 @@ -using Xunit; -using FluentAssertions; -using Microsoft.Extensions.DependencyInjection; -using MongoDB.Driver; -using BCards.Web.Models; -using BCards.Web.ViewModels; -using BCards.Web.Services; -using BCards.IntegrationTests.Fixtures; -using BCards.IntegrationTests.Helpers; -using System.Net; - -namespace BCards.IntegrationTests.Tests; - -public class ModerationWorkflowTests : IClassFixture, IAsyncLifetime -{ - private readonly BCardsWebApplicationFactory _factory; - private readonly HttpClient _client; - private MongoDbTestFixture _dbFixture = null!; - - public ModerationWorkflowTests(BCardsWebApplicationFactory factory) - { - _factory = factory; - _client = _factory.CreateClient(); - } - - public async Task InitializeAsync() - { - using var scope = _factory.Services.CreateScope(); - var database = scope.ServiceProvider.GetRequiredService(); - _dbFixture = new MongoDbTestFixture(database); - - await _factory.CleanDatabaseAsync(); - await _dbFixture.InitializeTestDataAsync(); - } - - public Task DisposeAsync() => Task.CompletedTask; - - [Fact] - public async Task SubmitPageForModeration_ShouldChangeStatusToPendingModeration() - { - // Arrange - var user = await _dbFixture.CreateTestUserAsync(PlanType.Basic); - var page = await _dbFixture.CreateTestUserPageAsync(user.Id, PageStatus.Creating, "tecnologia", 3, 1); - var authenticatedClient = await AuthenticationHelper.CreateAuthenticatedClientAsync(_factory, user); - - // Act - Submit page for moderation - var response = await authenticatedClient.PostAsync($"/Admin/SubmitForModeration/{page.Id}", null); - - // Assert - response.IsSuccessStatusCode.Should().BeTrue(); - - // Verify page status changed in database - var updatedPages = await _dbFixture.GetUserPagesAsync(user.Id); - var updatedPage = updatedPages.First(); - - updatedPage.Status.Should().Be(PageStatus.PendingModeration); - updatedPage.ModerationAttempts.Should().BeGreaterThan(0); - } - - [Fact] - public async Task SubmitPageForModeration_WithoutActiveLinks_ShouldFail() - { - // Arrange - var user = await _dbFixture.CreateTestUserAsync(PlanType.Basic); - var page = await _dbFixture.CreateTestUserPageAsync(user.Id, PageStatus.Creating, "tecnologia", 0, 0); // No links - var authenticatedClient = await AuthenticationHelper.CreateAuthenticatedClientAsync(_factory, user); - - // Act - var response = await authenticatedClient.PostAsync($"/Admin/SubmitForModeration/{page.Id}", null); - - // Assert - response.IsSuccessStatusCode.Should().BeFalse(); - - // Verify page status didn't change - var updatedPages = await _dbFixture.GetUserPagesAsync(user.Id); - var updatedPage = updatedPages.First(); - - updatedPage.Status.Should().Be(PageStatus.Creating); - } - - [Fact] - public async Task ApprovePage_ShouldChangeStatusToActive() - { - // Arrange - using var scope = _factory.Services.CreateScope(); - var moderationService = scope.ServiceProvider.GetRequiredService(); - - var user = await _dbFixture.CreateTestUserAsync(PlanType.Basic); - var page = await _dbFixture.CreateTestUserPageAsync(user.Id, PageStatus.PendingModeration, "tecnologia", 3, 1); - - // Act - Approve the page - await moderationService.ApprovePageAsync(page.Id, "test-moderator-id", "Page looks good"); - - // Assert - var updatedPages = await _dbFixture.GetUserPagesAsync(user.Id); - var updatedPage = updatedPages.First(); - - updatedPage.Status.Should().Be(PageStatus.Active); - updatedPage.ApprovedAt.Should().BeCloseTo(DateTime.UtcNow, TimeSpan.FromMinutes(1)); - updatedPage.ModerationHistory.Should().HaveCount(1); - updatedPage.ModerationHistory.First().Status.Should().Be("approved"); - } - - [Fact] - public async Task RejectPage_ShouldChangeStatusToRejected() - { - // Arrange - using var scope = _factory.Services.CreateScope(); - var moderationService = scope.ServiceProvider.GetRequiredService(); - - var user = await _dbFixture.CreateTestUserAsync(PlanType.Basic); - var page = await _dbFixture.CreateTestUserPageAsync(user.Id, PageStatus.PendingModeration, "tecnologia", 3, 1); - - // Act - Reject the page - await moderationService.RejectPageAsync(page.Id, "test-moderator-id", "Inappropriate content", new List { "spam", "offensive" }); - - // Assert - var updatedPages = await _dbFixture.GetUserPagesAsync(user.Id); - var updatedPage = updatedPages.First(); - - updatedPage.Status.Should().Be(PageStatus.Inactive); // First rejection goes to Inactive - updatedPage.ModerationHistory.Should().HaveCount(1); - - var rejectionHistory = updatedPage.ModerationHistory.First(); - rejectionHistory.Status.Should().Be("rejected"); - rejectionHistory.Reason.Should().Be("Inappropriate content"); - rejectionHistory.Issues.Should().Contain("spam"); - rejectionHistory.Issues.Should().Contain("offensive"); - } - - [Fact] - public async Task AccessApprovedPage_WithoutPreviewToken_ShouldSucceed() - { - // Arrange - using var scope = _factory.Services.CreateScope(); - var moderationService = scope.ServiceProvider.GetRequiredService(); - - var user = await _dbFixture.CreateTestUserAsync(PlanType.Basic); - var page = await _dbFixture.CreateTestUserPageAsync(user.Id, PageStatus.PendingModeration, "tecnologia", 3, 1); - - // Approve the page - await moderationService.ApprovePageAsync(page.Id, "test-moderator-id", "Approved"); - - // Act - Access the page without preview token - var response = await _client.GetAsync($"/page/{page.Category}/{page.Slug}"); - - // Assert - response.StatusCode.Should().Be(HttpStatusCode.OK); - - var content = await response.Content.ReadAsStringAsync(); - content.Should().Contain(page.DisplayName); - content.Should().NotContain("MODO PREVIEW"); // Should not show preview banner - } - - [Fact] - public async Task GetPendingModerationPages_ShouldReturnCorrectPages() - { - // Arrange - using var scope = _factory.Services.CreateScope(); - var moderationService = scope.ServiceProvider.GetRequiredService(); - - var user1 = await _dbFixture.CreateTestUserAsync(PlanType.Basic, "user1@example.com"); - var user2 = await _dbFixture.CreateTestUserAsync(PlanType.Basic, "user2@example.com"); - - // Create pages in different statuses - await _dbFixture.CreateTestUserPageAsync(user1.Id, PageStatus.PendingModeration, "tecnologia", 3, 1); - await _dbFixture.CreateTestUserPageAsync(user2.Id, PageStatus.PendingModeration, "negocios", 4, 2); - await _dbFixture.CreateTestUserPageAsync(user1.Id, PageStatus.Creating, "pessoal", 2, 0); // Should not appear - await _dbFixture.CreateTestUserPageAsync(user2.Id, PageStatus.Active, "saude", 5, 1); // Should not appear - - // Act - var pendingPages = await moderationService.GetPendingModerationAsync(); - - // Assert - pendingPages.Should().HaveCount(2); - pendingPages.Should().OnlyContain(p => p.Status == PageStatus.PendingModeration); - } - - [Fact] - public async Task ModerationStats_ShouldReturnCorrectCounts() - { - // Arrange - using var scope = _factory.Services.CreateScope(); - var moderationService = scope.ServiceProvider.GetRequiredService(); - - var user = await _dbFixture.CreateTestUserAsync(PlanType.Basic); - - // Create pages with different statuses - var pendingPage1 = await _dbFixture.CreateTestUserPageAsync(user.Id, PageStatus.PendingModeration, "tecnologia", 3, 1); - var pendingPage2 = await _dbFixture.CreateTestUserPageAsync(user.Id, PageStatus.PendingModeration, "negocios", 3, 1); - var activePage = await _dbFixture.CreateTestUserPageAsync(user.Id, PageStatus.PendingModeration, "pessoal", 3, 1); - - // Approve one page today - await moderationService.ApprovePageAsync(activePage.Id, "moderator", "Good"); - - // Act - var stats = await moderationService.GetModerationStatsAsync(); - - // Assert - stats["pending"].Should().Be(2); - stats["approvedToday"].Should().Be(1); - stats["rejectedToday"].Should().Be(0); - } +using Xunit; +using FluentAssertions; +using Microsoft.Extensions.DependencyInjection; +using MongoDB.Driver; +using BCards.Web.Models; +using BCards.Web.ViewModels; +using BCards.Web.Services; +using BCards.IntegrationTests.Fixtures; +using BCards.IntegrationTests.Helpers; +using System.Net; + +namespace BCards.IntegrationTests.Tests; + +public class ModerationWorkflowTests : IClassFixture, IAsyncLifetime +{ + private readonly BCardsWebApplicationFactory _factory; + private readonly HttpClient _client; + private MongoDbTestFixture _dbFixture = null!; + + public ModerationWorkflowTests(BCardsWebApplicationFactory factory) + { + _factory = factory; + _client = _factory.CreateClient(); + } + + public async Task InitializeAsync() + { + using var scope = _factory.Services.CreateScope(); + var database = scope.ServiceProvider.GetRequiredService(); + _dbFixture = new MongoDbTestFixture(database); + + await _factory.CleanDatabaseAsync(); + await _dbFixture.InitializeTestDataAsync(); + } + + public Task DisposeAsync() => Task.CompletedTask; + + [Fact] + public async Task SubmitPageForModeration_ShouldChangeStatusToPendingModeration() + { + // Arrange + var user = await _dbFixture.CreateTestUserAsync(PlanType.Basic); + var page = await _dbFixture.CreateTestUserPageAsync(user.Id, PageStatus.Creating, "tecnologia", 3, 1); + var authenticatedClient = await AuthenticationHelper.CreateAuthenticatedClientAsync(_factory, user); + + // Act - Submit page for moderation + var response = await authenticatedClient.PostAsync($"/Admin/SubmitForModeration/{page.Id}", null); + + // Assert + response.IsSuccessStatusCode.Should().BeTrue(); + + // Verify page status changed in database + var updatedPages = await _dbFixture.GetUserPagesAsync(user.Id); + var updatedPage = updatedPages.First(); + + updatedPage.Status.Should().Be(PageStatus.PendingModeration); + updatedPage.ModerationAttempts.Should().BeGreaterThan(0); + } + + [Fact] + public async Task SubmitPageForModeration_WithoutActiveLinks_ShouldFail() + { + // Arrange + var user = await _dbFixture.CreateTestUserAsync(PlanType.Basic); + var page = await _dbFixture.CreateTestUserPageAsync(user.Id, PageStatus.Creating, "tecnologia", 0, 0); // No links + var authenticatedClient = await AuthenticationHelper.CreateAuthenticatedClientAsync(_factory, user); + + // Act + var response = await authenticatedClient.PostAsync($"/Admin/SubmitForModeration/{page.Id}", null); + + // Assert + response.IsSuccessStatusCode.Should().BeFalse(); + + // Verify page status didn't change + var updatedPages = await _dbFixture.GetUserPagesAsync(user.Id); + var updatedPage = updatedPages.First(); + + updatedPage.Status.Should().Be(PageStatus.Creating); + } + + [Fact] + public async Task ApprovePage_ShouldChangeStatusToActive() + { + // Arrange + using var scope = _factory.Services.CreateScope(); + var moderationService = scope.ServiceProvider.GetRequiredService(); + + var user = await _dbFixture.CreateTestUserAsync(PlanType.Basic); + var page = await _dbFixture.CreateTestUserPageAsync(user.Id, PageStatus.PendingModeration, "tecnologia", 3, 1); + + // Act - Approve the page + await moderationService.ApprovePageAsync(page.Id, "test-moderator-id", "Page looks good"); + + // Assert + var updatedPages = await _dbFixture.GetUserPagesAsync(user.Id); + var updatedPage = updatedPages.First(); + + updatedPage.Status.Should().Be(PageStatus.Active); + updatedPage.ApprovedAt.Should().BeCloseTo(DateTime.UtcNow, TimeSpan.FromMinutes(1)); + updatedPage.ModerationHistory.Should().HaveCount(1); + updatedPage.ModerationHistory.First().Status.Should().Be("approved"); + } + + [Fact] + public async Task RejectPage_ShouldChangeStatusToRejected() + { + // Arrange + using var scope = _factory.Services.CreateScope(); + var moderationService = scope.ServiceProvider.GetRequiredService(); + + var user = await _dbFixture.CreateTestUserAsync(PlanType.Basic); + var page = await _dbFixture.CreateTestUserPageAsync(user.Id, PageStatus.PendingModeration, "tecnologia", 3, 1); + + // Act - Reject the page + await moderationService.RejectPageAsync(page.Id, "test-moderator-id", "Inappropriate content", new List { "spam", "offensive" }); + + // Assert + var updatedPages = await _dbFixture.GetUserPagesAsync(user.Id); + var updatedPage = updatedPages.First(); + + updatedPage.Status.Should().Be(PageStatus.Inactive); // First rejection goes to Inactive + updatedPage.ModerationHistory.Should().HaveCount(1); + + var rejectionHistory = updatedPage.ModerationHistory.First(); + rejectionHistory.Status.Should().Be("rejected"); + rejectionHistory.Reason.Should().Be("Inappropriate content"); + rejectionHistory.Issues.Should().Contain("spam"); + rejectionHistory.Issues.Should().Contain("offensive"); + } + + [Fact] + public async Task AccessApprovedPage_WithoutPreviewToken_ShouldSucceed() + { + // Arrange + using var scope = _factory.Services.CreateScope(); + var moderationService = scope.ServiceProvider.GetRequiredService(); + + var user = await _dbFixture.CreateTestUserAsync(PlanType.Basic); + var page = await _dbFixture.CreateTestUserPageAsync(user.Id, PageStatus.PendingModeration, "tecnologia", 3, 1); + + // Approve the page + await moderationService.ApprovePageAsync(page.Id, "test-moderator-id", "Approved"); + + // Act - Access the page without preview token + var response = await _client.GetAsync($"/page/{page.Category}/{page.Slug}"); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.OK); + + var content = await response.Content.ReadAsStringAsync(); + content.Should().Contain(page.DisplayName); + content.Should().NotContain("MODO PREVIEW"); // Should not show preview banner + } + + [Fact] + public async Task GetPendingModerationPages_ShouldReturnCorrectPages() + { + // Arrange + using var scope = _factory.Services.CreateScope(); + var moderationService = scope.ServiceProvider.GetRequiredService(); + + var user1 = await _dbFixture.CreateTestUserAsync(PlanType.Basic, "user1@example.com"); + var user2 = await _dbFixture.CreateTestUserAsync(PlanType.Basic, "user2@example.com"); + + // Create pages in different statuses + await _dbFixture.CreateTestUserPageAsync(user1.Id, PageStatus.PendingModeration, "tecnologia", 3, 1); + await _dbFixture.CreateTestUserPageAsync(user2.Id, PageStatus.PendingModeration, "negocios", 4, 2); + await _dbFixture.CreateTestUserPageAsync(user1.Id, PageStatus.Creating, "pessoal", 2, 0); // Should not appear + await _dbFixture.CreateTestUserPageAsync(user2.Id, PageStatus.Active, "saude", 5, 1); // Should not appear + + // Act + var pendingPages = await moderationService.GetPendingModerationAsync(); + + // Assert + pendingPages.Should().HaveCount(2); + pendingPages.Should().OnlyContain(p => p.Status == PageStatus.PendingModeration); + } + + [Fact] + public async Task ModerationStats_ShouldReturnCorrectCounts() + { + // Arrange + using var scope = _factory.Services.CreateScope(); + var moderationService = scope.ServiceProvider.GetRequiredService(); + + var user = await _dbFixture.CreateTestUserAsync(PlanType.Basic); + + // Create pages with different statuses + var pendingPage1 = await _dbFixture.CreateTestUserPageAsync(user.Id, PageStatus.PendingModeration, "tecnologia", 3, 1); + var pendingPage2 = await _dbFixture.CreateTestUserPageAsync(user.Id, PageStatus.PendingModeration, "negocios", 3, 1); + var activePage = await _dbFixture.CreateTestUserPageAsync(user.Id, PageStatus.PendingModeration, "pessoal", 3, 1); + + // Approve one page today + await moderationService.ApprovePageAsync(activePage.Id, "moderator", "Good"); + + // Act + var stats = await moderationService.GetModerationStatsAsync(); + + // Assert + stats["pending"].Should().Be(2); + stats["approvedToday"].Should().Be(1); + stats["rejectedToday"].Should().Be(0); + } } \ No newline at end of file diff --git a/src/BCards.IntegrationTests/Tests/PageCreationTests.cs b/src/BCards.IntegrationTests/Tests/PageCreationTests.cs index 9e994a1..d7e03ce 100644 --- a/src/BCards.IntegrationTests/Tests/PageCreationTests.cs +++ b/src/BCards.IntegrationTests/Tests/PageCreationTests.cs @@ -1,238 +1,238 @@ -using Xunit; -using FluentAssertions; -using Microsoft.Extensions.DependencyInjection; -using MongoDB.Driver; -using BCards.Web.Models; -using BCards.Web.ViewModels; -using BCards.IntegrationTests.Fixtures; -using BCards.IntegrationTests.Helpers; -using System.Net.Http.Json; - -namespace BCards.IntegrationTests.Tests; - -public class PageCreationTests : IClassFixture, IAsyncLifetime -{ - private readonly BCardsWebApplicationFactory _factory; - private readonly HttpClient _client; - private MongoDbTestFixture _dbFixture = null!; - - public PageCreationTests(BCardsWebApplicationFactory factory) - { - _factory = factory; - _client = _factory.CreateClient(); - } - - public async Task InitializeAsync() - { - using var scope = _factory.Services.CreateScope(); - var database = scope.ServiceProvider.GetRequiredService(); - _dbFixture = new MongoDbTestFixture(database); - - await _factory.CleanDatabaseAsync(); - await _dbFixture.InitializeTestDataAsync(); - } - - public Task DisposeAsync() => Task.CompletedTask; - - [Fact] - public async Task CreatePage_WithBasicPlan_ShouldAllowUpTo5Links() - { - // Arrange - var user = await _dbFixture.CreateTestUserAsync(PlanType.Basic); - var authenticatedClient = await AuthenticationHelper.CreateAuthenticatedClientAsync(_factory, user); - - // Act - Create a page with 5 links (should succeed) - var pageData = new - { - DisplayName = "Test Business Page", - Category = "tecnologia", - BusinessType = "company", - Bio = "A test business page", - Slug = "test-business", - SelectedTheme = "minimalist", - Links = new[] - { - new { Title = "Website", Url = "https://example.com", Description = "Main website", Icon = "fas fa-globe" }, - new { Title = "Email", Url = "mailto:contact@example.com", Description = "Contact email", Icon = "fas fa-envelope" }, - new { Title = "Phone", Url = "tel:+5511999999999", Description = "Contact phone", Icon = "fas fa-phone" }, - new { Title = "LinkedIn", Url = "https://linkedin.com/company/example", Description = "LinkedIn profile", Icon = "fab fa-linkedin" }, - new { Title = "Instagram", Url = "https://instagram.com/example", Description = "Instagram profile", Icon = "fab fa-instagram" } - } - }; - - var createResponse = await authenticatedClient.PostAsJsonAsync("/Admin/ManagePage", pageData); - - // Assert - createResponse.IsSuccessStatusCode.Should().BeTrue("Basic plan should allow 5 links"); - - // Verify page was created in database - var createdPages = await _dbFixture.GetUserPagesAsync(user.Id); - createdPages.Should().HaveCount(1); - - var createdPage = createdPages.First(); - createdPage.DisplayName.Should().Be("Test Business Page"); - createdPage.Category.Should().Be("tecnologia"); - createdPage.Status.Should().Be(PageStatus.Creating); - createdPage.Links.Should().HaveCount(5); - } - - [Fact] - public async Task CreatePage_WithBasicPlanExceedingLimits_ShouldFail() - { - // Arrange - var user = await _dbFixture.CreateTestUserAsync(PlanType.Basic); - var authenticatedClient = await AuthenticationHelper.CreateAuthenticatedClientAsync(_factory, user); - - // Act - Try to create a page with 6 links (should fail for Basic plan) - var pageData = new - { - DisplayName = "Test Page Exceeding Limits", - Category = "tecnologia", - BusinessType = "individual", - Bio = "A test page with too many links", - Slug = "test-exceeding", - SelectedTheme = "minimalist", - Links = new[] - { - new { Title = "Link 1", Url = "https://example1.com", Description = "Link 1", Icon = "fas fa-link" }, - new { Title = "Link 2", Url = "https://example2.com", Description = "Link 2", Icon = "fas fa-link" }, - new { Title = "Link 3", Url = "https://example3.com", Description = "Link 3", Icon = "fas fa-link" }, - new { Title = "Link 4", Url = "https://example4.com", Description = "Link 4", Icon = "fas fa-link" }, - new { Title = "Link 5", Url = "https://example5.com", Description = "Link 5", Icon = "fas fa-link" }, - new { Title = "Link 6", Url = "https://example6.com", Description = "Link 6", Icon = "fas fa-link" } // This should fail - } - }; - - var createResponse = await authenticatedClient.PostAsJsonAsync("/Admin/ManagePage", pageData); - - // Assert - createResponse.IsSuccessStatusCode.Should().BeFalse("Basic plan should not allow more than 5 links"); - - // Verify no page was created - var createdPages = await _dbFixture.GetUserPagesAsync(user.Id); - createdPages.Should().BeEmpty(); - } - - [Fact] - public async Task CreatePage_ShouldStartInCreatingStatus() - { - // Arrange - var user = await _dbFixture.CreateTestUserAsync(PlanType.Basic); - var authenticatedClient = await AuthenticationHelper.CreateAuthenticatedClientAsync(_factory, user); - - // Act - var pageData = new - { - DisplayName = "New Page", - Category = "pessoal", - BusinessType = "individual", - Bio = "Test page bio", - Slug = "new-page", - SelectedTheme = "minimalist", - Links = new[] - { - new { Title = "Portfolio", Url = "https://myportfolio.com", Description = "My work", Icon = "fas fa-briefcase" } - } - }; - - var createResponse = await authenticatedClient.PostAsJsonAsync("/Admin/ManagePage", pageData); - - // Assert - createResponse.IsSuccessStatusCode.Should().BeTrue(); - - var createdPages = await _dbFixture.GetUserPagesAsync(user.Id); - var page = createdPages.First(); - - page.Status.Should().Be(PageStatus.Creating); - page.PreviewToken.Should().NotBeNullOrEmpty("Creating pages should have preview tokens"); - page.PreviewTokenExpiry.Should().BeAfter(DateTime.UtcNow.AddHours(3), "Preview token should be valid for ~4 hours"); - } - - [Fact] - public async Task CreatePage_WithTrialPlan_ShouldAllowOnePageOnly() - { - // Arrange - var user = await _dbFixture.CreateTestUserAsync(PlanType.Trial); - var authenticatedClient = await AuthenticationHelper.CreateAuthenticatedClientAsync(_factory, user); - - // Act - Create first page (should succeed) - var firstPageData = new - { - DisplayName = "First Trial Page", - Category = "pessoal", - BusinessType = "individual", - Bio = "First page in trial", - Slug = "first-trial", - SelectedTheme = "minimalist", - Links = new[] - { - new { Title = "Website", Url = "https://example.com", Description = "My website", Icon = "fas fa-globe" } - } - }; - - var firstResponse = await authenticatedClient.PostAsJsonAsync("/Admin/ManagePage", firstPageData); - firstResponse.IsSuccessStatusCode.Should().BeTrue("Trial should allow first page"); - - // Act - Try to create second page (should fail) - var secondPageData = new - { - DisplayName = "Second Trial Page", - Category = "tecnologia", - BusinessType = "individual", - Bio = "Second page in trial - should fail", - Slug = "second-trial", - SelectedTheme = "minimalist", - Links = new[] - { - new { Title = "LinkedIn", Url = "https://linkedin.com/in/test", Description = "LinkedIn", Icon = "fab fa-linkedin" } - } - }; - - var secondResponse = await authenticatedClient.PostAsJsonAsync("/Admin/ManagePage", secondPageData); - - // Assert - secondResponse.IsSuccessStatusCode.Should().BeFalse("Trial should not allow second page"); - - var createdPages = await _dbFixture.GetUserPagesAsync(user.Id); - createdPages.Should().HaveCount(1, "Trial should only have one page"); - } - - [Fact] - public async Task CreatePage_ShouldGenerateUniqueSlug() - { - // Arrange - var user = await _dbFixture.CreateTestUserAsync(PlanType.Professional); - var authenticatedClient = await AuthenticationHelper.CreateAuthenticatedClientAsync(_factory, user); - - // Create first page with specific slug - await _dbFixture.CreateTestUserPageAsync(user.Id, PageStatus.Active, "tecnologia", 3, 1, "test-slug"); - - // Act - Try to create another page with same name (should get different slug) - var pageData = new - { - DisplayName = "Test Page", // Same display name, should generate different slug - Category = "tecnologia", - BusinessType = "individual", - Bio = "Another test page", - Slug = "test-slug", // Try to use same slug - SelectedTheme = "minimalist", - Links = new[] - { - new { Title = "Website", Url = "https://example.com", Description = "Website", Icon = "fas fa-globe" } - } - }; - - var createResponse = await authenticatedClient.PostAsJsonAsync("/Admin/ManagePage", pageData); - - // Assert - createResponse.IsSuccessStatusCode.Should().BeTrue(); - - var userPages = await _dbFixture.GetUserPagesAsync(user.Id); - userPages.Should().HaveCount(2); - - var slugs = userPages.Select(p => p.Slug).ToList(); - slugs.Should().OnlyHaveUniqueItems("All pages should have unique slugs"); - slugs.Should().Contain("test-slug"); - slugs.Should().Contain(slug => slug.StartsWith("test-slug-") || slug == "test-page"); - } +using Xunit; +using FluentAssertions; +using Microsoft.Extensions.DependencyInjection; +using MongoDB.Driver; +using BCards.Web.Models; +using BCards.Web.ViewModels; +using BCards.IntegrationTests.Fixtures; +using BCards.IntegrationTests.Helpers; +using System.Net.Http.Json; + +namespace BCards.IntegrationTests.Tests; + +public class PageCreationTests : IClassFixture, IAsyncLifetime +{ + private readonly BCardsWebApplicationFactory _factory; + private readonly HttpClient _client; + private MongoDbTestFixture _dbFixture = null!; + + public PageCreationTests(BCardsWebApplicationFactory factory) + { + _factory = factory; + _client = _factory.CreateClient(); + } + + public async Task InitializeAsync() + { + using var scope = _factory.Services.CreateScope(); + var database = scope.ServiceProvider.GetRequiredService(); + _dbFixture = new MongoDbTestFixture(database); + + await _factory.CleanDatabaseAsync(); + await _dbFixture.InitializeTestDataAsync(); + } + + public Task DisposeAsync() => Task.CompletedTask; + + [Fact] + public async Task CreatePage_WithBasicPlan_ShouldAllowUpTo5Links() + { + // Arrange + var user = await _dbFixture.CreateTestUserAsync(PlanType.Basic); + var authenticatedClient = await AuthenticationHelper.CreateAuthenticatedClientAsync(_factory, user); + + // Act - Create a page with 5 links (should succeed) + var pageData = new + { + DisplayName = "Test Business Page", + Category = "tecnologia", + BusinessType = "company", + Bio = "A test business page", + Slug = "test-business", + SelectedTheme = "minimalist", + Links = new[] + { + new { Title = "Website", Url = "https://example.com", Description = "Main website", Icon = "fas fa-globe" }, + new { Title = "Email", Url = "mailto:contact@example.com", Description = "Contact email", Icon = "fas fa-envelope" }, + new { Title = "Phone", Url = "tel:+5511999999999", Description = "Contact phone", Icon = "fas fa-phone" }, + new { Title = "LinkedIn", Url = "https://linkedin.com/company/example", Description = "LinkedIn profile", Icon = "fab fa-linkedin" }, + new { Title = "Instagram", Url = "https://instagram.com/example", Description = "Instagram profile", Icon = "fab fa-instagram" } + } + }; + + var createResponse = await authenticatedClient.PostAsJsonAsync("/Admin/ManagePage", pageData); + + // Assert + createResponse.IsSuccessStatusCode.Should().BeTrue("Basic plan should allow 5 links"); + + // Verify page was created in database + var createdPages = await _dbFixture.GetUserPagesAsync(user.Id); + createdPages.Should().HaveCount(1); + + var createdPage = createdPages.First(); + createdPage.DisplayName.Should().Be("Test Business Page"); + createdPage.Category.Should().Be("tecnologia"); + createdPage.Status.Should().Be(PageStatus.Creating); + createdPage.Links.Should().HaveCount(5); + } + + [Fact] + public async Task CreatePage_WithBasicPlanExceedingLimits_ShouldFail() + { + // Arrange + var user = await _dbFixture.CreateTestUserAsync(PlanType.Basic); + var authenticatedClient = await AuthenticationHelper.CreateAuthenticatedClientAsync(_factory, user); + + // Act - Try to create a page with 6 links (should fail for Basic plan) + var pageData = new + { + DisplayName = "Test Page Exceeding Limits", + Category = "tecnologia", + BusinessType = "individual", + Bio = "A test page with too many links", + Slug = "test-exceeding", + SelectedTheme = "minimalist", + Links = new[] + { + new { Title = "Link 1", Url = "https://example1.com", Description = "Link 1", Icon = "fas fa-link" }, + new { Title = "Link 2", Url = "https://example2.com", Description = "Link 2", Icon = "fas fa-link" }, + new { Title = "Link 3", Url = "https://example3.com", Description = "Link 3", Icon = "fas fa-link" }, + new { Title = "Link 4", Url = "https://example4.com", Description = "Link 4", Icon = "fas fa-link" }, + new { Title = "Link 5", Url = "https://example5.com", Description = "Link 5", Icon = "fas fa-link" }, + new { Title = "Link 6", Url = "https://example6.com", Description = "Link 6", Icon = "fas fa-link" } // This should fail + } + }; + + var createResponse = await authenticatedClient.PostAsJsonAsync("/Admin/ManagePage", pageData); + + // Assert + createResponse.IsSuccessStatusCode.Should().BeFalse("Basic plan should not allow more than 5 links"); + + // Verify no page was created + var createdPages = await _dbFixture.GetUserPagesAsync(user.Id); + createdPages.Should().BeEmpty(); + } + + [Fact] + public async Task CreatePage_ShouldStartInCreatingStatus() + { + // Arrange + var user = await _dbFixture.CreateTestUserAsync(PlanType.Basic); + var authenticatedClient = await AuthenticationHelper.CreateAuthenticatedClientAsync(_factory, user); + + // Act + var pageData = new + { + DisplayName = "New Page", + Category = "pessoal", + BusinessType = "individual", + Bio = "Test page bio", + Slug = "new-page", + SelectedTheme = "minimalist", + Links = new[] + { + new { Title = "Portfolio", Url = "https://myportfolio.com", Description = "My work", Icon = "fas fa-briefcase" } + } + }; + + var createResponse = await authenticatedClient.PostAsJsonAsync("/Admin/ManagePage", pageData); + + // Assert + createResponse.IsSuccessStatusCode.Should().BeTrue(); + + var createdPages = await _dbFixture.GetUserPagesAsync(user.Id); + var page = createdPages.First(); + + page.Status.Should().Be(PageStatus.Creating); + page.PreviewToken.Should().NotBeNullOrEmpty("Creating pages should have preview tokens"); + page.PreviewTokenExpiry.Should().BeAfter(DateTime.UtcNow.AddHours(3), "Preview token should be valid for ~4 hours"); + } + + [Fact] + public async Task CreatePage_WithTrialPlan_ShouldAllowOnePageOnly() + { + // Arrange + var user = await _dbFixture.CreateTestUserAsync(PlanType.Trial); + var authenticatedClient = await AuthenticationHelper.CreateAuthenticatedClientAsync(_factory, user); + + // Act - Create first page (should succeed) + var firstPageData = new + { + DisplayName = "First Trial Page", + Category = "pessoal", + BusinessType = "individual", + Bio = "First page in trial", + Slug = "first-trial", + SelectedTheme = "minimalist", + Links = new[] + { + new { Title = "Website", Url = "https://example.com", Description = "My website", Icon = "fas fa-globe" } + } + }; + + var firstResponse = await authenticatedClient.PostAsJsonAsync("/Admin/ManagePage", firstPageData); + firstResponse.IsSuccessStatusCode.Should().BeTrue("Trial should allow first page"); + + // Act - Try to create second page (should fail) + var secondPageData = new + { + DisplayName = "Second Trial Page", + Category = "tecnologia", + BusinessType = "individual", + Bio = "Second page in trial - should fail", + Slug = "second-trial", + SelectedTheme = "minimalist", + Links = new[] + { + new { Title = "LinkedIn", Url = "https://linkedin.com/in/test", Description = "LinkedIn", Icon = "fab fa-linkedin" } + } + }; + + var secondResponse = await authenticatedClient.PostAsJsonAsync("/Admin/ManagePage", secondPageData); + + // Assert + secondResponse.IsSuccessStatusCode.Should().BeFalse("Trial should not allow second page"); + + var createdPages = await _dbFixture.GetUserPagesAsync(user.Id); + createdPages.Should().HaveCount(1, "Trial should only have one page"); + } + + [Fact] + public async Task CreatePage_ShouldGenerateUniqueSlug() + { + // Arrange + var user = await _dbFixture.CreateTestUserAsync(PlanType.Professional); + var authenticatedClient = await AuthenticationHelper.CreateAuthenticatedClientAsync(_factory, user); + + // Create first page with specific slug + await _dbFixture.CreateTestUserPageAsync(user.Id, PageStatus.Active, "tecnologia", 3, 1, "test-slug"); + + // Act - Try to create another page with same name (should get different slug) + var pageData = new + { + DisplayName = "Test Page", // Same display name, should generate different slug + Category = "tecnologia", + BusinessType = "individual", + Bio = "Another test page", + Slug = "test-slug", // Try to use same slug + SelectedTheme = "minimalist", + Links = new[] + { + new { Title = "Website", Url = "https://example.com", Description = "Website", Icon = "fas fa-globe" } + } + }; + + var createResponse = await authenticatedClient.PostAsJsonAsync("/Admin/ManagePage", pageData); + + // Assert + createResponse.IsSuccessStatusCode.Should().BeTrue(); + + var userPages = await _dbFixture.GetUserPagesAsync(user.Id); + userPages.Should().HaveCount(2); + + var slugs = userPages.Select(p => p.Slug).ToList(); + slugs.Should().OnlyHaveUniqueItems("All pages should have unique slugs"); + slugs.Should().Contain("test-slug"); + slugs.Should().Contain(slug => slug.StartsWith("test-slug-") || slug == "test-page"); + } } \ No newline at end of file diff --git a/src/BCards.IntegrationTests/Tests/PreviewTokenTests.cs b/src/BCards.IntegrationTests/Tests/PreviewTokenTests.cs index d045efe..6fec854 100644 --- a/src/BCards.IntegrationTests/Tests/PreviewTokenTests.cs +++ b/src/BCards.IntegrationTests/Tests/PreviewTokenTests.cs @@ -1,240 +1,240 @@ -using Xunit; -using FluentAssertions; -using Microsoft.Extensions.DependencyInjection; -using MongoDB.Driver; -using BCards.Web.Models; -using BCards.Web.ViewModels; -using BCards.IntegrationTests.Fixtures; -using BCards.IntegrationTests.Helpers; -using System.Net; - -namespace BCards.IntegrationTests.Tests; - -public class PreviewTokenTests : IClassFixture, IAsyncLifetime -{ - private readonly BCardsWebApplicationFactory _factory; - private readonly HttpClient _client; - private MongoDbTestFixture _dbFixture = null!; - - public PreviewTokenTests(BCardsWebApplicationFactory factory) - { - _factory = factory; - _client = _factory.CreateClient(); - } - - public async Task InitializeAsync() - { - using var scope = _factory.Services.CreateScope(); - var database = scope.ServiceProvider.GetRequiredService(); - _dbFixture = new MongoDbTestFixture(database); - - await _factory.CleanDatabaseAsync(); - await _dbFixture.InitializeTestDataAsync(); - } - - public Task DisposeAsync() => Task.CompletedTask; - - [Fact] - public async Task AccessPageInCreatingStatus_WithValidPreviewToken_ShouldSucceed() - { - // Arrange - var user = await _dbFixture.CreateTestUserAsync(PlanType.Basic); - var page = await _dbFixture.CreateTestUserPageAsync(user.Id, PageStatus.Creating, "tecnologia", 3, 1); - - // Act - var response = await _client.GetAsync($"/page/{page.Category}/{page.Slug}?preview={page.PreviewToken}"); - - // Assert - response.StatusCode.Should().Be(HttpStatusCode.OK); - - var content = await response.Content.ReadAsStringAsync(); - content.Should().Contain(page.DisplayName); - content.Should().Contain("MODO PREVIEW"); // Preview banner should be shown - } - - [Fact] - public async Task AccessPageInCreatingStatus_WithoutPreviewToken_ShouldReturn404() - { - // Arrange - var user = await _dbFixture.CreateTestUserAsync(PlanType.Basic); - var page = await _dbFixture.CreateTestUserPageAsync(user.Id, PageStatus.Creating, "tecnologia", 3, 1); - - // Act - var response = await _client.GetAsync($"/page/{page.Category}/{page.Slug}"); - - // Assert - response.StatusCode.Should().Be(HttpStatusCode.NotFound); - - var content = await response.Content.ReadAsStringAsync(); - content.Should().Contain("Página em desenvolvimento. Acesso restrito."); - } - - [Fact] - public async Task AccessPageInCreatingStatus_WithInvalidPreviewToken_ShouldReturn404() - { - // Arrange - var user = await _dbFixture.CreateTestUserAsync(PlanType.Basic); - var page = await _dbFixture.CreateTestUserPageAsync(user.Id, PageStatus.Creating, "tecnologia", 3, 1); - - // Act - var response = await _client.GetAsync($"/page/{page.Category}/{page.Slug}?preview=invalid-token"); - - // Assert - response.StatusCode.Should().Be(HttpStatusCode.NotFound); - - var content = await response.Content.ReadAsStringAsync(); - content.Should().Contain("Página em desenvolvimento. Acesso restrito."); - } - - [Fact] - public async Task AccessPageInCreatingStatus_WithExpiredPreviewToken_ShouldReturn404() - { - // Arrange - var user = await _dbFixture.CreateTestUserAsync(PlanType.Basic); - var page = await _dbFixture.CreateTestUserPageAsync(user.Id, PageStatus.Creating, "tecnologia", 3, 1); - - // Simulate expired token - page.PreviewTokenExpiry = DateTime.UtcNow.AddHours(-1); // Expired 1 hour ago - await _dbFixture.UserPageRepository.UpdateAsync(page); - - // Act - var response = await _client.GetAsync($"/page/{page.Category}/{page.Slug}?preview={page.PreviewToken}"); - - // Assert - response.StatusCode.Should().Be(HttpStatusCode.NotFound); - - var content = await response.Content.ReadAsStringAsync(); - content.Should().Contain("Página em desenvolvimento. Acesso restrito."); - } - - [Fact] - public async Task GeneratePreviewToken_ForCreatingPage_ShouldReturnNewToken() - { - // Arrange - var user = await _dbFixture.CreateTestUserAsync(PlanType.Basic); - var page = await _dbFixture.CreateTestUserPageAsync(user.Id, PageStatus.Creating, "tecnologia", 3, 1); - var authenticatedClient = await AuthenticationHelper.CreateAuthenticatedClientAsync(_factory, user); - - var oldToken = page.PreviewToken; - - // Act - var response = await authenticatedClient.PostAsync($"/Admin/GeneratePreviewToken/{page.Id}", null); - - // Assert - response.IsSuccessStatusCode.Should().BeTrue(); - - var jsonResponse = await response.Content.ReadAsStringAsync(); - jsonResponse.Should().Contain("success"); - jsonResponse.Should().Contain("previewToken"); - - // Verify new token is different and works - var updatedPages = await _dbFixture.GetUserPagesAsync(user.Id); - var updatedPage = updatedPages.First(); - - updatedPage.PreviewToken.Should().NotBe(oldToken); - updatedPage.PreviewTokenExpiry.Should().BeAfter(DateTime.UtcNow.AddHours(3)); - - // Test new token works - var pageResponse = await _client.GetAsync($"/page/{page.Category}/{page.Slug}?preview={updatedPage.PreviewToken}"); - pageResponse.StatusCode.Should().Be(HttpStatusCode.OK); - } - - [Fact] - public async Task GeneratePreviewToken_ForActivePageByNonOwner_ShouldFail() - { - // Arrange - var pageOwner = await _dbFixture.CreateTestUserAsync(PlanType.Basic, "owner@example.com"); - var otherUser = await _dbFixture.CreateTestUserAsync(PlanType.Basic, "other@example.com"); - var page = await _dbFixture.CreateTestUserPageAsync(pageOwner.Id, PageStatus.Active, "tecnologia", 3, 1); - - var otherUserClient = await AuthenticationHelper.CreateAuthenticatedClientAsync(_factory, otherUser); - - // Act - var response = await otherUserClient.PostAsync($"/Admin/GeneratePreviewToken/{page.Id}", null); - - // Assert - response.IsSuccessStatusCode.Should().BeFalse(); - } - - [Theory] - [InlineData(PageStatus.Creating)] - [InlineData(PageStatus.PendingModeration)] - [InlineData(PageStatus.Rejected)] - public async Task AccessPage_WithPreviewToken_ShouldWorkForNonActiveStatuses(PageStatus status) - { - // Arrange - var user = await _dbFixture.CreateTestUserAsync(PlanType.Basic); - var page = await _dbFixture.CreateTestUserPageAsync(user.Id, status, "tecnologia", 3, 1); - - // Act - var response = await _client.GetAsync($"/page/{page.Category}/{page.Slug}?preview={page.PreviewToken}"); - - // Assert - response.StatusCode.Should().Be(HttpStatusCode.OK, $"Preview token should work for {status} status"); - - var content = await response.Content.ReadAsStringAsync(); - content.Should().Contain(page.DisplayName); - } - - [Theory] - [InlineData(PageStatus.Creating)] - [InlineData(PageStatus.PendingModeration)] - [InlineData(PageStatus.Rejected)] - public async Task AccessPage_WithoutPreviewToken_ShouldFailForNonActiveStatuses(PageStatus status) - { - // Arrange - var user = await _dbFixture.CreateTestUserAsync(PlanType.Basic); - var page = await _dbFixture.CreateTestUserPageAsync(user.Id, status, "tecnologia", 3, 1); - - // Act - var response = await _client.GetAsync($"/page/{page.Category}/{page.Slug}"); - - // Assert - response.StatusCode.Should().Be(HttpStatusCode.NotFound, $"Access without preview token should fail for {status} status"); - } - - [Fact] - public async Task AccessActivePage_WithoutPreviewToken_ShouldSucceed() - { - // Arrange - var user = await _dbFixture.CreateTestUserAsync(PlanType.Basic); - var page = await _dbFixture.CreateTestUserPageAsync(user.Id, PageStatus.Active, "tecnologia", 3, 1); - - // Act - var response = await _client.GetAsync($"/page/{page.Category}/{page.Slug}"); - - // Assert - response.StatusCode.Should().Be(HttpStatusCode.OK); - - var content = await response.Content.ReadAsStringAsync(); - content.Should().Contain(page.DisplayName); - content.Should().NotContain("MODO PREVIEW"); // No preview banner for active pages - } - - [Fact] - public async Task RefreshPreviewToken_ShouldExtendExpiry() - { - // Arrange - var user = await _dbFixture.CreateTestUserAsync(PlanType.Basic); - var page = await _dbFixture.CreateTestUserPageAsync(user.Id, PageStatus.Creating, "tecnologia", 3, 1); - var authenticatedClient = await AuthenticationHelper.CreateAuthenticatedClientAsync(_factory, user); - - // Make token close to expiry - page.PreviewTokenExpiry = DateTime.UtcNow.AddMinutes(30); - await _dbFixture.UserPageRepository.UpdateAsync(page); - - var oldExpiry = page.PreviewTokenExpiry; - - // Act - var response = await authenticatedClient.PostAsync($"/Admin/RefreshPreviewToken/{page.Id}", null); - - // Assert - response.IsSuccessStatusCode.Should().BeTrue(); - - var updatedPages = await _dbFixture.GetUserPagesAsync(user.Id); - var updatedPage = updatedPages.First(); - - updatedPage.PreviewTokenExpiry.Should().BeAfter(oldExpiry.Value.AddHours(3)); - updatedPage.PreviewToken.Should().NotBeNullOrEmpty(); - } +using Xunit; +using FluentAssertions; +using Microsoft.Extensions.DependencyInjection; +using MongoDB.Driver; +using BCards.Web.Models; +using BCards.Web.ViewModels; +using BCards.IntegrationTests.Fixtures; +using BCards.IntegrationTests.Helpers; +using System.Net; + +namespace BCards.IntegrationTests.Tests; + +public class PreviewTokenTests : IClassFixture, IAsyncLifetime +{ + private readonly BCardsWebApplicationFactory _factory; + private readonly HttpClient _client; + private MongoDbTestFixture _dbFixture = null!; + + public PreviewTokenTests(BCardsWebApplicationFactory factory) + { + _factory = factory; + _client = _factory.CreateClient(); + } + + public async Task InitializeAsync() + { + using var scope = _factory.Services.CreateScope(); + var database = scope.ServiceProvider.GetRequiredService(); + _dbFixture = new MongoDbTestFixture(database); + + await _factory.CleanDatabaseAsync(); + await _dbFixture.InitializeTestDataAsync(); + } + + public Task DisposeAsync() => Task.CompletedTask; + + [Fact] + public async Task AccessPageInCreatingStatus_WithValidPreviewToken_ShouldSucceed() + { + // Arrange + var user = await _dbFixture.CreateTestUserAsync(PlanType.Basic); + var page = await _dbFixture.CreateTestUserPageAsync(user.Id, PageStatus.Creating, "tecnologia", 3, 1); + + // Act + var response = await _client.GetAsync($"/page/{page.Category}/{page.Slug}?preview={page.PreviewToken}"); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.OK); + + var content = await response.Content.ReadAsStringAsync(); + content.Should().Contain(page.DisplayName); + content.Should().Contain("MODO PREVIEW"); // Preview banner should be shown + } + + [Fact] + public async Task AccessPageInCreatingStatus_WithoutPreviewToken_ShouldReturn404() + { + // Arrange + var user = await _dbFixture.CreateTestUserAsync(PlanType.Basic); + var page = await _dbFixture.CreateTestUserPageAsync(user.Id, PageStatus.Creating, "tecnologia", 3, 1); + + // Act + var response = await _client.GetAsync($"/page/{page.Category}/{page.Slug}"); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.NotFound); + + var content = await response.Content.ReadAsStringAsync(); + content.Should().Contain("Página em desenvolvimento. Acesso restrito."); + } + + [Fact] + public async Task AccessPageInCreatingStatus_WithInvalidPreviewToken_ShouldReturn404() + { + // Arrange + var user = await _dbFixture.CreateTestUserAsync(PlanType.Basic); + var page = await _dbFixture.CreateTestUserPageAsync(user.Id, PageStatus.Creating, "tecnologia", 3, 1); + + // Act + var response = await _client.GetAsync($"/page/{page.Category}/{page.Slug}?preview=invalid-token"); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.NotFound); + + var content = await response.Content.ReadAsStringAsync(); + content.Should().Contain("Página em desenvolvimento. Acesso restrito."); + } + + [Fact] + public async Task AccessPageInCreatingStatus_WithExpiredPreviewToken_ShouldReturn404() + { + // Arrange + var user = await _dbFixture.CreateTestUserAsync(PlanType.Basic); + var page = await _dbFixture.CreateTestUserPageAsync(user.Id, PageStatus.Creating, "tecnologia", 3, 1); + + // Simulate expired token + page.PreviewTokenExpiry = DateTime.UtcNow.AddHours(-1); // Expired 1 hour ago + await _dbFixture.UserPageRepository.UpdateAsync(page); + + // Act + var response = await _client.GetAsync($"/page/{page.Category}/{page.Slug}?preview={page.PreviewToken}"); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.NotFound); + + var content = await response.Content.ReadAsStringAsync(); + content.Should().Contain("Página em desenvolvimento. Acesso restrito."); + } + + [Fact] + public async Task GeneratePreviewToken_ForCreatingPage_ShouldReturnNewToken() + { + // Arrange + var user = await _dbFixture.CreateTestUserAsync(PlanType.Basic); + var page = await _dbFixture.CreateTestUserPageAsync(user.Id, PageStatus.Creating, "tecnologia", 3, 1); + var authenticatedClient = await AuthenticationHelper.CreateAuthenticatedClientAsync(_factory, user); + + var oldToken = page.PreviewToken; + + // Act + var response = await authenticatedClient.PostAsync($"/Admin/GeneratePreviewToken/{page.Id}", null); + + // Assert + response.IsSuccessStatusCode.Should().BeTrue(); + + var jsonResponse = await response.Content.ReadAsStringAsync(); + jsonResponse.Should().Contain("success"); + jsonResponse.Should().Contain("previewToken"); + + // Verify new token is different and works + var updatedPages = await _dbFixture.GetUserPagesAsync(user.Id); + var updatedPage = updatedPages.First(); + + updatedPage.PreviewToken.Should().NotBe(oldToken); + updatedPage.PreviewTokenExpiry.Should().BeAfter(DateTime.UtcNow.AddHours(3)); + + // Test new token works + var pageResponse = await _client.GetAsync($"/page/{page.Category}/{page.Slug}?preview={updatedPage.PreviewToken}"); + pageResponse.StatusCode.Should().Be(HttpStatusCode.OK); + } + + [Fact] + public async Task GeneratePreviewToken_ForActivePageByNonOwner_ShouldFail() + { + // Arrange + var pageOwner = await _dbFixture.CreateTestUserAsync(PlanType.Basic, "owner@example.com"); + var otherUser = await _dbFixture.CreateTestUserAsync(PlanType.Basic, "other@example.com"); + var page = await _dbFixture.CreateTestUserPageAsync(pageOwner.Id, PageStatus.Active, "tecnologia", 3, 1); + + var otherUserClient = await AuthenticationHelper.CreateAuthenticatedClientAsync(_factory, otherUser); + + // Act + var response = await otherUserClient.PostAsync($"/Admin/GeneratePreviewToken/{page.Id}", null); + + // Assert + response.IsSuccessStatusCode.Should().BeFalse(); + } + + [Theory] + [InlineData(PageStatus.Creating)] + [InlineData(PageStatus.PendingModeration)] + [InlineData(PageStatus.Rejected)] + public async Task AccessPage_WithPreviewToken_ShouldWorkForNonActiveStatuses(PageStatus status) + { + // Arrange + var user = await _dbFixture.CreateTestUserAsync(PlanType.Basic); + var page = await _dbFixture.CreateTestUserPageAsync(user.Id, status, "tecnologia", 3, 1); + + // Act + var response = await _client.GetAsync($"/page/{page.Category}/{page.Slug}?preview={page.PreviewToken}"); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.OK, $"Preview token should work for {status} status"); + + var content = await response.Content.ReadAsStringAsync(); + content.Should().Contain(page.DisplayName); + } + + [Theory] + [InlineData(PageStatus.Creating)] + [InlineData(PageStatus.PendingModeration)] + [InlineData(PageStatus.Rejected)] + public async Task AccessPage_WithoutPreviewToken_ShouldFailForNonActiveStatuses(PageStatus status) + { + // Arrange + var user = await _dbFixture.CreateTestUserAsync(PlanType.Basic); + var page = await _dbFixture.CreateTestUserPageAsync(user.Id, status, "tecnologia", 3, 1); + + // Act + var response = await _client.GetAsync($"/page/{page.Category}/{page.Slug}"); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.NotFound, $"Access without preview token should fail for {status} status"); + } + + [Fact] + public async Task AccessActivePage_WithoutPreviewToken_ShouldSucceed() + { + // Arrange + var user = await _dbFixture.CreateTestUserAsync(PlanType.Basic); + var page = await _dbFixture.CreateTestUserPageAsync(user.Id, PageStatus.Active, "tecnologia", 3, 1); + + // Act + var response = await _client.GetAsync($"/page/{page.Category}/{page.Slug}"); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.OK); + + var content = await response.Content.ReadAsStringAsync(); + content.Should().Contain(page.DisplayName); + content.Should().NotContain("MODO PREVIEW"); // No preview banner for active pages + } + + [Fact] + public async Task RefreshPreviewToken_ShouldExtendExpiry() + { + // Arrange + var user = await _dbFixture.CreateTestUserAsync(PlanType.Basic); + var page = await _dbFixture.CreateTestUserPageAsync(user.Id, PageStatus.Creating, "tecnologia", 3, 1); + var authenticatedClient = await AuthenticationHelper.CreateAuthenticatedClientAsync(_factory, user); + + // Make token close to expiry + page.PreviewTokenExpiry = DateTime.UtcNow.AddMinutes(30); + await _dbFixture.UserPageRepository.UpdateAsync(page); + + var oldExpiry = page.PreviewTokenExpiry; + + // Act + var response = await authenticatedClient.PostAsync($"/Admin/RefreshPreviewToken/{page.Id}", null); + + // Assert + response.IsSuccessStatusCode.Should().BeTrue(); + + var updatedPages = await _dbFixture.GetUserPagesAsync(user.Id); + var updatedPage = updatedPages.First(); + + updatedPage.PreviewTokenExpiry.Should().BeAfter(oldExpiry.Value.AddHours(3)); + updatedPage.PreviewToken.Should().NotBeNullOrEmpty(); + } } \ No newline at end of file diff --git a/src/BCards.IntegrationTests/appsettings.Testing.json b/src/BCards.IntegrationTests/appsettings.Testing.json index 8d18e46..cd91407 100644 --- a/src/BCards.IntegrationTests/appsettings.Testing.json +++ b/src/BCards.IntegrationTests/appsettings.Testing.json @@ -1,43 +1,43 @@ -{ - "ConnectionStrings": { - "DefaultConnection": "mongodb://localhost:27017/BCardsDB_Test" - }, - "MongoDb": { - "ConnectionString": "mongodb://localhost:27017", - "DatabaseName": "BCardsDB_Test" - }, - "Stripe": { - "PublishableKey": "pk_test_51234567890abcdef", - "SecretKey": "sk_test_51234567890abcdef", - "WebhookSecret": "whsec_test_1234567890abcdef" - }, - "Authentication": { - "Google": { - "ClientId": "test-google-client-id.apps.googleusercontent.com", - "ClientSecret": "GOCSPX-test-google-client-secret" - }, - "Microsoft": { - "ClientId": "test-microsoft-client-id", - "ClientSecret": "test-microsoft-client-secret" - } - }, - "SendGrid": { - "ApiKey": "SG.test-sendgrid-api-key" - }, - "Moderation": { - "RequireApproval": true, - "AuthKey": "test-moderation-auth-key", - "MaxPendingPages": 100, - "MaxRejectionsBeforeBan": 3 - }, - "Logging": { - "LogLevel": { - "Default": "Warning", - "Microsoft.AspNetCore": "Warning", - "BCards": "Information", - "Microsoft.EntityFrameworkCore": "Warning" - } - }, - "AllowedHosts": "*", - "ASPNETCORE_ENVIRONMENT": "Testing" +{ + "ConnectionStrings": { + "DefaultConnection": "mongodb://localhost:27017/BCardsDB_Test" + }, + "MongoDb": { + "ConnectionString": "mongodb://localhost:27017", + "DatabaseName": "BCardsDB_Test" + }, + "Stripe": { + "PublishableKey": "pk_test_51234567890abcdef", + "SecretKey": "sk_test_51234567890abcdef", + "WebhookSecret": "whsec_test_1234567890abcdef" + }, + "Authentication": { + "Google": { + "ClientId": "test-google-client-id.apps.googleusercontent.com", + "ClientSecret": "GOCSPX-test-google-client-secret" + }, + "Microsoft": { + "ClientId": "test-microsoft-client-id", + "ClientSecret": "test-microsoft-client-secret" + } + }, + "SendGrid": { + "ApiKey": "SG.test-sendgrid-api-key" + }, + "Moderation": { + "RequireApproval": true, + "AuthKey": "test-moderation-auth-key", + "MaxPendingPages": 100, + "MaxRejectionsBeforeBan": 3 + }, + "Logging": { + "LogLevel": { + "Default": "Warning", + "Microsoft.AspNetCore": "Warning", + "BCards": "Information", + "Microsoft.EntityFrameworkCore": "Warning" + } + }, + "AllowedHosts": "*", + "ASPNETCORE_ENVIRONMENT": "Testing" } \ No newline at end of file diff --git a/src/BCards.Web/Attributes/ModeratorAuthorizeAttribute.cs b/src/BCards.Web/Attributes/ModeratorAuthorizeAttribute.cs index 990992f..2bebfb3 100644 --- a/src/BCards.Web/Attributes/ModeratorAuthorizeAttribute.cs +++ b/src/BCards.Web/Attributes/ModeratorAuthorizeAttribute.cs @@ -1,33 +1,33 @@ -using BCards.Web.Services; -using Microsoft.AspNetCore.Mvc.Filters; -using Microsoft.AspNetCore.Mvc; - -namespace BCards.Web.Attributes -{ - public class ModeratorAuthorizeAttribute : Attribute, IAuthorizationFilter - { - public void OnAuthorization(AuthorizationFilterContext context) - { - var user = context.HttpContext.User; - - if (!user.Identity?.IsAuthenticated == true) - { - context.Result = new RedirectToActionResult("Login", "Auth", - new { returnUrl = context.HttpContext.Request.Path }); - return; - } - - var moderationAuth = context.HttpContext.RequestServices - .GetRequiredService(); - - if (!moderationAuth.IsUserModerator(user)) - { - context.Result = new ForbidResult(); - return; - } - - // Adicionar flag para views - context.HttpContext.Items["IsModerator"] = true; - } - } -} +using BCards.Web.Services; +using Microsoft.AspNetCore.Mvc.Filters; +using Microsoft.AspNetCore.Mvc; + +namespace BCards.Web.Attributes +{ + public class ModeratorAuthorizeAttribute : Attribute, IAuthorizationFilter + { + public void OnAuthorization(AuthorizationFilterContext context) + { + var user = context.HttpContext.User; + + if (!user.Identity?.IsAuthenticated == true) + { + context.Result = new RedirectToActionResult("Login", "Auth", + new { returnUrl = context.HttpContext.Request.Path }); + return; + } + + var moderationAuth = context.HttpContext.RequestServices + .GetRequiredService(); + + if (!moderationAuth.IsUserModerator(user)) + { + context.Result = new ForbidResult(); + return; + } + + // Adicionar flag para views + context.HttpContext.Items["IsModerator"] = true; + } + } +} diff --git a/src/BCards.Web/BCards.Web.csproj b/src/BCards.Web/BCards.Web.csproj index 921bf20..d2c8ef8 100644 --- a/src/BCards.Web/BCards.Web.csproj +++ b/src/BCards.Web/BCards.Web.csproj @@ -1,38 +1,38 @@ - - - - net8.0 - enable - enable - false - linux-x64;linux-arm64 - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + net8.0 + enable + enable + false + linux-x64;linux-arm64 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/BCards.Web/Configuration/ModerationSettings.cs b/src/BCards.Web/Configuration/ModerationSettings.cs index c7d6f7a..19d5ca2 100644 --- a/src/BCards.Web/Configuration/ModerationSettings.cs +++ b/src/BCards.Web/Configuration/ModerationSettings.cs @@ -1,10 +1,10 @@ -namespace BCards.Web.Configuration -{ - public class ModerationSettings - { - public Dictionary PriorityTimeframes { get; set; } = new(); - public int MaxAttempts { get; set; } = 3; - public string ModeratorEmail { get; set; } = ""; - public List ModeratorEmails { get; set; } = new(); - } -} +namespace BCards.Web.Configuration +{ + public class ModerationSettings + { + public Dictionary PriorityTimeframes { get; set; } = new(); + public int MaxAttempts { get; set; } = 3; + public string ModeratorEmail { get; set; } = ""; + public List ModeratorEmails { get; set; } = new(); + } +} diff --git a/src/BCards.Web/Controllers/AdminController.cs b/src/BCards.Web/Controllers/AdminController.cs index bd3a945..c03bd18 100644 --- a/src/BCards.Web/Controllers/AdminController.cs +++ b/src/BCards.Web/Controllers/AdminController.cs @@ -1,1184 +1,1184 @@ -using BCards.Web.Models; -using BCards.Web.Services; -using BCards.Web.Utils; -using BCards.Web.ViewModels; -using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Mvc; -using Microsoft.AspNetCore.Mvc.ModelBinding; -using System.Security.Claims; -using System.Text; -using System.Text.Json; - -namespace BCards.Web.Controllers; - -[Authorize] -[Route("Admin")] -public class AdminController : Controller -{ - private readonly IAuthService _authService; - private readonly IUserPageService _userPageService; - private readonly ICategoryService _categoryService; - private readonly IThemeService _themeService; - private readonly IModerationService _moderationService; - private readonly IEmailService _emailService; - private readonly ILivePageService _livePageService; - private readonly IImageStorageService _imageStorage; - private readonly IPaymentService _paymentService; - private readonly ILogger _logger; - - public AdminController( - IAuthService authService, - IUserPageService userPageService, - ICategoryService categoryService, - IThemeService themeService, - IModerationService moderationService, - IEmailService emailService, - ILivePageService livePageService, - IImageStorageService imageStorage, - IPaymentService paymentService, - ILogger logger) - { - _authService = authService; - _userPageService = userPageService; - _categoryService = categoryService; - _themeService = themeService; - _moderationService = moderationService; - _emailService = emailService; - _livePageService = livePageService; - _imageStorage = imageStorage; - _paymentService = paymentService; - _logger = logger; - } - - - [HttpGet] - [Route("Dashboard")] - public async Task Dashboard() - { - ViewBag.IsHomePage = false; // Menu normal do dashboard - - var user = await _authService.GetCurrentUserAsync(User); - if (user == null) - return RedirectToAction("Login", "Auth"); - - _logger.LogInformation("[DASHBOARD DEBUG] User {UserId} ({Email}) - CurrentPlan: '{CurrentPlan}'", - user.Id, user.Email, user.CurrentPlan ?? "NULL"); - - var userPlanType = Enum.TryParse(user.CurrentPlan, true, out var planType) ? planType : PlanType.Trial; - - _logger.LogInformation("[DASHBOARD DEBUG] Parsed PlanType: {PlanType} (from '{CurrentPlan}')", - userPlanType, user.CurrentPlan ?? "NULL"); - var userPages = await _userPageService.GetUserPagesAsync(user.Id); - - var listCounts = new Dictionary(); - - // Atualizar status das baseado nas livepasges - foreach (var page in userPages) - { - if (page.Status == ViewModels.PageStatus.Active) - { - var livePage = await _livePageService.GetLivePageFromUserPageId(page.Id); - if (livePage != null) - { - listCounts.Add(page.Id, new { TotalViews = livePage.Analytics?.TotalViews ?? 0, TotalClicks = livePage.Analytics?.TotalClicks ?? 0 }); - } - } - else - { - listCounts.Add(page.Id, new { TotalViews = (long)(page.Analytics?.TotalViews ?? 0), TotalClicks = (long)(page.Analytics?.TotalClicks ?? 0) }); - } - } - - var dashboardModel = new DashboardViewModel - { - CurrentUser = user, - UserPages = userPages.Select(p => new UserPageSummary - { - Id = p.Id, - DisplayName = p.DisplayName, - Slug = p.Slug, - Category = p.Category, - Status = p.Status, - TotalClicks = listCounts[p.Id].TotalClicks ?? 0, - TotalViews = listCounts[p.Id].TotalViews ?? 0, - PreviewToken = p.PreviewToken, - CreatedAt = p.CreatedAt, - LastModerationStatus = p.ModerationHistory == null || p.ModerationHistory.Count == 0 || p.ModerationHistory.Last().Status == "rejected" - ? null - : Enum.Parse(p.ModerationHistory.Last().Status, true), - Motive = p.ModerationHistory == null || p.ModerationHistory.Count == 0 || p.ModerationHistory.Last().Status != "rejected" - ? "" - : p.ModerationHistory.Last().Reason - }).ToList(), - CurrentPlan = new PlanInfo - { - Type = userPlanType, - Name = userPlanType.GetDisplayName(), - MaxPages = userPlanType.GetMaxPages(), - MaxLinksPerPage = userPlanType.GetMaxLinksPerPage(), - DurationDays = userPlanType.GetTrialDays(), - Price = userPlanType.GetPrice(), - AllowsAnalytics = userPlanType.AllowsAnalytics(), - AllowsCustomThemes = userPlanType.AllowsCustomThemes() - }, - CanCreateNewPage = userPages.Count < userPlanType.GetMaxPages(), - DaysRemaining = userPlanType == PlanType.Trial ? CalculateTrialDaysRemaining(user) : 0 - }; - - return View(dashboardModel); - } - - private int CalculateTrialDaysRemaining(User user) - { - // This would be calculated based on subscription data - // For now, return a default value - return 7; - } - - [HttpGet] - [Route("ManagePage")] - public async Task ManagePage(string id = null) - { - try - { - ViewBag.IsHomePage = false; - - var user = await _authService.GetCurrentUserAsync(User); - if (user == null) - return RedirectToAction("Login", "Auth"); - - - var userPlanType = Enum.TryParse(user.CurrentPlan, true, out var planType) ? planType : PlanType.Trial; - var categories = await _categoryService.GetAllCategoriesAsync(); - var themes = await _themeService.GetAvailableThemesAsync(); - - if (string.IsNullOrEmpty(id) || id == "new") - { - // Check if user can create new page - var existingPages = await _userPageService.GetUserPagesAsync(user.Id); - var maxPages = userPlanType.GetMaxPages(); - - if (existingPages.Count >= maxPages) - { - TempData["Error"] = $"Você já atingiu o limite de {maxPages} página(s) do seu plano atual. Faça upgrade para criar mais páginas."; - return RedirectToAction("Dashboard"); - } - - // CRIAR NOVA PÁGINA - var planLimitations = await _paymentService.GetPlanLimitationsAsync(userPlanType.ToString()); - var model = new ManagePageViewModel - { - IsNewPage = true, - AvailableCategories = categories, - AvailableThemes = themes.Where(t => !t.IsPremium || userPlanType.AllowsCustomThemes()).ToList(), - MaxLinksAllowed = userPlanType.GetMaxLinksPerPage(), - AllowProductLinks = planLimitations.AllowProductLinks - }; - return View(model); - } - else - { - // EDITAR PÁGINA EXISTENTE - var page = await _userPageService.GetPageByIdAsync(id); - if (page == null || page.UserId != user.Id) - return NotFound(); - - var model = await MapToManageViewModel(page, categories, themes, userPlanType); - return View(model); - } - } - catch (Exception ex) - { - _logger.LogError(ex, "Error in ManagePage GET"); - TempData["Error"] = "Ocorreu um erro ao carregar a página. Tente novamente."; - throw new Exception("Erro ao salvar o bcard", ex); - } - } - - [HttpPost] - [Route("ManagePage")] - [RequestSizeLimit(5 * 1024 * 1024)] // Allow 5MB uploads - [RequestFormLimits(MultipartBodyLengthLimit = 5 * 1024 * 1024)] - public async Task ManagePage(ManagePageViewModel model) - { - string userId = ""; - try - { - ViewBag.IsHomePage = false; - - var user = await _authService.GetCurrentUserAsync(User); - if (user == null) - return RedirectToAction("Login", "Auth"); - - userId = user.Id; - - // Limpar campos de redes sociais que são apenas espaços (tratados como vazios) - CleanSocialMediaFields(model); - AdjustModelState(ModelState, model); - - _logger.LogInformation($"ManagePage POST: IsNewPage={model.IsNewPage}, DisplayName={model.DisplayName}, Category={model.Category}, Links={model.Links?.Count ?? 0}"); - - //Logar modelstate em information - _logger.LogInformation($"ModelState: {JsonSerializer.Serialize(ModelState)}"); - - // Processar upload de imagem se fornecida - if (model.ProfileImageFile != null && model.ProfileImageFile.Length > 0) - { - try - { - using var memoryStream = new MemoryStream(); - await model.ProfileImageFile.CopyToAsync(memoryStream); - var imageBytes = memoryStream.ToArray(); - - var imageId = await _imageStorage.SaveImageAsync( - imageBytes, - model.ProfileImageFile.FileName, - model.ProfileImageFile.ContentType - ); - - model.ProfileImageId = imageId; - _logger.LogInformation("Profile image uploaded successfully: {ImageId}", imageId); - } - catch (Exception ex) - { - _logger.LogError(ex, "Userid: {UserId} - Error uploading profile image. FileName: {FileName}, ContentType: {ContentType}, Size: {Size}KB, ExceptionType: {ExceptionType}", - userId, model.ProfileImageFile?.FileName ?? "Unknown", model.ProfileImageFile?.ContentType ?? "Unknown", - model.ProfileImageFile?.Length / 1024 ?? 0, ex.GetType().Name); - - // Mensagem específica baseada no tipo de erro - var errorMessage = ex is ArgumentException argEx ? argEx.Message : "Erro ao processar a imagem. Verifique o formato e tamanho."; - - ModelState.AddModelError("ProfileImageFile", errorMessage); - TempData["ImageError"] = errorMessage; - - // Preservar dados do form e repopular dropdowns - var userPlanType = Enum.TryParse(user.CurrentPlan, true, out var planType) ? planType : PlanType.Trial; - var planLimitations = await _paymentService.GetPlanLimitationsAsync(userPlanType.ToString()); - - model.AvailableCategories = await _categoryService.GetAllCategoriesAsync(); - model.AvailableThemes = await _themeService.GetAvailableThemesAsync(); - model.MaxLinksAllowed = userPlanType.GetMaxLinksPerPage(); - model.AllowProductLinks = planLimitations.AllowProductLinks; - - // Preservar ProfileImageId existente se estava editando - if (!model.IsNewPage && !string.IsNullOrEmpty(model.Id)) - { - var existingPage = await _userPageService.GetPageByIdAsync(model.Id); - if (existingPage != null) - { - model.ProfileImageId = existingPage.ProfileImageId; - } - } - - return View(model); - } - } - - if (!ModelState.IsValid) - { - var sbError = new StringBuilder(); - sbError.AppendLine("ModelState is invalid!"); - foreach (var error in ModelState) - { - var erroMsg = string.Join(", ", error.Value.Errors.Select(e => e.ErrorMessage)); - if (!string.IsNullOrEmpty(erroMsg)) - { - sbError.AppendLine($"Key: {error.Key}, Errors: {erroMsg}"); - } - } - - _logger.LogWarning(sbError.ToString()); - - // Repopulate dropdowns - var slug = await _userPageService.GenerateSlugAsync(model.Category, model.DisplayName); - model.Slug = slug; - model.AvailableCategories = await _categoryService.GetAllCategoriesAsync(); - model.AvailableThemes = await _themeService.GetAvailableThemesAsync(); - return View(model); - } - - if (model.IsNewPage) - { - // Generate slug if not provided - if (string.IsNullOrEmpty(model.Slug)) - { - model.Slug = await _userPageService.GenerateSlugAsync(model.Category, model.DisplayName); - } - - // Check if slug is available - if (!await _userPageService.ValidateSlugAsync(model.Category, model.Slug)) - { - ModelState.AddModelError("Slug", "Esta URL já está em uso. Tente outra."); - model.AvailableCategories = await _categoryService.GetAllCategoriesAsync(); - model.AvailableThemes = await _themeService.GetAvailableThemesAsync(); - return View(model); - } - - // Check if user can create the requested number of links - var activeLinksCount = model.Links?.Count ?? 0; - if (activeLinksCount > model.MaxLinksAllowed) - { - ModelState.AddModelError("", $"Você excedeu o limite de {model.MaxLinksAllowed} links do seu plano atual."); - model.AvailableCategories = await _categoryService.GetAllCategoriesAsync(); - model.AvailableThemes = await _themeService.GetAvailableThemesAsync(); - return View(model); - } - - try - { - // Create new page - var userPage = await MapToUserPage(model, user.Id); - _logger.LogInformation($"Mapped to UserPage: {userPage.DisplayName}, Category: {userPage.Category}, Slug: {userPage.Slug}"); - - // Set status to Creating for new pages - userPage.Status = ViewModels.PageStatus.Creating; - - await _userPageService.CreatePageAsync(userPage); - _logger.LogInformation("Page created successfully!"); - - // Token será gerado apenas quando usuário clicar "Testar Página" - - TempData["Success"] = "Página criada com sucesso! Use o botão 'Enviar para Moderação' quando estiver pronta."; - } - catch (Exception ex) - { - _logger.LogError(ex, $"Userid: {userId} - Error creating page"); - ModelState.AddModelError("", "Erro ao criar página. Tente novamente."); - model.AvailableCategories = await _categoryService.GetAllCategoriesAsync(); - model.AvailableThemes = await _themeService.GetAvailableThemesAsync(); - TempData["Error"] = $"Erro ao criar página. Tente novamente. TechMsg: {ex.Message}"; - return View(model); - } - } - else - { - // Update existing page - var existingPage = await _userPageService.GetPageByIdAsync(model.Id); - if (existingPage == null || existingPage.UserId != user.Id) - return NotFound(); - - // Check if user can create pages (for users with rejected pages) - var canCreatePage = await _moderationService.CanUserCreatePageAsync(user.Id); - if (!canCreatePage) - { - TempData["Error"] = "Você não pode editar páginas devido a muitas rejeições. Entre em contato com o suporte."; - return RedirectToAction("Dashboard"); - } - - // IMPORTANTE: Tratar remoção de imagem ou preservar existente se não houver novo upload - if (model.ProfileImageFile == null || model.ProfileImageFile.Length == 0) - { - if (model.ProfileImageId == "REMOVE_IMAGE") - { - // Usuário quer remover a imagem existente - model.ProfileImageId = null; - _logger.LogInformation("Profile image removed by user request"); - } - else - { - // Preservar imagem existente - model.ProfileImageId = existingPage.ProfileImageId; - } - } - - await UpdateUserPageFromModel(existingPage, model); - - // Set status to PendingModeration for updates - existingPage.Status = ViewModels.PageStatus.Creating; - existingPage.ModerationAttempts = existingPage.ModerationAttempts; - - await _userPageService.UpdatePageAsync(existingPage); - - // Token será gerado apenas quando usuário clicar "Testar Página" - - // Send email to user - await _emailService.SendModerationStatusAsync( - user.Email, - user.Name, - existingPage.DisplayName, - "pending", - null, - null); // previewUrl não é mais necessário - token será gerado no clique - - TempData["Success"] = "Página atualizada! Teste e envie para moderação."; - } - - return RedirectToAction("Dashboard"); - } - catch (Exception ex) - { - _logger.LogError(ex, $"Userid: {userId} - Error in ManagePage GET"); - TempData["Error"] = "Ocorreu um erro ao carregar a página. Tente novamente."; - throw new Exception("Erro ao salvar o bcard", ex); - } - } - - [HttpPost] - [Route("CreatePage")] - public async Task CreatePage(CreatePageViewModel model) - { - var user = await _authService.GetCurrentUserAsync(User); - if (user == null) - return RedirectToAction("Login", "Auth"); - - // Check if user already has a page - var existingPage = await _userPageService.GetUserPageAsync(user.Id); - if (existingPage != null) - return RedirectToAction("EditPage"); - - if (!ModelState.IsValid) - { - var categories = await _categoryService.GetAllCategoriesAsync(); - var themes = await _themeService.GetAvailableThemesAsync(); - ViewBag.Categories = categories; - ViewBag.Themes = themes; - return View(model); - } - - // Generate slug if not provided - if (string.IsNullOrEmpty(model.Slug)) - { - model.Slug = await _userPageService.GenerateSlugAsync(model.Category, model.DisplayName); - } - - // Check if slug is available - if (!await _userPageService.ValidateSlugAsync(model.Category, model.Slug)) - { - ModelState.AddModelError("Slug", "Esta URL já está em uso. Tente outra."); - var categories = await _categoryService.GetAllCategoriesAsync(); - var themes = await _themeService.GetAvailableThemesAsync(); - ViewBag.Categories = categories; - ViewBag.Themes = themes; - return View(model); - } - - // Check if user can create the requested number of links - var activeLinksCount = model.Links?.Count ?? 0; - if (!await _userPageService.CanCreateLinksAsync(user.Id, activeLinksCount)) - { - ModelState.AddModelError("", "Você excedeu o limite de links do seu plano atual."); - var categories = await _categoryService.GetAllCategoriesAsync(); - var themes = await _themeService.GetAvailableThemesAsync(); - ViewBag.Categories = categories; - ViewBag.Themes = themes; - return View(model); - } - - // Convert ViewModel to UserPage - var userPage = new UserPage - { - UserId = user.Id, - DisplayName = model.DisplayName, - Category = model.Category, - BusinessType = model.BusinessType, - Bio = model.Bio, - Slug = model.Slug, - Theme = await _themeService.GetThemeByNameAsync(model.SelectedTheme) ?? _themeService.GetDefaultTheme(), - Links = model.Links?.Select(l => new LinkItem - { - Title = l.Title, - Url = l.Url, - Description = l.Description, - Icon = l.Icon, - IsActive = true, - Order = model.Links.IndexOf(l) - }).ToList() ?? new List() - }; - - // Add social media links - var socialLinks = new List(); - if (!string.IsNullOrEmpty(model.WhatsAppNumber)) - { - socialLinks.Add(new LinkItem - { - Title = "WhatsApp", - Url = $"https://wa.me/{model.WhatsAppNumber.Replace("+", "").Replace(" ", "").Replace("-", "").Replace("(", "").Replace(")", "")}", - Icon = "fab fa-whatsapp", - IsActive = true, - Order = userPage.Links.Count + socialLinks.Count - }); - } - - if (!string.IsNullOrEmpty(model.FacebookUrl)) - { - socialLinks.Add(new LinkItem - { - Title = "Facebook", - Url = model.FacebookUrl, - Icon = "fab fa-facebook", - IsActive = true, - Order = userPage.Links.Count + socialLinks.Count - }); - } - - if (!string.IsNullOrEmpty(model.TwitterUrl)) - { - socialLinks.Add(new LinkItem - { - Title = "X / Twitter", - Url = model.TwitterUrl, - Icon = "fab fa-x-twitter", - IsActive = true, - Order = userPage.Links.Count + socialLinks.Count - }); - } - - if (!string.IsNullOrEmpty(model.InstagramUrl)) - { - socialLinks.Add(new LinkItem - { - Title = "Instagram", - Url = model.InstagramUrl, - Icon = "fab fa-instagram", - IsActive = true, - Order = userPage.Links.Count + socialLinks.Count - }); - } - - userPage.Links.AddRange(socialLinks); - - await _userPageService.CreatePageAsync(userPage); - - TempData["Success"] = "Página criada com sucesso!"; - return RedirectToAction("Dashboard"); - } - - [HttpGet] - [Route("EditPage")] - public async Task EditPage() - { - var user = await _authService.GetCurrentUserAsync(User); - if (user == null) - return RedirectToAction("Login", "Auth"); - - var userPage = await _userPageService.GetUserPageAsync(user.Id); - var categories = await _categoryService.GetAllCategoriesAsync(); - var themes = await _themeService.GetAvailableThemesAsync(); - - ViewBag.Categories = categories; - ViewBag.Themes = themes; - ViewBag.IsNew = userPage == null; - - return View(userPage ?? new UserPage { UserId = user.Id }); - } - - [HttpPost] - [Route("EditPage")] - public async Task EditPage(UserPage model) - { - var user = await _authService.GetCurrentUserAsync(User); - if (user == null) - return RedirectToAction("Login", "Auth"); - - if (!ModelState.IsValid) - { - var categories = await _categoryService.GetAllCategoriesAsync(); - var themes = await _themeService.GetAvailableThemesAsync(); - ViewBag.Categories = categories; - ViewBag.Themes = themes; - return View(model); - } - - // Check if user can create the requested number of links - var activeLinksCount = model.Links?.Count(l => l.IsActive) ?? 0; - if (!await _userPageService.CanCreateLinksAsync(user.Id, activeLinksCount)) - { - ModelState.AddModelError("", "Você excedeu o limite de links do seu plano atual."); - var categories = await _categoryService.GetAllCategoriesAsync(); - var themes = await _themeService.GetAvailableThemesAsync(); - ViewBag.Categories = categories; - ViewBag.Themes = themes; - return View(model); - } - - model.UserId = user.Id; - - // Check if slug is available - if (!await _userPageService.ValidateSlugAsync(model.Category, model.Slug, model.Id)) - { - ModelState.AddModelError("Slug", "Esta URL já está em uso. Tente outra."); - var categories = await _categoryService.GetAllCategoriesAsync(); - var themes = await _themeService.GetAvailableThemesAsync(); - ViewBag.Categories = categories; - ViewBag.Themes = themes; - return View(model); - } - - if (string.IsNullOrEmpty(model.Id)) - { - await _userPageService.CreatePageAsync(model); - } - else - { - await _userPageService.UpdatePageAsync(model); - } - - TempData["Success"] = "Página salva com sucesso!"; - return RedirectToAction("Dashboard"); - } - - [HttpPost] - [Route("CheckSlugAvailability")] - public async Task CheckSlugAvailability(string category, string slug, string? excludeId = null) - { - if (string.IsNullOrWhiteSpace(category) || string.IsNullOrWhiteSpace(slug)) - return Json(new { available = false, message = "Categoria e slug são obrigatórios." }); - - var isValid = await _userPageService.ValidateSlugAsync(category, slug, excludeId); - - return Json(new { available = isValid, message = isValid ? "URL disponível!" : "Esta URL já está em uso." }); - } - - [HttpPost] - [Route("GenerateSlug")] - public async Task GenerateSlug(string category, string name) - { - if (string.IsNullOrWhiteSpace(category) || string.IsNullOrWhiteSpace(name)) - return Json(new { slug = "", category = "" }); - - var slug = await _userPageService.GenerateSlugAsync(category, name); - var categorySlug = SlugHelper.CreateCategorySlug(category).ToLower(); - return Json(new { slug = slug, category = categorySlug }); - } - - [HttpGet] - [Route("Analytics")] - public async Task Analytics() - { - ViewBag.IsHomePage = false; - - var user = await _authService.GetCurrentUserAsync(User); - if (user == null) - return RedirectToAction("Login", "Auth"); - - var userPage = await _userPageService.GetUserPageAsync(user.Id); - if (userPage == null || !userPage.PlanLimitations.AllowAnalytics) - return RedirectToAction("Dashboard"); - - return View(userPage); - } - - [HttpPost] - [Route("DeletePage/{id}")] - public async Task DeletePage(string id) - { - var user = await _authService.GetCurrentUserAsync(User); - if (user == null) - return RedirectToAction("Login", "Auth"); - - var userPage = await _userPageService.GetPageByIdAsync(id); - if (userPage == null || userPage.UserId != user.Id) - { - TempData["Error"] = "Página não encontrada!"; - return RedirectToAction("Dashboard"); - } - - await _userPageService.DeletePageAsync(userPage.Id); - TempData["Success"] = "Página excluída com sucesso!"; - - return RedirectToAction("Dashboard"); - } - - private async Task MapToManageViewModel(UserPage page, List categories, List themes, PlanType userPlanType) - { - return new ManagePageViewModel - { - Id = page.Id, - IsNewPage = false, - DisplayName = page.DisplayName, - Category = page.Category, - BusinessType = page.BusinessType, - Bio = page.Bio, - Slug = page.Slug, - SelectedTheme = page.Theme?.Name ?? "minimalist", - ProfileImageId = page.ProfileImageId, - Links = page.Links?.Select((l, index) => new ManageLinkViewModel - { - Id = $"link_{index}", - Title = l.Title, - Url = l.Url, - Description = l.Description, - Icon = l.Icon, - Order = l.Order, - IsActive = l.IsActive, - Type = l.Type, - ProductTitle = l.ProductTitle, - ProductImage = l.ProductImage, - ProductPrice = l.ProductPrice, - ProductDescription = l.ProductDescription, - ProductDataCachedAt = l.ProductDataCachedAt - }).ToList() ?? new List(), - AvailableCategories = categories, - AvailableThemes = themes.Where(t => !t.IsPremium || userPlanType.AllowsCustomThemes()).ToList(), - MaxLinksAllowed = userPlanType.GetMaxLinksPerPage(), - AllowProductLinks = (await _paymentService.GetPlanLimitationsAsync(userPlanType.ToString())).AllowProductLinks - }; - } - - private async Task MapToUserPage(ManagePageViewModel model, string userId) - { - var theme = await _themeService.GetThemeByNameAsync(model.SelectedTheme) ?? _themeService.GetDefaultTheme(); - - var userPage = new UserPage - { - UserId = userId, - DisplayName = model.DisplayName, - Category = SlugHelper.ConvertCategory(model.Category.ToLower()), - BusinessType = model.BusinessType, - Bio = model.Bio, - Slug = SlugHelper.CreateSlug(model.Slug.ToLower()), - Theme = theme, - Status = ViewModels.PageStatus.Active, - ProfileImageId = model.ProfileImageId, - Links = new List() - }; - - // Add regular links - if (model.Links?.Any() == true) - { - userPage.Links.AddRange(model.Links.Where(l => !string.IsNullOrEmpty(l.Title) && !string.IsNullOrEmpty(l.Url)) - .Select((l, index) => new LinkItem - { - Title = l.Title, - Url = l.Url.ToLower(), - Description = l.Description, - Icon = l.Icon, - IsActive = l.IsActive, - Order = index, - Type = l.Type, - ProductTitle = l.ProductTitle, - ProductImage = l.ProductImage, - ProductPrice = l.ProductPrice, - ProductDescription = l.ProductDescription, - ProductDataCachedAt = l.ProductDataCachedAt - })); - } - - // Add social media links - var socialLinks = new List(); - var currentOrder = userPage.Links.Count; - - if (!string.IsNullOrEmpty(model.WhatsAppNumber)) - { - socialLinks.Add(new LinkItem - { - Title = "WhatsApp", - Url = $"https://wa.me/{model.WhatsAppNumber.Replace("+", "").Replace(" ", "").Replace("-", "").Replace("(", "").Replace(")", "")}", - Icon = "fab fa-whatsapp", - IsActive = true, - Order = currentOrder++ - }); - } - - if (!string.IsNullOrEmpty(model.FacebookUrl)) - { - socialLinks.Add(new LinkItem - { - Title = "Facebook", - Url = model.FacebookUrl, - Icon = "fab fa-facebook", - IsActive = true, - Order = currentOrder++ - }); - } - - if (!string.IsNullOrEmpty(model.TwitterUrl)) - { - socialLinks.Add(new LinkItem - { - Title = "X / Twitter", - Url = model.TwitterUrl, - Icon = "fab fa-x-twitter", - IsActive = true, - Order = currentOrder++ - }); - } - - if (!string.IsNullOrEmpty(model.InstagramUrl)) - { - socialLinks.Add(new LinkItem - { - Title = "Instagram", - Url = model.InstagramUrl, - Icon = "fab fa-instagram", - IsActive = true, - Order = currentOrder++ - }); - } - - userPage.Links.AddRange(socialLinks); - return userPage; - } - - private async Task UpdateUserPageFromModel(UserPage page, ManagePageViewModel model) - { - page.DisplayName = model.DisplayName; - page.Category = model.Category; - page.BusinessType = model.BusinessType; - page.Bio = model.Bio; - page.Slug = model.Slug; - page.ProfileImageId = model.ProfileImageId; // CRUCIAL: Atualizar ProfileImageId - - // CRUCIAL: Atualizar tema selecionado - var selectedTheme = await _themeService.GetThemeByNameAsync(model.SelectedTheme) ?? _themeService.GetDefaultTheme(); - page.Theme = selectedTheme; - - page.UpdatedAt = DateTime.UtcNow; - - // Update links - page.Links = new List(); - - // Add regular links - if (model.Links?.Any() == true) - { - // Validar links de produto baseado no plano do usuário - var user = await _authService.GetCurrentUserAsync(User); - var userPlanType = Enum.TryParse(user.CurrentPlan, true, out var planType) ? planType : PlanType.Trial; - var planLimitations = await _paymentService.GetPlanLimitationsAsync(userPlanType.ToString()); - - var filteredLinks = model.Links.Where(l => !string.IsNullOrEmpty(l.Title) && !string.IsNullOrEmpty(l.Url)); - - foreach (var link in filteredLinks) - { - // Verificar se usuário pode criar links de produto - if (link.Type == LinkType.Product && !planLimitations.AllowProductLinks) - { - throw new InvalidOperationException("Links de produto disponíveis apenas no plano Premium + Afiliados"); - } - } - - page.Links.AddRange(filteredLinks.Select((l, index) => new LinkItem - { - Title = l.Title, - Url = l.Url, - Description = l.Description, - Icon = l.Icon, - IsActive = l.IsActive, - Order = index, - Type = l.Type, - ProductTitle = l.ProductTitle, - ProductImage = l.ProductImage, - ProductPrice = l.ProductPrice, - ProductDescription = l.ProductDescription, - ProductDataCachedAt = l.ProductDataCachedAt - })); - } - - // Add social media links (same logic as create) - var socialLinks = new List(); - var currentOrder = page.Links.Count; - - if (!string.IsNullOrEmpty(model.WhatsAppNumber)) - { - socialLinks.Add(new LinkItem - { - Title = "WhatsApp", - Url = $"https://wa.me/{model.WhatsAppNumber.Replace("+", "").Replace(" ", "").Replace("-", "").Replace("(", "").Replace(")", "")}", - Icon = "fab fa-whatsapp", - IsActive = true, - Order = currentOrder++ - }); - } - - if (!string.IsNullOrEmpty(model.FacebookUrl)) - { - socialLinks.Add(new LinkItem - { - Title = "Facebook", - Url = model.FacebookUrl, - Icon = "fab fa-facebook", - IsActive = true, - Order = currentOrder++ - }); - } - - if (!string.IsNullOrEmpty(model.TwitterUrl)) - { - socialLinks.Add(new LinkItem - { - Title = "X / Twitter", - Url = model.TwitterUrl, - Icon = "fab fa-x-twitter", - IsActive = true, - Order = currentOrder++ - }); - } - - if (!string.IsNullOrEmpty(model.InstagramUrl)) - { - socialLinks.Add(new LinkItem - { - Title = "Instagram", - Url = model.InstagramUrl, - Icon = "fab fa-instagram", - IsActive = true, - Order = currentOrder++ - }); - } - - page.Links.AddRange(socialLinks); - } - - [HttpPost] - [Route("SubmitForModeration/{id}")] - public async Task SubmitForModeration(string id) - { - var user = await _authService.GetCurrentUserAsync(User); - if (user == null) - return Json(new { success = false, message = "Usuário não autenticado" }); - - var pageItem = await _userPageService.GetPageByIdAsync(id); - if (pageItem == null || pageItem.UserId != user.Id) - return Json(new { success = false, message = "Página não encontrada" }); - - // Validar status atual - if (pageItem.Status != ViewModels.PageStatus.Creating && pageItem.Status != ViewModels.PageStatus.Rejected) - return Json(new { success = false, message = "Página não pode ser enviada para moderação neste momento" }); - - // Validar se tem pelo menos 1 link ativo - var activeLinksCount = pageItem.Links?.Count(l => l.IsActive) ?? 0; - if (activeLinksCount < 1) - return Json(new { success = false, message = "Página deve ter pelo menos 1 link ativo para ser enviada" }); - - try - { - // Mudar status para PendingModeration - pageItem.Status = ViewModels.PageStatus.PendingModeration; - pageItem.ModerationAttempts++; - pageItem.UpdatedAt = DateTime.UtcNow; - - await _userPageService.UpdatePageAsync(pageItem); - - // Enviar email de notificação ao usuário - await _emailService.SendModerationStatusAsync( - user.Email, - user.Name, - pageItem.DisplayName, - "pending", - null, - $"{Request.Scheme}://{Request.Host}/page/{pageItem.Category}/{pageItem.Slug}?preview={pageItem.PreviewToken}"); - - _logger.LogInformation($"Page {pageItem.Id} submitted for moderation by user {user.Id}"); - - return Json(new { - success = true, - message = "Página enviada para moderação com sucesso! Você receberá um email quando for processada.", - newStatus = "PendingModeration" - }); - } - catch (Exception ex) - { - _logger.LogError(ex, $"Error submitting page {id} for moderation"); - return Json(new { success = false, message = "Erro interno. Tente novamente." }); - } - } - - [HttpPost] - [Route("RefreshPreviewToken/{id}")] - public async Task RefreshPreviewToken(string id) - { - var user = await _authService.GetCurrentUserAsync(User); - if (user == null) - return Json(new { success = false, message = "Não autorizado" }); - - var pageItem = await _userPageService.GetPageByIdAsync(id); - if (pageItem == null || pageItem.UserId != user.Id) - return Json(new { success = false, message = "Página não encontrada" }); - - // Só renovar token para páginas "Creating" e "Rejected" - if (pageItem.Status != ViewModels.PageStatus.Creating && - pageItem.Status != ViewModels.PageStatus.Rejected) - return Json(new { success = false, message = "Token só pode ser renovado para páginas em desenvolvimento ou rejeitadas" }); - - try - { - // Gerar novo token com 4 horas de validade - var newToken = await _moderationService.GeneratePreviewTokenAsync(pageItem.Id); - - var previewUrl = $"{Request.Scheme}://{Request.Host}/page/{pageItem.Category}/{pageItem.Slug}?preview={newToken}"; - - return Json(new { - success = true, - previewToken = newToken, - previewUrl = previewUrl, - expiresAt = DateTime.UtcNow.AddMinutes(5).ToString("yyyy-MM-dd HH:mm:ss") - }); - } - catch (Exception ex) - { - _logger.LogError(ex, $"Error refreshing preview token for page {id}"); - return Json(new { success = false, message = "Erro ao renovar token" }); - } - } - - [HttpPost] - [Route("GeneratePreviewToken/{id}")] - public async Task GeneratePreviewToken(string id) - { - var user = await _authService.GetCurrentUserAsync(User); - if (user == null) - return Json(new { success = false, message = "Usuário não autenticado" }); - - _logger.LogInformation($"Generating preview token for page {id} by user {user.Id}"); - - var pageItem = await _userPageService.GetPageByIdAsync(id); - if (pageItem == null || pageItem.UserId != user.Id) - return Json(new { success = false, message = "Página não encontrada" }); - - _logger.LogInformation($"Generating preview token for page {id} preview token {pageItem.PreviewToken} by user {user.Id}"); - - // Verificar se página pode ter preview - if (pageItem.Status != ViewModels.PageStatus.Creating && - pageItem.Status != ViewModels.PageStatus.PendingModeration && - pageItem.Status != ViewModels.PageStatus.Rejected) - { - return Json(new { success = false, message = "Preview não disponível para este status" }); - } - - try - { - // Gerar novo token com 4 horas de validade - var newToken = await _moderationService.GeneratePreviewTokenAsync(pageItem.Id); - - _logger.LogInformation($"Generating preview token for page {id} preview token {pageItem.PreviewToken} by user {user.Id}"); - - return Json(new { - success = true, - previewToken = newToken, - message = "Preview gerado com sucesso!", - expiresAt = DateTime.UtcNow.AddMinutes(5).ToString("yyyy-MM-dd HH:mm:ss") - }); - } - catch (Exception ex) - { - _logger.LogError(ex, $"Error generating preview token for page {id}"); - return Json(new { success = false, message = "Erro interno. Tente novamente." }); - } - } - - [HttpPost] - [Route("MigrateToLivePages")] - public async Task MigrateToLivePages() - { - var user = await _authService.GetCurrentUserAsync(User); - if (user == null) - return Json(new { success = false, message = "Usuário não autenticado" }); - - try - { - // Buscar todas as páginas ativas do usuário atual - var activePages = await _userPageService.GetUserPagesAsync(user.Id); - var eligiblePages = activePages.Where(p => p.Status == ViewModels.PageStatus.Active).ToList(); - - if (!eligiblePages.Any()) - { - return Json(new { - success = false, - message = "Nenhuma página ativa encontrada para migração" - }); - } - - int successCount = 0; - int errorCount = 0; - var errors = new List(); - - foreach (var page in eligiblePages) - { - try - { - await _livePageService.SyncFromUserPageAsync(page.Id); - successCount++; - _logger.LogInformation($"Successfully migrated page {page.Id} ({page.DisplayName}) to LivePages"); - } - catch (Exception ex) - { - errorCount++; - var errorMsg = $"Erro ao migrar '{page.DisplayName}': {ex.Message}"; - errors.Add(errorMsg); - _logger.LogError(ex, $"Failed to migrate page {page.Id} to LivePages"); - } - } - - var message = $"Migração concluída: {successCount} páginas migradas com sucesso"; - if (errorCount > 0) - { - message += $", {errorCount} erros encontrados"; - } - - return Json(new { - success = errorCount == 0, - message = message, - details = new { - totalPages = eligiblePages.Count, - successCount = successCount, - errorCount = errorCount, - errors = errors - } - }); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error during LivePages migration"); - return Json(new { - success = false, - message = $"Erro durante migração: {ex.Message}" - }); - } - } - - private void CleanSocialMediaFields(ManagePageViewModel model) - { - // Tratar espaço em branco como campo vazio para redes sociais - if (string.IsNullOrWhiteSpace(model.WhatsAppNumber) || model.WhatsAppNumber.Trim().Length <= 1) - model.WhatsAppNumber = string.Empty; - - if (string.IsNullOrWhiteSpace(model.FacebookUrl) || model.FacebookUrl.Trim().Length <= 1) - model.FacebookUrl = string.Empty; - - if (string.IsNullOrWhiteSpace(model.InstagramUrl) || model.InstagramUrl.Trim().Length <= 1) - model.InstagramUrl = string.Empty; - - if (string.IsNullOrWhiteSpace(model.TwitterUrl) || model.TwitterUrl.Trim().Length <= 1) - model.TwitterUrl = string.Empty; - } - - // 🔥 OTIMIZAÇÃO: Endpoint para manter a sessão do usuário ativa - [HttpPost] - [Route("KeepAlive")] - public IActionResult KeepAlive() - { - _logger.LogInformation("KeepAlive endpoint triggered for user {User}", User.Identity?.Name ?? "Anonymous"); - return Json(new { status = "session_extended" }); - } - - private void AdjustModelState(ModelStateDictionary modelState, ManagePageViewModel model) - { - modelState.Remove(x => x.InstagramUrl); - modelState.Remove(x => x.FacebookUrl); - modelState.Remove(x => x.TwitterUrl); - modelState.Remove(x => x.WhatsAppNumber); - - // Remover validação de 'Description' para links do tipo 'Normal' - if (model.Links != null) - { - for (int i = 0; i < model.Links.Count; i++) - { - if (model.Links[i].Type == LinkType.Normal) - { - string key = $"Links[{i}].Description"; - if (ModelState.ContainsKey(key)) - { - ModelState.Remove(key); - ModelState.MarkFieldValid(key); - } - key = $"Links[{i}].Url"; - if (ModelState.ContainsKey(key)) - { - ModelState.Remove(key); - ModelState.MarkFieldValid(key); - } - } - } - } - } +using BCards.Web.Models; +using BCards.Web.Services; +using BCards.Web.Utils; +using BCards.Web.ViewModels; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.ModelBinding; +using System.Security.Claims; +using System.Text; +using System.Text.Json; + +namespace BCards.Web.Controllers; + +[Authorize] +[Route("Admin")] +public class AdminController : Controller +{ + private readonly IAuthService _authService; + private readonly IUserPageService _userPageService; + private readonly ICategoryService _categoryService; + private readonly IThemeService _themeService; + private readonly IModerationService _moderationService; + private readonly IEmailService _emailService; + private readonly ILivePageService _livePageService; + private readonly IImageStorageService _imageStorage; + private readonly IPaymentService _paymentService; + private readonly ILogger _logger; + + public AdminController( + IAuthService authService, + IUserPageService userPageService, + ICategoryService categoryService, + IThemeService themeService, + IModerationService moderationService, + IEmailService emailService, + ILivePageService livePageService, + IImageStorageService imageStorage, + IPaymentService paymentService, + ILogger logger) + { + _authService = authService; + _userPageService = userPageService; + _categoryService = categoryService; + _themeService = themeService; + _moderationService = moderationService; + _emailService = emailService; + _livePageService = livePageService; + _imageStorage = imageStorage; + _paymentService = paymentService; + _logger = logger; + } + + + [HttpGet] + [Route("Dashboard")] + public async Task Dashboard() + { + ViewBag.IsHomePage = false; // Menu normal do dashboard + + var user = await _authService.GetCurrentUserAsync(User); + if (user == null) + return RedirectToAction("Login", "Auth"); + + _logger.LogInformation("[DASHBOARD DEBUG] User {UserId} ({Email}) - CurrentPlan: '{CurrentPlan}'", + user.Id, user.Email, user.CurrentPlan ?? "NULL"); + + var userPlanType = Enum.TryParse(user.CurrentPlan, true, out var planType) ? planType : PlanType.Trial; + + _logger.LogInformation("[DASHBOARD DEBUG] Parsed PlanType: {PlanType} (from '{CurrentPlan}')", + userPlanType, user.CurrentPlan ?? "NULL"); + var userPages = await _userPageService.GetUserPagesAsync(user.Id); + + var listCounts = new Dictionary(); + + // Atualizar status das baseado nas livepasges + foreach (var page in userPages) + { + if (page.Status == ViewModels.PageStatus.Active) + { + var livePage = await _livePageService.GetLivePageFromUserPageId(page.Id); + if (livePage != null) + { + listCounts.Add(page.Id, new { TotalViews = livePage.Analytics?.TotalViews ?? 0, TotalClicks = livePage.Analytics?.TotalClicks ?? 0 }); + } + } + else + { + listCounts.Add(page.Id, new { TotalViews = (long)(page.Analytics?.TotalViews ?? 0), TotalClicks = (long)(page.Analytics?.TotalClicks ?? 0) }); + } + } + + var dashboardModel = new DashboardViewModel + { + CurrentUser = user, + UserPages = userPages.Select(p => new UserPageSummary + { + Id = p.Id, + DisplayName = p.DisplayName, + Slug = p.Slug, + Category = p.Category, + Status = p.Status, + TotalClicks = listCounts[p.Id].TotalClicks ?? 0, + TotalViews = listCounts[p.Id].TotalViews ?? 0, + PreviewToken = p.PreviewToken, + CreatedAt = p.CreatedAt, + LastModerationStatus = p.ModerationHistory == null || p.ModerationHistory.Count == 0 || p.ModerationHistory.Last().Status == "rejected" + ? null + : Enum.Parse(p.ModerationHistory.Last().Status, true), + Motive = p.ModerationHistory == null || p.ModerationHistory.Count == 0 || p.ModerationHistory.Last().Status != "rejected" + ? "" + : p.ModerationHistory.Last().Reason + }).ToList(), + CurrentPlan = new PlanInfo + { + Type = userPlanType, + Name = userPlanType.GetDisplayName(), + MaxPages = userPlanType.GetMaxPages(), + MaxLinksPerPage = userPlanType.GetMaxLinksPerPage(), + DurationDays = userPlanType.GetTrialDays(), + Price = userPlanType.GetPrice(), + AllowsAnalytics = userPlanType.AllowsAnalytics(), + AllowsCustomThemes = userPlanType.AllowsCustomThemes() + }, + CanCreateNewPage = userPages.Count < userPlanType.GetMaxPages(), + DaysRemaining = userPlanType == PlanType.Trial ? CalculateTrialDaysRemaining(user) : 0 + }; + + return View(dashboardModel); + } + + private int CalculateTrialDaysRemaining(User user) + { + // This would be calculated based on subscription data + // For now, return a default value + return 7; + } + + [HttpGet] + [Route("ManagePage")] + public async Task ManagePage(string id = null) + { + try + { + ViewBag.IsHomePage = false; + + var user = await _authService.GetCurrentUserAsync(User); + if (user == null) + return RedirectToAction("Login", "Auth"); + + + var userPlanType = Enum.TryParse(user.CurrentPlan, true, out var planType) ? planType : PlanType.Trial; + var categories = await _categoryService.GetAllCategoriesAsync(); + var themes = await _themeService.GetAvailableThemesAsync(); + + if (string.IsNullOrEmpty(id) || id == "new") + { + // Check if user can create new page + var existingPages = await _userPageService.GetUserPagesAsync(user.Id); + var maxPages = userPlanType.GetMaxPages(); + + if (existingPages.Count >= maxPages) + { + TempData["Error"] = $"Você já atingiu o limite de {maxPages} página(s) do seu plano atual. Faça upgrade para criar mais páginas."; + return RedirectToAction("Dashboard"); + } + + // CRIAR NOVA PÁGINA + var planLimitations = await _paymentService.GetPlanLimitationsAsync(userPlanType.ToString()); + var model = new ManagePageViewModel + { + IsNewPage = true, + AvailableCategories = categories, + AvailableThemes = themes.Where(t => !t.IsPremium || userPlanType.AllowsCustomThemes()).ToList(), + MaxLinksAllowed = userPlanType.GetMaxLinksPerPage(), + AllowProductLinks = planLimitations.AllowProductLinks + }; + return View(model); + } + else + { + // EDITAR PÁGINA EXISTENTE + var page = await _userPageService.GetPageByIdAsync(id); + if (page == null || page.UserId != user.Id) + return NotFound(); + + var model = await MapToManageViewModel(page, categories, themes, userPlanType); + return View(model); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Error in ManagePage GET"); + TempData["Error"] = "Ocorreu um erro ao carregar a página. Tente novamente."; + throw new Exception("Erro ao salvar o bcard", ex); + } + } + + [HttpPost] + [Route("ManagePage")] + [RequestSizeLimit(5 * 1024 * 1024)] // Allow 5MB uploads + [RequestFormLimits(MultipartBodyLengthLimit = 5 * 1024 * 1024)] + public async Task ManagePage(ManagePageViewModel model) + { + string userId = ""; + try + { + ViewBag.IsHomePage = false; + + var user = await _authService.GetCurrentUserAsync(User); + if (user == null) + return RedirectToAction("Login", "Auth"); + + userId = user.Id; + + // Limpar campos de redes sociais que são apenas espaços (tratados como vazios) + CleanSocialMediaFields(model); + AdjustModelState(ModelState, model); + + _logger.LogInformation($"ManagePage POST: IsNewPage={model.IsNewPage}, DisplayName={model.DisplayName}, Category={model.Category}, Links={model.Links?.Count ?? 0}"); + + //Logar modelstate em information + _logger.LogInformation($"ModelState: {JsonSerializer.Serialize(ModelState)}"); + + // Processar upload de imagem se fornecida + if (model.ProfileImageFile != null && model.ProfileImageFile.Length > 0) + { + try + { + using var memoryStream = new MemoryStream(); + await model.ProfileImageFile.CopyToAsync(memoryStream); + var imageBytes = memoryStream.ToArray(); + + var imageId = await _imageStorage.SaveImageAsync( + imageBytes, + model.ProfileImageFile.FileName, + model.ProfileImageFile.ContentType + ); + + model.ProfileImageId = imageId; + _logger.LogInformation("Profile image uploaded successfully: {ImageId}", imageId); + } + catch (Exception ex) + { + _logger.LogError(ex, "Userid: {UserId} - Error uploading profile image. FileName: {FileName}, ContentType: {ContentType}, Size: {Size}KB, ExceptionType: {ExceptionType}", + userId, model.ProfileImageFile?.FileName ?? "Unknown", model.ProfileImageFile?.ContentType ?? "Unknown", + model.ProfileImageFile?.Length / 1024 ?? 0, ex.GetType().Name); + + // Mensagem específica baseada no tipo de erro + var errorMessage = ex is ArgumentException argEx ? argEx.Message : "Erro ao processar a imagem. Verifique o formato e tamanho."; + + ModelState.AddModelError("ProfileImageFile", errorMessage); + TempData["ImageError"] = errorMessage; + + // Preservar dados do form e repopular dropdowns + var userPlanType = Enum.TryParse(user.CurrentPlan, true, out var planType) ? planType : PlanType.Trial; + var planLimitations = await _paymentService.GetPlanLimitationsAsync(userPlanType.ToString()); + + model.AvailableCategories = await _categoryService.GetAllCategoriesAsync(); + model.AvailableThemes = await _themeService.GetAvailableThemesAsync(); + model.MaxLinksAllowed = userPlanType.GetMaxLinksPerPage(); + model.AllowProductLinks = planLimitations.AllowProductLinks; + + // Preservar ProfileImageId existente se estava editando + if (!model.IsNewPage && !string.IsNullOrEmpty(model.Id)) + { + var existingPage = await _userPageService.GetPageByIdAsync(model.Id); + if (existingPage != null) + { + model.ProfileImageId = existingPage.ProfileImageId; + } + } + + return View(model); + } + } + + if (!ModelState.IsValid) + { + var sbError = new StringBuilder(); + sbError.AppendLine("ModelState is invalid!"); + foreach (var error in ModelState) + { + var erroMsg = string.Join(", ", error.Value.Errors.Select(e => e.ErrorMessage)); + if (!string.IsNullOrEmpty(erroMsg)) + { + sbError.AppendLine($"Key: {error.Key}, Errors: {erroMsg}"); + } + } + + _logger.LogWarning(sbError.ToString()); + + // Repopulate dropdowns + var slug = await _userPageService.GenerateSlugAsync(model.Category, model.DisplayName); + model.Slug = slug; + model.AvailableCategories = await _categoryService.GetAllCategoriesAsync(); + model.AvailableThemes = await _themeService.GetAvailableThemesAsync(); + return View(model); + } + + if (model.IsNewPage) + { + // Generate slug if not provided + if (string.IsNullOrEmpty(model.Slug)) + { + model.Slug = await _userPageService.GenerateSlugAsync(model.Category, model.DisplayName); + } + + // Check if slug is available + if (!await _userPageService.ValidateSlugAsync(model.Category, model.Slug)) + { + ModelState.AddModelError("Slug", "Esta URL já está em uso. Tente outra."); + model.AvailableCategories = await _categoryService.GetAllCategoriesAsync(); + model.AvailableThemes = await _themeService.GetAvailableThemesAsync(); + return View(model); + } + + // Check if user can create the requested number of links + var activeLinksCount = model.Links?.Count ?? 0; + if (activeLinksCount > model.MaxLinksAllowed) + { + ModelState.AddModelError("", $"Você excedeu o limite de {model.MaxLinksAllowed} links do seu plano atual."); + model.AvailableCategories = await _categoryService.GetAllCategoriesAsync(); + model.AvailableThemes = await _themeService.GetAvailableThemesAsync(); + return View(model); + } + + try + { + // Create new page + var userPage = await MapToUserPage(model, user.Id); + _logger.LogInformation($"Mapped to UserPage: {userPage.DisplayName}, Category: {userPage.Category}, Slug: {userPage.Slug}"); + + // Set status to Creating for new pages + userPage.Status = ViewModels.PageStatus.Creating; + + await _userPageService.CreatePageAsync(userPage); + _logger.LogInformation("Page created successfully!"); + + // Token será gerado apenas quando usuário clicar "Testar Página" + + TempData["Success"] = "Página criada com sucesso! Use o botão 'Enviar para Moderação' quando estiver pronta."; + } + catch (Exception ex) + { + _logger.LogError(ex, $"Userid: {userId} - Error creating page"); + ModelState.AddModelError("", "Erro ao criar página. Tente novamente."); + model.AvailableCategories = await _categoryService.GetAllCategoriesAsync(); + model.AvailableThemes = await _themeService.GetAvailableThemesAsync(); + TempData["Error"] = $"Erro ao criar página. Tente novamente. TechMsg: {ex.Message}"; + return View(model); + } + } + else + { + // Update existing page + var existingPage = await _userPageService.GetPageByIdAsync(model.Id); + if (existingPage == null || existingPage.UserId != user.Id) + return NotFound(); + + // Check if user can create pages (for users with rejected pages) + var canCreatePage = await _moderationService.CanUserCreatePageAsync(user.Id); + if (!canCreatePage) + { + TempData["Error"] = "Você não pode editar páginas devido a muitas rejeições. Entre em contato com o suporte."; + return RedirectToAction("Dashboard"); + } + + // IMPORTANTE: Tratar remoção de imagem ou preservar existente se não houver novo upload + if (model.ProfileImageFile == null || model.ProfileImageFile.Length == 0) + { + if (model.ProfileImageId == "REMOVE_IMAGE") + { + // Usuário quer remover a imagem existente + model.ProfileImageId = null; + _logger.LogInformation("Profile image removed by user request"); + } + else + { + // Preservar imagem existente + model.ProfileImageId = existingPage.ProfileImageId; + } + } + + await UpdateUserPageFromModel(existingPage, model); + + // Set status to PendingModeration for updates + existingPage.Status = ViewModels.PageStatus.Creating; + existingPage.ModerationAttempts = existingPage.ModerationAttempts; + + await _userPageService.UpdatePageAsync(existingPage); + + // Token será gerado apenas quando usuário clicar "Testar Página" + + // Send email to user + await _emailService.SendModerationStatusAsync( + user.Email, + user.Name, + existingPage.DisplayName, + "pending", + null, + null); // previewUrl não é mais necessário - token será gerado no clique + + TempData["Success"] = "Página atualizada! Teste e envie para moderação."; + } + + return RedirectToAction("Dashboard"); + } + catch (Exception ex) + { + _logger.LogError(ex, $"Userid: {userId} - Error in ManagePage GET"); + TempData["Error"] = "Ocorreu um erro ao carregar a página. Tente novamente."; + throw new Exception("Erro ao salvar o bcard", ex); + } + } + + [HttpPost] + [Route("CreatePage")] + public async Task CreatePage(CreatePageViewModel model) + { + var user = await _authService.GetCurrentUserAsync(User); + if (user == null) + return RedirectToAction("Login", "Auth"); + + // Check if user already has a page + var existingPage = await _userPageService.GetUserPageAsync(user.Id); + if (existingPage != null) + return RedirectToAction("EditPage"); + + if (!ModelState.IsValid) + { + var categories = await _categoryService.GetAllCategoriesAsync(); + var themes = await _themeService.GetAvailableThemesAsync(); + ViewBag.Categories = categories; + ViewBag.Themes = themes; + return View(model); + } + + // Generate slug if not provided + if (string.IsNullOrEmpty(model.Slug)) + { + model.Slug = await _userPageService.GenerateSlugAsync(model.Category, model.DisplayName); + } + + // Check if slug is available + if (!await _userPageService.ValidateSlugAsync(model.Category, model.Slug)) + { + ModelState.AddModelError("Slug", "Esta URL já está em uso. Tente outra."); + var categories = await _categoryService.GetAllCategoriesAsync(); + var themes = await _themeService.GetAvailableThemesAsync(); + ViewBag.Categories = categories; + ViewBag.Themes = themes; + return View(model); + } + + // Check if user can create the requested number of links + var activeLinksCount = model.Links?.Count ?? 0; + if (!await _userPageService.CanCreateLinksAsync(user.Id, activeLinksCount)) + { + ModelState.AddModelError("", "Você excedeu o limite de links do seu plano atual."); + var categories = await _categoryService.GetAllCategoriesAsync(); + var themes = await _themeService.GetAvailableThemesAsync(); + ViewBag.Categories = categories; + ViewBag.Themes = themes; + return View(model); + } + + // Convert ViewModel to UserPage + var userPage = new UserPage + { + UserId = user.Id, + DisplayName = model.DisplayName, + Category = model.Category, + BusinessType = model.BusinessType, + Bio = model.Bio, + Slug = model.Slug, + Theme = await _themeService.GetThemeByNameAsync(model.SelectedTheme) ?? _themeService.GetDefaultTheme(), + Links = model.Links?.Select(l => new LinkItem + { + Title = l.Title, + Url = l.Url, + Description = l.Description, + Icon = l.Icon, + IsActive = true, + Order = model.Links.IndexOf(l) + }).ToList() ?? new List() + }; + + // Add social media links + var socialLinks = new List(); + if (!string.IsNullOrEmpty(model.WhatsAppNumber)) + { + socialLinks.Add(new LinkItem + { + Title = "WhatsApp", + Url = $"https://wa.me/{model.WhatsAppNumber.Replace("+", "").Replace(" ", "").Replace("-", "").Replace("(", "").Replace(")", "")}", + Icon = "fab fa-whatsapp", + IsActive = true, + Order = userPage.Links.Count + socialLinks.Count + }); + } + + if (!string.IsNullOrEmpty(model.FacebookUrl)) + { + socialLinks.Add(new LinkItem + { + Title = "Facebook", + Url = model.FacebookUrl, + Icon = "fab fa-facebook", + IsActive = true, + Order = userPage.Links.Count + socialLinks.Count + }); + } + + if (!string.IsNullOrEmpty(model.TwitterUrl)) + { + socialLinks.Add(new LinkItem + { + Title = "X / Twitter", + Url = model.TwitterUrl, + Icon = "fab fa-x-twitter", + IsActive = true, + Order = userPage.Links.Count + socialLinks.Count + }); + } + + if (!string.IsNullOrEmpty(model.InstagramUrl)) + { + socialLinks.Add(new LinkItem + { + Title = "Instagram", + Url = model.InstagramUrl, + Icon = "fab fa-instagram", + IsActive = true, + Order = userPage.Links.Count + socialLinks.Count + }); + } + + userPage.Links.AddRange(socialLinks); + + await _userPageService.CreatePageAsync(userPage); + + TempData["Success"] = "Página criada com sucesso!"; + return RedirectToAction("Dashboard"); + } + + [HttpGet] + [Route("EditPage")] + public async Task EditPage() + { + var user = await _authService.GetCurrentUserAsync(User); + if (user == null) + return RedirectToAction("Login", "Auth"); + + var userPage = await _userPageService.GetUserPageAsync(user.Id); + var categories = await _categoryService.GetAllCategoriesAsync(); + var themes = await _themeService.GetAvailableThemesAsync(); + + ViewBag.Categories = categories; + ViewBag.Themes = themes; + ViewBag.IsNew = userPage == null; + + return View(userPage ?? new UserPage { UserId = user.Id }); + } + + [HttpPost] + [Route("EditPage")] + public async Task EditPage(UserPage model) + { + var user = await _authService.GetCurrentUserAsync(User); + if (user == null) + return RedirectToAction("Login", "Auth"); + + if (!ModelState.IsValid) + { + var categories = await _categoryService.GetAllCategoriesAsync(); + var themes = await _themeService.GetAvailableThemesAsync(); + ViewBag.Categories = categories; + ViewBag.Themes = themes; + return View(model); + } + + // Check if user can create the requested number of links + var activeLinksCount = model.Links?.Count(l => l.IsActive) ?? 0; + if (!await _userPageService.CanCreateLinksAsync(user.Id, activeLinksCount)) + { + ModelState.AddModelError("", "Você excedeu o limite de links do seu plano atual."); + var categories = await _categoryService.GetAllCategoriesAsync(); + var themes = await _themeService.GetAvailableThemesAsync(); + ViewBag.Categories = categories; + ViewBag.Themes = themes; + return View(model); + } + + model.UserId = user.Id; + + // Check if slug is available + if (!await _userPageService.ValidateSlugAsync(model.Category, model.Slug, model.Id)) + { + ModelState.AddModelError("Slug", "Esta URL já está em uso. Tente outra."); + var categories = await _categoryService.GetAllCategoriesAsync(); + var themes = await _themeService.GetAvailableThemesAsync(); + ViewBag.Categories = categories; + ViewBag.Themes = themes; + return View(model); + } + + if (string.IsNullOrEmpty(model.Id)) + { + await _userPageService.CreatePageAsync(model); + } + else + { + await _userPageService.UpdatePageAsync(model); + } + + TempData["Success"] = "Página salva com sucesso!"; + return RedirectToAction("Dashboard"); + } + + [HttpPost] + [Route("CheckSlugAvailability")] + public async Task CheckSlugAvailability(string category, string slug, string? excludeId = null) + { + if (string.IsNullOrWhiteSpace(category) || string.IsNullOrWhiteSpace(slug)) + return Json(new { available = false, message = "Categoria e slug são obrigatórios." }); + + var isValid = await _userPageService.ValidateSlugAsync(category, slug, excludeId); + + return Json(new { available = isValid, message = isValid ? "URL disponível!" : "Esta URL já está em uso." }); + } + + [HttpPost] + [Route("GenerateSlug")] + public async Task GenerateSlug(string category, string name) + { + if (string.IsNullOrWhiteSpace(category) || string.IsNullOrWhiteSpace(name)) + return Json(new { slug = "", category = "" }); + + var slug = await _userPageService.GenerateSlugAsync(category, name); + var categorySlug = SlugHelper.CreateCategorySlug(category).ToLower(); + return Json(new { slug = slug, category = categorySlug }); + } + + [HttpGet] + [Route("Analytics")] + public async Task Analytics() + { + ViewBag.IsHomePage = false; + + var user = await _authService.GetCurrentUserAsync(User); + if (user == null) + return RedirectToAction("Login", "Auth"); + + var userPage = await _userPageService.GetUserPageAsync(user.Id); + if (userPage == null || !userPage.PlanLimitations.AllowAnalytics) + return RedirectToAction("Dashboard"); + + return View(userPage); + } + + [HttpPost] + [Route("DeletePage/{id}")] + public async Task DeletePage(string id) + { + var user = await _authService.GetCurrentUserAsync(User); + if (user == null) + return RedirectToAction("Login", "Auth"); + + var userPage = await _userPageService.GetPageByIdAsync(id); + if (userPage == null || userPage.UserId != user.Id) + { + TempData["Error"] = "Página não encontrada!"; + return RedirectToAction("Dashboard"); + } + + await _userPageService.DeletePageAsync(userPage.Id); + TempData["Success"] = "Página excluída com sucesso!"; + + return RedirectToAction("Dashboard"); + } + + private async Task MapToManageViewModel(UserPage page, List categories, List themes, PlanType userPlanType) + { + return new ManagePageViewModel + { + Id = page.Id, + IsNewPage = false, + DisplayName = page.DisplayName, + Category = page.Category, + BusinessType = page.BusinessType, + Bio = page.Bio, + Slug = page.Slug, + SelectedTheme = page.Theme?.Name ?? "minimalist", + ProfileImageId = page.ProfileImageId, + Links = page.Links?.Select((l, index) => new ManageLinkViewModel + { + Id = $"link_{index}", + Title = l.Title, + Url = l.Url, + Description = l.Description, + Icon = l.Icon, + Order = l.Order, + IsActive = l.IsActive, + Type = l.Type, + ProductTitle = l.ProductTitle, + ProductImage = l.ProductImage, + ProductPrice = l.ProductPrice, + ProductDescription = l.ProductDescription, + ProductDataCachedAt = l.ProductDataCachedAt + }).ToList() ?? new List(), + AvailableCategories = categories, + AvailableThemes = themes.Where(t => !t.IsPremium || userPlanType.AllowsCustomThemes()).ToList(), + MaxLinksAllowed = userPlanType.GetMaxLinksPerPage(), + AllowProductLinks = (await _paymentService.GetPlanLimitationsAsync(userPlanType.ToString())).AllowProductLinks + }; + } + + private async Task MapToUserPage(ManagePageViewModel model, string userId) + { + var theme = await _themeService.GetThemeByNameAsync(model.SelectedTheme) ?? _themeService.GetDefaultTheme(); + + var userPage = new UserPage + { + UserId = userId, + DisplayName = model.DisplayName, + Category = SlugHelper.ConvertCategory(model.Category.ToLower()), + BusinessType = model.BusinessType, + Bio = model.Bio, + Slug = SlugHelper.CreateSlug(model.Slug.ToLower()), + Theme = theme, + Status = ViewModels.PageStatus.Active, + ProfileImageId = model.ProfileImageId, + Links = new List() + }; + + // Add regular links + if (model.Links?.Any() == true) + { + userPage.Links.AddRange(model.Links.Where(l => !string.IsNullOrEmpty(l.Title) && !string.IsNullOrEmpty(l.Url)) + .Select((l, index) => new LinkItem + { + Title = l.Title, + Url = l.Url.ToLower(), + Description = l.Description, + Icon = l.Icon, + IsActive = l.IsActive, + Order = index, + Type = l.Type, + ProductTitle = l.ProductTitle, + ProductImage = l.ProductImage, + ProductPrice = l.ProductPrice, + ProductDescription = l.ProductDescription, + ProductDataCachedAt = l.ProductDataCachedAt + })); + } + + // Add social media links + var socialLinks = new List(); + var currentOrder = userPage.Links.Count; + + if (!string.IsNullOrEmpty(model.WhatsAppNumber)) + { + socialLinks.Add(new LinkItem + { + Title = "WhatsApp", + Url = $"https://wa.me/{model.WhatsAppNumber.Replace("+", "").Replace(" ", "").Replace("-", "").Replace("(", "").Replace(")", "")}", + Icon = "fab fa-whatsapp", + IsActive = true, + Order = currentOrder++ + }); + } + + if (!string.IsNullOrEmpty(model.FacebookUrl)) + { + socialLinks.Add(new LinkItem + { + Title = "Facebook", + Url = model.FacebookUrl, + Icon = "fab fa-facebook", + IsActive = true, + Order = currentOrder++ + }); + } + + if (!string.IsNullOrEmpty(model.TwitterUrl)) + { + socialLinks.Add(new LinkItem + { + Title = "X / Twitter", + Url = model.TwitterUrl, + Icon = "fab fa-x-twitter", + IsActive = true, + Order = currentOrder++ + }); + } + + if (!string.IsNullOrEmpty(model.InstagramUrl)) + { + socialLinks.Add(new LinkItem + { + Title = "Instagram", + Url = model.InstagramUrl, + Icon = "fab fa-instagram", + IsActive = true, + Order = currentOrder++ + }); + } + + userPage.Links.AddRange(socialLinks); + return userPage; + } + + private async Task UpdateUserPageFromModel(UserPage page, ManagePageViewModel model) + { + page.DisplayName = model.DisplayName; + page.Category = model.Category; + page.BusinessType = model.BusinessType; + page.Bio = model.Bio; + page.Slug = model.Slug; + page.ProfileImageId = model.ProfileImageId; // CRUCIAL: Atualizar ProfileImageId + + // CRUCIAL: Atualizar tema selecionado + var selectedTheme = await _themeService.GetThemeByNameAsync(model.SelectedTheme) ?? _themeService.GetDefaultTheme(); + page.Theme = selectedTheme; + + page.UpdatedAt = DateTime.UtcNow; + + // Update links + page.Links = new List(); + + // Add regular links + if (model.Links?.Any() == true) + { + // Validar links de produto baseado no plano do usuário + var user = await _authService.GetCurrentUserAsync(User); + var userPlanType = Enum.TryParse(user.CurrentPlan, true, out var planType) ? planType : PlanType.Trial; + var planLimitations = await _paymentService.GetPlanLimitationsAsync(userPlanType.ToString()); + + var filteredLinks = model.Links.Where(l => !string.IsNullOrEmpty(l.Title) && !string.IsNullOrEmpty(l.Url)); + + foreach (var link in filteredLinks) + { + // Verificar se usuário pode criar links de produto + if (link.Type == LinkType.Product && !planLimitations.AllowProductLinks) + { + throw new InvalidOperationException("Links de produto disponíveis apenas no plano Premium + Afiliados"); + } + } + + page.Links.AddRange(filteredLinks.Select((l, index) => new LinkItem + { + Title = l.Title, + Url = l.Url, + Description = l.Description, + Icon = l.Icon, + IsActive = l.IsActive, + Order = index, + Type = l.Type, + ProductTitle = l.ProductTitle, + ProductImage = l.ProductImage, + ProductPrice = l.ProductPrice, + ProductDescription = l.ProductDescription, + ProductDataCachedAt = l.ProductDataCachedAt + })); + } + + // Add social media links (same logic as create) + var socialLinks = new List(); + var currentOrder = page.Links.Count; + + if (!string.IsNullOrEmpty(model.WhatsAppNumber)) + { + socialLinks.Add(new LinkItem + { + Title = "WhatsApp", + Url = $"https://wa.me/{model.WhatsAppNumber.Replace("+", "").Replace(" ", "").Replace("-", "").Replace("(", "").Replace(")", "")}", + Icon = "fab fa-whatsapp", + IsActive = true, + Order = currentOrder++ + }); + } + + if (!string.IsNullOrEmpty(model.FacebookUrl)) + { + socialLinks.Add(new LinkItem + { + Title = "Facebook", + Url = model.FacebookUrl, + Icon = "fab fa-facebook", + IsActive = true, + Order = currentOrder++ + }); + } + + if (!string.IsNullOrEmpty(model.TwitterUrl)) + { + socialLinks.Add(new LinkItem + { + Title = "X / Twitter", + Url = model.TwitterUrl, + Icon = "fab fa-x-twitter", + IsActive = true, + Order = currentOrder++ + }); + } + + if (!string.IsNullOrEmpty(model.InstagramUrl)) + { + socialLinks.Add(new LinkItem + { + Title = "Instagram", + Url = model.InstagramUrl, + Icon = "fab fa-instagram", + IsActive = true, + Order = currentOrder++ + }); + } + + page.Links.AddRange(socialLinks); + } + + [HttpPost] + [Route("SubmitForModeration/{id}")] + public async Task SubmitForModeration(string id) + { + var user = await _authService.GetCurrentUserAsync(User); + if (user == null) + return Json(new { success = false, message = "Usuário não autenticado" }); + + var pageItem = await _userPageService.GetPageByIdAsync(id); + if (pageItem == null || pageItem.UserId != user.Id) + return Json(new { success = false, message = "Página não encontrada" }); + + // Validar status atual + if (pageItem.Status != ViewModels.PageStatus.Creating && pageItem.Status != ViewModels.PageStatus.Rejected) + return Json(new { success = false, message = "Página não pode ser enviada para moderação neste momento" }); + + // Validar se tem pelo menos 1 link ativo + var activeLinksCount = pageItem.Links?.Count(l => l.IsActive) ?? 0; + if (activeLinksCount < 1) + return Json(new { success = false, message = "Página deve ter pelo menos 1 link ativo para ser enviada" }); + + try + { + // Mudar status para PendingModeration + pageItem.Status = ViewModels.PageStatus.PendingModeration; + pageItem.ModerationAttempts++; + pageItem.UpdatedAt = DateTime.UtcNow; + + await _userPageService.UpdatePageAsync(pageItem); + + // Enviar email de notificação ao usuário + await _emailService.SendModerationStatusAsync( + user.Email, + user.Name, + pageItem.DisplayName, + "pending", + null, + $"{Request.Scheme}://{Request.Host}/page/{pageItem.Category}/{pageItem.Slug}?preview={pageItem.PreviewToken}"); + + _logger.LogInformation($"Page {pageItem.Id} submitted for moderation by user {user.Id}"); + + return Json(new { + success = true, + message = "Página enviada para moderação com sucesso! Você receberá um email quando for processada.", + newStatus = "PendingModeration" + }); + } + catch (Exception ex) + { + _logger.LogError(ex, $"Error submitting page {id} for moderation"); + return Json(new { success = false, message = "Erro interno. Tente novamente." }); + } + } + + [HttpPost] + [Route("RefreshPreviewToken/{id}")] + public async Task RefreshPreviewToken(string id) + { + var user = await _authService.GetCurrentUserAsync(User); + if (user == null) + return Json(new { success = false, message = "Não autorizado" }); + + var pageItem = await _userPageService.GetPageByIdAsync(id); + if (pageItem == null || pageItem.UserId != user.Id) + return Json(new { success = false, message = "Página não encontrada" }); + + // Só renovar token para páginas "Creating" e "Rejected" + if (pageItem.Status != ViewModels.PageStatus.Creating && + pageItem.Status != ViewModels.PageStatus.Rejected) + return Json(new { success = false, message = "Token só pode ser renovado para páginas em desenvolvimento ou rejeitadas" }); + + try + { + // Gerar novo token com 4 horas de validade + var newToken = await _moderationService.GeneratePreviewTokenAsync(pageItem.Id); + + var previewUrl = $"{Request.Scheme}://{Request.Host}/page/{pageItem.Category}/{pageItem.Slug}?preview={newToken}"; + + return Json(new { + success = true, + previewToken = newToken, + previewUrl = previewUrl, + expiresAt = DateTime.UtcNow.AddMinutes(5).ToString("yyyy-MM-dd HH:mm:ss") + }); + } + catch (Exception ex) + { + _logger.LogError(ex, $"Error refreshing preview token for page {id}"); + return Json(new { success = false, message = "Erro ao renovar token" }); + } + } + + [HttpPost] + [Route("GeneratePreviewToken/{id}")] + public async Task GeneratePreviewToken(string id) + { + var user = await _authService.GetCurrentUserAsync(User); + if (user == null) + return Json(new { success = false, message = "Usuário não autenticado" }); + + _logger.LogInformation($"Generating preview token for page {id} by user {user.Id}"); + + var pageItem = await _userPageService.GetPageByIdAsync(id); + if (pageItem == null || pageItem.UserId != user.Id) + return Json(new { success = false, message = "Página não encontrada" }); + + _logger.LogInformation($"Generating preview token for page {id} preview token {pageItem.PreviewToken} by user {user.Id}"); + + // Verificar se página pode ter preview + if (pageItem.Status != ViewModels.PageStatus.Creating && + pageItem.Status != ViewModels.PageStatus.PendingModeration && + pageItem.Status != ViewModels.PageStatus.Rejected) + { + return Json(new { success = false, message = "Preview não disponível para este status" }); + } + + try + { + // Gerar novo token com 4 horas de validade + var newToken = await _moderationService.GeneratePreviewTokenAsync(pageItem.Id); + + _logger.LogInformation($"Generating preview token for page {id} preview token {pageItem.PreviewToken} by user {user.Id}"); + + return Json(new { + success = true, + previewToken = newToken, + message = "Preview gerado com sucesso!", + expiresAt = DateTime.UtcNow.AddMinutes(5).ToString("yyyy-MM-dd HH:mm:ss") + }); + } + catch (Exception ex) + { + _logger.LogError(ex, $"Error generating preview token for page {id}"); + return Json(new { success = false, message = "Erro interno. Tente novamente." }); + } + } + + [HttpPost] + [Route("MigrateToLivePages")] + public async Task MigrateToLivePages() + { + var user = await _authService.GetCurrentUserAsync(User); + if (user == null) + return Json(new { success = false, message = "Usuário não autenticado" }); + + try + { + // Buscar todas as páginas ativas do usuário atual + var activePages = await _userPageService.GetUserPagesAsync(user.Id); + var eligiblePages = activePages.Where(p => p.Status == ViewModels.PageStatus.Active).ToList(); + + if (!eligiblePages.Any()) + { + return Json(new { + success = false, + message = "Nenhuma página ativa encontrada para migração" + }); + } + + int successCount = 0; + int errorCount = 0; + var errors = new List(); + + foreach (var page in eligiblePages) + { + try + { + await _livePageService.SyncFromUserPageAsync(page.Id); + successCount++; + _logger.LogInformation($"Successfully migrated page {page.Id} ({page.DisplayName}) to LivePages"); + } + catch (Exception ex) + { + errorCount++; + var errorMsg = $"Erro ao migrar '{page.DisplayName}': {ex.Message}"; + errors.Add(errorMsg); + _logger.LogError(ex, $"Failed to migrate page {page.Id} to LivePages"); + } + } + + var message = $"Migração concluída: {successCount} páginas migradas com sucesso"; + if (errorCount > 0) + { + message += $", {errorCount} erros encontrados"; + } + + return Json(new { + success = errorCount == 0, + message = message, + details = new { + totalPages = eligiblePages.Count, + successCount = successCount, + errorCount = errorCount, + errors = errors + } + }); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error during LivePages migration"); + return Json(new { + success = false, + message = $"Erro durante migração: {ex.Message}" + }); + } + } + + private void CleanSocialMediaFields(ManagePageViewModel model) + { + // Tratar espaço em branco como campo vazio para redes sociais + if (string.IsNullOrWhiteSpace(model.WhatsAppNumber) || model.WhatsAppNumber.Trim().Length <= 1) + model.WhatsAppNumber = string.Empty; + + if (string.IsNullOrWhiteSpace(model.FacebookUrl) || model.FacebookUrl.Trim().Length <= 1) + model.FacebookUrl = string.Empty; + + if (string.IsNullOrWhiteSpace(model.InstagramUrl) || model.InstagramUrl.Trim().Length <= 1) + model.InstagramUrl = string.Empty; + + if (string.IsNullOrWhiteSpace(model.TwitterUrl) || model.TwitterUrl.Trim().Length <= 1) + model.TwitterUrl = string.Empty; + } + + // 🔥 OTIMIZAÇÃO: Endpoint para manter a sessão do usuário ativa + [HttpPost] + [Route("KeepAlive")] + public IActionResult KeepAlive() + { + _logger.LogInformation("KeepAlive endpoint triggered for user {User}", User.Identity?.Name ?? "Anonymous"); + return Json(new { status = "session_extended" }); + } + + private void AdjustModelState(ModelStateDictionary modelState, ManagePageViewModel model) + { + modelState.Remove(x => x.InstagramUrl); + modelState.Remove(x => x.FacebookUrl); + modelState.Remove(x => x.TwitterUrl); + modelState.Remove(x => x.WhatsAppNumber); + + // Remover validação de 'Description' para links do tipo 'Normal' + if (model.Links != null) + { + for (int i = 0; i < model.Links.Count; i++) + { + if (model.Links[i].Type == LinkType.Normal) + { + string key = $"Links[{i}].Description"; + if (ModelState.ContainsKey(key)) + { + ModelState.Remove(key); + ModelState.MarkFieldValid(key); + } + key = $"Links[{i}].Url"; + if (ModelState.ContainsKey(key)) + { + ModelState.Remove(key); + ModelState.MarkFieldValid(key); + } + } + } + } + } } \ No newline at end of file diff --git a/src/BCards.Web/Controllers/HealthController.cs b/src/BCards.Web/Controllers/HealthController.cs index 137d17b..6aeb6b4 100644 --- a/src/BCards.Web/Controllers/HealthController.cs +++ b/src/BCards.Web/Controllers/HealthController.cs @@ -1,305 +1,305 @@ -using Microsoft.AspNetCore.Mvc; -using Microsoft.Extensions.Diagnostics.HealthChecks; -using System.Diagnostics; -using System.Text.Json; - -namespace BCards.Web.Controllers; - -[ApiController] -[Route("health")] -public class HealthController : ControllerBase -{ - private readonly HealthCheckService _healthCheckService; - private readonly ILogger _logger; - private readonly IConfiguration _configuration; - private static readonly DateTime _startTime = DateTime.UtcNow; - - public HealthController(HealthCheckService healthCheckService, ILogger logger, IConfiguration configuration) - { - _healthCheckService = healthCheckService; - _logger = logger; - _configuration = configuration; - } - - /// - /// Health check simples - retorna apenas status geral - /// - [HttpGet] - public async Task GetHealth() - { - var stopwatch = Stopwatch.StartNew(); - - try - { - var healthReport = await _healthCheckService.CheckHealthAsync(); - stopwatch.Stop(); - - var response = new - { - status = healthReport.Status.ToString().ToLower(), - timestamp = DateTime.UtcNow, - duration = $"{stopwatch.ElapsedMilliseconds}ms" - }; - - _logger.LogInformation("Simple health check completed: {Status} in {Duration}ms", - response.status, stopwatch.ElapsedMilliseconds); - - return healthReport.Status == HealthStatus.Healthy - ? Ok(response) - : StatusCode(503, response); - } - catch (Exception ex) - { - stopwatch.Stop(); - _logger.LogError(ex, "Health check failed after {Duration}ms", stopwatch.ElapsedMilliseconds); - - return StatusCode(503, new - { - status = "unhealthy", - timestamp = DateTime.UtcNow, - duration = $"{stopwatch.ElapsedMilliseconds}ms", - error = ex.Message - }); - } - } - - /// - /// Health check detalhado - formato completo com métricas - /// - [HttpGet("detailed")] - public async Task GetDetailedHealth() - { - var stopwatch = Stopwatch.StartNew(); - - try - { - var healthReport = await _healthCheckService.CheckHealthAsync(); - stopwatch.Stop(); - - var checks = new Dictionary(); - - foreach (var entry in healthReport.Entries) - { - checks[entry.Key] = new - { - status = entry.Value.Status.ToString().ToLower(), - duration = entry.Value.Duration.TotalMilliseconds + "ms", - description = entry.Value.Description, - data = entry.Value.Data, - exception = entry.Value.Exception?.Message - }; - } - - var uptime = DateTime.UtcNow - _startTime; - - var response = new - { - applicationName = _configuration["ApplicationName"] ?? "BCards", - status = healthReport.Status.ToString().ToLower(), - timestamp = DateTime.UtcNow, - uptime = FormatUptime(uptime), - totalDuration = $"{stopwatch.ElapsedMilliseconds}ms", - checks = checks, - version = "1.0.0", - environment = Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT") ?? "Production" - }; - - _logger.LogInformation("Detailed health check completed: {Status} in {Duration}ms - {HealthyCount}/{TotalCount} services healthy", - response.status, stopwatch.ElapsedMilliseconds, - healthReport.Entries.Count(e => e.Value.Status == HealthStatus.Healthy), - healthReport.Entries.Count); - - return healthReport.Status == HealthStatus.Unhealthy - ? StatusCode(503, response) - : Ok(response); - } - catch (Exception ex) - { - stopwatch.Stop(); - _logger.LogError(ex, "Detailed health check failed after {Duration}ms", stopwatch.ElapsedMilliseconds); - - return StatusCode(503, new - { - applicationName = "BCards", - status = "unhealthy", - timestamp = DateTime.UtcNow, - uptime = FormatUptime(DateTime.UtcNow - _startTime), - totalDuration = $"{stopwatch.ElapsedMilliseconds}ms", - error = ex.Message, - version = "1.0.0", - environment = Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT") ?? "Production" - }); - } - } - - /// - /// Health check para Uptime Kuma - formato específico - /// - [HttpGet("uptime-kuma")] - public async Task GetUptimeKumaHealth() - { - var stopwatch = Stopwatch.StartNew(); - - try - { - var healthReport = await _healthCheckService.CheckHealthAsync(); - stopwatch.Stop(); - - var isHealthy = healthReport.Status == HealthStatus.Healthy; - var response = new - { - status = isHealthy ? "up" : "down", - message = isHealthy ? "All services operational" : $"Issues detected: {healthReport.Status}", - timestamp = DateTime.UtcNow.ToString("O"), - responseTime = stopwatch.ElapsedMilliseconds, - services = healthReport.Entries.ToDictionary( - e => e.Key, - e => new { - status = e.Value.Status.ToString().ToLower(), - responseTime = e.Value.Duration.TotalMilliseconds - } - ) - }; - - _logger.LogInformation("Uptime Kuma health check: {Status} in {Duration}ms", - response.status, stopwatch.ElapsedMilliseconds); - - - var memoryMB = Math.Round(GC.GetTotalMemory(false) / 1024.0 / 1024.0, 2); - var uptimeMinutes = (DateTime.UtcNow - Process.GetCurrentProcess().StartTime).TotalMinutes; - - _logger.LogInformation("[HEALTH] Memory: {memoryBytes} MemoryMB: {memoryMB} ThreadPool: {threadPool} ProcessId: {processId} ActiveConnections: {activeConnections} UptimeMinutes: {uptimeMinutes}", - GC.GetTotalMemory(false), - memoryMB, - ThreadPool.ThreadCount, - Process.GetCurrentProcess().Id, - HttpContext.Connection?.Id ?? "null", - Math.Round(uptimeMinutes, 1)); - - return Ok(response); - } - catch (Exception ex) - { - stopwatch.Stop(); - _logger.LogError(ex, "Uptime Kuma health check failed after {Duration}ms", stopwatch.ElapsedMilliseconds); - - return Ok(new - { - status = "down", - message = $"Health check failed: {ex.Message}", - timestamp = DateTime.UtcNow.ToString("O"), - responseTime = stopwatch.ElapsedMilliseconds - }); - } - } - - /// - /// Health checks específicos por serviço - /// - [HttpGet("mongodb")] - public async Task GetMongoDbHealth() - { - return await GetSpecificServiceHealth("mongodb"); - } - - [HttpGet("stripe")] - public async Task GetStripeHealth() - { - return await GetSpecificServiceHealth("stripe"); - } - - [HttpGet("sendgrid")] - public async Task GetSendGridHealth() - { - return await GetSpecificServiceHealth("sendgrid"); - } - - [HttpGet("external")] - public async Task GetExternalServicesHealth() - { - return await GetSpecificServiceHealth("external_services"); - } - - [HttpGet("resources")] - public async Task GetSystemResourcesHealth() - { - return await GetSpecificServiceHealth("resources"); - } - - private async Task GetSpecificServiceHealth(string serviceName) - { - var stopwatch = Stopwatch.StartNew(); - - try - { - var healthReport = await _healthCheckService.CheckHealthAsync(check => check.Name == serviceName); - stopwatch.Stop(); - - if (!healthReport.Entries.Any()) - { - return NotFound(new { error = $"Service '{serviceName}' not found" }); - } - - var entry = healthReport.Entries.First().Value; - - _logger.LogInformation("[HEALTH_CHECK] Service: {serviceName} Status: {status} Duration: {duration} ResponseTime: {responseTime} Timestamp: {timestamp} Data: {data}", - serviceName, - entry.Status.ToString().ToLower(), - entry.Duration.TotalMilliseconds, - stopwatch.ElapsedMilliseconds, - DateTime.UtcNow, - entry.Data != null ? string.Join(", ", entry.Data.Select(d => $"{d.Key}:{d.Value}")) : "none"); - - if (entry.Exception != null) - { - _logger.LogError("[HEALTH_CHECK] Service: {serviceName} Status: {status} Duration: {duration} Error: {error} Exception: {exception}", - serviceName, entry.Status.ToString().ToLower(), stopwatch.ElapsedMilliseconds, - entry.Exception.Message, entry.Exception.ToString()); - } - - var response = new - { - service = serviceName, - status = entry.Status.ToString().ToLower(), - timestamp = DateTime.UtcNow, - duration = $"{entry.Duration.TotalMilliseconds}ms", - description = entry.Description, - data = entry.Data, - exception = entry.Exception?.Message - }; - - _logger.LogInformation("Service {ServiceName} health check: {Status} in {Duration}ms", - serviceName, response.status, entry.Duration.TotalMilliseconds); - - return entry.Status == HealthStatus.Unhealthy - ? StatusCode(503, response) - : Ok(response); - } - catch (Exception ex) - { - stopwatch.Stop(); - _logger.LogError(ex, "Service {ServiceName} health check failed after {Duration}ms", - serviceName, stopwatch.ElapsedMilliseconds); - - return StatusCode(503, new - { - service = serviceName, - status = "unhealthy", - timestamp = DateTime.UtcNow, - duration = $"{stopwatch.ElapsedMilliseconds}ms", - error = ex.Message - }); - } - } - - private static string FormatUptime(TimeSpan uptime) - { - if (uptime.TotalDays >= 1) - return $"{(int)uptime.TotalDays}d {uptime.Hours}h {uptime.Minutes}m"; - if (uptime.TotalHours >= 1) - return $"{uptime.Hours}h {uptime.Minutes}m"; - if (uptime.TotalMinutes >= 1) - return $"{uptime.Minutes}m {uptime.Seconds}s"; - return $"{uptime.Seconds}s"; - } +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Diagnostics.HealthChecks; +using System.Diagnostics; +using System.Text.Json; + +namespace BCards.Web.Controllers; + +[ApiController] +[Route("health")] +public class HealthController : ControllerBase +{ + private readonly HealthCheckService _healthCheckService; + private readonly ILogger _logger; + private readonly IConfiguration _configuration; + private static readonly DateTime _startTime = DateTime.UtcNow; + + public HealthController(HealthCheckService healthCheckService, ILogger logger, IConfiguration configuration) + { + _healthCheckService = healthCheckService; + _logger = logger; + _configuration = configuration; + } + + /// + /// Health check simples - retorna apenas status geral + /// + [HttpGet] + public async Task GetHealth() + { + var stopwatch = Stopwatch.StartNew(); + + try + { + var healthReport = await _healthCheckService.CheckHealthAsync(); + stopwatch.Stop(); + + var response = new + { + status = healthReport.Status.ToString().ToLower(), + timestamp = DateTime.UtcNow, + duration = $"{stopwatch.ElapsedMilliseconds}ms" + }; + + _logger.LogInformation("Simple health check completed: {Status} in {Duration}ms", + response.status, stopwatch.ElapsedMilliseconds); + + return healthReport.Status == HealthStatus.Healthy + ? Ok(response) + : StatusCode(503, response); + } + catch (Exception ex) + { + stopwatch.Stop(); + _logger.LogError(ex, "Health check failed after {Duration}ms", stopwatch.ElapsedMilliseconds); + + return StatusCode(503, new + { + status = "unhealthy", + timestamp = DateTime.UtcNow, + duration = $"{stopwatch.ElapsedMilliseconds}ms", + error = ex.Message + }); + } + } + + /// + /// Health check detalhado - formato completo com métricas + /// + [HttpGet("detailed")] + public async Task GetDetailedHealth() + { + var stopwatch = Stopwatch.StartNew(); + + try + { + var healthReport = await _healthCheckService.CheckHealthAsync(); + stopwatch.Stop(); + + var checks = new Dictionary(); + + foreach (var entry in healthReport.Entries) + { + checks[entry.Key] = new + { + status = entry.Value.Status.ToString().ToLower(), + duration = entry.Value.Duration.TotalMilliseconds + "ms", + description = entry.Value.Description, + data = entry.Value.Data, + exception = entry.Value.Exception?.Message + }; + } + + var uptime = DateTime.UtcNow - _startTime; + + var response = new + { + applicationName = _configuration["ApplicationName"] ?? "BCards", + status = healthReport.Status.ToString().ToLower(), + timestamp = DateTime.UtcNow, + uptime = FormatUptime(uptime), + totalDuration = $"{stopwatch.ElapsedMilliseconds}ms", + checks = checks, + version = "1.0.0", + environment = Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT") ?? "Production" + }; + + _logger.LogInformation("Detailed health check completed: {Status} in {Duration}ms - {HealthyCount}/{TotalCount} services healthy", + response.status, stopwatch.ElapsedMilliseconds, + healthReport.Entries.Count(e => e.Value.Status == HealthStatus.Healthy), + healthReport.Entries.Count); + + return healthReport.Status == HealthStatus.Unhealthy + ? StatusCode(503, response) + : Ok(response); + } + catch (Exception ex) + { + stopwatch.Stop(); + _logger.LogError(ex, "Detailed health check failed after {Duration}ms", stopwatch.ElapsedMilliseconds); + + return StatusCode(503, new + { + applicationName = "BCards", + status = "unhealthy", + timestamp = DateTime.UtcNow, + uptime = FormatUptime(DateTime.UtcNow - _startTime), + totalDuration = $"{stopwatch.ElapsedMilliseconds}ms", + error = ex.Message, + version = "1.0.0", + environment = Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT") ?? "Production" + }); + } + } + + /// + /// Health check para Uptime Kuma - formato específico + /// + [HttpGet("uptime-kuma")] + public async Task GetUptimeKumaHealth() + { + var stopwatch = Stopwatch.StartNew(); + + try + { + var healthReport = await _healthCheckService.CheckHealthAsync(); + stopwatch.Stop(); + + var isHealthy = healthReport.Status == HealthStatus.Healthy; + var response = new + { + status = isHealthy ? "up" : "down", + message = isHealthy ? "All services operational" : $"Issues detected: {healthReport.Status}", + timestamp = DateTime.UtcNow.ToString("O"), + responseTime = stopwatch.ElapsedMilliseconds, + services = healthReport.Entries.ToDictionary( + e => e.Key, + e => new { + status = e.Value.Status.ToString().ToLower(), + responseTime = e.Value.Duration.TotalMilliseconds + } + ) + }; + + _logger.LogInformation("Uptime Kuma health check: {Status} in {Duration}ms", + response.status, stopwatch.ElapsedMilliseconds); + + + var memoryMB = Math.Round(GC.GetTotalMemory(false) / 1024.0 / 1024.0, 2); + var uptimeMinutes = (DateTime.UtcNow - Process.GetCurrentProcess().StartTime).TotalMinutes; + + _logger.LogInformation("[HEALTH] Memory: {memoryBytes} MemoryMB: {memoryMB} ThreadPool: {threadPool} ProcessId: {processId} ActiveConnections: {activeConnections} UptimeMinutes: {uptimeMinutes}", + GC.GetTotalMemory(false), + memoryMB, + ThreadPool.ThreadCount, + Process.GetCurrentProcess().Id, + HttpContext.Connection?.Id ?? "null", + Math.Round(uptimeMinutes, 1)); + + return Ok(response); + } + catch (Exception ex) + { + stopwatch.Stop(); + _logger.LogError(ex, "Uptime Kuma health check failed after {Duration}ms", stopwatch.ElapsedMilliseconds); + + return Ok(new + { + status = "down", + message = $"Health check failed: {ex.Message}", + timestamp = DateTime.UtcNow.ToString("O"), + responseTime = stopwatch.ElapsedMilliseconds + }); + } + } + + /// + /// Health checks específicos por serviço + /// + [HttpGet("mongodb")] + public async Task GetMongoDbHealth() + { + return await GetSpecificServiceHealth("mongodb"); + } + + [HttpGet("stripe")] + public async Task GetStripeHealth() + { + return await GetSpecificServiceHealth("stripe"); + } + + [HttpGet("sendgrid")] + public async Task GetSendGridHealth() + { + return await GetSpecificServiceHealth("sendgrid"); + } + + [HttpGet("external")] + public async Task GetExternalServicesHealth() + { + return await GetSpecificServiceHealth("external_services"); + } + + [HttpGet("resources")] + public async Task GetSystemResourcesHealth() + { + return await GetSpecificServiceHealth("resources"); + } + + private async Task GetSpecificServiceHealth(string serviceName) + { + var stopwatch = Stopwatch.StartNew(); + + try + { + var healthReport = await _healthCheckService.CheckHealthAsync(check => check.Name == serviceName); + stopwatch.Stop(); + + if (!healthReport.Entries.Any()) + { + return NotFound(new { error = $"Service '{serviceName}' not found" }); + } + + var entry = healthReport.Entries.First().Value; + + _logger.LogInformation("[HEALTH_CHECK] Service: {serviceName} Status: {status} Duration: {duration} ResponseTime: {responseTime} Timestamp: {timestamp} Data: {data}", + serviceName, + entry.Status.ToString().ToLower(), + entry.Duration.TotalMilliseconds, + stopwatch.ElapsedMilliseconds, + DateTime.UtcNow, + entry.Data != null ? string.Join(", ", entry.Data.Select(d => $"{d.Key}:{d.Value}")) : "none"); + + if (entry.Exception != null) + { + _logger.LogError("[HEALTH_CHECK] Service: {serviceName} Status: {status} Duration: {duration} Error: {error} Exception: {exception}", + serviceName, entry.Status.ToString().ToLower(), stopwatch.ElapsedMilliseconds, + entry.Exception.Message, entry.Exception.ToString()); + } + + var response = new + { + service = serviceName, + status = entry.Status.ToString().ToLower(), + timestamp = DateTime.UtcNow, + duration = $"{entry.Duration.TotalMilliseconds}ms", + description = entry.Description, + data = entry.Data, + exception = entry.Exception?.Message + }; + + _logger.LogInformation("Service {ServiceName} health check: {Status} in {Duration}ms", + serviceName, response.status, entry.Duration.TotalMilliseconds); + + return entry.Status == HealthStatus.Unhealthy + ? StatusCode(503, response) + : Ok(response); + } + catch (Exception ex) + { + stopwatch.Stop(); + _logger.LogError(ex, "Service {ServiceName} health check failed after {Duration}ms", + serviceName, stopwatch.ElapsedMilliseconds); + + return StatusCode(503, new + { + service = serviceName, + status = "unhealthy", + timestamp = DateTime.UtcNow, + duration = $"{stopwatch.ElapsedMilliseconds}ms", + error = ex.Message + }); + } + } + + private static string FormatUptime(TimeSpan uptime) + { + if (uptime.TotalDays >= 1) + return $"{(int)uptime.TotalDays}d {uptime.Hours}h {uptime.Minutes}m"; + if (uptime.TotalHours >= 1) + return $"{uptime.Hours}h {uptime.Minutes}m"; + if (uptime.TotalMinutes >= 1) + return $"{uptime.Minutes}m {uptime.Seconds}s"; + return $"{uptime.Seconds}s"; + } } \ No newline at end of file diff --git a/src/BCards.Web/Controllers/HomeController.cs b/src/BCards.Web/Controllers/HomeController.cs index e5fe2a7..69844b6 100644 --- a/src/BCards.Web/Controllers/HomeController.cs +++ b/src/BCards.Web/Controllers/HomeController.cs @@ -1,108 +1,108 @@ -using BCards.Web.Services; -using BCards.Web.Configuration; -using Microsoft.AspNetCore.Mvc; -using Microsoft.Extensions.Options; - -namespace BCards.Web.Controllers; - -public class HomeController : Controller -{ - private readonly ICategoryService _categoryService; - private readonly IUserPageService _userPageService; - private readonly StripeSettings _stripeSettings; - - public HomeController( - ICategoryService categoryService, - IUserPageService userPageService, - IOptions stripeSettings) - { - _categoryService = categoryService; - _userPageService = userPageService; - _stripeSettings = stripeSettings.Value; - } - - public async Task Index() - { - // Cache condicional: apenas para usuários não logados - if (User.Identity?.IsAuthenticated != true) - { - Response.Headers["Cache-Control"] = "public, max-age=600"; // 10 minutos - } - else - { - Response.Headers["Cache-Control"] = "no-cache, must-revalidate"; - Response.Headers["Vary"] = "Cookie"; - } - - ViewBag.IsHomePage = true; // Flag para identificar home - ViewBag.Categories = await _categoryService.GetAllCategoriesAsync(); - ViewBag.RecentPages = await _userPageService.GetRecentPagesAsync(6); - return View(); - } - - [Route("Privacy")] - [ResponseCache(Duration = 3600, Location = ResponseCacheLocation.Any)] // 1 hora - public IActionResult Privacy() - { - ViewBag.IsHomePage = true; - return View(); - } - - [Route("Pricing")] - public IActionResult Pricing() - { - // Cache condicional: apenas para usuários não logados - if (User.Identity?.IsAuthenticated != true) - { - Response.Headers["Cache-Control"] = "public, max-age=1800"; // 30 minutos - } - else - { - Response.Headers["Cache-Control"] = "no-cache, must-revalidate"; - Response.Headers["Vary"] = "Cookie"; - } - - ViewBag.IsHomePage = true; - return View(); - } - - [Route("health")] - public IActionResult Health() - { - return Ok(new { - status = "healthy", - timestamp = DateTime.UtcNow, - version = "1.0.0" - }); - } - - [Route("stripe-info")] - public IActionResult StripeInfo() - { - // Apenas para usuários logados ou em desenvolvimento - if (!User.Identity?.IsAuthenticated == true && !HttpContext.RequestServices.GetRequiredService().IsDevelopment()) - { - return NotFound(); - } - - var info = new - { - Environment = _stripeSettings.Environment, - IsTestMode = _stripeSettings.IsTestMode, - IsLiveMode = _stripeSettings.IsLiveMode, - PublishableKeyPrefix = _stripeSettings.PublishableKey?.Substring(0, Math.Min(12, _stripeSettings.PublishableKey.Length)) + "...", - SecretKeyPrefix = _stripeSettings.SecretKey?.Substring(0, Math.Min(12, _stripeSettings.SecretKey.Length)) + "...", - WebhookConfigured = !string.IsNullOrEmpty(_stripeSettings.WebhookSecret), - Timestamp = DateTime.UtcNow - }; - - return Json(info); - } - - - [ResponseCache(Duration = 0, Location = ResponseCacheLocation.None, NoStore = true)] - public IActionResult Error() - { - return View(); - } +using BCards.Web.Services; +using BCards.Web.Configuration; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Options; + +namespace BCards.Web.Controllers; + +public class HomeController : Controller +{ + private readonly ICategoryService _categoryService; + private readonly IUserPageService _userPageService; + private readonly StripeSettings _stripeSettings; + + public HomeController( + ICategoryService categoryService, + IUserPageService userPageService, + IOptions stripeSettings) + { + _categoryService = categoryService; + _userPageService = userPageService; + _stripeSettings = stripeSettings.Value; + } + + public async Task Index() + { + // Cache condicional: apenas para usuários não logados + if (User.Identity?.IsAuthenticated != true) + { + Response.Headers["Cache-Control"] = "public, max-age=600"; // 10 minutos + } + else + { + Response.Headers["Cache-Control"] = "no-cache, must-revalidate"; + Response.Headers["Vary"] = "Cookie"; + } + + ViewBag.IsHomePage = true; // Flag para identificar home + ViewBag.Categories = await _categoryService.GetAllCategoriesAsync(); + ViewBag.RecentPages = await _userPageService.GetRecentPagesAsync(6); + return View(); + } + + [Route("Privacy")] + [ResponseCache(Duration = 3600, Location = ResponseCacheLocation.Any)] // 1 hora + public IActionResult Privacy() + { + ViewBag.IsHomePage = true; + return View(); + } + + [Route("Pricing")] + public IActionResult Pricing() + { + // Cache condicional: apenas para usuários não logados + if (User.Identity?.IsAuthenticated != true) + { + Response.Headers["Cache-Control"] = "public, max-age=1800"; // 30 minutos + } + else + { + Response.Headers["Cache-Control"] = "no-cache, must-revalidate"; + Response.Headers["Vary"] = "Cookie"; + } + + ViewBag.IsHomePage = true; + return View(); + } + + [Route("health")] + public IActionResult Health() + { + return Ok(new { + status = "healthy", + timestamp = DateTime.UtcNow, + version = "1.0.0" + }); + } + + [Route("stripe-info")] + public IActionResult StripeInfo() + { + // Apenas para usuários logados ou em desenvolvimento + if (!User.Identity?.IsAuthenticated == true && !HttpContext.RequestServices.GetRequiredService().IsDevelopment()) + { + return NotFound(); + } + + var info = new + { + Environment = _stripeSettings.Environment, + IsTestMode = _stripeSettings.IsTestMode, + IsLiveMode = _stripeSettings.IsLiveMode, + PublishableKeyPrefix = _stripeSettings.PublishableKey?.Substring(0, Math.Min(12, _stripeSettings.PublishableKey.Length)) + "...", + SecretKeyPrefix = _stripeSettings.SecretKey?.Substring(0, Math.Min(12, _stripeSettings.SecretKey.Length)) + "...", + WebhookConfigured = !string.IsNullOrEmpty(_stripeSettings.WebhookSecret), + Timestamp = DateTime.UtcNow + }; + + return Json(info); + } + + + [ResponseCache(Duration = 0, Location = ResponseCacheLocation.None, NoStore = true)] + public IActionResult Error() + { + return View(); + } } \ No newline at end of file diff --git a/src/BCards.Web/Controllers/ImageController.cs b/src/BCards.Web/Controllers/ImageController.cs index 9ad0744..2b8eb0d 100644 --- a/src/BCards.Web/Controllers/ImageController.cs +++ b/src/BCards.Web/Controllers/ImageController.cs @@ -1,202 +1,202 @@ -using BCards.Web.Services; -using Microsoft.AspNetCore.Mvc; - -namespace BCards.Web.Controllers; - -[Route("api/[controller]")] -[ApiController] -public class ImageController : ControllerBase -{ - private readonly IImageStorageService _imageStorage; - private readonly ILogger _logger; - - public ImageController(IImageStorageService imageStorage, ILogger logger) - { - _imageStorage = imageStorage; - _logger = logger; - } - - [HttpGet("{imageId}")] - [ResponseCache(Duration = 3600, Location = ResponseCacheLocation.Any, VaryByQueryKeys = new[] { "imageId" })] - public async Task GetImage(string imageId) - { - try - { - if (string.IsNullOrEmpty(imageId)) - { - _logger.LogWarning("Image request with empty ID"); - return BadRequest("Image ID is required"); - } - - var imageBytes = await _imageStorage.GetImageAsync(imageId); - - if (imageBytes == null || imageBytes.Length == 0) - { - _logger.LogWarning("Image not found: {ImageId}", imageId); - return NotFound("Image not found"); - } - - // Headers de cache mais agressivos para imagens - Response.Headers["Cache-Control"] = "public, max-age=31536000"; // 1 ano - Response.Headers["Expires"] = DateTime.UtcNow.AddYears(1).ToString("R"); - Response.Headers["ETag"] = $"\"{imageId}\""; - - return File(imageBytes, "image/jpeg", enableRangeProcessing: true); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error retrieving image: {ImageId}", imageId); - return NotFound("Image not found"); - } - } - - [HttpPost("upload")] - [RequestSizeLimit(2 * 1024 * 1024)] // 2MB máximo - otimizado para celulares - [DisableRequestSizeLimit] // Para formulários grandes - public async Task UploadImage(IFormFile file) - { - try - { - if (file == null || file.Length == 0) - { - _logger.LogWarning("Upload request with no file"); - return BadRequest(new { error = "No file provided", code = "NO_FILE" }); - } - - // Validações de tipo - var allowedTypes = new[] { "image/jpeg", "image/jpg", "image/png", "image/gif" }; - if (!allowedTypes.Contains(file.ContentType.ToLower())) - { - _logger.LogWarning("Invalid file type uploaded: {ContentType}", file.ContentType); - return BadRequest(new { - error = "Invalid file type. Only JPEG, PNG and GIF are allowed.", - code = "INVALID_TYPE" - }); - } - - // Validação de tamanho - if (file.Length > 2 * 1024 * 1024) // 2MB - { - _logger.LogWarning("File too large: {Size}MB", file.Length / (1024 * 1024)); - return BadRequest(new { - error = "Arquivo muito grande. Tamanho máximo: 2MB.", - code = "FILE_TOO_LARGE" - }); - } - - // Processar upload - using var memoryStream = new MemoryStream(); - await file.CopyToAsync(memoryStream); - var imageBytes = memoryStream.ToArray(); - - // Validação adicional: verificar se é realmente uma imagem - if (!IsValidImageBytes(imageBytes)) - { - _logger.LogWarning("Invalid image data uploaded"); - return BadRequest(new { - error = "Invalid image data.", - code = "INVALID_IMAGE" - }); - } - - var imageId = await _imageStorage.SaveImageAsync(imageBytes, file.FileName, file.ContentType); - - _logger.LogInformation("Image uploaded successfully: {ImageId}, Original: {FileName}, Size: {Size}KB", - imageId, file.FileName, file.Length / 1024); - - return Ok(new { - success = true, - imageId, - url = $"/api/image/{imageId}", - originalSize = file.Length, - fileName = file.FileName - }); - } - catch (ArgumentException ex) - { - _logger.LogWarning(ex, "Invalid upload parameters"); - return BadRequest(new { - error = ex.Message, - code = "VALIDATION_ERROR" - }); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error uploading image: {FileName}", file?.FileName); - return StatusCode(500, new { - error = "Error uploading image. Please try again.", - code = "UPLOAD_ERROR" - }); - } - } - - [HttpDelete("{imageId}")] - public async Task DeleteImage(string imageId) - { - try - { - if (string.IsNullOrEmpty(imageId)) - return BadRequest(new { error = "Image ID is required" }); - - var deleted = await _imageStorage.DeleteImageAsync(imageId); - - if (!deleted) - return NotFound(new { error = "Image not found" }); - - _logger.LogInformation("Image deleted: {ImageId}", imageId); - return Ok(new { success = true, message = "Image deleted successfully" }); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error deleting image: {ImageId}", imageId); - return StatusCode(500, new { error = "Error deleting image" }); - } - } - - [HttpHead("{imageId}")] - public async Task ImageExists(string imageId) - { - try - { - if (string.IsNullOrEmpty(imageId)) - return BadRequest(); - - var exists = await _imageStorage.ImageExistsAsync(imageId); - return exists ? Ok() : NotFound(); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error checking image existence: {ImageId}", imageId); - return StatusCode(500); - } - } - - private static bool IsValidImageBytes(byte[] bytes) - { - if (bytes == null || bytes.Length < 4) - return false; - - // Verificar assinaturas de arquivos de imagem - var jpegSignature = new byte[] { 0xFF, 0xD8, 0xFF }; - var pngSignature = new byte[] { 0x89, 0x50, 0x4E, 0x47 }; - var gifSignature = new byte[] { 0x47, 0x49, 0x46 }; - - return StartsWithSignature(bytes, jpegSignature) || - StartsWithSignature(bytes, pngSignature) || - StartsWithSignature(bytes, gifSignature); - } - - private static bool StartsWithSignature(byte[] bytes, byte[] signature) - { - if (bytes.Length < signature.Length) - return false; - - for (int i = 0; i < signature.Length; i++) - { - if (bytes[i] != signature[i]) - return false; - } - - return true; - } +using BCards.Web.Services; +using Microsoft.AspNetCore.Mvc; + +namespace BCards.Web.Controllers; + +[Route("api/[controller]")] +[ApiController] +public class ImageController : ControllerBase +{ + private readonly IImageStorageService _imageStorage; + private readonly ILogger _logger; + + public ImageController(IImageStorageService imageStorage, ILogger logger) + { + _imageStorage = imageStorage; + _logger = logger; + } + + [HttpGet("{imageId}")] + [ResponseCache(Duration = 3600, Location = ResponseCacheLocation.Any, VaryByQueryKeys = new[] { "imageId" })] + public async Task GetImage(string imageId) + { + try + { + if (string.IsNullOrEmpty(imageId)) + { + _logger.LogWarning("Image request with empty ID"); + return BadRequest("Image ID is required"); + } + + var imageBytes = await _imageStorage.GetImageAsync(imageId); + + if (imageBytes == null || imageBytes.Length == 0) + { + _logger.LogWarning("Image not found: {ImageId}", imageId); + return NotFound("Image not found"); + } + + // Headers de cache mais agressivos para imagens + Response.Headers["Cache-Control"] = "public, max-age=31536000"; // 1 ano + Response.Headers["Expires"] = DateTime.UtcNow.AddYears(1).ToString("R"); + Response.Headers["ETag"] = $"\"{imageId}\""; + + return File(imageBytes, "image/jpeg", enableRangeProcessing: true); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error retrieving image: {ImageId}", imageId); + return NotFound("Image not found"); + } + } + + [HttpPost("upload")] + [RequestSizeLimit(2 * 1024 * 1024)] // 2MB máximo - otimizado para celulares + [DisableRequestSizeLimit] // Para formulários grandes + public async Task UploadImage(IFormFile file) + { + try + { + if (file == null || file.Length == 0) + { + _logger.LogWarning("Upload request with no file"); + return BadRequest(new { error = "No file provided", code = "NO_FILE" }); + } + + // Validações de tipo + var allowedTypes = new[] { "image/jpeg", "image/jpg", "image/png", "image/gif" }; + if (!allowedTypes.Contains(file.ContentType.ToLower())) + { + _logger.LogWarning("Invalid file type uploaded: {ContentType}", file.ContentType); + return BadRequest(new { + error = "Invalid file type. Only JPEG, PNG and GIF are allowed.", + code = "INVALID_TYPE" + }); + } + + // Validação de tamanho + if (file.Length > 2 * 1024 * 1024) // 2MB + { + _logger.LogWarning("File too large: {Size}MB", file.Length / (1024 * 1024)); + return BadRequest(new { + error = "Arquivo muito grande. Tamanho máximo: 2MB.", + code = "FILE_TOO_LARGE" + }); + } + + // Processar upload + using var memoryStream = new MemoryStream(); + await file.CopyToAsync(memoryStream); + var imageBytes = memoryStream.ToArray(); + + // Validação adicional: verificar se é realmente uma imagem + if (!IsValidImageBytes(imageBytes)) + { + _logger.LogWarning("Invalid image data uploaded"); + return BadRequest(new { + error = "Invalid image data.", + code = "INVALID_IMAGE" + }); + } + + var imageId = await _imageStorage.SaveImageAsync(imageBytes, file.FileName, file.ContentType); + + _logger.LogInformation("Image uploaded successfully: {ImageId}, Original: {FileName}, Size: {Size}KB", + imageId, file.FileName, file.Length / 1024); + + return Ok(new { + success = true, + imageId, + url = $"/api/image/{imageId}", + originalSize = file.Length, + fileName = file.FileName + }); + } + catch (ArgumentException ex) + { + _logger.LogWarning(ex, "Invalid upload parameters"); + return BadRequest(new { + error = ex.Message, + code = "VALIDATION_ERROR" + }); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error uploading image: {FileName}", file?.FileName); + return StatusCode(500, new { + error = "Error uploading image. Please try again.", + code = "UPLOAD_ERROR" + }); + } + } + + [HttpDelete("{imageId}")] + public async Task DeleteImage(string imageId) + { + try + { + if (string.IsNullOrEmpty(imageId)) + return BadRequest(new { error = "Image ID is required" }); + + var deleted = await _imageStorage.DeleteImageAsync(imageId); + + if (!deleted) + return NotFound(new { error = "Image not found" }); + + _logger.LogInformation("Image deleted: {ImageId}", imageId); + return Ok(new { success = true, message = "Image deleted successfully" }); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error deleting image: {ImageId}", imageId); + return StatusCode(500, new { error = "Error deleting image" }); + } + } + + [HttpHead("{imageId}")] + public async Task ImageExists(string imageId) + { + try + { + if (string.IsNullOrEmpty(imageId)) + return BadRequest(); + + var exists = await _imageStorage.ImageExistsAsync(imageId); + return exists ? Ok() : NotFound(); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error checking image existence: {ImageId}", imageId); + return StatusCode(500); + } + } + + private static bool IsValidImageBytes(byte[] bytes) + { + if (bytes == null || bytes.Length < 4) + return false; + + // Verificar assinaturas de arquivos de imagem + var jpegSignature = new byte[] { 0xFF, 0xD8, 0xFF }; + var pngSignature = new byte[] { 0x89, 0x50, 0x4E, 0x47 }; + var gifSignature = new byte[] { 0x47, 0x49, 0x46 }; + + return StartsWithSignature(bytes, jpegSignature) || + StartsWithSignature(bytes, pngSignature) || + StartsWithSignature(bytes, gifSignature); + } + + private static bool StartsWithSignature(byte[] bytes, byte[] signature) + { + if (bytes.Length < signature.Length) + return false; + + for (int i = 0; i < signature.Length; i++) + { + if (bytes[i] != signature[i]) + return false; + } + + return true; + } } \ No newline at end of file diff --git a/src/BCards.Web/Controllers/LivePageController.cs b/src/BCards.Web/Controllers/LivePageController.cs index 7727300..1cb5117 100644 --- a/src/BCards.Web/Controllers/LivePageController.cs +++ b/src/BCards.Web/Controllers/LivePageController.cs @@ -1,97 +1,97 @@ -using BCards.Web.Services; -using Microsoft.AspNetCore.Mvc; - -namespace BCards.Web.Controllers; - -[Route("page")] -public class LivePageController : Controller -{ - private readonly ILivePageService _livePageService; - private readonly ILogger _logger; - - public LivePageController(ILivePageService livePageService, ILogger logger) - { - _livePageService = livePageService; - _logger = logger; - } - - [Route("{category}/{slug}")] - [ResponseCache(Duration = 3600, Location = ResponseCacheLocation.Any, VaryByQueryKeys = new string[] { })] - public async Task Display(string category, string slug) - { - // Se tem parâmetro preview, redirecionar para sistema de preview - if (HttpContext.Request.Query.ContainsKey("preview")) - { - _logger.LogInformation("Redirecting preview request for {Category}/{Slug} to UserPageController", category, slug); - return RedirectToAction("Display", "UserPage", new { - category = category, - slug = slug, - preview = HttpContext.Request.Query["preview"].ToString() - }); - } - - var livePage = await _livePageService.GetByCategoryAndSlugAsync(category, slug); - if (livePage == null) - { - _logger.LogInformation("LivePage not found for {Category}/{Slug}, falling back to UserPageController", category, slug); - // Fallback: tentar no sistema antigo - return RedirectToAction("Display", "UserPage", new { category = category, slug = slug }); - } - - // Incrementar view de forma assíncrona (não bloquear response) - _ = IncrementViewSafelyAsync(livePage.Id); - - // Configurar ViewBag para indicar que é uma live page - ViewBag.IsLivePage = true; - ViewBag.PageUrl = $"https://bcards.site/page/{category}/{slug}"; - ViewBag.Title = $"{livePage.DisplayName} - {livePage.Category} | BCards"; - - _logger.LogInformation("Serving LivePage {LivePageId} for {Category}/{Slug}", livePage.Id, category, slug); - - // Usar a mesma view do UserPage mas com dados da LivePage - return View("~/Views/UserPage/Display.cshtml", livePage); - } - - [Route("{category}/{slug}/link/{linkIndex}")] - public async Task TrackLinkClick(string category, string slug, int linkIndex) - { - var livePage = await _livePageService.GetByCategoryAndSlugAsync(category, slug); - if (livePage == null || linkIndex < 0 || linkIndex >= livePage.Links.Count) - { - return NotFound(); - } - - var link = livePage.Links[linkIndex]; - - // Track click de forma assíncrona - _ = IncrementLinkClickSafelyAsync(livePage.Id, linkIndex); - - _logger.LogInformation("Tracking click for LivePage {LivePageId} link {LinkIndex} -> {Url}", livePage.Id, linkIndex, link.Url); - - return Redirect(link.Url); - } - - private async Task IncrementViewSafelyAsync(string livePageId) - { - try - { - await _livePageService.IncrementViewAsync(livePageId); - } - catch (Exception ex) - { - _logger.LogError(ex, "Failed to increment view for LivePage {LivePageId}", livePageId); - } - } - - private async Task IncrementLinkClickSafelyAsync(string livePageId, int linkIndex) - { - try - { - await _livePageService.IncrementLinkClickAsync(livePageId, linkIndex); - } - catch (Exception ex) - { - _logger.LogError(ex, "Failed to track click for LivePage {LivePageId} link {LinkIndex}", livePageId, linkIndex); - } - } +using BCards.Web.Services; +using Microsoft.AspNetCore.Mvc; + +namespace BCards.Web.Controllers; + +[Route("page")] +public class LivePageController : Controller +{ + private readonly ILivePageService _livePageService; + private readonly ILogger _logger; + + public LivePageController(ILivePageService livePageService, ILogger logger) + { + _livePageService = livePageService; + _logger = logger; + } + + [Route("{category}/{slug}")] + [ResponseCache(Duration = 3600, Location = ResponseCacheLocation.Any, VaryByQueryKeys = new string[] { })] + public async Task Display(string category, string slug) + { + // Se tem parâmetro preview, redirecionar para sistema de preview + if (HttpContext.Request.Query.ContainsKey("preview")) + { + _logger.LogInformation("Redirecting preview request for {Category}/{Slug} to UserPageController", category, slug); + return RedirectToAction("Display", "UserPage", new { + category = category, + slug = slug, + preview = HttpContext.Request.Query["preview"].ToString() + }); + } + + var livePage = await _livePageService.GetByCategoryAndSlugAsync(category, slug); + if (livePage == null) + { + _logger.LogInformation("LivePage not found for {Category}/{Slug}, falling back to UserPageController", category, slug); + // Fallback: tentar no sistema antigo + return RedirectToAction("Display", "UserPage", new { category = category, slug = slug }); + } + + // Incrementar view de forma assíncrona (não bloquear response) + _ = IncrementViewSafelyAsync(livePage.Id); + + // Configurar ViewBag para indicar que é uma live page + ViewBag.IsLivePage = true; + ViewBag.PageUrl = $"https://bcards.site/page/{category}/{slug}"; + ViewBag.Title = $"{livePage.DisplayName} - {livePage.Category} | BCards"; + + _logger.LogInformation("Serving LivePage {LivePageId} for {Category}/{Slug}", livePage.Id, category, slug); + + // Usar a mesma view do UserPage mas com dados da LivePage + return View("~/Views/UserPage/Display.cshtml", livePage); + } + + [Route("{category}/{slug}/link/{linkIndex}")] + public async Task TrackLinkClick(string category, string slug, int linkIndex) + { + var livePage = await _livePageService.GetByCategoryAndSlugAsync(category, slug); + if (livePage == null || linkIndex < 0 || linkIndex >= livePage.Links.Count) + { + return NotFound(); + } + + var link = livePage.Links[linkIndex]; + + // Track click de forma assíncrona + _ = IncrementLinkClickSafelyAsync(livePage.Id, linkIndex); + + _logger.LogInformation("Tracking click for LivePage {LivePageId} link {LinkIndex} -> {Url}", livePage.Id, linkIndex, link.Url); + + return Redirect(link.Url); + } + + private async Task IncrementViewSafelyAsync(string livePageId) + { + try + { + await _livePageService.IncrementViewAsync(livePageId); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to increment view for LivePage {LivePageId}", livePageId); + } + } + + private async Task IncrementLinkClickSafelyAsync(string livePageId, int linkIndex) + { + try + { + await _livePageService.IncrementLinkClickAsync(livePageId, linkIndex); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to track click for LivePage {LivePageId} link {LinkIndex}", livePageId, linkIndex); + } + } } \ No newline at end of file diff --git a/src/BCards.Web/Controllers/ModerationController.cs b/src/BCards.Web/Controllers/ModerationController.cs index d5e1bd0..6ea9959 100644 --- a/src/BCards.Web/Controllers/ModerationController.cs +++ b/src/BCards.Web/Controllers/ModerationController.cs @@ -1,232 +1,232 @@ -using Microsoft.AspNetCore.Mvc; -using BCards.Web.Services; -using BCards.Web.Models; -using BCards.Web.ViewModels; -using BCards.Web.Repositories; -using System.Security.Claims; -using BCards.Web.Attributes; - -namespace BCards.Web.Controllers; - -[ModeratorAuthorize] -[Route("Moderation")] -public class ModerationController : Controller -{ - private readonly IModerationService _moderationService; - private readonly IEmailService _emailService; - private readonly IUserRepository _userRepository; - private readonly ILogger _logger; - - public ModerationController( - IModerationService moderationService, - IEmailService emailService, - IUserRepository userRepository, - ILogger logger) - { - _moderationService = moderationService; - _emailService = emailService; - _userRepository = userRepository; - _logger = logger; - } - - [HttpGet("Dashboard")] - public async Task Dashboard(int page = 1, int size = 20, string? filter = null) - { - var skip = (page - 1) * size; - var pendingPages = await _moderationService.GetPendingModerationAsync(skip, size, filter); - var stats = await _moderationService.GetModerationStatsAsync(); - - var viewModel = new ModerationDashboardViewModel - { - PendingPages = pendingPages.Select(p => new PendingPageViewModel - { - Id = p.Id, - DisplayName = p.DisplayName, - Category = p.Category, - Slug = p.Slug, - CreatedAt = p.CreatedAt, - ModerationAttempts = p.ModerationAttempts, - PlanType = p.PlanLimitations.PlanType.ToString(), - IsSpecialModeration = p.PlanLimitations.SpecialModeration ?? false, - PreviewUrl = !string.IsNullOrEmpty(p.PreviewToken) - ? $"/page/{p.Category}/{p.Slug}?preview={p.PreviewToken}" - : null - }).ToList(), - Stats = stats, - CurrentPage = page, - PageSize = size, - HasNextPage = pendingPages.Count == size, - CurrentFilter = filter ?? "all" - }; - - return View(viewModel); - } - - [HttpGet("Review/{id}")] - public async Task Review(string id) - { - var page = await _moderationService.GetPageForModerationAsync(id); - if (page == null) - { - TempData["Error"] = "Página não encontrada ou não está pendente de moderação."; - return RedirectToAction("Dashboard"); - } - - var user = await _userRepository.GetByIdAsync(page.UserId); - if (user == null) - { - TempData["Error"] = "Usuário não encontrado."; - return RedirectToAction("Dashboard"); - } - - var viewModel = new ModerationReviewViewModel - { - Page = page, - User = user, - PreviewUrl = !string.IsNullOrEmpty(page.PreviewToken) - ? $"/page/{page.Category}/{page.Slug}?preview={page.PreviewToken}" - : null, - ModerationCriteria = GetModerationCriteria() - }; - - return View(viewModel); - } - - [HttpPost("Approve/{id}")] - [ValidateAntiForgeryToken] - public async Task Approve(string id, string notes) - { - try - { - var page = await _moderationService.GetPageForModerationAsync(id); - if (page == null) - { - TempData["Error"] = "Página não encontrada."; - return RedirectToAction("Dashboard"); - } - - var user = await _userRepository.GetByIdAsync(page.UserId); - var moderatorId = User.FindFirst(ClaimTypes.NameIdentifier)?.Value ?? "system"; - - await _moderationService.ApprovePageAsync(id, moderatorId, notes); - - if (user != null) - { - await _emailService.SendModerationStatusAsync( - user.Email, - user.Name, - page.DisplayName, - PageStatus.Active.ToString()); - } - - TempData["Success"] = $"Página '{page.DisplayName}' aprovada com sucesso!"; - return RedirectToAction("Dashboard"); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error approving page {PageId}", id); - TempData["Error"] = "Erro ao aprovar página."; - return RedirectToAction("Review", new { id }); - } - } - - [HttpPost("Reject/{id}")] - [ValidateAntiForgeryToken] - public async Task Reject(string id, string reason, List issues) - { - try - { - var page = await _moderationService.GetPageForModerationAsync(id); - if (page == null) - { - TempData["Error"] = "Página não encontrada."; - return RedirectToAction("Dashboard"); - } - - var user = await _userRepository.GetByIdAsync(page.UserId); - var moderatorId = User.FindFirst(ClaimTypes.NameIdentifier)?.Value ?? "system"; - - await _moderationService.RejectPageAsync(id, moderatorId, reason, issues); - - if (user != null) - { - await _emailService.SendModerationStatusAsync( - user.Email, - user.Name, - page.DisplayName, - "rejected", - reason); - } - - TempData["Success"] = $"Página '{page.DisplayName}' rejeitada."; - return RedirectToAction("Dashboard"); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error rejecting page {PageId}", id); - TempData["Error"] = "Erro ao rejeitar página."; - return RedirectToAction("Review", new { id }); - } - } - - [HttpGet("History")] - public async Task History(int page = 1, int size = 20) - { - var skip = (page - 1) * size; - var historyPages = await _moderationService.GetModerationHistoryAsync(skip, size); - - var viewModel = new ModerationHistoryViewModel - { - Pages = historyPages.Select(p => new ModerationPageViewModel - { - Id = p.Id, - DisplayName = p.DisplayName, - Category = p.Category, - Slug = p.Slug, - CreatedAt = p.CreatedAt, - Status = p.Status.ToString(), - ModerationAttempts = p.ModerationAttempts, - PlanType = p.PlanLimitations.PlanType.ToString(), - ApprovedAt = p.ApprovedAt, - LastModerationEntry = p.ModerationHistory.LastOrDefault() - }).ToList(), - CurrentPage = page, - PageSize = size, - HasNextPage = historyPages.Count == size - }; - - return View(viewModel); - } - - private List GetModerationCriteria() - { - return new List - { - new() { Category = "Conteúdo Proibido", Items = new List - { - "Pornografia e conteúdo sexual explícito", - "Drogas ilegais e substâncias controladas", - "Armas e explosivos", - "Atividades ilegais (fraudes, pirataria)", - "Apostas e jogos de azar", - "Criptomoedas e esquemas de pirâmide", - "Conteúdo que promove violência ou ódio", - "Spam e links suspeitos/maliciosos" - }}, - new() { Category = "Conteúdo Suspeito", Items = new List - { - "Excesso de anúncios (>30% dos links)", - "Sites com pop-ups excessivos", - "Links encurtados suspeitos", - "Conteúdo que imita marcas conhecidas", - "Produtos \"milagrosos\"" - }}, - new() { Category = "Verificações Técnicas", Items = new List - { - "Links funcionais (não quebrados)", - "Sites com SSL válido", - "Não redirecionamentos maliciosos" - }} - }; - } +using Microsoft.AspNetCore.Mvc; +using BCards.Web.Services; +using BCards.Web.Models; +using BCards.Web.ViewModels; +using BCards.Web.Repositories; +using System.Security.Claims; +using BCards.Web.Attributes; + +namespace BCards.Web.Controllers; + +[ModeratorAuthorize] +[Route("Moderation")] +public class ModerationController : Controller +{ + private readonly IModerationService _moderationService; + private readonly IEmailService _emailService; + private readonly IUserRepository _userRepository; + private readonly ILogger _logger; + + public ModerationController( + IModerationService moderationService, + IEmailService emailService, + IUserRepository userRepository, + ILogger logger) + { + _moderationService = moderationService; + _emailService = emailService; + _userRepository = userRepository; + _logger = logger; + } + + [HttpGet("Dashboard")] + public async Task Dashboard(int page = 1, int size = 20, string? filter = null) + { + var skip = (page - 1) * size; + var pendingPages = await _moderationService.GetPendingModerationAsync(skip, size, filter); + var stats = await _moderationService.GetModerationStatsAsync(); + + var viewModel = new ModerationDashboardViewModel + { + PendingPages = pendingPages.Select(p => new PendingPageViewModel + { + Id = p.Id, + DisplayName = p.DisplayName, + Category = p.Category, + Slug = p.Slug, + CreatedAt = p.CreatedAt, + ModerationAttempts = p.ModerationAttempts, + PlanType = p.PlanLimitations.PlanType.ToString(), + IsSpecialModeration = p.PlanLimitations.SpecialModeration ?? false, + PreviewUrl = !string.IsNullOrEmpty(p.PreviewToken) + ? $"/page/{p.Category}/{p.Slug}?preview={p.PreviewToken}" + : null + }).ToList(), + Stats = stats, + CurrentPage = page, + PageSize = size, + HasNextPage = pendingPages.Count == size, + CurrentFilter = filter ?? "all" + }; + + return View(viewModel); + } + + [HttpGet("Review/{id}")] + public async Task Review(string id) + { + var page = await _moderationService.GetPageForModerationAsync(id); + if (page == null) + { + TempData["Error"] = "Página não encontrada ou não está pendente de moderação."; + return RedirectToAction("Dashboard"); + } + + var user = await _userRepository.GetByIdAsync(page.UserId); + if (user == null) + { + TempData["Error"] = "Usuário não encontrado."; + return RedirectToAction("Dashboard"); + } + + var viewModel = new ModerationReviewViewModel + { + Page = page, + User = user, + PreviewUrl = !string.IsNullOrEmpty(page.PreviewToken) + ? $"/page/{page.Category}/{page.Slug}?preview={page.PreviewToken}" + : null, + ModerationCriteria = GetModerationCriteria() + }; + + return View(viewModel); + } + + [HttpPost("Approve/{id}")] + [ValidateAntiForgeryToken] + public async Task Approve(string id, string notes) + { + try + { + var page = await _moderationService.GetPageForModerationAsync(id); + if (page == null) + { + TempData["Error"] = "Página não encontrada."; + return RedirectToAction("Dashboard"); + } + + var user = await _userRepository.GetByIdAsync(page.UserId); + var moderatorId = User.FindFirst(ClaimTypes.NameIdentifier)?.Value ?? "system"; + + await _moderationService.ApprovePageAsync(id, moderatorId, notes); + + if (user != null) + { + await _emailService.SendModerationStatusAsync( + user.Email, + user.Name, + page.DisplayName, + PageStatus.Active.ToString()); + } + + TempData["Success"] = $"Página '{page.DisplayName}' aprovada com sucesso!"; + return RedirectToAction("Dashboard"); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error approving page {PageId}", id); + TempData["Error"] = "Erro ao aprovar página."; + return RedirectToAction("Review", new { id }); + } + } + + [HttpPost("Reject/{id}")] + [ValidateAntiForgeryToken] + public async Task Reject(string id, string reason, List issues) + { + try + { + var page = await _moderationService.GetPageForModerationAsync(id); + if (page == null) + { + TempData["Error"] = "Página não encontrada."; + return RedirectToAction("Dashboard"); + } + + var user = await _userRepository.GetByIdAsync(page.UserId); + var moderatorId = User.FindFirst(ClaimTypes.NameIdentifier)?.Value ?? "system"; + + await _moderationService.RejectPageAsync(id, moderatorId, reason, issues); + + if (user != null) + { + await _emailService.SendModerationStatusAsync( + user.Email, + user.Name, + page.DisplayName, + "rejected", + reason); + } + + TempData["Success"] = $"Página '{page.DisplayName}' rejeitada."; + return RedirectToAction("Dashboard"); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error rejecting page {PageId}", id); + TempData["Error"] = "Erro ao rejeitar página."; + return RedirectToAction("Review", new { id }); + } + } + + [HttpGet("History")] + public async Task History(int page = 1, int size = 20) + { + var skip = (page - 1) * size; + var historyPages = await _moderationService.GetModerationHistoryAsync(skip, size); + + var viewModel = new ModerationHistoryViewModel + { + Pages = historyPages.Select(p => new ModerationPageViewModel + { + Id = p.Id, + DisplayName = p.DisplayName, + Category = p.Category, + Slug = p.Slug, + CreatedAt = p.CreatedAt, + Status = p.Status.ToString(), + ModerationAttempts = p.ModerationAttempts, + PlanType = p.PlanLimitations.PlanType.ToString(), + ApprovedAt = p.ApprovedAt, + LastModerationEntry = p.ModerationHistory.LastOrDefault() + }).ToList(), + CurrentPage = page, + PageSize = size, + HasNextPage = historyPages.Count == size + }; + + return View(viewModel); + } + + private List GetModerationCriteria() + { + return new List + { + new() { Category = "Conteúdo Proibido", Items = new List + { + "Pornografia e conteúdo sexual explícito", + "Drogas ilegais e substâncias controladas", + "Armas e explosivos", + "Atividades ilegais (fraudes, pirataria)", + "Apostas e jogos de azar", + "Criptomoedas e esquemas de pirâmide", + "Conteúdo que promove violência ou ódio", + "Spam e links suspeitos/maliciosos" + }}, + new() { Category = "Conteúdo Suspeito", Items = new List + { + "Excesso de anúncios (>30% dos links)", + "Sites com pop-ups excessivos", + "Links encurtados suspeitos", + "Conteúdo que imita marcas conhecidas", + "Produtos \"milagrosos\"" + }}, + new() { Category = "Verificações Técnicas", Items = new List + { + "Links funcionais (não quebrados)", + "Sites com SSL válido", + "Não redirecionamentos maliciosos" + }} + }; + } } \ No newline at end of file diff --git a/src/BCards.Web/Controllers/PaymentController.cs b/src/BCards.Web/Controllers/PaymentController.cs index 280e8e4..742e1bf 100644 --- a/src/BCards.Web/Controllers/PaymentController.cs +++ b/src/BCards.Web/Controllers/PaymentController.cs @@ -1,279 +1,279 @@ -using BCards.Web.Models; -using BCards.Web.Repositories; -using BCards.Web.Services; -using BCards.Web.ViewModels; -using BCards.Web.Utils; -using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Identity; -using Microsoft.AspNetCore.Mvc; -using System.Globalization; - -namespace BCards.Web.Controllers; - -[Authorize] -public class PaymentController : Controller -{ - private readonly IPaymentService _paymentService; - private readonly IAuthService _authService; - private readonly IUserRepository _userService; - private readonly ISubscriptionRepository _subscriptionRepository; - private readonly IConfiguration _configuration; - - public PaymentController(IPaymentService paymentService, IAuthService authService, IUserRepository userService, ISubscriptionRepository subscriptionRepository, IConfiguration configuration) - { - _paymentService = paymentService; - _authService = authService; - _userService = userService; - _subscriptionRepository = subscriptionRepository; - _configuration = configuration; - } - - [HttpPost] - public async Task CreateCheckoutSession(string planType) - { - var user = await _authService.GetCurrentUserAsync(User); - if (user == null) - return RedirectToAction("Login", "Auth"); - - var successUrl = Url.Action("Success", "Payment", null, Request.Scheme); - var cancelUrl = Url.Action("Cancel", "Payment", null, Request.Scheme); - - TempData[$"PlanType|{user.Id}"] = planType; - - try - { - var checkoutUrl = await _paymentService.CreateCheckoutSessionAsync( - user.Id, - planType, - successUrl!, - cancelUrl!); - - return Redirect(checkoutUrl); - } - catch (Exception ex) - { - TempData["Error"] = $"Erro ao processar pagamento: {ex.Message}"; - return RedirectToAction("Pricing", "Home"); - } - } - - public async Task Success() - { - try - { - var user = await _authService.GetCurrentUserAsync(User); - var planType = TempData[$"PlanType|{user.Id}"].ToString(); - - TempData["Success"] = $"Assinatura {planType} ativada com sucesso!"; - return RedirectToAction("Dashboard", "Admin"); - } - catch (Exception ex) - { - TempData["Error"] = $"Erro ao processar pagamento: {ex.Message}"; - return RedirectToAction("Dashboard", "Admin"); - } - } - - public IActionResult Cancel() - { - TempData["Info"] = "Pagamento cancelado. Você pode tentar novamente quando quiser."; - return RedirectToAction("Pricing", "Home"); - } - - [HttpPost] - [Route("webhook/stripe")] - [AllowAnonymous] - public async Task StripeWebhook() - { - var signature = Request.Headers["Stripe-Signature"].FirstOrDefault(); - if (string.IsNullOrEmpty(signature)) - return BadRequest(); - - string requestBody; - using (var reader = new StreamReader(Request.Body)) - { - requestBody = await reader.ReadToEndAsync(); - } - - try - { - await _paymentService.HandleWebhookAsync(requestBody, signature); - return Ok(); - } - catch (Exception ex) - { - return BadRequest($"Webhook error: {ex.Message}"); - } - } - - public async Task ManageSubscription() - { - var user = await _authService.GetCurrentUserAsync(User); - if (user == null) - return RedirectToAction("Login", "Auth"); - - try - { - // Parse do plano atual (mesmo que o Dashboard) - var userPlanType = Enum.TryParse(user.CurrentPlan, true, out var planType) ? planType : PlanType.Trial; - var currentPlanString = userPlanType.ToString().ToLower(); - var subscription = await _subscriptionRepository.GetByUserIdAsync(user.Id); - var viewModel = new ManageSubscriptionViewModel - { - User = user, - StripeSubscription = await _paymentService.GetSubscriptionDetailsAsync(user.Id), - PaymentHistory = await _paymentService.GetPaymentHistoryAsync(user.Id), - AvailablePlans = GetAvailablePlans(currentPlanString), - CurrentPeriodEnd = (DateTime?) subscription.CurrentPeriodEnd - }; - - // Pegar assinatura local se existir - if (!string.IsNullOrEmpty(user.StripeCustomerId)) - { - // Aqui você poderia buscar a subscription local se necessário - // viewModel.LocalSubscription = await _subscriptionRepository.GetByUserIdAsync(user.Id); - } - - return View(viewModel); - } - catch (Exception ex) - { - // Parse do plano atual também no catch - var userPlanType = Enum.TryParse(user.CurrentPlan, true, out var planType) ? planType : PlanType.Trial; - var currentPlanString = userPlanType.ToString().ToLower(); - - var errorViewModel = new ManageSubscriptionViewModel - { - User = user, - ErrorMessage = $"Erro ao carregar dados da assinatura: {ex.Message}", - AvailablePlans = GetAvailablePlans(currentPlanString) - }; - - return View(errorViewModel); - } - } - - [HttpPost] - public async Task CancelSubscription(string subscriptionId) - { - try - { - await _paymentService.CancelSubscriptionAsync(subscriptionId); - TempData["Success"] = "Sua assinatura será cancelada no final do período atual."; - } - catch (Exception ex) - { - TempData["Error"] = $"Erro ao cancelar assinatura: {ex.Message}"; - } - - return RedirectToAction("ManageSubscription"); - } - - [HttpPost] - public async Task ChangePlan(string newPlanType) - { - var user = await _authService.GetCurrentUserAsync(User); - if (user == null) - return RedirectToAction("Login", "Auth"); - - try - { - // Para mudanças de plano, vamos usar o Stripe Checkout - var returnUrl = Url.Action("ManageSubscription", "Payment", null, Request.Scheme); - var cancelUrl = Url.Action("ManageSubscription", "Payment", null, Request.Scheme); - - var checkoutUrl = await _paymentService.CreateCheckoutSessionAsync( - user.Id, - newPlanType, - returnUrl!, - cancelUrl!); - - return Redirect(checkoutUrl); - } - catch (Exception ex) - { - TempData["Error"] = $"Erro ao alterar plano: {ex.Message}"; - return RedirectToAction("ManageSubscription"); - } - } - - [HttpPost] - public async Task OpenStripePortal() - { - var user = await _authService.GetCurrentUserAsync(User); - if (user == null || string.IsNullOrEmpty(user.StripeCustomerId)) - { - TempData["Error"] = "Erro: dados de assinatura não encontrados."; - return RedirectToAction("ManageSubscription"); - } - - try - { - var returnUrl = Url.Action("ManageSubscription", "Payment", null, Request.Scheme); - var portalUrl = await _paymentService.CreatePortalSessionAsync(user.StripeCustomerId, returnUrl!); - - return Redirect(portalUrl); - } - catch (Exception ex) - { - TempData["Error"] = $"Erro ao abrir portal de pagamento: {ex.Message}"; - return RedirectToAction("ManageSubscription"); - } - } - - private List GetAvailablePlans(string currentPlan) - { - var plansConfig = _configuration.GetSection("Plans"); - var plans = new List(); - - // Adicionar planos mensais apenas (excluir Trial e planos anuais) - var monthlyPlans = new[] { "Basic", "Professional", "Premium", "PremiumAffiliate" }; - - foreach (var planKey in monthlyPlans) - { - var planSection = plansConfig.GetSection(planKey); - if (planSection.Exists()) - { - plans.Add(new AvailablePlanViewModel - { - PlanType = planKey.ToLower(), - DisplayName = planSection["Name"] ?? planKey, - Price = decimal.Parse(planSection["Price"] ?? "0", new CultureInfo("en-US")), - PriceId = planSection["PriceId"] ?? "", - MaxLinks = int.Parse(planSection["MaxLinks"] ?? "0"), - AllowAnalytics = bool.Parse(planSection["AllowAnalytics"] ?? "false"), - AllowCustomDomain = true, // URL personalizada em todos os planos pagos - AllowCustomThemes = bool.Parse(planSection["AllowPremiumThemes"] ?? "false"), - AllowProductLinks = bool.Parse(planSection["AllowProductLinks"] ?? "false"), - Features = planSection.GetSection("Features").Get>() ?? new List(), - IsCurrentPlan = currentPlan.Equals(planKey, StringComparison.OrdinalIgnoreCase) - }); - } - } - - // Marcar upgrades e filtrar downgrades - var currentPlanIndex = plans.FindIndex(p => p.IsCurrentPlan); - - // Se usuário está no Trial (não encontrou plano atual), todos são upgrades - if (currentPlanIndex == -1 && (currentPlan == "trial" || currentPlan == "free")) - { - foreach (var plan in plans) - { - plan.IsUpgrade = true; - } - return plans; // Mostrar todos os planos pagos como upgrade - } - - // Para planos pagos, marcar apenas upgrades superiores - for (int i = 0; i < plans.Count; i++) - { - if (i > currentPlanIndex) - plans[i].IsUpgrade = true; - else if (i < currentPlanIndex) - plans[i].IsDowngrade = true; - } - - // Retornar apenas plano atual e upgrades (Stripe não gerencia downgrades automaticamente) - return plans.Where(p => p.IsCurrentPlan || p.IsUpgrade).ToList(); - } +using BCards.Web.Models; +using BCards.Web.Repositories; +using BCards.Web.Services; +using BCards.Web.ViewModels; +using BCards.Web.Utils; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc; +using System.Globalization; + +namespace BCards.Web.Controllers; + +[Authorize] +public class PaymentController : Controller +{ + private readonly IPaymentService _paymentService; + private readonly IAuthService _authService; + private readonly IUserRepository _userService; + private readonly ISubscriptionRepository _subscriptionRepository; + private readonly IConfiguration _configuration; + + public PaymentController(IPaymentService paymentService, IAuthService authService, IUserRepository userService, ISubscriptionRepository subscriptionRepository, IConfiguration configuration) + { + _paymentService = paymentService; + _authService = authService; + _userService = userService; + _subscriptionRepository = subscriptionRepository; + _configuration = configuration; + } + + [HttpPost] + public async Task CreateCheckoutSession(string planType) + { + var user = await _authService.GetCurrentUserAsync(User); + if (user == null) + return RedirectToAction("Login", "Auth"); + + var successUrl = Url.Action("Success", "Payment", null, Request.Scheme); + var cancelUrl = Url.Action("Cancel", "Payment", null, Request.Scheme); + + TempData[$"PlanType|{user.Id}"] = planType; + + try + { + var checkoutUrl = await _paymentService.CreateCheckoutSessionAsync( + user.Id, + planType, + successUrl!, + cancelUrl!); + + return Redirect(checkoutUrl); + } + catch (Exception ex) + { + TempData["Error"] = $"Erro ao processar pagamento: {ex.Message}"; + return RedirectToAction("Pricing", "Home"); + } + } + + public async Task Success() + { + try + { + var user = await _authService.GetCurrentUserAsync(User); + var planType = TempData[$"PlanType|{user.Id}"].ToString(); + + TempData["Success"] = $"Assinatura {planType} ativada com sucesso!"; + return RedirectToAction("Dashboard", "Admin"); + } + catch (Exception ex) + { + TempData["Error"] = $"Erro ao processar pagamento: {ex.Message}"; + return RedirectToAction("Dashboard", "Admin"); + } + } + + public IActionResult Cancel() + { + TempData["Info"] = "Pagamento cancelado. Você pode tentar novamente quando quiser."; + return RedirectToAction("Pricing", "Home"); + } + + [HttpPost] + [Route("webhook/stripe")] + [AllowAnonymous] + public async Task StripeWebhook() + { + var signature = Request.Headers["Stripe-Signature"].FirstOrDefault(); + if (string.IsNullOrEmpty(signature)) + return BadRequest(); + + string requestBody; + using (var reader = new StreamReader(Request.Body)) + { + requestBody = await reader.ReadToEndAsync(); + } + + try + { + await _paymentService.HandleWebhookAsync(requestBody, signature); + return Ok(); + } + catch (Exception ex) + { + return BadRequest($"Webhook error: {ex.Message}"); + } + } + + public async Task ManageSubscription() + { + var user = await _authService.GetCurrentUserAsync(User); + if (user == null) + return RedirectToAction("Login", "Auth"); + + try + { + // Parse do plano atual (mesmo que o Dashboard) + var userPlanType = Enum.TryParse(user.CurrentPlan, true, out var planType) ? planType : PlanType.Trial; + var currentPlanString = userPlanType.ToString().ToLower(); + var subscription = await _subscriptionRepository.GetByUserIdAsync(user.Id); + var viewModel = new ManageSubscriptionViewModel + { + User = user, + StripeSubscription = await _paymentService.GetSubscriptionDetailsAsync(user.Id), + PaymentHistory = await _paymentService.GetPaymentHistoryAsync(user.Id), + AvailablePlans = GetAvailablePlans(currentPlanString), + CurrentPeriodEnd = (DateTime?) subscription.CurrentPeriodEnd + }; + + // Pegar assinatura local se existir + if (!string.IsNullOrEmpty(user.StripeCustomerId)) + { + // Aqui você poderia buscar a subscription local se necessário + // viewModel.LocalSubscription = await _subscriptionRepository.GetByUserIdAsync(user.Id); + } + + return View(viewModel); + } + catch (Exception ex) + { + // Parse do plano atual também no catch + var userPlanType = Enum.TryParse(user.CurrentPlan, true, out var planType) ? planType : PlanType.Trial; + var currentPlanString = userPlanType.ToString().ToLower(); + + var errorViewModel = new ManageSubscriptionViewModel + { + User = user, + ErrorMessage = $"Erro ao carregar dados da assinatura: {ex.Message}", + AvailablePlans = GetAvailablePlans(currentPlanString) + }; + + return View(errorViewModel); + } + } + + [HttpPost] + public async Task CancelSubscription(string subscriptionId) + { + try + { + await _paymentService.CancelSubscriptionAsync(subscriptionId); + TempData["Success"] = "Sua assinatura será cancelada no final do período atual."; + } + catch (Exception ex) + { + TempData["Error"] = $"Erro ao cancelar assinatura: {ex.Message}"; + } + + return RedirectToAction("ManageSubscription"); + } + + [HttpPost] + public async Task ChangePlan(string newPlanType) + { + var user = await _authService.GetCurrentUserAsync(User); + if (user == null) + return RedirectToAction("Login", "Auth"); + + try + { + // Para mudanças de plano, vamos usar o Stripe Checkout + var returnUrl = Url.Action("ManageSubscription", "Payment", null, Request.Scheme); + var cancelUrl = Url.Action("ManageSubscription", "Payment", null, Request.Scheme); + + var checkoutUrl = await _paymentService.CreateCheckoutSessionAsync( + user.Id, + newPlanType, + returnUrl!, + cancelUrl!); + + return Redirect(checkoutUrl); + } + catch (Exception ex) + { + TempData["Error"] = $"Erro ao alterar plano: {ex.Message}"; + return RedirectToAction("ManageSubscription"); + } + } + + [HttpPost] + public async Task OpenStripePortal() + { + var user = await _authService.GetCurrentUserAsync(User); + if (user == null || string.IsNullOrEmpty(user.StripeCustomerId)) + { + TempData["Error"] = "Erro: dados de assinatura não encontrados."; + return RedirectToAction("ManageSubscription"); + } + + try + { + var returnUrl = Url.Action("ManageSubscription", "Payment", null, Request.Scheme); + var portalUrl = await _paymentService.CreatePortalSessionAsync(user.StripeCustomerId, returnUrl!); + + return Redirect(portalUrl); + } + catch (Exception ex) + { + TempData["Error"] = $"Erro ao abrir portal de pagamento: {ex.Message}"; + return RedirectToAction("ManageSubscription"); + } + } + + private List GetAvailablePlans(string currentPlan) + { + var plansConfig = _configuration.GetSection("Plans"); + var plans = new List(); + + // Adicionar planos mensais apenas (excluir Trial e planos anuais) + var monthlyPlans = new[] { "Basic", "Professional", "Premium", "PremiumAffiliate" }; + + foreach (var planKey in monthlyPlans) + { + var planSection = plansConfig.GetSection(planKey); + if (planSection.Exists()) + { + plans.Add(new AvailablePlanViewModel + { + PlanType = planKey.ToLower(), + DisplayName = planSection["Name"] ?? planKey, + Price = decimal.Parse(planSection["Price"] ?? "0", new CultureInfo("en-US")), + PriceId = planSection["PriceId"] ?? "", + MaxLinks = int.Parse(planSection["MaxLinks"] ?? "0"), + AllowAnalytics = bool.Parse(planSection["AllowAnalytics"] ?? "false"), + AllowCustomDomain = true, // URL personalizada em todos os planos pagos + AllowCustomThemes = bool.Parse(planSection["AllowPremiumThemes"] ?? "false"), + AllowProductLinks = bool.Parse(planSection["AllowProductLinks"] ?? "false"), + Features = planSection.GetSection("Features").Get>() ?? new List(), + IsCurrentPlan = currentPlan.Equals(planKey, StringComparison.OrdinalIgnoreCase) + }); + } + } + + // Marcar upgrades e filtrar downgrades + var currentPlanIndex = plans.FindIndex(p => p.IsCurrentPlan); + + // Se usuário está no Trial (não encontrou plano atual), todos são upgrades + if (currentPlanIndex == -1 && (currentPlan == "trial" || currentPlan == "free")) + { + foreach (var plan in plans) + { + plan.IsUpgrade = true; + } + return plans; // Mostrar todos os planos pagos como upgrade + } + + // Para planos pagos, marcar apenas upgrades superiores + for (int i = 0; i < plans.Count; i++) + { + if (i > currentPlanIndex) + plans[i].IsUpgrade = true; + else if (i < currentPlanIndex) + plans[i].IsDowngrade = true; + } + + // Retornar apenas plano atual e upgrades (Stripe não gerencia downgrades automaticamente) + return plans.Where(p => p.IsCurrentPlan || p.IsUpgrade).ToList(); + } } \ No newline at end of file diff --git a/src/BCards.Web/Controllers/ProductController.cs b/src/BCards.Web/Controllers/ProductController.cs index 830c9c5..3efb2cc 100644 --- a/src/BCards.Web/Controllers/ProductController.cs +++ b/src/BCards.Web/Controllers/ProductController.cs @@ -1,131 +1,131 @@ -using BCards.Web.Models; -using BCards.Web.Services; -using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Mvc; -using System.Security.Claims; - -namespace BCards.Web.Controllers; - -[Authorize] -[ApiController] -[Route("api/[controller]")] -public class ProductController : ControllerBase -{ - private readonly IOpenGraphService _openGraphService; - private readonly ILogger _logger; - - public ProductController( - IOpenGraphService openGraphService, - ILogger logger) - { - _openGraphService = openGraphService; - _logger = logger; - } - - [HttpPost("extract")] - public async Task ExtractProduct([FromBody] ExtractProductRequest request) - { - try - { - if (string.IsNullOrWhiteSpace(request.Url)) - { - return BadRequest(new { success = false, message = "URL é obrigatória." }); - } - - if (!Uri.TryCreate(request.Url, UriKind.Absolute, out var uri)) - { - return BadRequest(new { success = false, message = "URL inválida." }); - } - - var userId = User.FindFirst(ClaimTypes.NameIdentifier)?.Value; - if (string.IsNullOrEmpty(userId)) - { - return Unauthorized(new { success = false, message = "Usuário não autenticado." }); - } - - // Verificar rate limiting antes de tentar extrair - var isRateLimited = await _openGraphService.IsRateLimitedAsync(userId); - if (isRateLimited) - { - return this.TooManyRequests(new { - success = false, - message = "Aguarde 1 minuto antes de extrair dados de outro produto." - }); - } - - var ogData = await _openGraphService.ExtractDataAsync(request.Url, userId); - - if (!ogData.IsValid) - { - return BadRequest(new { - success = false, - message = string.IsNullOrEmpty(ogData.ErrorMessage) - ? "Não foi possível extrair dados desta página." - : ogData.ErrorMessage - }); - } - - return Ok(new { - success = true, - title = ogData.Title, - description = ogData.Description, - image = ogData.Image, - price = ogData.Price, - currency = ogData.Currency - }); - } - catch (InvalidOperationException ex) - { - _logger.LogWarning(ex, "Operação inválida na extração de produto para usuário {UserId}", - User.FindFirst(ClaimTypes.NameIdentifier)?.Value); - return BadRequest(new { success = false, message = ex.Message }); - } - catch (Exception ex) - { - _logger.LogError(ex, "Erro interno na extração de produto para usuário {UserId}", - User.FindFirst(ClaimTypes.NameIdentifier)?.Value); - return StatusCode(500, new { - success = false, - message = "Erro interno do servidor. Tente novamente em alguns instantes." - }); - } - } - - [HttpGet("cache/{urlHash}")] - public Task GetCachedData(string urlHash) - { - try - { - // Por segurança, vamos reconstruir a URL a partir do hash (se necessário) - // Por agora, apenas retornamos erro se não encontrado - return Task.FromResult(NotFound(new { success = false, message = "Cache não encontrado." })); - } - catch (Exception ex) - { - _logger.LogError(ex, "Erro ao buscar cache para hash {UrlHash}", urlHash); - return Task.FromResult(StatusCode(500, new { success = false, message = "Erro interno do servidor." })); - } - } -} - -public class ExtractProductRequest -{ - public string Url { get; set; } = string.Empty; -} - -// Custom result for 429 Too Many Requests -public class TooManyRequestsResult : ObjectResult -{ - public TooManyRequestsResult(object value) : base(value) - { - StatusCode = 429; - } -} - -public static class ControllerBaseExtensions -{ - public static TooManyRequestsResult TooManyRequests(this ControllerBase controller, object value) - { - return new TooManyRequestsResult(value); - } +using BCards.Web.Models; +using BCards.Web.Services; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using System.Security.Claims; + +namespace BCards.Web.Controllers; + +[Authorize] +[ApiController] +[Route("api/[controller]")] +public class ProductController : ControllerBase +{ + private readonly IOpenGraphService _openGraphService; + private readonly ILogger _logger; + + public ProductController( + IOpenGraphService openGraphService, + ILogger logger) + { + _openGraphService = openGraphService; + _logger = logger; + } + + [HttpPost("extract")] + public async Task ExtractProduct([FromBody] ExtractProductRequest request) + { + try + { + if (string.IsNullOrWhiteSpace(request.Url)) + { + return BadRequest(new { success = false, message = "URL é obrigatória." }); + } + + if (!Uri.TryCreate(request.Url, UriKind.Absolute, out var uri)) + { + return BadRequest(new { success = false, message = "URL inválida." }); + } + + var userId = User.FindFirst(ClaimTypes.NameIdentifier)?.Value; + if (string.IsNullOrEmpty(userId)) + { + return Unauthorized(new { success = false, message = "Usuário não autenticado." }); + } + + // Verificar rate limiting antes de tentar extrair + var isRateLimited = await _openGraphService.IsRateLimitedAsync(userId); + if (isRateLimited) + { + return this.TooManyRequests(new { + success = false, + message = "Aguarde 1 minuto antes de extrair dados de outro produto." + }); + } + + var ogData = await _openGraphService.ExtractDataAsync(request.Url, userId); + + if (!ogData.IsValid) + { + return BadRequest(new { + success = false, + message = string.IsNullOrEmpty(ogData.ErrorMessage) + ? "Não foi possível extrair dados desta página." + : ogData.ErrorMessage + }); + } + + return Ok(new { + success = true, + title = ogData.Title, + description = ogData.Description, + image = ogData.Image, + price = ogData.Price, + currency = ogData.Currency + }); + } + catch (InvalidOperationException ex) + { + _logger.LogWarning(ex, "Operação inválida na extração de produto para usuário {UserId}", + User.FindFirst(ClaimTypes.NameIdentifier)?.Value); + return BadRequest(new { success = false, message = ex.Message }); + } + catch (Exception ex) + { + _logger.LogError(ex, "Erro interno na extração de produto para usuário {UserId}", + User.FindFirst(ClaimTypes.NameIdentifier)?.Value); + return StatusCode(500, new { + success = false, + message = "Erro interno do servidor. Tente novamente em alguns instantes." + }); + } + } + + [HttpGet("cache/{urlHash}")] + public Task GetCachedData(string urlHash) + { + try + { + // Por segurança, vamos reconstruir a URL a partir do hash (se necessário) + // Por agora, apenas retornamos erro se não encontrado + return Task.FromResult(NotFound(new { success = false, message = "Cache não encontrado." })); + } + catch (Exception ex) + { + _logger.LogError(ex, "Erro ao buscar cache para hash {UrlHash}", urlHash); + return Task.FromResult(StatusCode(500, new { success = false, message = "Erro interno do servidor." })); + } + } +} + +public class ExtractProductRequest +{ + public string Url { get; set; } = string.Empty; +} + +// Custom result for 429 Too Many Requests +public class TooManyRequestsResult : ObjectResult +{ + public TooManyRequestsResult(object value) : base(value) + { + StatusCode = 429; + } +} + +public static class ControllerBaseExtensions +{ + public static TooManyRequestsResult TooManyRequests(this ControllerBase controller, object value) + { + return new TooManyRequestsResult(value); + } } \ No newline at end of file diff --git a/src/BCards.Web/Controllers/SitemapController.cs b/src/BCards.Web/Controllers/SitemapController.cs index 674b8fd..7a3e977 100644 --- a/src/BCards.Web/Controllers/SitemapController.cs +++ b/src/BCards.Web/Controllers/SitemapController.cs @@ -1,96 +1,96 @@ -using Microsoft.AspNetCore.Mvc; -using System.Text; -using System.Xml.Linq; -using BCards.Web.Services; - -namespace BCards.Web.Controllers; - -public class SitemapController : Controller -{ - private readonly IUserPageService _userPageService; - private readonly ILivePageService _livePageService; - private readonly ILogger _logger; - - public SitemapController( - IUserPageService userPageService, - ILivePageService livePageService, - ILogger logger) - { - _userPageService = userPageService; - _livePageService = livePageService; - _logger = logger; - } - - [Route("sitemap.xml")] - [ResponseCache(Duration = 86400)] // Cache for 24 hours - public async Task Index() - { - try - { - // 🔥 NOVA FUNCIONALIDADE: Usar LivePages em vez de UserPages - var livePages = await _livePageService.GetAllActiveAsync(); - - // Define namespace corretamente para evitar conflitos - XNamespace ns = "http://www.sitemaps.org/schemas/sitemap/0.9"; - - // Construir URLs das páginas dinâmicas separadamente para evitar problemas - var dynamicUrls = livePages.Select(page => - new XElement(ns + "url", - new XElement(ns + "loc", $"{Request.Scheme}://{Request.Host}/page/{page.Category?.Replace(" ", "-")?.ToLower()}/{page.Slug}"), - new XElement(ns + "lastmod", page.LastSyncAt.ToString("yyyy-MM-dd")), - new XElement(ns + "changefreq", "weekly"), - new XElement(ns + "priority", "0.8") - ) - ).ToList(); - - var sitemap = new XDocument( - new XDeclaration("1.0", "utf-8", "yes"), - new XElement(ns + "urlset", - // Add static pages - new XElement(ns + "url", - new XElement(ns + "loc", $"{Request.Scheme}://{Request.Host}/"), - new XElement(ns + "lastmod", DateTime.UtcNow.ToString("yyyy-MM-dd")), - new XElement(ns + "changefreq", "daily"), - new XElement(ns + "priority", "1.0") - ), - new XElement(ns + "url", - new XElement(ns + "loc", $"{Request.Scheme}://{Request.Host}/Home/Pricing"), - new XElement(ns + "lastmod", DateTime.UtcNow.ToString("yyyy-MM-dd")), - new XElement(ns + "changefreq", "weekly"), - new XElement(ns + "priority", "0.9") - ), - - // Add live pages (SEO-optimized URLs only) - dynamicUrls - ) - ); - - _logger.LogInformation($"Generated sitemap with {livePages.Count} live pages"); - - return Content(sitemap.ToString(SaveOptions.DisableFormatting), "application/xml", Encoding.UTF8); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error generating sitemap"); - return StatusCode(500, "Error generating sitemap"); - } - } - - [Route("robots.txt")] - [ResponseCache(Duration = 86400)] // Cache for 24 hours - public IActionResult RobotsTxt() - { - var robotsTxt = $@"User-agent: * -Allow: / -Allow: /page/ - -Disallow: /Admin/ -Disallow: /Auth/ -Disallow: /Payment/ -Disallow: /api/ - -Sitemap: {Request.Scheme}://{Request.Host}/sitemap.xml"; - - return Content(robotsTxt, "text/plain", Encoding.UTF8); - } +using Microsoft.AspNetCore.Mvc; +using System.Text; +using System.Xml.Linq; +using BCards.Web.Services; + +namespace BCards.Web.Controllers; + +public class SitemapController : Controller +{ + private readonly IUserPageService _userPageService; + private readonly ILivePageService _livePageService; + private readonly ILogger _logger; + + public SitemapController( + IUserPageService userPageService, + ILivePageService livePageService, + ILogger logger) + { + _userPageService = userPageService; + _livePageService = livePageService; + _logger = logger; + } + + [Route("sitemap.xml")] + [ResponseCache(Duration = 86400)] // Cache for 24 hours + public async Task Index() + { + try + { + // 🔥 NOVA FUNCIONALIDADE: Usar LivePages em vez de UserPages + var livePages = await _livePageService.GetAllActiveAsync(); + + // Define namespace corretamente para evitar conflitos + XNamespace ns = "http://www.sitemaps.org/schemas/sitemap/0.9"; + + // Construir URLs das páginas dinâmicas separadamente para evitar problemas + var dynamicUrls = livePages.Select(page => + new XElement(ns + "url", + new XElement(ns + "loc", $"{Request.Scheme}://{Request.Host}/page/{page.Category?.Replace(" ", "-")?.ToLower()}/{page.Slug}"), + new XElement(ns + "lastmod", page.LastSyncAt.ToString("yyyy-MM-dd")), + new XElement(ns + "changefreq", "weekly"), + new XElement(ns + "priority", "0.8") + ) + ).ToList(); + + var sitemap = new XDocument( + new XDeclaration("1.0", "utf-8", "yes"), + new XElement(ns + "urlset", + // Add static pages + new XElement(ns + "url", + new XElement(ns + "loc", $"{Request.Scheme}://{Request.Host}/"), + new XElement(ns + "lastmod", DateTime.UtcNow.ToString("yyyy-MM-dd")), + new XElement(ns + "changefreq", "daily"), + new XElement(ns + "priority", "1.0") + ), + new XElement(ns + "url", + new XElement(ns + "loc", $"{Request.Scheme}://{Request.Host}/Home/Pricing"), + new XElement(ns + "lastmod", DateTime.UtcNow.ToString("yyyy-MM-dd")), + new XElement(ns + "changefreq", "weekly"), + new XElement(ns + "priority", "0.9") + ), + + // Add live pages (SEO-optimized URLs only) + dynamicUrls + ) + ); + + _logger.LogInformation($"Generated sitemap with {livePages.Count} live pages"); + + return Content(sitemap.ToString(SaveOptions.DisableFormatting), "application/xml", Encoding.UTF8); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error generating sitemap"); + return StatusCode(500, "Error generating sitemap"); + } + } + + [Route("robots.txt")] + [ResponseCache(Duration = 86400)] // Cache for 24 hours + public IActionResult RobotsTxt() + { + var robotsTxt = $@"User-agent: * +Allow: / +Allow: /page/ + +Disallow: /Admin/ +Disallow: /Auth/ +Disallow: /Payment/ +Disallow: /api/ + +Sitemap: {Request.Scheme}://{Request.Host}/sitemap.xml"; + + return Content(robotsTxt, "text/plain", Encoding.UTF8); + } } \ No newline at end of file diff --git a/src/BCards.Web/Controllers/SubscriptionController.cs b/src/BCards.Web/Controllers/SubscriptionController.cs index 986ce75..f5077a7 100644 --- a/src/BCards.Web/Controllers/SubscriptionController.cs +++ b/src/BCards.Web/Controllers/SubscriptionController.cs @@ -1,155 +1,155 @@ -using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Mvc; -using BCards.Web.Services; -using BCards.Web.ViewModels; -using System.Security.Claims; - -namespace BCards.Web.Controllers; - -[Authorize] -[Route("subscription")] -public class SubscriptionController : Controller -{ - private readonly IPaymentService _paymentService; - private readonly ILogger _logger; - - public SubscriptionController( - IPaymentService paymentService, - ILogger logger) - { - _paymentService = paymentService; - _logger = logger; - } - - [HttpGet("cancel")] - public async Task Cancel() - { - var userId = User.FindFirst(ClaimTypes.NameIdentifier)?.Value; - if (string.IsNullOrEmpty(userId)) - return RedirectToAction("Login", "Auth"); - - var subscription = await _paymentService.GetSubscriptionDetailsAsync(userId); - if (subscription == null) - { - TempData["Error"] = "Nenhuma assinatura ativa encontrada."; - return RedirectToAction("ManageSubscription", "Payment"); - } - - // Calcular opções de reembolso - var (canRefundFull, canRefundPartial, refundAmount) = await _paymentService.CalculateRefundAsync(subscription.Id); - - // Obter datas via SubscriptionItem - var subscriptionItemService = new Stripe.SubscriptionItemService(); - var subItem = await subscriptionItemService.GetAsync(subscription.Items.Data[0].Id); - - var viewModel = new CancelSubscriptionViewModel - { - SubscriptionId = subscription.Id, - PlanName = subscription.Items.Data.FirstOrDefault()?.Price.Nickname ?? "Plano Atual", - CurrentPeriodEnd = subItem.CurrentPeriodEnd, - CanRefundFull = canRefundFull, - CanRefundPartial = canRefundPartial, - RefundAmount = refundAmount, - DaysRemaining = (subItem.CurrentPeriodEnd - DateTime.UtcNow).Days - }; - - return View(viewModel); - } - - [HttpPost("cancel")] - [ValidateAntiForgeryToken] - public async Task ProcessCancel(CancelSubscriptionRequest request) - { - var userId = User.FindFirst(ClaimTypes.NameIdentifier)?.Value; - if (string.IsNullOrEmpty(userId)) - return RedirectToAction("Login", "Auth"); - - try - { - bool success = false; - string message = ""; - - switch (request.CancelType) - { - case "immediate_with_refund": - success = await _paymentService.CancelSubscriptionImmediatelyAsync(request.SubscriptionId, true); - message = success ? "Assinatura cancelada. Reembolso será processado manualmente em até 10 dias úteis." : "Erro ao processar cancelamento."; - break; - - case "immediate_no_refund": - success = await _paymentService.CancelSubscriptionImmediatelyAsync(request.SubscriptionId, false); - message = success ? "Assinatura cancelada imediatamente." : "Erro ao cancelar assinatura."; - break; - - case "partial_refund": - success = await _paymentService.CancelSubscriptionImmediatelyAsync(request.SubscriptionId, false); - var (_, canRefundPartial, refundAmount) = await _paymentService.CalculateRefundAsync(request.SubscriptionId); - if (success && canRefundPartial && refundAmount > 0) - { - message = $"Assinatura cancelada. Reembolso parcial de R$ {refundAmount:F2} será processado manualmente em até 10 dias úteis."; - } - else - { - message = success ? "Assinatura cancelada. Reembolso parcial não disponível." : "Erro ao cancelar assinatura."; - } - break; - - case "at_period_end": - default: - success = await _paymentService.CancelSubscriptionAtPeriodEndAsync(request.SubscriptionId); - message = success ? "Assinatura será cancelada no final do período atual." : "Erro ao agendar cancelamento."; - break; - } - - if (success) - { - TempData["Success"] = message; - _logger.LogInformation($"User {userId} cancelled subscription {request.SubscriptionId} with type {request.CancelType}"); - } - else - { - TempData["Error"] = message; - _logger.LogError($"Failed to cancel subscription {request.SubscriptionId} for user {userId}"); - } - } - catch (Exception ex) - { - TempData["Error"] = "Ocorreu um erro ao processar o cancelamento. Tente novamente."; - _logger.LogError(ex, $"Error cancelling subscription {request.SubscriptionId} for user {userId}"); - } - - return RedirectToAction("ManageSubscription", "Payment"); - } - - [HttpPost("reactivate")] - [ValidateAntiForgeryToken] - public async Task Reactivate(string subscriptionId) - { - var userId = User.FindFirst(ClaimTypes.NameIdentifier)?.Value; - if (string.IsNullOrEmpty(userId)) - return RedirectToAction("Login", "Auth"); - - try - { - // Reativar assinatura removendo o agendamento de cancelamento - var success = await _paymentService.ReactivateSubscriptionAsync(subscriptionId); - - if (success) - { - TempData["Success"] = "Assinatura reativada com sucesso!"; - _logger.LogInformation($"User {userId} reactivated subscription {subscriptionId}"); - } - else - { - TempData["Error"] = "Erro ao reativar assinatura."; - } - } - catch (Exception ex) - { - TempData["Error"] = "Ocorreu um erro ao reativar a assinatura."; - _logger.LogError(ex, $"Error reactivating subscription {subscriptionId} for user {userId}"); - } - - return RedirectToAction("ManageSubscription", "Payment"); - } +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using BCards.Web.Services; +using BCards.Web.ViewModels; +using System.Security.Claims; + +namespace BCards.Web.Controllers; + +[Authorize] +[Route("subscription")] +public class SubscriptionController : Controller +{ + private readonly IPaymentService _paymentService; + private readonly ILogger _logger; + + public SubscriptionController( + IPaymentService paymentService, + ILogger logger) + { + _paymentService = paymentService; + _logger = logger; + } + + [HttpGet("cancel")] + public async Task Cancel() + { + var userId = User.FindFirst(ClaimTypes.NameIdentifier)?.Value; + if (string.IsNullOrEmpty(userId)) + return RedirectToAction("Login", "Auth"); + + var subscription = await _paymentService.GetSubscriptionDetailsAsync(userId); + if (subscription == null) + { + TempData["Error"] = "Nenhuma assinatura ativa encontrada."; + return RedirectToAction("ManageSubscription", "Payment"); + } + + // Calcular opções de reembolso + var (canRefundFull, canRefundPartial, refundAmount) = await _paymentService.CalculateRefundAsync(subscription.Id); + + // Obter datas via SubscriptionItem + var subscriptionItemService = new Stripe.SubscriptionItemService(); + var subItem = await subscriptionItemService.GetAsync(subscription.Items.Data[0].Id); + + var viewModel = new CancelSubscriptionViewModel + { + SubscriptionId = subscription.Id, + PlanName = subscription.Items.Data.FirstOrDefault()?.Price.Nickname ?? "Plano Atual", + CurrentPeriodEnd = subItem.CurrentPeriodEnd, + CanRefundFull = canRefundFull, + CanRefundPartial = canRefundPartial, + RefundAmount = refundAmount, + DaysRemaining = (subItem.CurrentPeriodEnd - DateTime.UtcNow).Days + }; + + return View(viewModel); + } + + [HttpPost("cancel")] + [ValidateAntiForgeryToken] + public async Task ProcessCancel(CancelSubscriptionRequest request) + { + var userId = User.FindFirst(ClaimTypes.NameIdentifier)?.Value; + if (string.IsNullOrEmpty(userId)) + return RedirectToAction("Login", "Auth"); + + try + { + bool success = false; + string message = ""; + + switch (request.CancelType) + { + case "immediate_with_refund": + success = await _paymentService.CancelSubscriptionImmediatelyAsync(request.SubscriptionId, true); + message = success ? "Assinatura cancelada. Reembolso será processado manualmente em até 10 dias úteis." : "Erro ao processar cancelamento."; + break; + + case "immediate_no_refund": + success = await _paymentService.CancelSubscriptionImmediatelyAsync(request.SubscriptionId, false); + message = success ? "Assinatura cancelada imediatamente." : "Erro ao cancelar assinatura."; + break; + + case "partial_refund": + success = await _paymentService.CancelSubscriptionImmediatelyAsync(request.SubscriptionId, false); + var (_, canRefundPartial, refundAmount) = await _paymentService.CalculateRefundAsync(request.SubscriptionId); + if (success && canRefundPartial && refundAmount > 0) + { + message = $"Assinatura cancelada. Reembolso parcial de R$ {refundAmount:F2} será processado manualmente em até 10 dias úteis."; + } + else + { + message = success ? "Assinatura cancelada. Reembolso parcial não disponível." : "Erro ao cancelar assinatura."; + } + break; + + case "at_period_end": + default: + success = await _paymentService.CancelSubscriptionAtPeriodEndAsync(request.SubscriptionId); + message = success ? "Assinatura será cancelada no final do período atual." : "Erro ao agendar cancelamento."; + break; + } + + if (success) + { + TempData["Success"] = message; + _logger.LogInformation($"User {userId} cancelled subscription {request.SubscriptionId} with type {request.CancelType}"); + } + else + { + TempData["Error"] = message; + _logger.LogError($"Failed to cancel subscription {request.SubscriptionId} for user {userId}"); + } + } + catch (Exception ex) + { + TempData["Error"] = "Ocorreu um erro ao processar o cancelamento. Tente novamente."; + _logger.LogError(ex, $"Error cancelling subscription {request.SubscriptionId} for user {userId}"); + } + + return RedirectToAction("ManageSubscription", "Payment"); + } + + [HttpPost("reactivate")] + [ValidateAntiForgeryToken] + public async Task Reactivate(string subscriptionId) + { + var userId = User.FindFirst(ClaimTypes.NameIdentifier)?.Value; + if (string.IsNullOrEmpty(userId)) + return RedirectToAction("Login", "Auth"); + + try + { + // Reativar assinatura removendo o agendamento de cancelamento + var success = await _paymentService.ReactivateSubscriptionAsync(subscriptionId); + + if (success) + { + TempData["Success"] = "Assinatura reativada com sucesso!"; + _logger.LogInformation($"User {userId} reactivated subscription {subscriptionId}"); + } + else + { + TempData["Error"] = "Erro ao reativar assinatura."; + } + } + catch (Exception ex) + { + TempData["Error"] = "Ocorreu um erro ao reativar a assinatura."; + _logger.LogError(ex, $"Error reactivating subscription {subscriptionId} for user {userId}"); + } + + return RedirectToAction("ManageSubscription", "Payment"); + } } \ No newline at end of file diff --git a/src/BCards.Web/Controllers/UserPageController.cs b/src/BCards.Web/Controllers/UserPageController.cs index 9c28d3b..689192e 100644 --- a/src/BCards.Web/Controllers/UserPageController.cs +++ b/src/BCards.Web/Controllers/UserPageController.cs @@ -1,144 +1,144 @@ -using BCards.Web.Models; -using BCards.Web.Services; -using BCards.Web.Utils; -using Microsoft.AspNetCore.Mvc; - -namespace BCards.Web.Controllers; - -public class UserPageController : Controller -{ - private readonly IUserPageService _userPageService; - private readonly ICategoryService _categoryService; - private readonly ISeoService _seoService; - private readonly IThemeService _themeService; - private readonly IModerationService _moderationService; - private readonly ILogger _logger; - - public UserPageController( - IUserPageService userPageService, - ICategoryService categoryService, - ISeoService seoService, - IThemeService themeService, - IModerationService moderationService, - ILogger logger) - { - _userPageService = userPageService; - _categoryService = categoryService; - _seoService = seoService; - _themeService = themeService; - _moderationService = moderationService; - _logger = logger; - } - - [ResponseCache(Duration = 300, Location = ResponseCacheLocation.Any, VaryByQueryKeys = new[] { "preview" })] - public async Task Display(string category, string slug) - { - var userPage = await _userPageService.GetPageAsync(category, slug); - if (userPage == null) - return NotFound(); - - var categoryObj = await _categoryService.GetCategoryBySlugAsync(category); - if (categoryObj == null) - return NotFound(); - - // Check if it's a preview request - var isPreview = HttpContext.Items.ContainsKey("IsPreview") && (bool)HttpContext.Items["IsPreview"]; - var previewToken = Request.Query["preview"].FirstOrDefault(); - - _logger.LogDebug("Request - Page: {Slug}, Status: {Status}, IsPreview: {IsPreview}, PreviewToken: {PreviewToken}, UserId: {UserId}", - userPage.Slug, userPage.Status, isPreview, !string.IsNullOrEmpty(previewToken), userPage.UserId); - - if (!string.IsNullOrEmpty(previewToken)) - { - // Handle preview request - var isValidPreview = await _moderationService.ValidatePreviewTokenAsync(userPage.Id, previewToken); - if (!isValidPreview) - { - return View("PreviewExpired"); - } - - // Set preview flag - ViewBag.IsPreview = true; - ViewBag.PreviewToken = previewToken; - } - else - { - // Regular request - check if page is active - if (userPage.Status == ViewModels.PageStatus.PendingModeration) - { - return View("PendingModeration"); - } - - if (userPage.Status == ViewModels.PageStatus.Rejected) - { - return View("PageRejected"); - } - - if (userPage.Status == ViewModels.PageStatus.Inactive || !userPage.IsActive) - { - return NotFound(); - } - } - - // Ensure theme is loaded - critical fix for theme display issue - if (userPage.Theme?.Id == null || string.IsNullOrEmpty(userPage.Theme.PrimaryColor)) - { - userPage.Theme = _themeService.GetDefaultTheme(); - } - - // Generate SEO settings - var seoSettings = _seoService.GenerateSeoSettings(userPage, categoryObj); - - // Record page view (async, don't wait) - only for non-preview requests - _logger.LogDebug("View Count - Page: {Slug}, Status: {Status}, IsPreview: {IsPreview}, PreviewToken: {PreviewToken}", - userPage.Slug, userPage.Status, isPreview, !string.IsNullOrEmpty(previewToken)); - if (!isPreview) - { - _logger.LogDebug("Recording view for page {Slug}", userPage.Slug); - var referrer = Request.Headers["Referer"].FirstOrDefault(); - var userAgent = Request.Headers["User-Agent"].FirstOrDefault(); - _ = _userPageService.RecordPageViewAsync(userPage.Id, referrer, userAgent); - } - else - { - _logger.LogDebug("NOT recording view - isPreview = true for page {Slug}", userPage.Slug); - } - - ViewBag.SeoSettings = seoSettings; - ViewBag.Category = categoryObj; - - return View(userPage); - } - - [HttpPost] - [Route("click/{pageId}")] - public async Task RecordClick(string pageId, int linkIndex) - { - await _userPageService.RecordLinkClickAsync(pageId, linkIndex); - return Ok(); - } - - [Route("preview/{category}/{slug}")] - public async Task Preview(string category, string slug) - { - var userPage = await _userPageService.GetPageAsync(category, slug); - if (userPage == null) - return NotFound(); - - var categoryObj = await _categoryService.GetCategoryBySlugAsync(category); - if (categoryObj == null) - return NotFound(); - - // Ensure theme is loaded for preview too - if (userPage.Theme?.Id == null || string.IsNullOrEmpty(userPage.Theme.PrimaryColor)) - { - userPage.Theme = _themeService.GetDefaultTheme(); - } - - ViewBag.Category = categoryObj; - ViewBag.IsPreview = true; - - return View("Display", userPage); - } - +using BCards.Web.Models; +using BCards.Web.Services; +using BCards.Web.Utils; +using Microsoft.AspNetCore.Mvc; + +namespace BCards.Web.Controllers; + +public class UserPageController : Controller +{ + private readonly IUserPageService _userPageService; + private readonly ICategoryService _categoryService; + private readonly ISeoService _seoService; + private readonly IThemeService _themeService; + private readonly IModerationService _moderationService; + private readonly ILogger _logger; + + public UserPageController( + IUserPageService userPageService, + ICategoryService categoryService, + ISeoService seoService, + IThemeService themeService, + IModerationService moderationService, + ILogger logger) + { + _userPageService = userPageService; + _categoryService = categoryService; + _seoService = seoService; + _themeService = themeService; + _moderationService = moderationService; + _logger = logger; + } + + [ResponseCache(Duration = 300, Location = ResponseCacheLocation.Any, VaryByQueryKeys = new[] { "preview" })] + public async Task Display(string category, string slug) + { + var userPage = await _userPageService.GetPageAsync(category, slug); + if (userPage == null) + return NotFound(); + + var categoryObj = await _categoryService.GetCategoryBySlugAsync(category); + if (categoryObj == null) + return NotFound(); + + // Check if it's a preview request + var isPreview = HttpContext.Items.ContainsKey("IsPreview") && (bool)HttpContext.Items["IsPreview"]; + var previewToken = Request.Query["preview"].FirstOrDefault(); + + _logger.LogDebug("Request - Page: {Slug}, Status: {Status}, IsPreview: {IsPreview}, PreviewToken: {PreviewToken}, UserId: {UserId}", + userPage.Slug, userPage.Status, isPreview, !string.IsNullOrEmpty(previewToken), userPage.UserId); + + if (!string.IsNullOrEmpty(previewToken)) + { + // Handle preview request + var isValidPreview = await _moderationService.ValidatePreviewTokenAsync(userPage.Id, previewToken); + if (!isValidPreview) + { + return View("PreviewExpired"); + } + + // Set preview flag + ViewBag.IsPreview = true; + ViewBag.PreviewToken = previewToken; + } + else + { + // Regular request - check if page is active + if (userPage.Status == ViewModels.PageStatus.PendingModeration) + { + return View("PendingModeration"); + } + + if (userPage.Status == ViewModels.PageStatus.Rejected) + { + return View("PageRejected"); + } + + if (userPage.Status == ViewModels.PageStatus.Inactive || !userPage.IsActive) + { + return NotFound(); + } + } + + // Ensure theme is loaded - critical fix for theme display issue + if (userPage.Theme?.Id == null || string.IsNullOrEmpty(userPage.Theme.PrimaryColor)) + { + userPage.Theme = _themeService.GetDefaultTheme(); + } + + // Generate SEO settings + var seoSettings = _seoService.GenerateSeoSettings(userPage, categoryObj); + + // Record page view (async, don't wait) - only for non-preview requests + _logger.LogDebug("View Count - Page: {Slug}, Status: {Status}, IsPreview: {IsPreview}, PreviewToken: {PreviewToken}", + userPage.Slug, userPage.Status, isPreview, !string.IsNullOrEmpty(previewToken)); + if (!isPreview) + { + _logger.LogDebug("Recording view for page {Slug}", userPage.Slug); + var referrer = Request.Headers["Referer"].FirstOrDefault(); + var userAgent = Request.Headers["User-Agent"].FirstOrDefault(); + _ = _userPageService.RecordPageViewAsync(userPage.Id, referrer, userAgent); + } + else + { + _logger.LogDebug("NOT recording view - isPreview = true for page {Slug}", userPage.Slug); + } + + ViewBag.SeoSettings = seoSettings; + ViewBag.Category = categoryObj; + + return View(userPage); + } + + [HttpPost] + [Route("click/{pageId}")] + public async Task RecordClick(string pageId, int linkIndex) + { + await _userPageService.RecordLinkClickAsync(pageId, linkIndex); + return Ok(); + } + + [Route("preview/{category}/{slug}")] + public async Task Preview(string category, string slug) + { + var userPage = await _userPageService.GetPageAsync(category, slug); + if (userPage == null) + return NotFound(); + + var categoryObj = await _categoryService.GetCategoryBySlugAsync(category); + if (categoryObj == null) + return NotFound(); + + // Ensure theme is loaded for preview too + if (userPage.Theme?.Id == null || string.IsNullOrEmpty(userPage.Theme.PrimaryColor)) + { + userPage.Theme = _themeService.GetDefaultTheme(); + } + + ViewBag.Category = categoryObj; + ViewBag.IsPreview = true; + + return View("Display", userPage); + } + } \ No newline at end of file diff --git a/src/BCards.Web/HealthChecks/CriticalServicesHealthCheck.cs b/src/BCards.Web/HealthChecks/CriticalServicesHealthCheck.cs index 843ca6d..e8a63b0 100644 --- a/src/BCards.Web/HealthChecks/CriticalServicesHealthCheck.cs +++ b/src/BCards.Web/HealthChecks/CriticalServicesHealthCheck.cs @@ -1,110 +1,110 @@ -using Microsoft.Extensions.Diagnostics.HealthChecks; -using MongoDB.Driver; -using MongoDB.Bson; -using Stripe; - -namespace BCards.Web.HealthChecks; - -/// -/// Health check para serviços CRÍTICOS - falha = ALERTA VERMELHO 🔴 -/// - MongoDB (páginas não funcionam) -/// - Stripe (pagamentos não funcionam) -/// - Website (implícito - se este check executa, o site funciona) -/// -public class CriticalServicesHealthCheck : IHealthCheck -{ - private readonly IMongoDatabase _database; - private readonly HttpClient _httpClient; - private readonly ILogger _logger; - - public CriticalServicesHealthCheck( - IMongoDatabase database, - HttpClient httpClient, - ILogger logger) - { - _database = database; - _httpClient = httpClient; - _logger = logger; - } - - public async Task CheckHealthAsync( - HealthCheckContext context, - CancellationToken cancellationToken = default) - { - var results = new Dictionary(); - var allCritical = true; - var criticalFailures = new List(); - - // 1. MongoDB Check - try - { - await _database.RunCommandAsync(new BsonDocument("ping", 1), cancellationToken: cancellationToken); - results["mongodb"] = new { status = "healthy", service = "database" }; - _logger.LogDebug("✅ MongoDB está respondendo"); - } - catch (Exception ex) - { - allCritical = false; - criticalFailures.Add("MongoDB"); - results["mongodb"] = new { status = "unhealthy", error = ex.Message, service = "database" }; - - _logger.LogError(ex, "🔴 CRÍTICO: MongoDB falhou - páginas de usuários não funcionam!"); - - // Pequeno delay para garantir que logs críticos sejam enviados - await Task.Delay(1000, cancellationToken); - } - - // 2. Stripe API Check - try - { - using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); - using var response = await _httpClient.GetAsync("https://api.stripe.com/healthcheck", cts.Token); - - if (response.IsSuccessStatusCode) - { - results["stripe"] = new { status = "healthy", service = "payment" }; - _logger.LogDebug("✅ Stripe API está respondendo"); - } - else - { - allCritical = false; - criticalFailures.Add("Stripe"); - results["stripe"] = new { status = "unhealthy", status_code = (int)response.StatusCode, service = "payment" }; - - _logger.LogError("🔴 CRÍTICO: Stripe API falhou - pagamentos não funcionam! Status: {StatusCode}", - (int)response.StatusCode); - await Task.Delay(1000, cancellationToken); - } - } - catch (Exception ex) - { - allCritical = false; - criticalFailures.Add("Stripe"); - results["stripe"] = new { status = "unhealthy", error = ex.Message, service = "payment" }; - - _logger.LogError(ex, "🔴 CRÍTICO: Stripe API inacessível - pagamentos não funcionam!"); - await Task.Delay(1000, cancellationToken); - } - - // 3. Self Health Check removido - se este código está executando, o website já está funcionando - results["website"] = new { status = "healthy", service = "website" }; - _logger.LogDebug("✅ Website está operacional (health check executando)"); - - var data = new Dictionary - { - { "services", results }, - { "critical_failures", criticalFailures }, - { "failure_count", criticalFailures.Count }, - { "total_critical_services", 3 } - }; - - if (!allCritical) - { - var failureList = string.Join(", ", criticalFailures); - _logger.LogError("🔴 ALERTA VERMELHO: Serviços críticos falharam: {Services}", failureList); - return HealthCheckResult.Unhealthy($"Serviços críticos falharam: {failureList}", data: data); - } - - return HealthCheckResult.Healthy("Todos os serviços críticos funcionando", data: data); - } +using Microsoft.Extensions.Diagnostics.HealthChecks; +using MongoDB.Driver; +using MongoDB.Bson; +using Stripe; + +namespace BCards.Web.HealthChecks; + +/// +/// Health check para serviços CRÍTICOS - falha = ALERTA VERMELHO 🔴 +/// - MongoDB (páginas não funcionam) +/// - Stripe (pagamentos não funcionam) +/// - Website (implícito - se este check executa, o site funciona) +/// +public class CriticalServicesHealthCheck : IHealthCheck +{ + private readonly IMongoDatabase _database; + private readonly HttpClient _httpClient; + private readonly ILogger _logger; + + public CriticalServicesHealthCheck( + IMongoDatabase database, + HttpClient httpClient, + ILogger logger) + { + _database = database; + _httpClient = httpClient; + _logger = logger; + } + + public async Task CheckHealthAsync( + HealthCheckContext context, + CancellationToken cancellationToken = default) + { + var results = new Dictionary(); + var allCritical = true; + var criticalFailures = new List(); + + // 1. MongoDB Check + try + { + await _database.RunCommandAsync(new BsonDocument("ping", 1), cancellationToken: cancellationToken); + results["mongodb"] = new { status = "healthy", service = "database" }; + _logger.LogDebug("✅ MongoDB está respondendo"); + } + catch (Exception ex) + { + allCritical = false; + criticalFailures.Add("MongoDB"); + results["mongodb"] = new { status = "unhealthy", error = ex.Message, service = "database" }; + + _logger.LogError(ex, "🔴 CRÍTICO: MongoDB falhou - páginas de usuários não funcionam!"); + + // Pequeno delay para garantir que logs críticos sejam enviados + await Task.Delay(1000, cancellationToken); + } + + // 2. Stripe API Check + try + { + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + using var response = await _httpClient.GetAsync("https://api.stripe.com/healthcheck", cts.Token); + + if (response.IsSuccessStatusCode) + { + results["stripe"] = new { status = "healthy", service = "payment" }; + _logger.LogDebug("✅ Stripe API está respondendo"); + } + else + { + allCritical = false; + criticalFailures.Add("Stripe"); + results["stripe"] = new { status = "unhealthy", status_code = (int)response.StatusCode, service = "payment" }; + + _logger.LogError("🔴 CRÍTICO: Stripe API falhou - pagamentos não funcionam! Status: {StatusCode}", + (int)response.StatusCode); + await Task.Delay(1000, cancellationToken); + } + } + catch (Exception ex) + { + allCritical = false; + criticalFailures.Add("Stripe"); + results["stripe"] = new { status = "unhealthy", error = ex.Message, service = "payment" }; + + _logger.LogError(ex, "🔴 CRÍTICO: Stripe API inacessível - pagamentos não funcionam!"); + await Task.Delay(1000, cancellationToken); + } + + // 3. Self Health Check removido - se este código está executando, o website já está funcionando + results["website"] = new { status = "healthy", service = "website" }; + _logger.LogDebug("✅ Website está operacional (health check executando)"); + + var data = new Dictionary + { + { "services", results }, + { "critical_failures", criticalFailures }, + { "failure_count", criticalFailures.Count }, + { "total_critical_services", 3 } + }; + + if (!allCritical) + { + var failureList = string.Join(", ", criticalFailures); + _logger.LogError("🔴 ALERTA VERMELHO: Serviços críticos falharam: {Services}", failureList); + return HealthCheckResult.Unhealthy($"Serviços críticos falharam: {failureList}", data: data); + } + + return HealthCheckResult.Healthy("Todos os serviços críticos funcionando", data: data); + } } \ No newline at end of file diff --git a/src/BCards.Web/HealthChecks/MongoDbHealthCheck.cs b/src/BCards.Web/HealthChecks/MongoDbHealthCheck.cs index 4f53123..d5812ee 100644 --- a/src/BCards.Web/HealthChecks/MongoDbHealthCheck.cs +++ b/src/BCards.Web/HealthChecks/MongoDbHealthCheck.cs @@ -1,73 +1,73 @@ -using Microsoft.Extensions.Diagnostics.HealthChecks; -using MongoDB.Driver; -using MongoDB.Bson; -using System.Diagnostics; - -namespace BCards.Web.HealthChecks; - -public class MongoDbHealthCheck : IHealthCheck -{ - private readonly IMongoDatabase _database; - private readonly ILogger _logger; - - public MongoDbHealthCheck(IMongoDatabase database, ILogger logger) - { - _database = database; - _logger = logger; - } - - public async Task CheckHealthAsync( - HealthCheckContext context, - CancellationToken cancellationToken = default) - { - var stopwatch = Stopwatch.StartNew(); - - try - { - // Executa ping no MongoDB - var command = new BsonDocument("ping", 1); - await _database.RunCommandAsync(command, cancellationToken: cancellationToken); - - stopwatch.Stop(); - var duration = stopwatch.ElapsedMilliseconds; - - _logger.LogInformation("MongoDB health check completed successfully in {Duration}ms", duration); - - var data = new Dictionary - { - { "status", "healthy" }, - { "duration", $"{duration}ms" }, - { "database", _database.DatabaseNamespace.DatabaseName }, - { "connection_state", "connected" }, - { "latency", duration } - }; - - // Status baseado na latência - if (duration > 5000) // > 5s - return HealthCheckResult.Unhealthy($"MongoDB response time too high: {duration}ms", data: data); - - if (duration > 2000) // > 2s - return HealthCheckResult.Degraded($"MongoDB response time elevated: {duration}ms", data: data); - - return HealthCheckResult.Healthy($"MongoDB is responsive ({duration}ms)", data: data); - } - catch (Exception ex) - { - stopwatch.Stop(); - var duration = stopwatch.ElapsedMilliseconds; - - _logger.LogError(ex, "MongoDB health check failed after {Duration}ms", duration); - - var data = new Dictionary - { - { "status", "unhealthy" }, - { "duration", $"{duration}ms" }, - { "database", _database.DatabaseNamespace.DatabaseName }, - { "connection_state", "disconnected" }, - { "error", ex.Message } - }; - - return HealthCheckResult.Unhealthy($"MongoDB connection failed: {ex.Message}", ex, data); - } - } +using Microsoft.Extensions.Diagnostics.HealthChecks; +using MongoDB.Driver; +using MongoDB.Bson; +using System.Diagnostics; + +namespace BCards.Web.HealthChecks; + +public class MongoDbHealthCheck : IHealthCheck +{ + private readonly IMongoDatabase _database; + private readonly ILogger _logger; + + public MongoDbHealthCheck(IMongoDatabase database, ILogger logger) + { + _database = database; + _logger = logger; + } + + public async Task CheckHealthAsync( + HealthCheckContext context, + CancellationToken cancellationToken = default) + { + var stopwatch = Stopwatch.StartNew(); + + try + { + // Executa ping no MongoDB + var command = new BsonDocument("ping", 1); + await _database.RunCommandAsync(command, cancellationToken: cancellationToken); + + stopwatch.Stop(); + var duration = stopwatch.ElapsedMilliseconds; + + _logger.LogInformation("MongoDB health check completed successfully in {Duration}ms", duration); + + var data = new Dictionary + { + { "status", "healthy" }, + { "duration", $"{duration}ms" }, + { "database", _database.DatabaseNamespace.DatabaseName }, + { "connection_state", "connected" }, + { "latency", duration } + }; + + // Status baseado na latência + if (duration > 5000) // > 5s + return HealthCheckResult.Unhealthy($"MongoDB response time too high: {duration}ms", data: data); + + if (duration > 2000) // > 2s + return HealthCheckResult.Degraded($"MongoDB response time elevated: {duration}ms", data: data); + + return HealthCheckResult.Healthy($"MongoDB is responsive ({duration}ms)", data: data); + } + catch (Exception ex) + { + stopwatch.Stop(); + var duration = stopwatch.ElapsedMilliseconds; + + _logger.LogError(ex, "MongoDB health check failed after {Duration}ms", duration); + + var data = new Dictionary + { + { "status", "unhealthy" }, + { "duration", $"{duration}ms" }, + { "database", _database.DatabaseNamespace.DatabaseName }, + { "connection_state", "disconnected" }, + { "error", ex.Message } + }; + + return HealthCheckResult.Unhealthy($"MongoDB connection failed: {ex.Message}", ex, data); + } + } } \ No newline at end of file diff --git a/src/BCards.Web/HealthChecks/OAuthProvidersHealthCheck.cs b/src/BCards.Web/HealthChecks/OAuthProvidersHealthCheck.cs index ac522ec..7a4648a 100644 --- a/src/BCards.Web/HealthChecks/OAuthProvidersHealthCheck.cs +++ b/src/BCards.Web/HealthChecks/OAuthProvidersHealthCheck.cs @@ -1,61 +1,61 @@ -using Microsoft.Extensions.Diagnostics.HealthChecks; -using BCards.Web.Services; - -namespace BCards.Web.HealthChecks; - -public class OAuthProvidersHealthCheck : IHealthCheck -{ - private readonly IOAuthHealthService _oauthHealthService; - private readonly ILogger _logger; - - public OAuthProvidersHealthCheck(IOAuthHealthService oauthHealthService, ILogger logger) - { - _oauthHealthService = oauthHealthService; - _logger = logger; - } - - public async Task CheckHealthAsync( - HealthCheckContext context, - CancellationToken cancellationToken = default) - { - try - { - var status = await _oauthHealthService.CheckOAuthProvidersAsync(); - - var data = new Dictionary - { - { "google_status", status.GoogleStatus }, - { "microsoft_status", status.MicrosoftStatus }, - { "all_providers_healthy", status.AllProvidersHealthy }, - { "any_provider_healthy", status.AnyProviderHealthy }, - { "checked_at", status.CheckedAt } - }; - - // Política de saúde: OAuth offline é DEGRADED, não UNHEALTHY - if (!status.AnyProviderHealthy) - { - _logger.LogError("🔴 CRÍTICO: Todos os OAuth providers estão offline - login totalmente indisponível!"); - return HealthCheckResult.Degraded("Todos os OAuth providers estão offline", data: data); - } - - if (!status.AllProvidersHealthy) - { - var offlineProviders = new List(); - if (!status.GoogleAvailable) offlineProviders.Add("Google"); - if (!status.MicrosoftAvailable) offlineProviders.Add("Microsoft"); - - _logger.LogWarning("🟡 OAuth providers offline: {Providers} - alguns usuários não conseguem fazer login", - string.Join(", ", offlineProviders)); - - return HealthCheckResult.Degraded($"OAuth providers offline: {string.Join(", ", offlineProviders)}", data: data); - } - - return HealthCheckResult.Healthy("Todos os OAuth providers estão funcionando", data: data); - } - catch (Exception ex) - { - _logger.LogError(ex, "🔴 Erro ao verificar saúde dos OAuth providers"); - return HealthCheckResult.Degraded($"Erro na verificação OAuth: {ex.Message}"); - } - } +using Microsoft.Extensions.Diagnostics.HealthChecks; +using BCards.Web.Services; + +namespace BCards.Web.HealthChecks; + +public class OAuthProvidersHealthCheck : IHealthCheck +{ + private readonly IOAuthHealthService _oauthHealthService; + private readonly ILogger _logger; + + public OAuthProvidersHealthCheck(IOAuthHealthService oauthHealthService, ILogger logger) + { + _oauthHealthService = oauthHealthService; + _logger = logger; + } + + public async Task CheckHealthAsync( + HealthCheckContext context, + CancellationToken cancellationToken = default) + { + try + { + var status = await _oauthHealthService.CheckOAuthProvidersAsync(); + + var data = new Dictionary + { + { "google_status", status.GoogleStatus }, + { "microsoft_status", status.MicrosoftStatus }, + { "all_providers_healthy", status.AllProvidersHealthy }, + { "any_provider_healthy", status.AnyProviderHealthy }, + { "checked_at", status.CheckedAt } + }; + + // Política de saúde: OAuth offline é DEGRADED, não UNHEALTHY + if (!status.AnyProviderHealthy) + { + _logger.LogError("🔴 CRÍTICO: Todos os OAuth providers estão offline - login totalmente indisponível!"); + return HealthCheckResult.Degraded("Todos os OAuth providers estão offline", data: data); + } + + if (!status.AllProvidersHealthy) + { + var offlineProviders = new List(); + if (!status.GoogleAvailable) offlineProviders.Add("Google"); + if (!status.MicrosoftAvailable) offlineProviders.Add("Microsoft"); + + _logger.LogWarning("🟡 OAuth providers offline: {Providers} - alguns usuários não conseguem fazer login", + string.Join(", ", offlineProviders)); + + return HealthCheckResult.Degraded($"OAuth providers offline: {string.Join(", ", offlineProviders)}", data: data); + } + + return HealthCheckResult.Healthy("Todos os OAuth providers estão funcionando", data: data); + } + catch (Exception ex) + { + _logger.LogError(ex, "🔴 Erro ao verificar saúde dos OAuth providers"); + return HealthCheckResult.Degraded($"Erro na verificação OAuth: {ex.Message}"); + } + } } \ No newline at end of file diff --git a/src/BCards.Web/HealthChecks/SendGridHealthCheck.cs b/src/BCards.Web/HealthChecks/SendGridHealthCheck.cs index 9901ffc..07c1047 100644 --- a/src/BCards.Web/HealthChecks/SendGridHealthCheck.cs +++ b/src/BCards.Web/HealthChecks/SendGridHealthCheck.cs @@ -1,95 +1,95 @@ -using Microsoft.Extensions.Diagnostics.HealthChecks; -using SendGrid; -using SendGrid.Helpers.Mail; -using System.Diagnostics; - -namespace BCards.Web.HealthChecks; - -public class SendGridHealthCheck : IHealthCheck -{ - private readonly ISendGridClient _sendGridClient; - private readonly ILogger _logger; - private readonly IConfiguration _configuration; - - public SendGridHealthCheck(ISendGridClient sendGridClient, ILogger logger, IConfiguration configuration) - { - _sendGridClient = sendGridClient; - _logger = logger; - _configuration = configuration; - } - - public async Task CheckHealthAsync( - HealthCheckContext context, - CancellationToken cancellationToken = default) - { - var stopwatch = Stopwatch.StartNew(); - - try - { - // Testa a API do SendGrid fazendo uma validação de API key - // Usando endpoint de templates que não requer parâmetros específicos - var response = await _sendGridClient.RequestAsync( - method: SendGridClient.Method.GET, - urlPath: "templates", - queryParams: "{\"generations\":\"legacy,dynamic\",\"page_size\":1}", - cancellationToken: cancellationToken); - - stopwatch.Stop(); - var duration = stopwatch.ElapsedMilliseconds; - - var apiKey = _configuration["SendGrid:ApiKey"]; - var apiKeyPrefix = string.IsNullOrEmpty(apiKey) ? "not_configured" : - apiKey.Substring(0, Math.Min(8, apiKey.Length)) + "..."; - - _logger.LogInformation("SendGrid health check completed with status {StatusCode} in {Duration}ms", - response.StatusCode, duration); - - var data = new Dictionary - { - { "status", response.IsSuccessStatusCode ? "healthy" : "unhealthy" }, - { "duration", $"{duration}ms" }, - { "status_code", (int)response.StatusCode }, - { "api_key_prefix", apiKeyPrefix }, - { "latency", duration } - }; - - // Verifica se a resposta foi bem-sucedida - if (response.IsSuccessStatusCode) - { - // Status baseado na latência - if (duration > 8000) // > 8s - return HealthCheckResult.Unhealthy($"SendGrid response time too high: {duration}ms", data: data); - - if (duration > 4000) // > 4s - return HealthCheckResult.Degraded($"SendGrid response time elevated: {duration}ms", data: data); - - return HealthCheckResult.Healthy($"SendGrid API is responsive ({duration}ms)", data: data); - } - else - { - data["error"] = $"HTTP {response.StatusCode}"; - data["response_body"] = response.Body; - - return HealthCheckResult.Unhealthy( - $"SendGrid API returned {response.StatusCode}", - data: data); - } - } - catch (Exception ex) - { - stopwatch.Stop(); - var duration = stopwatch.ElapsedMilliseconds; - - _logger.LogError(ex, "SendGrid health check failed after {Duration}ms", duration); - - var data = new Dictionary - { - { "status", "unhealthy" }, - { "duration", $"{duration}ms" }, - { "error", ex.Message } - }; - - return HealthCheckResult.Unhealthy($"SendGrid connection failed: {ex.Message}", ex, data); - } - } +using Microsoft.Extensions.Diagnostics.HealthChecks; +using SendGrid; +using SendGrid.Helpers.Mail; +using System.Diagnostics; + +namespace BCards.Web.HealthChecks; + +public class SendGridHealthCheck : IHealthCheck +{ + private readonly ISendGridClient _sendGridClient; + private readonly ILogger _logger; + private readonly IConfiguration _configuration; + + public SendGridHealthCheck(ISendGridClient sendGridClient, ILogger logger, IConfiguration configuration) + { + _sendGridClient = sendGridClient; + _logger = logger; + _configuration = configuration; + } + + public async Task CheckHealthAsync( + HealthCheckContext context, + CancellationToken cancellationToken = default) + { + var stopwatch = Stopwatch.StartNew(); + + try + { + // Testa a API do SendGrid fazendo uma validação de API key + // Usando endpoint de templates que não requer parâmetros específicos + var response = await _sendGridClient.RequestAsync( + method: SendGridClient.Method.GET, + urlPath: "templates", + queryParams: "{\"generations\":\"legacy,dynamic\",\"page_size\":1}", + cancellationToken: cancellationToken); + + stopwatch.Stop(); + var duration = stopwatch.ElapsedMilliseconds; + + var apiKey = _configuration["SendGrid:ApiKey"]; + var apiKeyPrefix = string.IsNullOrEmpty(apiKey) ? "not_configured" : + apiKey.Substring(0, Math.Min(8, apiKey.Length)) + "..."; + + _logger.LogInformation("SendGrid health check completed with status {StatusCode} in {Duration}ms", + response.StatusCode, duration); + + var data = new Dictionary + { + { "status", response.IsSuccessStatusCode ? "healthy" : "unhealthy" }, + { "duration", $"{duration}ms" }, + { "status_code", (int)response.StatusCode }, + { "api_key_prefix", apiKeyPrefix }, + { "latency", duration } + }; + + // Verifica se a resposta foi bem-sucedida + if (response.IsSuccessStatusCode) + { + // Status baseado na latência + if (duration > 8000) // > 8s + return HealthCheckResult.Unhealthy($"SendGrid response time too high: {duration}ms", data: data); + + if (duration > 4000) // > 4s + return HealthCheckResult.Degraded($"SendGrid response time elevated: {duration}ms", data: data); + + return HealthCheckResult.Healthy($"SendGrid API is responsive ({duration}ms)", data: data); + } + else + { + data["error"] = $"HTTP {response.StatusCode}"; + data["response_body"] = response.Body; + + return HealthCheckResult.Unhealthy( + $"SendGrid API returned {response.StatusCode}", + data: data); + } + } + catch (Exception ex) + { + stopwatch.Stop(); + var duration = stopwatch.ElapsedMilliseconds; + + _logger.LogError(ex, "SendGrid health check failed after {Duration}ms", duration); + + var data = new Dictionary + { + { "status", "unhealthy" }, + { "duration", $"{duration}ms" }, + { "error", ex.Message } + }; + + return HealthCheckResult.Unhealthy($"SendGrid connection failed: {ex.Message}", ex, data); + } + } } \ No newline at end of file diff --git a/src/BCards.Web/HealthChecks/StripeHealthCheck.cs b/src/BCards.Web/HealthChecks/StripeHealthCheck.cs index ae01f2f..0c648e5 100644 --- a/src/BCards.Web/HealthChecks/StripeHealthCheck.cs +++ b/src/BCards.Web/HealthChecks/StripeHealthCheck.cs @@ -1,94 +1,94 @@ -using Microsoft.Extensions.Diagnostics.HealthChecks; -using Microsoft.Extensions.Options; -using BCards.Web.Configuration; -using Stripe; -using System.Diagnostics; - -namespace BCards.Web.HealthChecks; - -public class StripeHealthCheck : IHealthCheck -{ - private readonly StripeSettings _stripeSettings; - private readonly ILogger _logger; - - public StripeHealthCheck(IOptions stripeSettings, ILogger logger) - { - _stripeSettings = stripeSettings.Value; - _logger = logger; - } - - public async Task CheckHealthAsync( - HealthCheckContext context, - CancellationToken cancellationToken = default) - { - var stopwatch = Stopwatch.StartNew(); - - try - { - // Configura Stripe temporariamente para o teste - StripeConfiguration.ApiKey = _stripeSettings.SecretKey; - - // Testa conectividade listando produtos (limite 1 para ser rápido) - var productService = new ProductService(); - var options = new ProductListOptions { Limit = 1 }; - - await productService.ListAsync(options, cancellationToken: cancellationToken); - - stopwatch.Stop(); - var duration = stopwatch.ElapsedMilliseconds; - - _logger.LogInformation("Stripe health check completed successfully in {Duration}ms", duration); - - var data = new Dictionary - { - { "status", "healthy" }, - { "duration", $"{duration}ms" }, - { "api_key_prefix", _stripeSettings.SecretKey?.Substring(0, Math.Min(12, _stripeSettings.SecretKey.Length)) + "..." }, - { "latency", duration } - }; - - // Status baseado na latência - if (duration > 10000) // > 10s - return HealthCheckResult.Unhealthy($"Stripe response time too high: {duration}ms", data: data); - - if (duration > 5000) // > 5s - return HealthCheckResult.Degraded($"Stripe response time elevated: {duration}ms", data: data); - - return HealthCheckResult.Healthy($"Stripe API is responsive ({duration}ms)", data: data); - } - catch (StripeException stripeEx) - { - stopwatch.Stop(); - var duration = stopwatch.ElapsedMilliseconds; - - _logger.LogError(stripeEx, "Stripe health check failed after {Duration}ms: {Error}", duration, stripeEx.Message); - - var data = new Dictionary - { - { "status", "unhealthy" }, - { "duration", $"{duration}ms" }, - { "error", stripeEx.Message }, - { "error_code", stripeEx.StripeError?.Code ?? "unknown" }, - { "error_type", stripeEx.StripeError?.Type ?? "unknown" } - }; - - return HealthCheckResult.Unhealthy($"Stripe API error: {stripeEx.Message}", stripeEx, data); - } - catch (Exception ex) - { - stopwatch.Stop(); - var duration = stopwatch.ElapsedMilliseconds; - - _logger.LogError(ex, "Stripe health check failed after {Duration}ms", duration); - - var data = new Dictionary - { - { "status", "unhealthy" }, - { "duration", $"{duration}ms" }, - { "error", ex.Message } - }; - - return HealthCheckResult.Unhealthy($"Stripe connection failed: {ex.Message}", ex, data); - } - } +using Microsoft.Extensions.Diagnostics.HealthChecks; +using Microsoft.Extensions.Options; +using BCards.Web.Configuration; +using Stripe; +using System.Diagnostics; + +namespace BCards.Web.HealthChecks; + +public class StripeHealthCheck : IHealthCheck +{ + private readonly StripeSettings _stripeSettings; + private readonly ILogger _logger; + + public StripeHealthCheck(IOptions stripeSettings, ILogger logger) + { + _stripeSettings = stripeSettings.Value; + _logger = logger; + } + + public async Task CheckHealthAsync( + HealthCheckContext context, + CancellationToken cancellationToken = default) + { + var stopwatch = Stopwatch.StartNew(); + + try + { + // Configura Stripe temporariamente para o teste + StripeConfiguration.ApiKey = _stripeSettings.SecretKey; + + // Testa conectividade listando produtos (limite 1 para ser rápido) + var productService = new ProductService(); + var options = new ProductListOptions { Limit = 1 }; + + await productService.ListAsync(options, cancellationToken: cancellationToken); + + stopwatch.Stop(); + var duration = stopwatch.ElapsedMilliseconds; + + _logger.LogInformation("Stripe health check completed successfully in {Duration}ms", duration); + + var data = new Dictionary + { + { "status", "healthy" }, + { "duration", $"{duration}ms" }, + { "api_key_prefix", _stripeSettings.SecretKey?.Substring(0, Math.Min(12, _stripeSettings.SecretKey.Length)) + "..." }, + { "latency", duration } + }; + + // Status baseado na latência + if (duration > 10000) // > 10s + return HealthCheckResult.Unhealthy($"Stripe response time too high: {duration}ms", data: data); + + if (duration > 5000) // > 5s + return HealthCheckResult.Degraded($"Stripe response time elevated: {duration}ms", data: data); + + return HealthCheckResult.Healthy($"Stripe API is responsive ({duration}ms)", data: data); + } + catch (StripeException stripeEx) + { + stopwatch.Stop(); + var duration = stopwatch.ElapsedMilliseconds; + + _logger.LogError(stripeEx, "Stripe health check failed after {Duration}ms: {Error}", duration, stripeEx.Message); + + var data = new Dictionary + { + { "status", "unhealthy" }, + { "duration", $"{duration}ms" }, + { "error", stripeEx.Message }, + { "error_code", stripeEx.StripeError?.Code ?? "unknown" }, + { "error_type", stripeEx.StripeError?.Type ?? "unknown" } + }; + + return HealthCheckResult.Unhealthy($"Stripe API error: {stripeEx.Message}", stripeEx, data); + } + catch (Exception ex) + { + stopwatch.Stop(); + var duration = stopwatch.ElapsedMilliseconds; + + _logger.LogError(ex, "Stripe health check failed after {Duration}ms", duration); + + var data = new Dictionary + { + { "status", "unhealthy" }, + { "duration", $"{duration}ms" }, + { "error", ex.Message } + }; + + return HealthCheckResult.Unhealthy($"Stripe connection failed: {ex.Message}", ex, data); + } + } } \ No newline at end of file diff --git a/src/BCards.Web/HealthChecks/SystemResourcesHealthCheck.cs b/src/BCards.Web/HealthChecks/SystemResourcesHealthCheck.cs index d5ad880..77fcae7 100644 --- a/src/BCards.Web/HealthChecks/SystemResourcesHealthCheck.cs +++ b/src/BCards.Web/HealthChecks/SystemResourcesHealthCheck.cs @@ -1,134 +1,134 @@ -using Microsoft.Extensions.Diagnostics.HealthChecks; -using System.Diagnostics; - -namespace BCards.Web.HealthChecks; - -public class SystemResourcesHealthCheck : IHealthCheck -{ - private readonly ILogger _logger; - private static readonly DateTime _startTime = DateTime.UtcNow; - - public SystemResourcesHealthCheck(ILogger logger) - { - _logger = logger; - } - - public Task CheckHealthAsync( - HealthCheckContext context, - CancellationToken cancellationToken = default) - { - var stopwatch = Stopwatch.StartNew(); - - try - { - // Informações de memória - var totalMemory = GC.GetTotalMemory(false); - var workingSet = Environment.WorkingSet; - - // Informações do processo atual - using var currentProcess = Process.GetCurrentProcess(); - var cpuUsage = GetCpuUsage(currentProcess); - - // Uptime - var uptime = DateTime.UtcNow - _startTime; - var uptimeString = FormatUptime(uptime); - - // Thread count - var threadCount = currentProcess.Threads.Count; - - stopwatch.Stop(); - var duration = stopwatch.ElapsedMilliseconds; - - var data = new Dictionary - { - { "status", "healthy" }, - { "duration", $"{duration}ms" }, - { "memory", new Dictionary - { - { "total_managed_mb", Math.Round(totalMemory / 1024.0 / 1024.0, 2) }, - { "working_set_mb", Math.Round(workingSet / 1024.0 / 1024.0, 2) }, - { "gc_generation_0", GC.CollectionCount(0) }, - { "gc_generation_1", GC.CollectionCount(1) }, - { "gc_generation_2", GC.CollectionCount(2) } - } - }, - { "process", new Dictionary - { - { "id", currentProcess.Id }, - { "threads", threadCount }, - { "handles", currentProcess.HandleCount }, - { "uptime", uptimeString }, - { "uptime_seconds", (int)uptime.TotalSeconds } - } - }, - { "system", new Dictionary - { - { "processor_count", Environment.ProcessorCount }, - { "os_version", Environment.OSVersion.ToString() }, - { "machine_name", Environment.MachineName }, - { "user_name", Environment.UserName } - } - } - }; - - _logger.LogInformation("System resources health check completed in {Duration}ms - Memory: {Memory}MB, Threads: {Threads}", - duration, Math.Round(totalMemory / 1024.0 / 1024.0, 1), threadCount); - - // Definir thresholds para status - var memoryMb = totalMemory / 1024.0 / 1024.0; - - if (memoryMb > 1000) // > 1GB - { - data["status"] = "degraded"; - return Task.FromResult(HealthCheckResult.Degraded($"High memory usage: {memoryMb:F1}MB", data: data)); - } - - if (threadCount > 500) - { - data["status"] = "degraded"; - return Task.FromResult(HealthCheckResult.Degraded($"High thread count: {threadCount}", data: data)); - } - - return Task.FromResult(HealthCheckResult.Healthy($"System resources normal (Memory: {memoryMb:F1}MB, Threads: {threadCount})", data: data)); - } - catch (Exception ex) - { - stopwatch.Stop(); - var duration = stopwatch.ElapsedMilliseconds; - - _logger.LogError(ex, "System resources health check failed after {Duration}ms", duration); - - var data = new Dictionary - { - { "status", "unhealthy" }, - { "duration", $"{duration}ms" }, - { "error", ex.Message } - }; - - return Task.FromResult(HealthCheckResult.Unhealthy($"System resources check failed: {ex.Message}", ex, data)); - } - } - - private static double GetCpuUsage(Process process) - { - try - { - return process.TotalProcessorTime.TotalMilliseconds; - } - catch - { - return 0; - } - } - - private static string FormatUptime(TimeSpan uptime) - { - if (uptime.TotalDays >= 1) - return $"{(int)uptime.TotalDays}d {uptime.Hours}h {uptime.Minutes}m"; - if (uptime.TotalHours >= 1) - return $"{uptime.Hours}h {uptime.Minutes}m"; - if (uptime.TotalMinutes >= 1) - return $"{uptime.Minutes}m {uptime.Seconds}s"; - return $"{uptime.Seconds}s"; - } +using Microsoft.Extensions.Diagnostics.HealthChecks; +using System.Diagnostics; + +namespace BCards.Web.HealthChecks; + +public class SystemResourcesHealthCheck : IHealthCheck +{ + private readonly ILogger _logger; + private static readonly DateTime _startTime = DateTime.UtcNow; + + public SystemResourcesHealthCheck(ILogger logger) + { + _logger = logger; + } + + public Task CheckHealthAsync( + HealthCheckContext context, + CancellationToken cancellationToken = default) + { + var stopwatch = Stopwatch.StartNew(); + + try + { + // Informações de memória + var totalMemory = GC.GetTotalMemory(false); + var workingSet = Environment.WorkingSet; + + // Informações do processo atual + using var currentProcess = Process.GetCurrentProcess(); + var cpuUsage = GetCpuUsage(currentProcess); + + // Uptime + var uptime = DateTime.UtcNow - _startTime; + var uptimeString = FormatUptime(uptime); + + // Thread count + var threadCount = currentProcess.Threads.Count; + + stopwatch.Stop(); + var duration = stopwatch.ElapsedMilliseconds; + + var data = new Dictionary + { + { "status", "healthy" }, + { "duration", $"{duration}ms" }, + { "memory", new Dictionary + { + { "total_managed_mb", Math.Round(totalMemory / 1024.0 / 1024.0, 2) }, + { "working_set_mb", Math.Round(workingSet / 1024.0 / 1024.0, 2) }, + { "gc_generation_0", GC.CollectionCount(0) }, + { "gc_generation_1", GC.CollectionCount(1) }, + { "gc_generation_2", GC.CollectionCount(2) } + } + }, + { "process", new Dictionary + { + { "id", currentProcess.Id }, + { "threads", threadCount }, + { "handles", currentProcess.HandleCount }, + { "uptime", uptimeString }, + { "uptime_seconds", (int)uptime.TotalSeconds } + } + }, + { "system", new Dictionary + { + { "processor_count", Environment.ProcessorCount }, + { "os_version", Environment.OSVersion.ToString() }, + { "machine_name", Environment.MachineName }, + { "user_name", Environment.UserName } + } + } + }; + + _logger.LogInformation("System resources health check completed in {Duration}ms - Memory: {Memory}MB, Threads: {Threads}", + duration, Math.Round(totalMemory / 1024.0 / 1024.0, 1), threadCount); + + // Definir thresholds para status + var memoryMb = totalMemory / 1024.0 / 1024.0; + + if (memoryMb > 1000) // > 1GB + { + data["status"] = "degraded"; + return Task.FromResult(HealthCheckResult.Degraded($"High memory usage: {memoryMb:F1}MB", data: data)); + } + + if (threadCount > 500) + { + data["status"] = "degraded"; + return Task.FromResult(HealthCheckResult.Degraded($"High thread count: {threadCount}", data: data)); + } + + return Task.FromResult(HealthCheckResult.Healthy($"System resources normal (Memory: {memoryMb:F1}MB, Threads: {threadCount})", data: data)); + } + catch (Exception ex) + { + stopwatch.Stop(); + var duration = stopwatch.ElapsedMilliseconds; + + _logger.LogError(ex, "System resources health check failed after {Duration}ms", duration); + + var data = new Dictionary + { + { "status", "unhealthy" }, + { "duration", $"{duration}ms" }, + { "error", ex.Message } + }; + + return Task.FromResult(HealthCheckResult.Unhealthy($"System resources check failed: {ex.Message}", ex, data)); + } + } + + private static double GetCpuUsage(Process process) + { + try + { + return process.TotalProcessorTime.TotalMilliseconds; + } + catch + { + return 0; + } + } + + private static string FormatUptime(TimeSpan uptime) + { + if (uptime.TotalDays >= 1) + return $"{(int)uptime.TotalDays}d {uptime.Hours}h {uptime.Minutes}m"; + if (uptime.TotalHours >= 1) + return $"{uptime.Hours}h {uptime.Minutes}m"; + if (uptime.TotalMinutes >= 1) + return $"{uptime.Minutes}m {uptime.Seconds}s"; + return $"{uptime.Seconds}s"; + } } \ No newline at end of file diff --git a/src/BCards.Web/Middleware/ModerationAuthMiddleware.cs b/src/BCards.Web/Middleware/ModerationAuthMiddleware.cs index b4ace14..2d22e3c 100644 --- a/src/BCards.Web/Middleware/ModerationAuthMiddleware.cs +++ b/src/BCards.Web/Middleware/ModerationAuthMiddleware.cs @@ -1,45 +1,45 @@ -using BCards.Web.Services; - -namespace BCards.Web.Middleware -{ - public class ModerationAuthMiddleware - { - private readonly RequestDelegate _next; - private readonly IModerationAuthService _moderationAuth; - - public ModerationAuthMiddleware(RequestDelegate next, IModerationAuthService moderationAuth) - { - _next = next; - _moderationAuth = moderationAuth; - } - - public async Task InvokeAsync(HttpContext context) - { - var path = context.Request.Path.Value?.ToLowerInvariant(); - - // Verificar se é uma rota de moderação - if (path != null && path.StartsWith("/moderation")) - { - // Verificar se usuário está autenticado - if (!context.User.Identity?.IsAuthenticated == true) - { - context.Response.Redirect("/Auth/Login?returnUrl=" + Uri.EscapeDataString(context.Request.Path)); - return; - } - - // Verificar se é moderador - if (!_moderationAuth.IsUserModerator(context.User)) - { - context.Response.StatusCode = 403; - await context.Response.WriteAsync("Acesso negado. Você não tem permissão para acessar esta área."); - return; - } - - // Adicionar flag para usar nas views - context.Items["IsModerator"] = true; - } - - await _next(context); - } - } -} +using BCards.Web.Services; + +namespace BCards.Web.Middleware +{ + public class ModerationAuthMiddleware + { + private readonly RequestDelegate _next; + private readonly IModerationAuthService _moderationAuth; + + public ModerationAuthMiddleware(RequestDelegate next, IModerationAuthService moderationAuth) + { + _next = next; + _moderationAuth = moderationAuth; + } + + public async Task InvokeAsync(HttpContext context) + { + var path = context.Request.Path.Value?.ToLowerInvariant(); + + // Verificar se é uma rota de moderação + if (path != null && path.StartsWith("/moderation")) + { + // Verificar se usuário está autenticado + if (!context.User.Identity?.IsAuthenticated == true) + { + context.Response.Redirect("/Auth/Login?returnUrl=" + Uri.EscapeDataString(context.Request.Path)); + return; + } + + // Verificar se é moderador + if (!_moderationAuth.IsUserModerator(context.User)) + { + context.Response.StatusCode = 403; + await context.Response.WriteAsync("Acesso negado. Você não tem permissão para acessar esta área."); + return; + } + + // Adicionar flag para usar nas views + context.Items["IsModerator"] = true; + } + + await _next(context); + } + } +} diff --git a/src/BCards.Web/Middleware/PageStatusMiddleware.cs b/src/BCards.Web/Middleware/PageStatusMiddleware.cs index 0d3f844..27b8b78 100644 --- a/src/BCards.Web/Middleware/PageStatusMiddleware.cs +++ b/src/BCards.Web/Middleware/PageStatusMiddleware.cs @@ -1,188 +1,188 @@ -using BCards.Web.Services; -using BCards.Web.ViewModels; -using System.Text.RegularExpressions; - -namespace BCards.Web.Middleware; - -public class PageStatusMiddleware -{ - private readonly RequestDelegate _next; - private readonly ILogger _logger; - private static readonly Regex UserPageRouteRegex = new(@"^/page/[a-z-]+/[a-z0-9-]+/?$", - RegexOptions.IgnoreCase | RegexOptions.Compiled); - private static readonly Regex RouteParametersRegex = new(@"^/page/([a-z-]+)/([a-z0-9-]+)/?$", - RegexOptions.IgnoreCase | RegexOptions.Compiled); - - public PageStatusMiddleware(RequestDelegate next, ILogger logger) - { - _next = next; - _logger = logger; - } - - public async Task InvokeAsync(HttpContext context, IUserPageService userPageService) - { - // Check if this is a user page route (page/{category}/{slug}) - if (IsUserPageRoute(context.Request.Path)) - { - try - { - var (category, slug) = ExtractRouteParameters(context.Request.Path); - - if (!string.IsNullOrEmpty(category) && !string.IsNullOrEmpty(slug)) - { - var page = await userPageService.GetPageAsync(category, slug); - - if (page != null) - { - var userId = page.UserId; - - switch (page.Status) - { - case PageStatus.Expired: - // 301 Redirect para não prejudicar SEO - _logger.LogInformation($"Redirecting expired page {category}/{slug} to upgrade page"); - context.Response.Redirect($"/Home/Pricing?expired={slug}&category={category}", permanent: true); - return; - - case PageStatus.PendingPayment: - // Mostrar página com aviso de pagamento - _logger.LogInformation($"Showing payment warning for page {category}/{slug}"); - await ShowPaymentWarning(context, page); - return; - - case PageStatus.Inactive: - // 404 temporário - _logger.LogInformation($"Page {category}/{slug} is inactive, returning 404"); - context.Response.StatusCode = 404; - await context.Response.WriteAsync("Página temporariamente indisponível."); - return; - - case PageStatus.Creating: - case PageStatus.PendingModeration: - case PageStatus.Rejected: - // Páginas em desenvolvimento/moderação requerem preview token - var previewToken = context.Request.Query["preview"].FirstOrDefault(); - - _logger.LogInformation($"User id: {userId} - Page {category}/{slug} (Status: {page.Status}) - Token provided: {!string.IsNullOrEmpty(previewToken)}, Page token: {!string.IsNullOrEmpty(page.PreviewToken)}, Expiry: {page.PreviewTokenExpiry}"); - - if (string.IsNullOrEmpty(previewToken)) - { - _logger.LogInformation($"User id: {userId} - Page {category}/{slug} requires preview token - no token provided"); - context.Response.StatusCode = 404; - await context.Response.WriteAsync("Página em desenvolvimento. Acesso restrito."); - return; - } - - if (string.IsNullOrEmpty(page.PreviewToken)) - { - _logger.LogWarning($"User id: {userId} - Page {category}/{slug} has no preview token set in database"); - context.Response.StatusCode = 404; - await context.Response.WriteAsync("Página em desenvolvimento. Acesso restrito."); - return; - } - - // LOG DETALHADO ANTES da comparação - _logger.LogInformation("Token comparison for page {PageId} - Provided: {ProvidedToken}, DB Token: {DbToken}, DB Expiry: {DbExpiry}", - page.Id, previewToken, page.PreviewToken, page.PreviewTokenExpiry); - - if (previewToken != page.PreviewToken) - { - _logger.LogInformation($"User id: {userId} - Page {category}/{slug} preview token mismatch - provided: {previewToken}, expected: {page.PreviewToken}"); - context.Response.StatusCode = 404; - await context.Response.WriteAsync("Página em desenvolvimento. Acesso restrito."); - return; - } - - if (page.PreviewTokenExpiry < DateTime.UtcNow) - { - _logger.LogInformation($"User id: {userId} - Page {category}/{slug} preview token expired at {page.PreviewTokenExpiry} (now: {DateTime.UtcNow})"); - context.Response.StatusCode = 404; - await context.Response.WriteAsync("Token de preview expirado. Gere um novo token no painel."); - return; - } - - _logger.LogInformation($"User id: {userId} - Page {category}/{slug} preview token validated successfully"); - break; - - case PageStatus.Active: - // Continuar processamento normal - break; - - default: - // Status desconhecido - tratar como inativo - _logger.LogWarning($"User id: {userId} - Unknown page status: {page.Status} for page {category}/{slug}"); - context.Response.StatusCode = 404; - await context.Response.WriteAsync("Página temporariamente indisponível."); - return; - } - } - } - } - catch (Exception ex) - { - _logger.LogError(ex, "Error in PageStatusMiddleware"); - } - } - - await _next(context); - } - - private static bool IsUserPageRoute(PathString path) - { - // Check if path matches pattern: /page/{category}/{slug} using compiled regex - return UserPageRouteRegex.IsMatch(path.Value ?? ""); - } - - private static (string category, string slug) ExtractRouteParameters(PathString path) - { - var match = RouteParametersRegex.Match(path.Value ?? ""); - - if (match.Success) - { - return (match.Groups[1].Value, match.Groups[2].Value); - } - - return (string.Empty, string.Empty); - } - - private async Task ShowPaymentWarning(HttpContext context, BCards.Web.Models.UserPage page) - { - // Generate a simple HTML page with payment warning - var html = $@" - - - - {page.DisplayName} - Pagamento Pendente - - - - - -
-
-
-
-
-
- - Pagamento Pendente -
-
-
-

{page.DisplayName}

-

Esta página está temporariamente indisponível devido a um pagamento pendente.

-

Para reativar esta página, o proprietário deve regularizar o pagamento.

- Ver Planos -
-
-
-
-
- -"; - - context.Response.ContentType = "text/html"; - context.Response.StatusCode = 200; // Keep as 200 for SEO, but show warning - await context.Response.WriteAsync(html); - } +using BCards.Web.Services; +using BCards.Web.ViewModels; +using System.Text.RegularExpressions; + +namespace BCards.Web.Middleware; + +public class PageStatusMiddleware +{ + private readonly RequestDelegate _next; + private readonly ILogger _logger; + private static readonly Regex UserPageRouteRegex = new(@"^/page/[a-z-]+/[a-z0-9-]+/?$", + RegexOptions.IgnoreCase | RegexOptions.Compiled); + private static readonly Regex RouteParametersRegex = new(@"^/page/([a-z-]+)/([a-z0-9-]+)/?$", + RegexOptions.IgnoreCase | RegexOptions.Compiled); + + public PageStatusMiddleware(RequestDelegate next, ILogger logger) + { + _next = next; + _logger = logger; + } + + public async Task InvokeAsync(HttpContext context, IUserPageService userPageService) + { + // Check if this is a user page route (page/{category}/{slug}) + if (IsUserPageRoute(context.Request.Path)) + { + try + { + var (category, slug) = ExtractRouteParameters(context.Request.Path); + + if (!string.IsNullOrEmpty(category) && !string.IsNullOrEmpty(slug)) + { + var page = await userPageService.GetPageAsync(category, slug); + + if (page != null) + { + var userId = page.UserId; + + switch (page.Status) + { + case PageStatus.Expired: + // 301 Redirect para não prejudicar SEO + _logger.LogInformation($"Redirecting expired page {category}/{slug} to upgrade page"); + context.Response.Redirect($"/Home/Pricing?expired={slug}&category={category}", permanent: true); + return; + + case PageStatus.PendingPayment: + // Mostrar página com aviso de pagamento + _logger.LogInformation($"Showing payment warning for page {category}/{slug}"); + await ShowPaymentWarning(context, page); + return; + + case PageStatus.Inactive: + // 404 temporário + _logger.LogInformation($"Page {category}/{slug} is inactive, returning 404"); + context.Response.StatusCode = 404; + await context.Response.WriteAsync("Página temporariamente indisponível."); + return; + + case PageStatus.Creating: + case PageStatus.PendingModeration: + case PageStatus.Rejected: + // Páginas em desenvolvimento/moderação requerem preview token + var previewToken = context.Request.Query["preview"].FirstOrDefault(); + + _logger.LogInformation($"User id: {userId} - Page {category}/{slug} (Status: {page.Status}) - Token provided: {!string.IsNullOrEmpty(previewToken)}, Page token: {!string.IsNullOrEmpty(page.PreviewToken)}, Expiry: {page.PreviewTokenExpiry}"); + + if (string.IsNullOrEmpty(previewToken)) + { + _logger.LogInformation($"User id: {userId} - Page {category}/{slug} requires preview token - no token provided"); + context.Response.StatusCode = 404; + await context.Response.WriteAsync("Página em desenvolvimento. Acesso restrito."); + return; + } + + if (string.IsNullOrEmpty(page.PreviewToken)) + { + _logger.LogWarning($"User id: {userId} - Page {category}/{slug} has no preview token set in database"); + context.Response.StatusCode = 404; + await context.Response.WriteAsync("Página em desenvolvimento. Acesso restrito."); + return; + } + + // LOG DETALHADO ANTES da comparação + _logger.LogInformation("Token comparison for page {PageId} - Provided: {ProvidedToken}, DB Token: {DbToken}, DB Expiry: {DbExpiry}", + page.Id, previewToken, page.PreviewToken, page.PreviewTokenExpiry); + + if (previewToken != page.PreviewToken) + { + _logger.LogInformation($"User id: {userId} - Page {category}/{slug} preview token mismatch - provided: {previewToken}, expected: {page.PreviewToken}"); + context.Response.StatusCode = 404; + await context.Response.WriteAsync("Página em desenvolvimento. Acesso restrito."); + return; + } + + if (page.PreviewTokenExpiry < DateTime.UtcNow) + { + _logger.LogInformation($"User id: {userId} - Page {category}/{slug} preview token expired at {page.PreviewTokenExpiry} (now: {DateTime.UtcNow})"); + context.Response.StatusCode = 404; + await context.Response.WriteAsync("Token de preview expirado. Gere um novo token no painel."); + return; + } + + _logger.LogInformation($"User id: {userId} - Page {category}/{slug} preview token validated successfully"); + break; + + case PageStatus.Active: + // Continuar processamento normal + break; + + default: + // Status desconhecido - tratar como inativo + _logger.LogWarning($"User id: {userId} - Unknown page status: {page.Status} for page {category}/{slug}"); + context.Response.StatusCode = 404; + await context.Response.WriteAsync("Página temporariamente indisponível."); + return; + } + } + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Error in PageStatusMiddleware"); + } + } + + await _next(context); + } + + private static bool IsUserPageRoute(PathString path) + { + // Check if path matches pattern: /page/{category}/{slug} using compiled regex + return UserPageRouteRegex.IsMatch(path.Value ?? ""); + } + + private static (string category, string slug) ExtractRouteParameters(PathString path) + { + var match = RouteParametersRegex.Match(path.Value ?? ""); + + if (match.Success) + { + return (match.Groups[1].Value, match.Groups[2].Value); + } + + return (string.Empty, string.Empty); + } + + private async Task ShowPaymentWarning(HttpContext context, BCards.Web.Models.UserPage page) + { + // Generate a simple HTML page with payment warning + var html = $@" + + + + {page.DisplayName} - Pagamento Pendente + + + + + +
+
+
+
+
+
+ + Pagamento Pendente +
+
+
+

{page.DisplayName}

+

Esta página está temporariamente indisponível devido a um pagamento pendente.

+

Para reativar esta página, o proprietário deve regularizar o pagamento.

+ Ver Planos +
+
+
+
+
+ +"; + + context.Response.ContentType = "text/html"; + context.Response.StatusCode = 200; // Keep as 200 for SEO, but show warning + await context.Response.WriteAsync(html); + } } \ No newline at end of file diff --git a/src/BCards.Web/Models/IPageDisplay.cs b/src/BCards.Web/Models/IPageDisplay.cs index 63da837..b0f2f0d 100644 --- a/src/BCards.Web/Models/IPageDisplay.cs +++ b/src/BCards.Web/Models/IPageDisplay.cs @@ -1,27 +1,27 @@ -namespace BCards.Web.Models -{ - /// - /// Interface comum para páginas que podem ser exibidas publicamente - /// Facilita o envio de dados para views sem duplicação de código - /// - public interface IPageDisplay - { - string Id { get; } - string UserId { get; } - string Category { get; } - string Slug { get; } - string DisplayName { get; } - string Bio { get; } - string? ProfileImageId { get; } - string BusinessType { get; } - PageTheme Theme { get; } - List Links { get; } - SeoSettings SeoSettings { get; } - string Language { get; } - DateTime CreatedAt { get; } - - // Propriedades calculadas comuns - string FullUrl { get; } - string ProfileImageUrl { get; } - } -} +namespace BCards.Web.Models +{ + /// + /// Interface comum para páginas que podem ser exibidas publicamente + /// Facilita o envio de dados para views sem duplicação de código + /// + public interface IPageDisplay + { + string Id { get; } + string UserId { get; } + string Category { get; } + string Slug { get; } + string DisplayName { get; } + string Bio { get; } + string? ProfileImageId { get; } + string BusinessType { get; } + PageTheme Theme { get; } + List Links { get; } + SeoSettings SeoSettings { get; } + string Language { get; } + DateTime CreatedAt { get; } + + // Propriedades calculadas comuns + string FullUrl { get; } + string ProfileImageUrl { get; } + } +} diff --git a/src/BCards.Web/Models/LinkItem.cs b/src/BCards.Web/Models/LinkItem.cs index 0ae91a8..a24a5b3 100644 --- a/src/BCards.Web/Models/LinkItem.cs +++ b/src/BCards.Web/Models/LinkItem.cs @@ -1,55 +1,55 @@ -using MongoDB.Bson.Serialization.Attributes; - -namespace BCards.Web.Models; - -public enum LinkType -{ - Normal = 0, // Link comum - Product = 1 // Link de produto com preview -} - -public class LinkItem -{ - [BsonElement("title")] - public string Title { get; set; } = string.Empty; - - [BsonElement("url")] - public string Url { get; set; } = string.Empty; - - [BsonElement("description")] - public string Description { get; set; } = string.Empty; - - [BsonElement("icon")] - public string Icon { get; set; } = string.Empty; - - [BsonElement("isActive")] - public bool IsActive { get; set; } = true; - - [BsonElement("order")] - public int Order { get; set; } - - [BsonElement("clicks")] - public int Clicks { get; set; } = 0; - - [BsonElement("createdAt")] - public DateTime CreatedAt { get; set; } = DateTime.UtcNow; - - // Campos para Link de Produto - [BsonElement("type")] - public LinkType Type { get; set; } = LinkType.Normal; - - [BsonElement("productTitle")] - public string ProductTitle { get; set; } = string.Empty; - - [BsonElement("productImage")] - public string ProductImage { get; set; } = string.Empty; - - [BsonElement("productPrice")] - public string ProductPrice { get; set; } = string.Empty; - - [BsonElement("productDescription")] - public string ProductDescription { get; set; } = string.Empty; - - [BsonElement("productDataCachedAt")] - public DateTime? ProductDataCachedAt { get; set; } +using MongoDB.Bson.Serialization.Attributes; + +namespace BCards.Web.Models; + +public enum LinkType +{ + Normal = 0, // Link comum + Product = 1 // Link de produto com preview +} + +public class LinkItem +{ + [BsonElement("title")] + public string Title { get; set; } = string.Empty; + + [BsonElement("url")] + public string Url { get; set; } = string.Empty; + + [BsonElement("description")] + public string Description { get; set; } = string.Empty; + + [BsonElement("icon")] + public string Icon { get; set; } = string.Empty; + + [BsonElement("isActive")] + public bool IsActive { get; set; } = true; + + [BsonElement("order")] + public int Order { get; set; } + + [BsonElement("clicks")] + public int Clicks { get; set; } = 0; + + [BsonElement("createdAt")] + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + + // Campos para Link de Produto + [BsonElement("type")] + public LinkType Type { get; set; } = LinkType.Normal; + + [BsonElement("productTitle")] + public string ProductTitle { get; set; } = string.Empty; + + [BsonElement("productImage")] + public string ProductImage { get; set; } = string.Empty; + + [BsonElement("productPrice")] + public string ProductPrice { get; set; } = string.Empty; + + [BsonElement("productDescription")] + public string ProductDescription { get; set; } = string.Empty; + + [BsonElement("productDataCachedAt")] + public DateTime? ProductDataCachedAt { get; set; } } \ No newline at end of file diff --git a/src/BCards.Web/Models/LivePage.cs b/src/BCards.Web/Models/LivePage.cs index 3a63e25..e8ce664 100644 --- a/src/BCards.Web/Models/LivePage.cs +++ b/src/BCards.Web/Models/LivePage.cs @@ -1,89 +1,89 @@ -using MongoDB.Bson; -using MongoDB.Bson.Serialization.Attributes; - -namespace BCards.Web.Models; - -public class LivePage : IPageDisplay -{ - [BsonId] - [BsonRepresentation(BsonType.ObjectId)] - public string Id { get; set; } = string.Empty; - - [BsonElement("originalPageId")] - [BsonRepresentation(BsonType.ObjectId)] - public string OriginalPageId { get; set; } = string.Empty; - - [BsonElement("userId")] - [BsonRepresentation(BsonType.ObjectId)] - public string UserId { get; set; } = string.Empty; - - [BsonElement("category")] - public string Category { get; set; } = string.Empty; - - [BsonElement("slug")] - public string Slug { get; set; } = string.Empty; - - [BsonElement("displayName")] - public string DisplayName { get; set; } = string.Empty; - - [BsonElement("bio")] - public string Bio { get; set; } = string.Empty; - - [BsonElement("profileImageId")] - public string? ProfileImageId { get; set; } - - // Campo antigo - ignorar durante deserialização para compatibilidade - [BsonElement("profileImage")] - [BsonIgnoreIfDefault] - [BsonIgnore] - public string? ProfileImage { get; set; } - - [BsonElement("businessType")] - public string BusinessType { get; set; } = string.Empty; - - [BsonElement("theme")] - public PageTheme Theme { get; set; } = new(); - - [BsonElement("links")] - public List Links { get; set; } = new(); - - [BsonElement("seoSettings")] - public SeoSettings SeoSettings { get; set; } = new(); - - [BsonElement("language")] - public string Language { get; set; } = "pt-BR"; - - [BsonElement("analytics")] - public LivePageAnalytics Analytics { get; set; } = new(); - - [BsonElement("publishedAt")] - public DateTime PublishedAt { get; set; } - - [BsonElement("lastSyncAt")] - public DateTime LastSyncAt { get; set; } - - [BsonElement("createdAt")] - public DateTime CreatedAt { get; set; } - - public string FullUrl => $"page/{Category}/{Slug}"; - - /// - /// URL da imagem de perfil ou imagem padrão se não houver upload - /// - [BsonIgnore] - public string ProfileImageUrl => !string.IsNullOrEmpty(ProfileImageId) - ? $"/api/image/{ProfileImageId}" - : "/images/default-avatar.svg"; -} - -public class LivePageAnalytics -{ - [BsonElement("totalViews")] - public long TotalViews { get; set; } - - [BsonElement("totalClicks")] - public long TotalClicks { get; set; } - - [BsonElement("lastViewedAt")] - public DateTime? LastViewedAt { get; set; } +using MongoDB.Bson; +using MongoDB.Bson.Serialization.Attributes; + +namespace BCards.Web.Models; + +public class LivePage : IPageDisplay +{ + [BsonId] + [BsonRepresentation(BsonType.ObjectId)] + public string Id { get; set; } = string.Empty; + + [BsonElement("originalPageId")] + [BsonRepresentation(BsonType.ObjectId)] + public string OriginalPageId { get; set; } = string.Empty; + + [BsonElement("userId")] + [BsonRepresentation(BsonType.ObjectId)] + public string UserId { get; set; } = string.Empty; + + [BsonElement("category")] + public string Category { get; set; } = string.Empty; + + [BsonElement("slug")] + public string Slug { get; set; } = string.Empty; + + [BsonElement("displayName")] + public string DisplayName { get; set; } = string.Empty; + + [BsonElement("bio")] + public string Bio { get; set; } = string.Empty; + + [BsonElement("profileImageId")] + public string? ProfileImageId { get; set; } + + // Campo antigo - ignorar durante deserialização para compatibilidade + [BsonElement("profileImage")] + [BsonIgnoreIfDefault] + [BsonIgnore] + public string? ProfileImage { get; set; } + + [BsonElement("businessType")] + public string BusinessType { get; set; } = string.Empty; + + [BsonElement("theme")] + public PageTheme Theme { get; set; } = new(); + + [BsonElement("links")] + public List Links { get; set; } = new(); + + [BsonElement("seoSettings")] + public SeoSettings SeoSettings { get; set; } = new(); + + [BsonElement("language")] + public string Language { get; set; } = "pt-BR"; + + [BsonElement("analytics")] + public LivePageAnalytics Analytics { get; set; } = new(); + + [BsonElement("publishedAt")] + public DateTime PublishedAt { get; set; } + + [BsonElement("lastSyncAt")] + public DateTime LastSyncAt { get; set; } + + [BsonElement("createdAt")] + public DateTime CreatedAt { get; set; } + + public string FullUrl => $"page/{Category}/{Slug}"; + + /// + /// URL da imagem de perfil ou imagem padrão se não houver upload + /// + [BsonIgnore] + public string ProfileImageUrl => !string.IsNullOrEmpty(ProfileImageId) + ? $"/api/image/{ProfileImageId}" + : "/images/default-avatar.svg"; +} + +public class LivePageAnalytics +{ + [BsonElement("totalViews")] + public long TotalViews { get; set; } + + [BsonElement("totalClicks")] + public long TotalClicks { get; set; } + + [BsonElement("lastViewedAt")] + public DateTime? LastViewedAt { get; set; } } \ No newline at end of file diff --git a/src/BCards.Web/Models/ModerationHistory.cs b/src/BCards.Web/Models/ModerationHistory.cs index edd4869..fcefb6c 100644 --- a/src/BCards.Web/Models/ModerationHistory.cs +++ b/src/BCards.Web/Models/ModerationHistory.cs @@ -1,25 +1,25 @@ -using MongoDB.Bson; -using MongoDB.Bson.Serialization.Attributes; - -namespace BCards.Web.Models; - -public class ModerationHistory -{ - [BsonElement("attempt")] - public int Attempt { get; set; } - - [BsonElement("status")] - public string Status { get; set; } = "pending"; // "pending", "approved", "rejected" - - [BsonElement("reason")] - public string? Reason { get; set; } - - [BsonElement("moderatorId")] - public string? ModeratorId { get; set; } - - [BsonElement("date")] - public DateTime Date { get; set; } = DateTime.UtcNow; - - [BsonElement("issues")] - public List Issues { get; set; } = new(); +using MongoDB.Bson; +using MongoDB.Bson.Serialization.Attributes; + +namespace BCards.Web.Models; + +public class ModerationHistory +{ + [BsonElement("attempt")] + public int Attempt { get; set; } + + [BsonElement("status")] + public string Status { get; set; } = "pending"; // "pending", "approved", "rejected" + + [BsonElement("reason")] + public string? Reason { get; set; } + + [BsonElement("moderatorId")] + public string? ModeratorId { get; set; } + + [BsonElement("date")] + public DateTime Date { get; set; } = DateTime.UtcNow; + + [BsonElement("issues")] + public List Issues { get; set; } = new(); } \ No newline at end of file diff --git a/src/BCards.Web/Models/OpenGraphCache.cs b/src/BCards.Web/Models/OpenGraphCache.cs index 3931cab..2d86da1 100644 --- a/src/BCards.Web/Models/OpenGraphCache.cs +++ b/src/BCards.Web/Models/OpenGraphCache.cs @@ -1,55 +1,55 @@ -using MongoDB.Bson; -using MongoDB.Bson.Serialization.Attributes; - -namespace BCards.Web.Models; - -public class OpenGraphCache -{ - [BsonId] - [BsonRepresentation(BsonType.ObjectId)] - public string Id { get; set; } = string.Empty; - - [BsonElement("url")] - public string Url { get; set; } = string.Empty; - - [BsonElement("urlHash")] - public string UrlHash { get; set; } = string.Empty; - - [BsonElement("title")] - public string Title { get; set; } = string.Empty; - - [BsonElement("description")] - public string Description { get; set; } = string.Empty; - - [BsonElement("image")] - public string Image { get; set; } = string.Empty; - - [BsonElement("price")] - public string Price { get; set; } = string.Empty; - - [BsonElement("currency")] - public string Currency { get; set; } = "BRL"; - - [BsonElement("isValid")] - public bool IsValid { get; set; } - - [BsonElement("errorMessage")] - public string ErrorMessage { get; set; } = string.Empty; - - [BsonElement("cachedAt")] - public DateTime CachedAt { get; set; } = DateTime.UtcNow; - - [BsonElement("expiresAt")] - public DateTime ExpiresAt { get; set; } = DateTime.UtcNow.AddHours(24); -} - -public class OpenGraphData -{ - public string Title { get; set; } = string.Empty; - public string Description { get; set; } = string.Empty; - public string Image { get; set; } = string.Empty; - public string Price { get; set; } = string.Empty; - public string Currency { get; set; } = "BRL"; - public bool IsValid { get; set; } - public string ErrorMessage { get; set; } = string.Empty; +using MongoDB.Bson; +using MongoDB.Bson.Serialization.Attributes; + +namespace BCards.Web.Models; + +public class OpenGraphCache +{ + [BsonId] + [BsonRepresentation(BsonType.ObjectId)] + public string Id { get; set; } = string.Empty; + + [BsonElement("url")] + public string Url { get; set; } = string.Empty; + + [BsonElement("urlHash")] + public string UrlHash { get; set; } = string.Empty; + + [BsonElement("title")] + public string Title { get; set; } = string.Empty; + + [BsonElement("description")] + public string Description { get; set; } = string.Empty; + + [BsonElement("image")] + public string Image { get; set; } = string.Empty; + + [BsonElement("price")] + public string Price { get; set; } = string.Empty; + + [BsonElement("currency")] + public string Currency { get; set; } = "BRL"; + + [BsonElement("isValid")] + public bool IsValid { get; set; } + + [BsonElement("errorMessage")] + public string ErrorMessage { get; set; } = string.Empty; + + [BsonElement("cachedAt")] + public DateTime CachedAt { get; set; } = DateTime.UtcNow; + + [BsonElement("expiresAt")] + public DateTime ExpiresAt { get; set; } = DateTime.UtcNow.AddHours(24); +} + +public class OpenGraphData +{ + public string Title { get; set; } = string.Empty; + public string Description { get; set; } = string.Empty; + public string Image { get; set; } = string.Empty; + public string Price { get; set; } = string.Empty; + public string Currency { get; set; } = "BRL"; + public bool IsValid { get; set; } + public string ErrorMessage { get; set; } = string.Empty; } \ No newline at end of file diff --git a/src/BCards.Web/Models/PageTheme.cs b/src/BCards.Web/Models/PageTheme.cs index c14e2bf..7ea86a0 100644 --- a/src/BCards.Web/Models/PageTheme.cs +++ b/src/BCards.Web/Models/PageTheme.cs @@ -1,46 +1,46 @@ -using MongoDB.Bson; -using MongoDB.Bson.Serialization.Attributes; - -namespace BCards.Web.Models; - -public class PageTheme -{ - [BsonId] - [BsonRepresentation(BsonType.ObjectId)] - public string Id { get; set; } = string.Empty; - - [BsonElement("name")] - public string Name { get; set; } = string.Empty; - - [BsonElement("primaryColor")] - public string PrimaryColor { get; set; } = "#007bff"; - - [BsonElement("secondaryColor")] - public string SecondaryColor { get; set; } = "#6c757d"; - - [BsonElement("backgroundColor")] - public string BackgroundColor { get; set; } = "#ffffff"; - - [BsonElement("textColor")] - public string TextColor { get; set; } = "#212529"; - - [BsonElement("backgroundImage")] - public string BackgroundImage { get; set; } = string.Empty; - - [BsonElement("isPremium")] - public bool IsPremium { get; set; } = false; - - [BsonElement("cssTemplate")] - public string CssTemplate { get; set; } = string.Empty; - - [BsonElement("isActive")] - public bool IsActive { get; set; } = true; - - [BsonElement("createdAt")] - public DateTime CreatedAt { get; set; } = DateTime.UtcNow; - - // Campo translations - para compatibilidade futura, não usado por enquanto - [BsonElement("translations")] - [BsonIgnoreIfDefault] - public object? Translations { get; set; } +using MongoDB.Bson; +using MongoDB.Bson.Serialization.Attributes; + +namespace BCards.Web.Models; + +public class PageTheme +{ + [BsonId] + [BsonRepresentation(BsonType.ObjectId)] + public string Id { get; set; } = string.Empty; + + [BsonElement("name")] + public string Name { get; set; } = string.Empty; + + [BsonElement("primaryColor")] + public string PrimaryColor { get; set; } = "#007bff"; + + [BsonElement("secondaryColor")] + public string SecondaryColor { get; set; } = "#6c757d"; + + [BsonElement("backgroundColor")] + public string BackgroundColor { get; set; } = "#ffffff"; + + [BsonElement("textColor")] + public string TextColor { get; set; } = "#212529"; + + [BsonElement("backgroundImage")] + public string BackgroundImage { get; set; } = string.Empty; + + [BsonElement("isPremium")] + public bool IsPremium { get; set; } = false; + + [BsonElement("cssTemplate")] + public string CssTemplate { get; set; } = string.Empty; + + [BsonElement("isActive")] + public bool IsActive { get; set; } = true; + + [BsonElement("createdAt")] + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + + // Campo translations - para compatibilidade futura, não usado por enquanto + [BsonElement("translations")] + [BsonIgnoreIfDefault] + public object? Translations { get; set; } } \ No newline at end of file diff --git a/src/BCards.Web/Models/PlanLimitations.cs b/src/BCards.Web/Models/PlanLimitations.cs index 45607d6..6e00392 100644 --- a/src/BCards.Web/Models/PlanLimitations.cs +++ b/src/BCards.Web/Models/PlanLimitations.cs @@ -1,46 +1,46 @@ -using MongoDB.Bson.Serialization.Attributes; - -namespace BCards.Web.Models; - -public class PlanLimitations -{ - [BsonElement("maxLinks")] - public int MaxLinks { get; set; } = 5; - - [BsonElement("allowCustomThemes")] - public bool AllowCustomThemes { get; set; } = false; - - [BsonElement("allowAnalytics")] - public bool AllowAnalytics { get; set; } = false; - - [BsonElement("allowCustomDomain")] - public bool AllowCustomDomain { get; set; } = false; - - [BsonElement("allowMultipleDomains")] - public bool AllowMultipleDomains { get; set; } = false; - - [BsonElement("prioritySupport")] - public bool PrioritySupport { get; set; } = false; - - [BsonElement("planType")] - public string PlanType { get; set; } = "free"; - - // Novos campos para Links de Produto - [BsonElement("maxProductLinks")] - public int MaxProductLinks { get; set; } = 0; - - [BsonElement("maxOGExtractionsPerDay")] - public int MaxOGExtractionsPerDay { get; set; } = 0; - - [BsonElement("allowProductLinks")] - public bool AllowProductLinks { get; set; } = false; - - [BsonElement("specialModeration")] - public bool? SpecialModeration { get; set; } = false; - - [BsonElement("ogExtractionsUsedToday")] - public int OGExtractionsUsedToday { get; set; } = 0; - - [BsonElement("lastExtractionDate")] - public DateTime? LastExtractionDate { get; set; } +using MongoDB.Bson.Serialization.Attributes; + +namespace BCards.Web.Models; + +public class PlanLimitations +{ + [BsonElement("maxLinks")] + public int MaxLinks { get; set; } = 5; + + [BsonElement("allowCustomThemes")] + public bool AllowCustomThemes { get; set; } = false; + + [BsonElement("allowAnalytics")] + public bool AllowAnalytics { get; set; } = false; + + [BsonElement("allowCustomDomain")] + public bool AllowCustomDomain { get; set; } = false; + + [BsonElement("allowMultipleDomains")] + public bool AllowMultipleDomains { get; set; } = false; + + [BsonElement("prioritySupport")] + public bool PrioritySupport { get; set; } = false; + + [BsonElement("planType")] + public string PlanType { get; set; } = "free"; + + // Novos campos para Links de Produto + [BsonElement("maxProductLinks")] + public int MaxProductLinks { get; set; } = 0; + + [BsonElement("maxOGExtractionsPerDay")] + public int MaxOGExtractionsPerDay { get; set; } = 0; + + [BsonElement("allowProductLinks")] + public bool AllowProductLinks { get; set; } = false; + + [BsonElement("specialModeration")] + public bool? SpecialModeration { get; set; } = false; + + [BsonElement("ogExtractionsUsedToday")] + public int OGExtractionsUsedToday { get; set; } = 0; + + [BsonElement("lastExtractionDate")] + public DateTime? LastExtractionDate { get; set; } } \ No newline at end of file diff --git a/src/BCards.Web/Models/PlanType.cs b/src/BCards.Web/Models/PlanType.cs index 930e774..8e1b369 100644 --- a/src/BCards.Web/Models/PlanType.cs +++ b/src/BCards.Web/Models/PlanType.cs @@ -1,138 +1,138 @@ -namespace BCards.Web.Models; - -public enum PlanType -{ - Trial = 0, // Gratuito por 7 dias - Basic = 1, - Professional = 2, - Premium = 4, - PremiumAffiliate = 5 -} - -public static class PlanTypeExtensions -{ - public static string GetDisplayName(this PlanType planType) - { - return planType switch - { - PlanType.Trial => "Trial Gratuito", - PlanType.Basic => "Básico", - PlanType.Professional => "Profissional", - PlanType.Premium => "Premium", - PlanType.PremiumAffiliate => "Premium+Afiliados", - _ => "Desconhecido" - }; - } - - // NOTA: Preços agora são configurados dinamicamente via IPlanConfigurationService - // Este método mantém valores fallback para compatibilidade - public static decimal GetPrice(this PlanType planType) - { - return planType switch - { - PlanType.Trial => 0.00m, - PlanType.Basic => 5.90m, - PlanType.Professional => 12.90m, - PlanType.Premium => 19.90m, - PlanType.PremiumAffiliate => 29.90m, - _ => 0.00m - }; - } - - // NOTA: Limitações agora são configuradas dinamicamente via IPlanConfigurationService - // Este método mantém valores fallback para compatibilidade - public static int GetMaxPages(this PlanType planType) - { - return planType switch - { - PlanType.Trial => 1, - PlanType.Basic => 3, - PlanType.Professional => 5, // DECOY - not attractive - PlanType.Premium => 15, - PlanType.PremiumAffiliate => 15, - _ => 1 - }; - } - - // NOTA: Limitações agora são configuradas dinamicamente via IPlanConfigurationService - // Este método mantém valores fallback para compatibilidade - public static int GetMaxLinksPerPage(this PlanType planType) - { - return planType switch - { - PlanType.Trial => 3, - PlanType.Basic => 8, - PlanType.Professional => 20, // DECOY - too expensive for the benefit - PlanType.Premium => int.MaxValue, // Unlimited - PlanType.PremiumAffiliate => int.MaxValue, // Unlimited - _ => 3 - }; - } - - public static int GetMaxLinks(this PlanType planType) - { - return GetMaxLinksPerPage(planType); - } - - public static bool AllowsAnalytics(this PlanType planType) - { - return planType switch - { - PlanType.Trial => false, - PlanType.Basic => true, - PlanType.Professional => true, - PlanType.Premium => true, - PlanType.PremiumAffiliate => true, - _ => false - }; - } - - public static bool AllowsCustomThemes(this PlanType planType) - { - return planType switch - { - PlanType.Trial => false, - PlanType.Basic => false, - PlanType.Professional => true, - PlanType.Premium => true, - PlanType.PremiumAffiliate => true, - _ => false - }; - } - - public static int GetTrialDays(this PlanType planType) - { - return planType == PlanType.Trial ? 7 : 0; - } - - public static int GetMaxProductLinks(this PlanType planType) - { - return planType switch - { - PlanType.Trial => 0, // 1 link de produto para trial - PlanType.Basic => 0, // 3 links de produto - PlanType.Professional => 0, // DECOY - mais caro para poucos benefícios - PlanType.Premium => 0, // Ilimitado - PlanType.PremiumAffiliate => int.MaxValue, // Ilimitado - _ => 0 - }; - } - - public static int GetMaxOGExtractionsPerDay(this PlanType planType) - { - return planType switch - { - PlanType.Trial => 0, // 2 extrações por dia no trial - PlanType.Basic => 0, // 5 extrações por dia - PlanType.Professional => 0, // 15 extrações por dia - PlanType.Premium => 0, // Ilimitado - PlanType.PremiumAffiliate => int.MaxValue, // Ilimitado - _ => 0 - }; - } - - public static bool AllowsProductLinks(this PlanType planType) - { - return GetMaxProductLinks(planType) > 0; - } +namespace BCards.Web.Models; + +public enum PlanType +{ + Trial = 0, // Gratuito por 7 dias + Basic = 1, + Professional = 2, + Premium = 4, + PremiumAffiliate = 5 +} + +public static class PlanTypeExtensions +{ + public static string GetDisplayName(this PlanType planType) + { + return planType switch + { + PlanType.Trial => "Trial Gratuito", + PlanType.Basic => "Básico", + PlanType.Professional => "Profissional", + PlanType.Premium => "Premium", + PlanType.PremiumAffiliate => "Premium+Afiliados", + _ => "Desconhecido" + }; + } + + // NOTA: Preços agora são configurados dinamicamente via IPlanConfigurationService + // Este método mantém valores fallback para compatibilidade + public static decimal GetPrice(this PlanType planType) + { + return planType switch + { + PlanType.Trial => 0.00m, + PlanType.Basic => 5.90m, + PlanType.Professional => 12.90m, + PlanType.Premium => 19.90m, + PlanType.PremiumAffiliate => 29.90m, + _ => 0.00m + }; + } + + // NOTA: Limitações agora são configuradas dinamicamente via IPlanConfigurationService + // Este método mantém valores fallback para compatibilidade + public static int GetMaxPages(this PlanType planType) + { + return planType switch + { + PlanType.Trial => 1, + PlanType.Basic => 3, + PlanType.Professional => 5, // DECOY - not attractive + PlanType.Premium => 15, + PlanType.PremiumAffiliate => 15, + _ => 1 + }; + } + + // NOTA: Limitações agora são configuradas dinamicamente via IPlanConfigurationService + // Este método mantém valores fallback para compatibilidade + public static int GetMaxLinksPerPage(this PlanType planType) + { + return planType switch + { + PlanType.Trial => 3, + PlanType.Basic => 8, + PlanType.Professional => 20, // DECOY - too expensive for the benefit + PlanType.Premium => int.MaxValue, // Unlimited + PlanType.PremiumAffiliate => int.MaxValue, // Unlimited + _ => 3 + }; + } + + public static int GetMaxLinks(this PlanType planType) + { + return GetMaxLinksPerPage(planType); + } + + public static bool AllowsAnalytics(this PlanType planType) + { + return planType switch + { + PlanType.Trial => false, + PlanType.Basic => true, + PlanType.Professional => true, + PlanType.Premium => true, + PlanType.PremiumAffiliate => true, + _ => false + }; + } + + public static bool AllowsCustomThemes(this PlanType planType) + { + return planType switch + { + PlanType.Trial => false, + PlanType.Basic => false, + PlanType.Professional => true, + PlanType.Premium => true, + PlanType.PremiumAffiliate => true, + _ => false + }; + } + + public static int GetTrialDays(this PlanType planType) + { + return planType == PlanType.Trial ? 7 : 0; + } + + public static int GetMaxProductLinks(this PlanType planType) + { + return planType switch + { + PlanType.Trial => 0, // 1 link de produto para trial + PlanType.Basic => 0, // 3 links de produto + PlanType.Professional => 0, // DECOY - mais caro para poucos benefícios + PlanType.Premium => 0, // Ilimitado + PlanType.PremiumAffiliate => int.MaxValue, // Ilimitado + _ => 0 + }; + } + + public static int GetMaxOGExtractionsPerDay(this PlanType planType) + { + return planType switch + { + PlanType.Trial => 0, // 2 extrações por dia no trial + PlanType.Basic => 0, // 5 extrações por dia + PlanType.Professional => 0, // 15 extrações por dia + PlanType.Premium => 0, // Ilimitado + PlanType.PremiumAffiliate => int.MaxValue, // Ilimitado + _ => 0 + }; + } + + public static bool AllowsProductLinks(this PlanType planType) + { + return GetMaxProductLinks(planType) > 0; + } } \ No newline at end of file diff --git a/src/BCards.Web/Models/User.cs b/src/BCards.Web/Models/User.cs index a7910cf..3b13deb 100644 --- a/src/BCards.Web/Models/User.cs +++ b/src/BCards.Web/Models/User.cs @@ -1,44 +1,44 @@ -using MongoDB.Bson; -using MongoDB.Bson.Serialization.Attributes; - -namespace BCards.Web.Models; - -public class User -{ - [BsonId] - [BsonRepresentation(BsonType.ObjectId)] - public string Id { get; set; } = string.Empty; - - [BsonElement("email")] - public string Email { get; set; } = string.Empty; - - [BsonElement("name")] - public string Name { get; set; } = string.Empty; - - [BsonElement("profileImage")] - public string ProfileImage { get; set; } = string.Empty; - - [BsonElement("authProvider")] - public string AuthProvider { get; set; } = string.Empty; - - [BsonElement("stripeCustomerId")] - public string StripeCustomerId { get; set; } = string.Empty; - - [BsonElement("subscriptionStatus")] - public string SubscriptionStatus { get; set; } = "trial"; - - [BsonElement("currentPlan")] - public string CurrentPlan { get; set; } = "trial"; - - [BsonElement("createdAt")] - public DateTime CreatedAt { get; set; } = DateTime.UtcNow; - - [BsonElement("updatedAt")] - public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; - - [BsonElement("isActive")] - public bool IsActive { get; set; } = true; - - [BsonElement("notifiedOfExpiration")] - public bool NotifiedOfExpiration { get; set; } = false; +using MongoDB.Bson; +using MongoDB.Bson.Serialization.Attributes; + +namespace BCards.Web.Models; + +public class User +{ + [BsonId] + [BsonRepresentation(BsonType.ObjectId)] + public string Id { get; set; } = string.Empty; + + [BsonElement("email")] + public string Email { get; set; } = string.Empty; + + [BsonElement("name")] + public string Name { get; set; } = string.Empty; + + [BsonElement("profileImage")] + public string ProfileImage { get; set; } = string.Empty; + + [BsonElement("authProvider")] + public string AuthProvider { get; set; } = string.Empty; + + [BsonElement("stripeCustomerId")] + public string StripeCustomerId { get; set; } = string.Empty; + + [BsonElement("subscriptionStatus")] + public string SubscriptionStatus { get; set; } = "trial"; + + [BsonElement("currentPlan")] + public string CurrentPlan { get; set; } = "trial"; + + [BsonElement("createdAt")] + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + + [BsonElement("updatedAt")] + public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; + + [BsonElement("isActive")] + public bool IsActive { get; set; } = true; + + [BsonElement("notifiedOfExpiration")] + public bool NotifiedOfExpiration { get; set; } = false; } \ No newline at end of file diff --git a/src/BCards.Web/Models/UserPage.cs b/src/BCards.Web/Models/UserPage.cs index 5ef168c..68bfe46 100644 --- a/src/BCards.Web/Models/UserPage.cs +++ b/src/BCards.Web/Models/UserPage.cs @@ -1,114 +1,114 @@ -using MongoDB.Bson; -using MongoDB.Bson.Serialization.Attributes; -using BCards.Web.ViewModels; - -namespace BCards.Web.Models; - -public class UserPage : IPageDisplay -{ - [BsonId] - [BsonRepresentation(BsonType.ObjectId)] - public string Id { get; set; } = string.Empty; - - [BsonElement("userId")] - [BsonRepresentation(BsonType.ObjectId)] - public string UserId { get; set; } = string.Empty; - - [BsonElement("slug")] - public string Slug { get; set; } = string.Empty; - - [BsonElement("category")] - public string Category { get; set; } = string.Empty; - - [BsonElement("businessType")] - public string BusinessType { get; set; } = "individual"; // individual, company - - [BsonElement("displayName")] - public string DisplayName { get; set; } = string.Empty; - - [BsonElement("bio")] - public string Bio { get; set; } = string.Empty; - - [BsonElement("profileImageId")] - public string? ProfileImageId { get; set; } - - // Campo antigo - ignorar durante deserialização para compatibilidade - [BsonElement("profileImage")] - [BsonIgnoreIfDefault] - [BsonIgnore] - public string? ProfileImage { get; set; } - - [BsonElement("theme")] - public PageTheme Theme { get; set; } = new(); - - [BsonElement("links")] - public List Links { get; set; } = new(); - - [BsonElement("seoSettings")] - public SeoSettings SeoSettings { get; set; } = new(); - - [BsonElement("language")] - public string Language { get; set; } = "pt-BR"; - - [BsonElement("analytics")] - public PageAnalytics Analytics { get; set; } = new(); - - [BsonElement("isActive")] - public bool IsActive { get; set; } = true; - - [BsonElement("planLimitations")] - public PlanLimitations PlanLimitations { get; set; } = new(); - - [BsonElement("createdAt")] - public DateTime CreatedAt { get; set; } = DateTime.UtcNow; - - [BsonElement("updatedAt")] - public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; - - [BsonElement("publishedAt")] - public DateTime? PublishedAt { get; set; } - - [BsonElement("status")] - public PageStatus Status { get; set; } = PageStatus.Active; - - [BsonElement("previewToken")] - public string? PreviewToken { get; set; } - - [BsonElement("previewTokenExpiry")] - public DateTime? PreviewTokenExpiry { get; set; } - - [BsonElement("moderationAttempts")] - public int ModerationAttempts { get; set; } = 0; - - [BsonElement("moderationHistory")] - public List ModerationHistory { get; set; } = new(); - - [BsonElement("approvedAt")] - public DateTime? ApprovedAt { get; set; } - - [BsonElement("userScore")] - public int UserScore { get; set; } = 100; - - [BsonElement("previewViewCount")] - public int PreviewViewCount { get; set; } = 0; - - // Exclusão lógica - [BsonElement("deletedAt")] - public DateTime? DeletedAt { get; set; } - - [BsonElement("deletionReason")] - public string? DeletionReason { get; set; } // "trial_expired", "user_requested", "moderation_violation" - - [BsonIgnore] - public bool IsDeleted => DeletedAt.HasValue; - - public string FullUrl => $"page/{Category}/{Slug}"; - - /// - /// URL da imagem de perfil ou imagem padrão se não houver upload - /// - [BsonIgnore] - public string ProfileImageUrl => !string.IsNullOrEmpty(ProfileImageId) - ? $"/api/image/{ProfileImageId}" - : "/images/default-avatar.svg"; +using MongoDB.Bson; +using MongoDB.Bson.Serialization.Attributes; +using BCards.Web.ViewModels; + +namespace BCards.Web.Models; + +public class UserPage : IPageDisplay +{ + [BsonId] + [BsonRepresentation(BsonType.ObjectId)] + public string Id { get; set; } = string.Empty; + + [BsonElement("userId")] + [BsonRepresentation(BsonType.ObjectId)] + public string UserId { get; set; } = string.Empty; + + [BsonElement("slug")] + public string Slug { get; set; } = string.Empty; + + [BsonElement("category")] + public string Category { get; set; } = string.Empty; + + [BsonElement("businessType")] + public string BusinessType { get; set; } = "individual"; // individual, company + + [BsonElement("displayName")] + public string DisplayName { get; set; } = string.Empty; + + [BsonElement("bio")] + public string Bio { get; set; } = string.Empty; + + [BsonElement("profileImageId")] + public string? ProfileImageId { get; set; } + + // Campo antigo - ignorar durante deserialização para compatibilidade + [BsonElement("profileImage")] + [BsonIgnoreIfDefault] + [BsonIgnore] + public string? ProfileImage { get; set; } + + [BsonElement("theme")] + public PageTheme Theme { get; set; } = new(); + + [BsonElement("links")] + public List Links { get; set; } = new(); + + [BsonElement("seoSettings")] + public SeoSettings SeoSettings { get; set; } = new(); + + [BsonElement("language")] + public string Language { get; set; } = "pt-BR"; + + [BsonElement("analytics")] + public PageAnalytics Analytics { get; set; } = new(); + + [BsonElement("isActive")] + public bool IsActive { get; set; } = true; + + [BsonElement("planLimitations")] + public PlanLimitations PlanLimitations { get; set; } = new(); + + [BsonElement("createdAt")] + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + + [BsonElement("updatedAt")] + public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; + + [BsonElement("publishedAt")] + public DateTime? PublishedAt { get; set; } + + [BsonElement("status")] + public PageStatus Status { get; set; } = PageStatus.Active; + + [BsonElement("previewToken")] + public string? PreviewToken { get; set; } + + [BsonElement("previewTokenExpiry")] + public DateTime? PreviewTokenExpiry { get; set; } + + [BsonElement("moderationAttempts")] + public int ModerationAttempts { get; set; } = 0; + + [BsonElement("moderationHistory")] + public List ModerationHistory { get; set; } = new(); + + [BsonElement("approvedAt")] + public DateTime? ApprovedAt { get; set; } + + [BsonElement("userScore")] + public int UserScore { get; set; } = 100; + + [BsonElement("previewViewCount")] + public int PreviewViewCount { get; set; } = 0; + + // Exclusão lógica + [BsonElement("deletedAt")] + public DateTime? DeletedAt { get; set; } + + [BsonElement("deletionReason")] + public string? DeletionReason { get; set; } // "trial_expired", "user_requested", "moderation_violation" + + [BsonIgnore] + public bool IsDeleted => DeletedAt.HasValue; + + public string FullUrl => $"page/{Category}/{Slug}"; + + /// + /// URL da imagem de perfil ou imagem padrão se não houver upload + /// + [BsonIgnore] + public string ProfileImageUrl => !string.IsNullOrEmpty(ProfileImageId) + ? $"/api/image/{ProfileImageId}" + : "/images/default-avatar.svg"; } \ No newline at end of file diff --git a/src/BCards.Web/Program.cs b/src/BCards.Web/Program.cs index 321ed90..43dd3ce 100644 --- a/src/BCards.Web/Program.cs +++ b/src/BCards.Web/Program.cs @@ -277,11 +277,11 @@ authBuilder.AddCookie(options => { options.LoginPath = "/Auth/Login"; options.LogoutPath = "/Auth/Logout"; - options.ExpireTimeSpan = TimeSpan.FromHours(8); + options.ExpireTimeSpan = TimeSpan.FromDays(7); // 7 dias em vez de 8 horas options.SlidingExpiration = true; options.Cookie.HttpOnly = true; options.Cookie.IsEssential = true; - options.Cookie.SameSite = SameSiteMode.Lax; + options.Cookie.SameSite = SameSiteMode.None; // Para Cloudflare options.Cookie.SecurePolicy = CookieSecurePolicy.Always; }); diff --git a/src/BCards.Web/Properties/launchSettings.json b/src/BCards.Web/Properties/launchSettings.json index 81929fb..d8bc651 100644 --- a/src/BCards.Web/Properties/launchSettings.json +++ b/src/BCards.Web/Properties/launchSettings.json @@ -1,20 +1,20 @@ -{ - "profiles": { - "BCards.Web": { - "commandName": "Project", - "launchBrowser": true, - "environmentVariables": { - "ASPNETCORE_ENVIRONMENT": "Development" - }, - "applicationUrl": "https://localhost:49178;http://localhost:49179" - }, - "BCards.Web.Testing": { - "commandName": "Project", - "launchBrowser": true, - "environmentVariables": { - "ASPNETCORE_ENVIRONMENT": "Testing" - }, - "applicationUrl": "https://localhost:49178;http://localhost:49179" - } - } +{ + "profiles": { + "BCards.Web": { + "commandName": "Project", + "launchBrowser": true, + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + }, + "applicationUrl": "https://localhost:49178;http://localhost:49179" + }, + "BCards.Web.Testing": { + "commandName": "Project", + "launchBrowser": true, + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Testing" + }, + "applicationUrl": "https://localhost:49178;http://localhost:49179" + } + } } \ No newline at end of file diff --git a/src/BCards.Web/Repositories/ILivePageRepository.cs b/src/BCards.Web/Repositories/ILivePageRepository.cs index 28660f3..474a4df 100644 --- a/src/BCards.Web/Repositories/ILivePageRepository.cs +++ b/src/BCards.Web/Repositories/ILivePageRepository.cs @@ -1,18 +1,18 @@ -using BCards.Web.Models; - -namespace BCards.Web.Repositories; - -public interface ILivePageRepository -{ - Task GetByCategoryAndSlugAsync(string category, string slug); - Task GetByOriginalPageIdAsync(string originalPageId); - Task GetByIdAsync(string pageId); - Task> GetAllActiveAsync(); - Task CreateAsync(LivePage livePage); - Task UpdateAsync(LivePage livePage); - Task DeleteAsync(string id); - Task DeleteByOriginalPageIdAsync(string originalPageId); - Task ExistsByCategoryAndSlugAsync(string category, string slug, string? excludeId = null); - Task IncrementViewAsync(string id); - Task IncrementLinkClickAsync(string id, int linkIndex); +using BCards.Web.Models; + +namespace BCards.Web.Repositories; + +public interface ILivePageRepository +{ + Task GetByCategoryAndSlugAsync(string category, string slug); + Task GetByOriginalPageIdAsync(string originalPageId); + Task GetByIdAsync(string pageId); + Task> GetAllActiveAsync(); + Task CreateAsync(LivePage livePage); + Task UpdateAsync(LivePage livePage); + Task DeleteAsync(string id); + Task DeleteByOriginalPageIdAsync(string originalPageId); + Task ExistsByCategoryAndSlugAsync(string category, string slug, string? excludeId = null); + Task IncrementViewAsync(string id); + Task IncrementLinkClickAsync(string id, int linkIndex); } \ No newline at end of file diff --git a/src/BCards.Web/Repositories/IUserPageRepository.cs b/src/BCards.Web/Repositories/IUserPageRepository.cs index d10d578..28f6277 100644 --- a/src/BCards.Web/Repositories/IUserPageRepository.cs +++ b/src/BCards.Web/Repositories/IUserPageRepository.cs @@ -1,31 +1,31 @@ -using BCards.Web.Models; -using MongoDB.Driver; - -namespace BCards.Web.Repositories; - -public interface IUserPageRepository -{ - Task GetByIdAsync(string id); - Task GetBySlugAsync(string category, string slug); - Task GetByUserIdAsync(string userId); - Task> GetByUserIdAllAsync(string userId); - Task> GetActivePagesAsync(); - Task CreateAsync(UserPage userPage); - Task UpdateAsync(UserPage userPage); - Task DeleteAsync(string id); - Task SlugExistsAsync(string category, string slug, string? excludeId = null); - Task> GetRecentPagesAsync(int limit = 10); - Task> GetByCategoryAsync(string category, int limit = 20); - Task UpdateAnalyticsAsync(string id, PageAnalytics analytics); - Task> GetManyAsync( - FilterDefinition filter, - SortDefinition? sort = null, - int skip = 0, - int take = 20); - Task> GetPendingModerationAsync(int skip = 0, int take = 20); - Task CountAsync(FilterDefinition filter); - Task UpdateAsync(string id, UpdateDefinition update); - Task UpdateManyAsync(FilterDefinition filter, UpdateDefinition update); - Task ApprovePageAsync(string pageId); - Task RejectPageAsync(string pageId, string reason, List issues); +using BCards.Web.Models; +using MongoDB.Driver; + +namespace BCards.Web.Repositories; + +public interface IUserPageRepository +{ + Task GetByIdAsync(string id); + Task GetBySlugAsync(string category, string slug); + Task GetByUserIdAsync(string userId); + Task> GetByUserIdAllAsync(string userId); + Task> GetActivePagesAsync(); + Task CreateAsync(UserPage userPage); + Task UpdateAsync(UserPage userPage); + Task DeleteAsync(string id); + Task SlugExistsAsync(string category, string slug, string? excludeId = null); + Task> GetRecentPagesAsync(int limit = 10); + Task> GetByCategoryAsync(string category, int limit = 20); + Task UpdateAnalyticsAsync(string id, PageAnalytics analytics); + Task> GetManyAsync( + FilterDefinition filter, + SortDefinition? sort = null, + int skip = 0, + int take = 20); + Task> GetPendingModerationAsync(int skip = 0, int take = 20); + Task CountAsync(FilterDefinition filter); + Task UpdateAsync(string id, UpdateDefinition update); + Task UpdateManyAsync(FilterDefinition filter, UpdateDefinition update); + Task ApprovePageAsync(string pageId); + Task RejectPageAsync(string pageId, string reason, List issues); } \ No newline at end of file diff --git a/src/BCards.Web/Repositories/LivePageRepository.cs b/src/BCards.Web/Repositories/LivePageRepository.cs index 0e6cea8..0410c51 100644 --- a/src/BCards.Web/Repositories/LivePageRepository.cs +++ b/src/BCards.Web/Repositories/LivePageRepository.cs @@ -1,127 +1,127 @@ -using BCards.Web.Models; -using MongoDB.Driver; - -namespace BCards.Web.Repositories; - -public class LivePageRepository : ILivePageRepository -{ - private readonly IMongoCollection _collection; - - public LivePageRepository(IMongoDatabase database) - { - _collection = database.GetCollection("livepages"); - - // Criar índices essenciais - CreateIndexes(); - } - - private void CreateIndexes() - { - try - { - // Índice único para category + slug - var categorySlugIndex = Builders.IndexKeys - .Ascending(x => x.Category) - .Ascending(x => x.Slug); - - var uniqueOptions = new CreateIndexOptions { Unique = true }; - _collection.Indexes.CreateOneAsync(new CreateIndexModel(categorySlugIndex, uniqueOptions)); - - // Outros índices importantes - _collection.Indexes.CreateOneAsync(new CreateIndexModel( - Builders.IndexKeys.Ascending(x => x.UserId))); - - _collection.Indexes.CreateOneAsync(new CreateIndexModel( - Builders.IndexKeys.Descending(x => x.PublishedAt))); - - _collection.Indexes.CreateOneAsync(new CreateIndexModel( - Builders.IndexKeys.Ascending(x => x.OriginalPageId))); - } - catch - { - // Ignora erros de criação de índices (já podem existir) - } - } - - public async Task GetByIdAsync(string pageId) - { - return await _collection.Find(x => x.Id == pageId).FirstOrDefaultAsync(); - } - - public async Task GetByCategoryAndSlugAsync(string category, string slug) - { - return await _collection.Find(x => x.Category == category && x.Slug == slug).FirstOrDefaultAsync(); - } - - public async Task GetByOriginalPageIdAsync(string originalPageId) - { - return await _collection.Find(x => x.OriginalPageId == originalPageId).FirstOrDefaultAsync(); - } - - public async Task> GetAllActiveAsync() - { - return await _collection.Find(x => true) - .Sort(Builders.Sort.Descending(x => x.PublishedAt)) - .ToListAsync(); - } - - public async Task CreateAsync(LivePage livePage) - { - livePage.CreatedAt = DateTime.UtcNow; - livePage.LastSyncAt = DateTime.UtcNow; - await _collection.InsertOneAsync(livePage); - return livePage; - } - - public async Task UpdateAsync(LivePage livePage) - { - livePage.LastSyncAt = DateTime.UtcNow; - await _collection.ReplaceOneAsync(x => x.Id == livePage.Id, livePage); - return livePage; - } - - public async Task DeleteAsync(string id) - { - var result = await _collection.DeleteOneAsync(x => x.Id == id); - return result.DeletedCount > 0; - } - - public async Task DeleteByOriginalPageIdAsync(string originalPageId) - { - var result = await _collection.DeleteOneAsync(x => x.OriginalPageId == originalPageId); - return result.DeletedCount > 0; - } - - public async Task ExistsByCategoryAndSlugAsync(string category, string slug, string? excludeId = null) - { - var filter = Builders.Filter.And( - Builders.Filter.Eq(x => x.Category, category), - Builders.Filter.Eq(x => x.Slug, slug) - ); - - if (!string.IsNullOrEmpty(excludeId)) - { - filter = Builders.Filter.And(filter, - Builders.Filter.Ne(x => x.Id, excludeId)); - } - - return await _collection.Find(filter).AnyAsync(); - } - - public async Task IncrementViewAsync(string id) - { - var update = Builders.Update - .Inc(x => x.Analytics.TotalViews, 1) - .Set(x => x.Analytics.LastViewedAt, DateTime.UtcNow); - - await _collection.UpdateOneAsync(x => x.Id == id, update); - } - - public async Task IncrementLinkClickAsync(string id, int linkIndex) - { - var update = Builders.Update - .Inc(x => x.Analytics.TotalClicks, 1); - - await _collection.UpdateOneAsync(x => x.Id == id, update); - } +using BCards.Web.Models; +using MongoDB.Driver; + +namespace BCards.Web.Repositories; + +public class LivePageRepository : ILivePageRepository +{ + private readonly IMongoCollection _collection; + + public LivePageRepository(IMongoDatabase database) + { + _collection = database.GetCollection("livepages"); + + // Criar índices essenciais + CreateIndexes(); + } + + private void CreateIndexes() + { + try + { + // Índice único para category + slug + var categorySlugIndex = Builders.IndexKeys + .Ascending(x => x.Category) + .Ascending(x => x.Slug); + + var uniqueOptions = new CreateIndexOptions { Unique = true }; + _collection.Indexes.CreateOneAsync(new CreateIndexModel(categorySlugIndex, uniqueOptions)); + + // Outros índices importantes + _collection.Indexes.CreateOneAsync(new CreateIndexModel( + Builders.IndexKeys.Ascending(x => x.UserId))); + + _collection.Indexes.CreateOneAsync(new CreateIndexModel( + Builders.IndexKeys.Descending(x => x.PublishedAt))); + + _collection.Indexes.CreateOneAsync(new CreateIndexModel( + Builders.IndexKeys.Ascending(x => x.OriginalPageId))); + } + catch + { + // Ignora erros de criação de índices (já podem existir) + } + } + + public async Task GetByIdAsync(string pageId) + { + return await _collection.Find(x => x.Id == pageId).FirstOrDefaultAsync(); + } + + public async Task GetByCategoryAndSlugAsync(string category, string slug) + { + return await _collection.Find(x => x.Category == category && x.Slug == slug).FirstOrDefaultAsync(); + } + + public async Task GetByOriginalPageIdAsync(string originalPageId) + { + return await _collection.Find(x => x.OriginalPageId == originalPageId).FirstOrDefaultAsync(); + } + + public async Task> GetAllActiveAsync() + { + return await _collection.Find(x => true) + .Sort(Builders.Sort.Descending(x => x.PublishedAt)) + .ToListAsync(); + } + + public async Task CreateAsync(LivePage livePage) + { + livePage.CreatedAt = DateTime.UtcNow; + livePage.LastSyncAt = DateTime.UtcNow; + await _collection.InsertOneAsync(livePage); + return livePage; + } + + public async Task UpdateAsync(LivePage livePage) + { + livePage.LastSyncAt = DateTime.UtcNow; + await _collection.ReplaceOneAsync(x => x.Id == livePage.Id, livePage); + return livePage; + } + + public async Task DeleteAsync(string id) + { + var result = await _collection.DeleteOneAsync(x => x.Id == id); + return result.DeletedCount > 0; + } + + public async Task DeleteByOriginalPageIdAsync(string originalPageId) + { + var result = await _collection.DeleteOneAsync(x => x.OriginalPageId == originalPageId); + return result.DeletedCount > 0; + } + + public async Task ExistsByCategoryAndSlugAsync(string category, string slug, string? excludeId = null) + { + var filter = Builders.Filter.And( + Builders.Filter.Eq(x => x.Category, category), + Builders.Filter.Eq(x => x.Slug, slug) + ); + + if (!string.IsNullOrEmpty(excludeId)) + { + filter = Builders.Filter.And(filter, + Builders.Filter.Ne(x => x.Id, excludeId)); + } + + return await _collection.Find(filter).AnyAsync(); + } + + public async Task IncrementViewAsync(string id) + { + var update = Builders.Update + .Inc(x => x.Analytics.TotalViews, 1) + .Set(x => x.Analytics.LastViewedAt, DateTime.UtcNow); + + await _collection.UpdateOneAsync(x => x.Id == id, update); + } + + public async Task IncrementLinkClickAsync(string id, int linkIndex) + { + var update = Builders.Update + .Inc(x => x.Analytics.TotalClicks, 1); + + await _collection.UpdateOneAsync(x => x.Id == id, update); + } } \ No newline at end of file diff --git a/src/BCards.Web/Repositories/UserPageRepository.cs b/src/BCards.Web/Repositories/UserPageRepository.cs index 7bf0e59..d50aadb 100644 --- a/src/BCards.Web/Repositories/UserPageRepository.cs +++ b/src/BCards.Web/Repositories/UserPageRepository.cs @@ -1,241 +1,241 @@ -using BCards.Web.Models; -using MongoDB.Driver; - -namespace BCards.Web.Repositories; - -public class UserPageRepository : IUserPageRepository -{ - private readonly IMongoCollection _pages; - - public UserPageRepository(IMongoDatabase database) - { - _pages = database.GetCollection("userpages"); - - // Create indexes - var slugIndex = Builders.IndexKeys - .Ascending(x => x.Category) - .Ascending(x => x.Slug); - var collation = new Collation("en", strength: CollationStrength.Primary); // Case-insensitive - _pages.Indexes.CreateOneAsync(new CreateIndexModel(slugIndex, new CreateIndexOptions { - Unique = true, - Collation = collation - })); - - var userIndex = Builders.IndexKeys.Ascending(x => x.UserId); - _pages.Indexes.CreateOneAsync(new CreateIndexModel(userIndex)); - - var categoryIndex = Builders.IndexKeys.Ascending(x => x.Category); - _pages.Indexes.CreateOneAsync(new CreateIndexModel(categoryIndex)); - } - - public async Task GetByIdAsync(string id) - { - // Forçar leitura do primary para consultas críticas (preview tokens) - return await _pages.WithReadPreference(ReadPreference.Primary) - .Find(x => x.Id == id && x.IsActive).FirstOrDefaultAsync(); - } - - public async Task GetBySlugAsync(string category, string slug) - { - // Usar filtro com collation case-insensitive para melhor performance - var filter = Builders.Filter.And( - Builders.Filter.Eq(x => x.Category, category), - Builders.Filter.Eq(x => x.Slug, slug), - Builders.Filter.Eq(x => x.IsActive, true) - ); - - var collation = new Collation("en", strength: CollationStrength.Primary); - var findOptions = new FindOptions - { - Collation = collation - }; - - // Forçar leitura do primary para consultas críticas (preview tokens) - var cursor = await _pages.WithReadPreference(ReadPreference.Primary) - .FindAsync(filter, findOptions); - return await cursor.FirstOrDefaultAsync(); - } - - public async Task GetByUserIdAsync(string userId) - { - return await _pages.Find(x => x.UserId == userId && x.IsActive).FirstOrDefaultAsync(); - } - - public async Task> GetByUserIdAllAsync(string userId) - { - return await _pages.Find(x => x.UserId == userId && x.IsActive).ToListAsync(); - } - - public async Task CreateAsync(UserPage userPage) - { - userPage.CreatedAt = DateTime.UtcNow; - userPage.UpdatedAt = DateTime.UtcNow; - await _pages.InsertOneAsync(userPage); - return userPage; - } - - public async Task UpdateAsync(UserPage userPage) - { - userPage.UpdatedAt = DateTime.UtcNow; - await _pages.ReplaceOneAsync(x => x.Id == userPage.Id, userPage); - return userPage; - } - - public async Task DeleteAsync(string id) - { - await _pages.UpdateOneAsync( - x => x.Id == id, - Builders.Update.Set(x => x.IsActive, false).Set(x => x.UpdatedAt, DateTime.UtcNow) - ); - } - - public async Task SlugExistsAsync(string category, string slug, string? excludeId = null) - { - var filter = Builders.Filter.And( - Builders.Filter.Eq(x => x.Category, category), - Builders.Filter.Eq(x => x.Slug, slug) - ); - - if (!string.IsNullOrEmpty(excludeId)) - { - filter = Builders.Filter.And(filter, - Builders.Filter.Ne(x => x.Id, excludeId)); - } - - return await _pages.Find(filter).AnyAsync(); - } - - public async Task> GetRecentPagesAsync(int limit = 10) - { - return await _pages.Find(x => x.IsActive && x.PublishedAt != null) - .SortByDescending(x => x.PublishedAt) - .Limit(limit) - .ToListAsync(); - } - - public async Task> GetByCategoryAsync(string category, int limit = 20) - { - return await _pages.Find(x => x.Category == category && x.IsActive && x.PublishedAt != null) - .SortByDescending(x => x.PublishedAt) - .Limit(limit) - .ToListAsync(); - } - - public async Task> GetActivePagesAsync() - { - return await _pages.Find(x => x.IsActive && x.Status == BCards.Web.ViewModels.PageStatus.Active) - .SortByDescending(x => x.UpdatedAt) - .ToListAsync(); - } - - public async Task UpdateAnalyticsAsync(string id, PageAnalytics analytics) - { - await _pages.UpdateOneAsync( - x => x.Id == id, - Builders.Update - .Set(x => x.Analytics, analytics) - .Set(x => x.UpdatedAt, DateTime.UtcNow) - ); - } - - // Adicione estes m�todos no UserPageRepository.cs - - public async Task> GetManyAsync( - FilterDefinition filter, - SortDefinition? sort = null, - int skip = 0, - int take = 20) - { - var query = _pages.Find(filter); - - if (sort != null) - query = query.Sort(sort); - - return await query.Skip(skip).Limit(take).ToListAsync(); - } - - public async Task CountAsync(FilterDefinition filter) - { - return await _pages.CountDocumentsAsync(filter); - } - - // M�todo espec�fico para modera��o (mais simples) - public async Task> GetPendingModerationAsync(int skip = 0, int take = 20) - { - var filter = Builders.Filter.Eq(x => x.Status, BCards.Web.ViewModels.PageStatus.PendingModeration); - - var sort = Builders.Sort - .Ascending("planLimitations.planType") // Premium primeiro - .Ascending(x => x.CreatedAt); // Mais antigos primeiro - - return await _pages.Find(filter) - .Sort(sort) - .Skip(skip) - .Limit(take) - .ToListAsync(); - } - - // Adicione estes m�todos no UserPageRepository.cs - - public async Task UpdateAsync(string id, UpdateDefinition update) - { - var combinedUpdate = Builders.Update - .Combine(update, Builders.Update.Set(x => x.UpdatedAt, DateTime.UtcNow)); - - return await _pages.UpdateOneAsync(x => x.Id == id, combinedUpdate); - } - - public async Task UpdateManyAsync(FilterDefinition filter, UpdateDefinition update) - { - var combinedUpdate = Builders.Update - .Combine(update, Builders.Update.Set(x => x.UpdatedAt, DateTime.UtcNow)); - - return await _pages.UpdateManyAsync(filter, combinedUpdate); - } - - // M�todos espec�ficos para modera��o (mais f�ceis de usar) - public async Task ApprovePageAsync(string pageId) - { - var update = Builders.Update - .Set(x => x.Status, BCards.Web.ViewModels.PageStatus.Active) - .Set(x => x.ApprovedAt, DateTime.UtcNow) - .Set(x => x.PublishedAt, DateTime.UtcNow) - .Unset(x => x.PreviewToken) - .Unset(x => x.PreviewTokenExpiry) - .Set(x => x.UpdatedAt, DateTime.UtcNow); - - var result = await _pages.UpdateOneAsync(x => x.Id == pageId, update); - return result.ModifiedCount > 0; - } - - public async Task RejectPageAsync(string pageId, string reason, List issues) - { - var page = await GetByIdAsync(pageId); - if (page == null) return false; - - // Adicionar � hist�ria de modera��o - var historyEntry = new ModerationHistory - { - Attempt = page.ModerationAttempts + 1, - Status = "rejected", - Reason = reason, - Date = DateTime.UtcNow, - Issues = issues - }; - - page.ModerationHistory.Add(historyEntry); - - var update = Builders.Update - .Set(x => x.Status, BCards.Web.ViewModels.PageStatus.Rejected) - .Set(x => x.ModerationAttempts, page.ModerationAttempts + 1) - .Set(x => x.ModerationHistory, page.ModerationHistory) - .Unset(x => x.PreviewToken) - .Unset(x => x.PreviewTokenExpiry) - .Set(x => x.UpdatedAt, DateTime.UtcNow); - - var result = await _pages.UpdateOneAsync(x => x.Id == pageId, update); - return result.ModifiedCount > 0; - } -} - - +using BCards.Web.Models; +using MongoDB.Driver; + +namespace BCards.Web.Repositories; + +public class UserPageRepository : IUserPageRepository +{ + private readonly IMongoCollection _pages; + + public UserPageRepository(IMongoDatabase database) + { + _pages = database.GetCollection("userpages"); + + // Create indexes + var slugIndex = Builders.IndexKeys + .Ascending(x => x.Category) + .Ascending(x => x.Slug); + var collation = new Collation("en", strength: CollationStrength.Primary); // Case-insensitive + _pages.Indexes.CreateOneAsync(new CreateIndexModel(slugIndex, new CreateIndexOptions { + Unique = true, + Collation = collation + })); + + var userIndex = Builders.IndexKeys.Ascending(x => x.UserId); + _pages.Indexes.CreateOneAsync(new CreateIndexModel(userIndex)); + + var categoryIndex = Builders.IndexKeys.Ascending(x => x.Category); + _pages.Indexes.CreateOneAsync(new CreateIndexModel(categoryIndex)); + } + + public async Task GetByIdAsync(string id) + { + // Forçar leitura do primary para consultas críticas (preview tokens) + return await _pages.WithReadPreference(ReadPreference.Primary) + .Find(x => x.Id == id && x.IsActive).FirstOrDefaultAsync(); + } + + public async Task GetBySlugAsync(string category, string slug) + { + // Usar filtro com collation case-insensitive para melhor performance + var filter = Builders.Filter.And( + Builders.Filter.Eq(x => x.Category, category), + Builders.Filter.Eq(x => x.Slug, slug), + Builders.Filter.Eq(x => x.IsActive, true) + ); + + var collation = new Collation("en", strength: CollationStrength.Primary); + var findOptions = new FindOptions + { + Collation = collation + }; + + // Forçar leitura do primary para consultas críticas (preview tokens) + var cursor = await _pages.WithReadPreference(ReadPreference.Primary) + .FindAsync(filter, findOptions); + return await cursor.FirstOrDefaultAsync(); + } + + public async Task GetByUserIdAsync(string userId) + { + return await _pages.Find(x => x.UserId == userId && x.IsActive).FirstOrDefaultAsync(); + } + + public async Task> GetByUserIdAllAsync(string userId) + { + return await _pages.Find(x => x.UserId == userId && x.IsActive).ToListAsync(); + } + + public async Task CreateAsync(UserPage userPage) + { + userPage.CreatedAt = DateTime.UtcNow; + userPage.UpdatedAt = DateTime.UtcNow; + await _pages.InsertOneAsync(userPage); + return userPage; + } + + public async Task UpdateAsync(UserPage userPage) + { + userPage.UpdatedAt = DateTime.UtcNow; + await _pages.ReplaceOneAsync(x => x.Id == userPage.Id, userPage); + return userPage; + } + + public async Task DeleteAsync(string id) + { + await _pages.UpdateOneAsync( + x => x.Id == id, + Builders.Update.Set(x => x.IsActive, false).Set(x => x.UpdatedAt, DateTime.UtcNow) + ); + } + + public async Task SlugExistsAsync(string category, string slug, string? excludeId = null) + { + var filter = Builders.Filter.And( + Builders.Filter.Eq(x => x.Category, category), + Builders.Filter.Eq(x => x.Slug, slug) + ); + + if (!string.IsNullOrEmpty(excludeId)) + { + filter = Builders.Filter.And(filter, + Builders.Filter.Ne(x => x.Id, excludeId)); + } + + return await _pages.Find(filter).AnyAsync(); + } + + public async Task> GetRecentPagesAsync(int limit = 10) + { + return await _pages.Find(x => x.IsActive && x.PublishedAt != null) + .SortByDescending(x => x.PublishedAt) + .Limit(limit) + .ToListAsync(); + } + + public async Task> GetByCategoryAsync(string category, int limit = 20) + { + return await _pages.Find(x => x.Category == category && x.IsActive && x.PublishedAt != null) + .SortByDescending(x => x.PublishedAt) + .Limit(limit) + .ToListAsync(); + } + + public async Task> GetActivePagesAsync() + { + return await _pages.Find(x => x.IsActive && x.Status == BCards.Web.ViewModels.PageStatus.Active) + .SortByDescending(x => x.UpdatedAt) + .ToListAsync(); + } + + public async Task UpdateAnalyticsAsync(string id, PageAnalytics analytics) + { + await _pages.UpdateOneAsync( + x => x.Id == id, + Builders.Update + .Set(x => x.Analytics, analytics) + .Set(x => x.UpdatedAt, DateTime.UtcNow) + ); + } + + // Adicione estes m�todos no UserPageRepository.cs + + public async Task> GetManyAsync( + FilterDefinition filter, + SortDefinition? sort = null, + int skip = 0, + int take = 20) + { + var query = _pages.Find(filter); + + if (sort != null) + query = query.Sort(sort); + + return await query.Skip(skip).Limit(take).ToListAsync(); + } + + public async Task CountAsync(FilterDefinition filter) + { + return await _pages.CountDocumentsAsync(filter); + } + + // M�todo espec�fico para modera��o (mais simples) + public async Task> GetPendingModerationAsync(int skip = 0, int take = 20) + { + var filter = Builders.Filter.Eq(x => x.Status, BCards.Web.ViewModels.PageStatus.PendingModeration); + + var sort = Builders.Sort + .Ascending("planLimitations.planType") // Premium primeiro + .Ascending(x => x.CreatedAt); // Mais antigos primeiro + + return await _pages.Find(filter) + .Sort(sort) + .Skip(skip) + .Limit(take) + .ToListAsync(); + } + + // Adicione estes m�todos no UserPageRepository.cs + + public async Task UpdateAsync(string id, UpdateDefinition update) + { + var combinedUpdate = Builders.Update + .Combine(update, Builders.Update.Set(x => x.UpdatedAt, DateTime.UtcNow)); + + return await _pages.UpdateOneAsync(x => x.Id == id, combinedUpdate); + } + + public async Task UpdateManyAsync(FilterDefinition filter, UpdateDefinition update) + { + var combinedUpdate = Builders.Update + .Combine(update, Builders.Update.Set(x => x.UpdatedAt, DateTime.UtcNow)); + + return await _pages.UpdateManyAsync(filter, combinedUpdate); + } + + // M�todos espec�ficos para modera��o (mais f�ceis de usar) + public async Task ApprovePageAsync(string pageId) + { + var update = Builders.Update + .Set(x => x.Status, BCards.Web.ViewModels.PageStatus.Active) + .Set(x => x.ApprovedAt, DateTime.UtcNow) + .Set(x => x.PublishedAt, DateTime.UtcNow) + .Unset(x => x.PreviewToken) + .Unset(x => x.PreviewTokenExpiry) + .Set(x => x.UpdatedAt, DateTime.UtcNow); + + var result = await _pages.UpdateOneAsync(x => x.Id == pageId, update); + return result.ModifiedCount > 0; + } + + public async Task RejectPageAsync(string pageId, string reason, List issues) + { + var page = await GetByIdAsync(pageId); + if (page == null) return false; + + // Adicionar � hist�ria de modera��o + var historyEntry = new ModerationHistory + { + Attempt = page.ModerationAttempts + 1, + Status = "rejected", + Reason = reason, + Date = DateTime.UtcNow, + Issues = issues + }; + + page.ModerationHistory.Add(historyEntry); + + var update = Builders.Update + .Set(x => x.Status, BCards.Web.ViewModels.PageStatus.Rejected) + .Set(x => x.ModerationAttempts, page.ModerationAttempts + 1) + .Set(x => x.ModerationHistory, page.ModerationHistory) + .Unset(x => x.PreviewToken) + .Unset(x => x.PreviewTokenExpiry) + .Set(x => x.UpdatedAt, DateTime.UtcNow); + + var result = await _pages.UpdateOneAsync(x => x.Id == pageId, update); + return result.ModifiedCount > 0; + } +} + + diff --git a/src/BCards.Web/Services/CategoryService.cs b/src/BCards.Web/Services/CategoryService.cs index 1ab72c0..d3207e7 100644 --- a/src/BCards.Web/Services/CategoryService.cs +++ b/src/BCards.Web/Services/CategoryService.cs @@ -1,192 +1,192 @@ -using BCards.Web.Models; -using BCards.Web.Repositories; -using System.Text.RegularExpressions; -using System.Globalization; -using System.Text; -using BCards.Web.Utils; - -namespace BCards.Web.Services; - -public class CategoryService : ICategoryService -{ - private readonly ICategoryRepository _categoryRepository; - - public CategoryService(ICategoryRepository categoryRepository) - { - _categoryRepository = categoryRepository; - } - - public async Task> GetAllCategoriesAsync() - { - return await _categoryRepository.GetAllActiveAsync(); - } - - public async Task GetCategoryBySlugAsync(string slug) - { - return await _categoryRepository.GetBySlugAsync(SlugHelper.CreateSlug(slug)); - } - - public async Task GenerateSlugAsync(string name) - { - var slug = GenerateSlug(name); - var originalSlug = slug; - var counter = 1; - - while (await _categoryRepository.SlugExistsAsync(slug)) - { - slug = $"{originalSlug}-{counter}"; - counter++; - } - - return slug; - } - - public async Task ValidateSlugAsync(string slug, string? excludeId = null) - { - if (string.IsNullOrWhiteSpace(slug)) - return false; - - if (!IsValidSlugFormat(slug)) - return false; - - return !await _categoryRepository.SlugExistsAsync(slug, excludeId); - } - - public async Task InitializeDefaultCategoriesAsync() - { - var categories = await _categoryRepository.GetAllActiveAsync(); - if (categories.Any()) return; - - var defaultCategories = new[] - { - new Category - { - Name = "Corretor de Imóveis", - Slug = "corretor", - Icon = "🏠", - Description = "Profissionais especializados em compra, venda e locação de imóveis", - SeoKeywords = new List { "corretor", "imóveis", "casa", "apartamento", "venda", "locação" } - }, - new Category - { - Name = "Tecnologia", - Slug = "tecnologia", - Icon = "💻", - Description = "Empresas e profissionais de tecnologia, desenvolvimento e TI", - SeoKeywords = new List { "desenvolvimento", "software", "programação", "tecnologia", "TI" } - }, - new Category - { - Name = "Saúde", - Slug = "saude", - Icon = "🏥", - Description = "Profissionais da saúde, clínicas e consultórios médicos", - SeoKeywords = new List { "médico", "saúde", "clínica", "consulta", "tratamento" } - }, - new Category - { - Name = "Educação", - Slug = "educacao", - Icon = "📚", - Description = "Professores, escolas, cursos e instituições de ensino", - SeoKeywords = new List { "educação", "ensino", "professor", "curso", "escola" } - }, - new Category - { - Name = "Comércio", - Slug = "comercio", - Icon = "🛍️", - Description = "Lojas, e-commerce e estabelecimentos comerciais", - SeoKeywords = new List { "loja", "comércio", "venda", "produtos", "e-commerce" } - }, - new Category - { - Name = "Serviços", - Slug = "servicos", - Icon = "🔧", - Description = "Prestadores de serviços gerais e especializados", - SeoKeywords = new List { "serviços", "prestador", "profissional", "especializado" } - }, - new Category - { - Name = "Alimentação", - Slug = "alimentacao", - Icon = "🍽️", - Description = "Restaurantes, delivery, food trucks e estabelecimentos alimentícios", - SeoKeywords = new List { "restaurante", "comida", "delivery", "alimentação", "gastronomia" } - }, - new Category - { - Name = "Beleza", - Slug = "beleza", - Icon = "💄", - Description = "Salões de beleza, barbearias, estética e cuidados pessoais", - SeoKeywords = new List { "beleza", "salão", "estética", "cabeleireiro", "manicure" } - }, - new Category - { - Name = "Advocacia", - Slug = "advocacia", - Icon = "⚖️", - Description = "Advogados, escritórios jurídicos e consultoria legal", - SeoKeywords = new List { "advogado", "jurídico", "direito", "advocacia", "legal" } - }, - new Category - { - Name = "Arquitetura", - Slug = "arquitetura", - Icon = "🏗️", - Description = "Arquitetos, engenheiros e profissionais da construção", - SeoKeywords = new List { "arquiteto", "engenheiro", "construção", "projeto", "reforma" } - } - }; - - foreach (var category in defaultCategories) - { - await _categoryRepository.CreateAsync(category); - } - } - - private static string GenerateSlug(string text) - { - if (string.IsNullOrWhiteSpace(text)) - return string.Empty; - - // Remove acentos - text = RemoveDiacritics(text); - - // Converter para minúsculas - text = text.ToLowerInvariant(); - - // Substituir espaços e caracteres especiais por hífens - text = Regex.Replace(text, @"[^a-z0-9\s-]", ""); - text = Regex.Replace(text, @"[\s-]+", "-"); - - // Remover hífens do início e fim - text = text.Trim('-'); - - return text; - } - - private static string RemoveDiacritics(string text) - { - var normalizedString = text.Normalize(NormalizationForm.FormD); - var stringBuilder = new StringBuilder(); - - foreach (var c in normalizedString) - { - var unicodeCategory = CharUnicodeInfo.GetUnicodeCategory(c); - if (unicodeCategory != UnicodeCategory.NonSpacingMark) - { - stringBuilder.Append(c); - } - } - - return stringBuilder.ToString().Normalize(NormalizationForm.FormC); - } - - private static bool IsValidSlugFormat(string slug) - { - return Regex.IsMatch(slug, @"^[a-z0-9-]+$") && !slug.StartsWith('-') && !slug.EndsWith('-'); - } +using BCards.Web.Models; +using BCards.Web.Repositories; +using System.Text.RegularExpressions; +using System.Globalization; +using System.Text; +using BCards.Web.Utils; + +namespace BCards.Web.Services; + +public class CategoryService : ICategoryService +{ + private readonly ICategoryRepository _categoryRepository; + + public CategoryService(ICategoryRepository categoryRepository) + { + _categoryRepository = categoryRepository; + } + + public async Task> GetAllCategoriesAsync() + { + return await _categoryRepository.GetAllActiveAsync(); + } + + public async Task GetCategoryBySlugAsync(string slug) + { + return await _categoryRepository.GetBySlugAsync(SlugHelper.CreateSlug(slug)); + } + + public async Task GenerateSlugAsync(string name) + { + var slug = GenerateSlug(name); + var originalSlug = slug; + var counter = 1; + + while (await _categoryRepository.SlugExistsAsync(slug)) + { + slug = $"{originalSlug}-{counter}"; + counter++; + } + + return slug; + } + + public async Task ValidateSlugAsync(string slug, string? excludeId = null) + { + if (string.IsNullOrWhiteSpace(slug)) + return false; + + if (!IsValidSlugFormat(slug)) + return false; + + return !await _categoryRepository.SlugExistsAsync(slug, excludeId); + } + + public async Task InitializeDefaultCategoriesAsync() + { + var categories = await _categoryRepository.GetAllActiveAsync(); + if (categories.Any()) return; + + var defaultCategories = new[] + { + new Category + { + Name = "Corretor de Imóveis", + Slug = "corretor", + Icon = "🏠", + Description = "Profissionais especializados em compra, venda e locação de imóveis", + SeoKeywords = new List { "corretor", "imóveis", "casa", "apartamento", "venda", "locação" } + }, + new Category + { + Name = "Tecnologia", + Slug = "tecnologia", + Icon = "💻", + Description = "Empresas e profissionais de tecnologia, desenvolvimento e TI", + SeoKeywords = new List { "desenvolvimento", "software", "programação", "tecnologia", "TI" } + }, + new Category + { + Name = "Saúde", + Slug = "saude", + Icon = "🏥", + Description = "Profissionais da saúde, clínicas e consultórios médicos", + SeoKeywords = new List { "médico", "saúde", "clínica", "consulta", "tratamento" } + }, + new Category + { + Name = "Educação", + Slug = "educacao", + Icon = "📚", + Description = "Professores, escolas, cursos e instituições de ensino", + SeoKeywords = new List { "educação", "ensino", "professor", "curso", "escola" } + }, + new Category + { + Name = "Comércio", + Slug = "comercio", + Icon = "🛍️", + Description = "Lojas, e-commerce e estabelecimentos comerciais", + SeoKeywords = new List { "loja", "comércio", "venda", "produtos", "e-commerce" } + }, + new Category + { + Name = "Serviços", + Slug = "servicos", + Icon = "🔧", + Description = "Prestadores de serviços gerais e especializados", + SeoKeywords = new List { "serviços", "prestador", "profissional", "especializado" } + }, + new Category + { + Name = "Alimentação", + Slug = "alimentacao", + Icon = "🍽️", + Description = "Restaurantes, delivery, food trucks e estabelecimentos alimentícios", + SeoKeywords = new List { "restaurante", "comida", "delivery", "alimentação", "gastronomia" } + }, + new Category + { + Name = "Beleza", + Slug = "beleza", + Icon = "💄", + Description = "Salões de beleza, barbearias, estética e cuidados pessoais", + SeoKeywords = new List { "beleza", "salão", "estética", "cabeleireiro", "manicure" } + }, + new Category + { + Name = "Advocacia", + Slug = "advocacia", + Icon = "⚖️", + Description = "Advogados, escritórios jurídicos e consultoria legal", + SeoKeywords = new List { "advogado", "jurídico", "direito", "advocacia", "legal" } + }, + new Category + { + Name = "Arquitetura", + Slug = "arquitetura", + Icon = "🏗️", + Description = "Arquitetos, engenheiros e profissionais da construção", + SeoKeywords = new List { "arquiteto", "engenheiro", "construção", "projeto", "reforma" } + } + }; + + foreach (var category in defaultCategories) + { + await _categoryRepository.CreateAsync(category); + } + } + + private static string GenerateSlug(string text) + { + if (string.IsNullOrWhiteSpace(text)) + return string.Empty; + + // Remove acentos + text = RemoveDiacritics(text); + + // Converter para minúsculas + text = text.ToLowerInvariant(); + + // Substituir espaços e caracteres especiais por hífens + text = Regex.Replace(text, @"[^a-z0-9\s-]", ""); + text = Regex.Replace(text, @"[\s-]+", "-"); + + // Remover hífens do início e fim + text = text.Trim('-'); + + return text; + } + + private static string RemoveDiacritics(string text) + { + var normalizedString = text.Normalize(NormalizationForm.FormD); + var stringBuilder = new StringBuilder(); + + foreach (var c in normalizedString) + { + var unicodeCategory = CharUnicodeInfo.GetUnicodeCategory(c); + if (unicodeCategory != UnicodeCategory.NonSpacingMark) + { + stringBuilder.Append(c); + } + } + + return stringBuilder.ToString().Normalize(NormalizationForm.FormC); + } + + private static bool IsValidSlugFormat(string slug) + { + return Regex.IsMatch(slug, @"^[a-z0-9-]+$") && !slug.StartsWith('-') && !slug.EndsWith('-'); + } } \ No newline at end of file diff --git a/src/BCards.Web/Services/EmailService.cs b/src/BCards.Web/Services/EmailService.cs index 53e62de..f072c6c 100644 --- a/src/BCards.Web/Services/EmailService.cs +++ b/src/BCards.Web/Services/EmailService.cs @@ -1,207 +1,207 @@ -using SendGrid; -using SendGrid.Helpers.Mail; - -namespace BCards.Web.Services; - -public class EmailService : IEmailService -{ - private readonly ISendGridClient _sendGridClient; - private readonly IConfiguration _configuration; - private readonly ILogger _logger; - - public EmailService( - ISendGridClient sendGridClient, - IConfiguration configuration, - ILogger logger) - { - _sendGridClient = sendGridClient; - _configuration = configuration; - _logger = logger; - } - - public async Task SendModerationStatusAsync(string userEmail, string userName, string pageTitle, string status, string? reason = null, string? previewUrl = null) - { - var (subject, htmlContent) = status switch - { - "pending" => GetPendingModerationTemplate(userName, pageTitle, previewUrl), - "approved" => GetApprovedTemplate(userName, pageTitle), - "rejected" => GetRejectedTemplate(userName, pageTitle, reason), - _ => throw new ArgumentException($"Unknown status: {status}") - }; - - await SendEmailAsync(userEmail, subject, htmlContent); - } - - public async Task SendModeratorNotificationAsync(string pageId, string pageTitle, string planType, string userName) - { - var moderatorEmail = _configuration["Moderation:ModeratorEmail"]; - if (string.IsNullOrEmpty(moderatorEmail)) - return; - - var priority = GetPriorityLabel(planType); - var subject = $"[{priority}] Nova página para moderação - {pageTitle}"; - - var htmlContent = $@" -
-

Nova página para moderação

-
-

Título: {pageTitle}

-

Usuário: {userName}

-

Plano: {planType}

-

Prioridade: {priority}

-

ID da Página: {pageId}

-
-

- - Moderar Página - -

-
"; - - await SendEmailAsync(moderatorEmail, subject, htmlContent); - } - - public async Task SendEmailAsync(string to, string subject, string htmlContent) - { - try - { - var from = new EmailAddress( - _configuration["SendGrid:FromEmail"] ?? "ricardo.carneiro@jobmaker.com.br", - _configuration["SendGrid:FromName"] ?? "BCards"); - - var toEmail = new EmailAddress(to); - var msg = MailHelper.CreateSingleEmail(from, toEmail, subject, null, htmlContent); - - var response = await _sendGridClient.SendEmailAsync(msg); - - if (response.StatusCode == System.Net.HttpStatusCode.Accepted) - { - _logger.LogInformation("Email sent successfully to {Email}", to); - return true; - } - else - { - var content = await response.Body.ReadAsStringAsync(); - _logger.LogWarning("Failed to send email to {Email}. Status: {StatusCode}", to, response.StatusCode); - return false; - } - } - catch (Exception ex) - { - _logger.LogError(ex, "Error sending email to {Email}", to); - return false; - } - } - - private (string subject, string htmlContent) GetPendingModerationTemplate(string userName, string pageTitle, string? previewUrl) - { - var subject = "📋 Sua página está sendo analisada - bcards.site"; - var previewButton = !string.IsNullOrEmpty(previewUrl) - ? $"

Ver Preview

" - : ""; - - var htmlContent = $@" -
-

Olá {userName}!

-

Sua página '{pageTitle}' foi enviada para análise e estará disponível em breve!

- -
-

🔍 Tempo estimado: 3-7 dias úteis

-

👀 Status: Em análise

-
- -

Nossa equipe verifica se o conteúdo segue nossos termos de uso para manter a qualidade da plataforma.

- - {previewButton} - -
-

- Você receberá outro email assim que sua página for aprovada ou se precisar de ajustes. -

-
"; - - return (subject, htmlContent); - } - - private (string subject, string htmlContent) GetApprovedTemplate(string userName, string pageTitle) - { - var subject = "✅ Sua página foi aprovada! - bcards.site"; - var htmlContent = $@" -
-

Parabéns {userName}! 🎉

-

Sua página '{pageTitle}' foi aprovada e já está no ar!

- -
-

Status: Aprovada

-

🌐 Sua página está online!

-
- -

Agora você pode:

-
    -
  • Compartilhar sua página nas redes sociais
  • -
  • Adicionar o link na sua bio
  • -
  • Acompanhar as estatísticas no painel
  • -
- -

- - Acessar Painel - -

-
"; - - return (subject, htmlContent); - } - - private (string subject, string htmlContent) GetRejectedTemplate(string userName, string pageTitle, string? reason) - { - var subject = "⚠️ Sua página precisa de ajustes - bcards.site"; - var reasonText = !string.IsNullOrEmpty(reason) ? $"

Motivo: {reason}

" : ""; - - var htmlContent = $@" -
-

Olá {userName}

-

Sua página '{pageTitle}' não foi aprovada, mas você pode corrigir e reenviar!

- -
-

Status: Necessita ajustes

- {reasonText} -
- -

Para que sua página seja aprovada, certifique-se de que:

-
    -
  • Não contém conteúdo proibido ou suspeito
  • -
  • Todos os links estão funcionando
  • -
  • As informações são precisas
  • -
  • Segue nossos termos de uso
  • -
- -

- - Editar Página - -

-
"; - - return (subject, htmlContent); - } - - private string GetPriorityLabel(string planType) => planType.ToLower() switch - { - "premium" => "ALTA", - "professional" => "ALTA", - "basic" => "MÉDIA", - _ => "BAIXA" - }; - - private string GetPriorityColor(string planType) => planType.ToLower() switch - { - "premium" => "#dc3545", - "professional" => "#fd7e14", - "basic" => "#ffc107", - _ => "#6c757d" - }; +using SendGrid; +using SendGrid.Helpers.Mail; + +namespace BCards.Web.Services; + +public class EmailService : IEmailService +{ + private readonly ISendGridClient _sendGridClient; + private readonly IConfiguration _configuration; + private readonly ILogger _logger; + + public EmailService( + ISendGridClient sendGridClient, + IConfiguration configuration, + ILogger logger) + { + _sendGridClient = sendGridClient; + _configuration = configuration; + _logger = logger; + } + + public async Task SendModerationStatusAsync(string userEmail, string userName, string pageTitle, string status, string? reason = null, string? previewUrl = null) + { + var (subject, htmlContent) = status switch + { + "pending" => GetPendingModerationTemplate(userName, pageTitle, previewUrl), + "approved" => GetApprovedTemplate(userName, pageTitle), + "rejected" => GetRejectedTemplate(userName, pageTitle, reason), + _ => throw new ArgumentException($"Unknown status: {status}") + }; + + await SendEmailAsync(userEmail, subject, htmlContent); + } + + public async Task SendModeratorNotificationAsync(string pageId, string pageTitle, string planType, string userName) + { + var moderatorEmail = _configuration["Moderation:ModeratorEmail"]; + if (string.IsNullOrEmpty(moderatorEmail)) + return; + + var priority = GetPriorityLabel(planType); + var subject = $"[{priority}] Nova página para moderação - {pageTitle}"; + + var htmlContent = $@" +
+

Nova página para moderação

+
+

Título: {pageTitle}

+

Usuário: {userName}

+

Plano: {planType}

+

Prioridade: {priority}

+

ID da Página: {pageId}

+
+

+ + Moderar Página + +

+
"; + + await SendEmailAsync(moderatorEmail, subject, htmlContent); + } + + public async Task SendEmailAsync(string to, string subject, string htmlContent) + { + try + { + var from = new EmailAddress( + _configuration["SendGrid:FromEmail"] ?? "ricardo.carneiro@jobmaker.com.br", + _configuration["SendGrid:FromName"] ?? "BCards"); + + var toEmail = new EmailAddress(to); + var msg = MailHelper.CreateSingleEmail(from, toEmail, subject, null, htmlContent); + + var response = await _sendGridClient.SendEmailAsync(msg); + + if (response.StatusCode == System.Net.HttpStatusCode.Accepted) + { + _logger.LogInformation("Email sent successfully to {Email}", to); + return true; + } + else + { + var content = await response.Body.ReadAsStringAsync(); + _logger.LogWarning("Failed to send email to {Email}. Status: {StatusCode}", to, response.StatusCode); + return false; + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Error sending email to {Email}", to); + return false; + } + } + + private (string subject, string htmlContent) GetPendingModerationTemplate(string userName, string pageTitle, string? previewUrl) + { + var subject = "📋 Sua página está sendo analisada - bcards.site"; + var previewButton = !string.IsNullOrEmpty(previewUrl) + ? $"

Ver Preview

" + : ""; + + var htmlContent = $@" +
+

Olá {userName}!

+

Sua página '{pageTitle}' foi enviada para análise e estará disponível em breve!

+ +
+

🔍 Tempo estimado: 3-7 dias úteis

+

👀 Status: Em análise

+
+ +

Nossa equipe verifica se o conteúdo segue nossos termos de uso para manter a qualidade da plataforma.

+ + {previewButton} + +
+

+ Você receberá outro email assim que sua página for aprovada ou se precisar de ajustes. +

+
"; + + return (subject, htmlContent); + } + + private (string subject, string htmlContent) GetApprovedTemplate(string userName, string pageTitle) + { + var subject = "✅ Sua página foi aprovada! - bcards.site"; + var htmlContent = $@" +
+

Parabéns {userName}! 🎉

+

Sua página '{pageTitle}' foi aprovada e já está no ar!

+ +
+

Status: Aprovada

+

🌐 Sua página está online!

+
+ +

Agora você pode:

+
    +
  • Compartilhar sua página nas redes sociais
  • +
  • Adicionar o link na sua bio
  • +
  • Acompanhar as estatísticas no painel
  • +
+ +

+ + Acessar Painel + +

+
"; + + return (subject, htmlContent); + } + + private (string subject, string htmlContent) GetRejectedTemplate(string userName, string pageTitle, string? reason) + { + var subject = "⚠️ Sua página precisa de ajustes - bcards.site"; + var reasonText = !string.IsNullOrEmpty(reason) ? $"

Motivo: {reason}

" : ""; + + var htmlContent = $@" +
+

Olá {userName}

+

Sua página '{pageTitle}' não foi aprovada, mas você pode corrigir e reenviar!

+ +
+

Status: Necessita ajustes

+ {reasonText} +
+ +

Para que sua página seja aprovada, certifique-se de que:

+
    +
  • Não contém conteúdo proibido ou suspeito
  • +
  • Todos os links estão funcionando
  • +
  • As informações são precisas
  • +
  • Segue nossos termos de uso
  • +
+ +

+ + Editar Página + +

+
"; + + return (subject, htmlContent); + } + + private string GetPriorityLabel(string planType) => planType.ToLower() switch + { + "premium" => "ALTA", + "professional" => "ALTA", + "basic" => "MÉDIA", + _ => "BAIXA" + }; + + private string GetPriorityColor(string planType) => planType.ToLower() switch + { + "premium" => "#dc3545", + "professional" => "#fd7e14", + "basic" => "#ffc107", + _ => "#6c757d" + }; } \ No newline at end of file diff --git a/src/BCards.Web/Services/GridFSImageStorage.cs b/src/BCards.Web/Services/GridFSImageStorage.cs index 2b2d9ed..56c3dce 100644 --- a/src/BCards.Web/Services/GridFSImageStorage.cs +++ b/src/BCards.Web/Services/GridFSImageStorage.cs @@ -1,284 +1,284 @@ -using MongoDB.Bson; -using MongoDB.Driver; -using MongoDB.Driver.GridFS; -using SixLabors.ImageSharp; -using SixLabors.ImageSharp.Processing; -using SixLabors.ImageSharp.Formats.Jpeg; - -namespace BCards.Web.Services; - -public class GridFSImageStorage : IImageStorageService -{ - private readonly IMongoDatabase _database; - private readonly GridFSBucket _gridFS; - private readonly ILogger _logger; - - private const int TARGET_SIZE = 400; - private const int MAX_FILE_SIZE = 2 * 1024 * 1024; // 2MB - optimal for mobile uploads - private const int MAX_RESOLUTION = 4000; // 4000x4000px máximo (16MP) - adequado para celulares modernos - private static readonly string[] ALLOWED_TYPES = { "image/jpeg", "image/jpg", "image/png", "image/gif" }; - - public GridFSImageStorage(IMongoDatabase database, ILogger logger) - { - _database = database; - _gridFS = new GridFSBucket(database, new GridFSBucketOptions - { - BucketName = "profile_images" - }); - _logger = logger; - } - - public async Task SaveImageAsync(byte[] imageBytes, string fileName, string contentType) - { - try - { - _logger.LogInformation("Starting image upload: FileName={FileName}, ContentType={ContentType}, Size={Size}KB", - fileName, contentType, imageBytes?.Length / 1024 ?? 0); - - // Validações - if (imageBytes == null || imageBytes.Length == 0) - { - _logger.LogWarning("Image upload failed: null or empty image bytes"); - throw new ArgumentException("Image bytes cannot be null or empty"); - } - - if (imageBytes.Length > MAX_FILE_SIZE) - { - _logger.LogWarning("Image upload failed: file too large {Size}KB (max: {MaxSize}KB)", - imageBytes.Length / 1024, MAX_FILE_SIZE / 1024); - throw new ArgumentException($"Arquivo muito grande. Tamanho máximo permitido: {MAX_FILE_SIZE / (1024 * 1024)}MB"); - } - - if (!ALLOWED_TYPES.Contains(contentType.ToLower())) - { - _logger.LogWarning("Image upload failed: invalid content type {ContentType}", contentType); - throw new ArgumentException($"Tipo de arquivo {contentType} não permitido"); - } - - // Validar resolução da imagem - _logger.LogDebug("Starting image resolution validation"); - await ValidateImageResolution(imageBytes); - _logger.LogDebug("Image resolution validation completed successfully"); - - // Processar e redimensionar imagem - _logger.LogDebug("Starting image processing"); - var processedImage = await ProcessImageAsync(imageBytes); - _logger.LogInformation("Image processed successfully: OriginalSize={OriginalSize}KB, ProcessedSize={ProcessedSize}KB", - imageBytes.Length / 1024, processedImage.Length / 1024); - - // Metadata - var options = new GridFSUploadOptions - { - Metadata = new BsonDocument - { - { "originalFileName", fileName }, - { "contentType", "image/jpeg" }, // Sempre JPEG após processamento - { "uploadDate", DateTime.UtcNow }, - { "originalSize", imageBytes.Length }, - { "processedSize", processedImage.Length }, - { "dimensions", $"{TARGET_SIZE}x{TARGET_SIZE}" }, - { "version", "1.0" } - } - }; - - // Nome único para o arquivo - var uniqueFileName = $"profile_{DateTime.UtcNow:yyyyMMdd_HHmmss}_{Guid.NewGuid():N}.jpg"; - - // Upload para GridFS - var fileId = await _gridFS.UploadFromBytesAsync(uniqueFileName, processedImage, options); - - _logger.LogInformation("Image uploaded successfully: {FileId}, Size: {Size}KB", - fileId, processedImage.Length / 1024); - - return fileId.ToString(); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error uploading image: {FileName}", fileName); - throw; - } - } - - public async Task GetImageAsync(string imageId) - { - try - { - if (string.IsNullOrEmpty(imageId)) - return null; - - if (!ObjectId.TryParse(imageId, out var objectId)) - { - _logger.LogWarning("Invalid ObjectId format: {ImageId}", imageId); - return null; - } - - var imageBytes = await _gridFS.DownloadAsBytesAsync(objectId); - return imageBytes; - } - catch (GridFSFileNotFoundException) - { - _logger.LogWarning("Image not found: {ImageId}", imageId); - return null; - } - catch (Exception ex) - { - _logger.LogError(ex, "Error retrieving image: {ImageId}", imageId); - return null; - } - } - - public async Task DeleteImageAsync(string imageId) - { - try - { - if (string.IsNullOrEmpty(imageId)) - return false; - - if (!ObjectId.TryParse(imageId, out var objectId)) - return false; - - await _gridFS.DeleteAsync(objectId); - _logger.LogInformation("Image deleted successfully: {ImageId}", imageId); - return true; - } - catch (GridFSFileNotFoundException) - { - _logger.LogWarning("Attempted to delete non-existent image: {ImageId}", imageId); - return false; - } - catch (Exception ex) - { - _logger.LogError(ex, "Error deleting image: {ImageId}", imageId); - return false; - } - } - - public async Task ImageExistsAsync(string imageId) - { - try - { - if (string.IsNullOrEmpty(imageId)) - return false; - - if (!ObjectId.TryParse(imageId, out var objectId)) - return false; - - var filter = Builders.Filter.Eq("_id", objectId); - var fileInfo = await _gridFS.Find(filter).FirstOrDefaultAsync(); - - return fileInfo != null; - } - catch (Exception ex) - { - _logger.LogError(ex, "Error checking image existence: {ImageId}", imageId); - return false; - } - } - - private async Task ProcessImageAsync(byte[] originalBytes) - { - return await Task.Run(() => - { - try - { - _logger.LogDebug("Loading original image for processing"); - using var originalImage = Image.Load(originalBytes); - - _logger.LogDebug("Original image loaded: {Width}x{Height}px, Format: {Format}", - originalImage.Width, originalImage.Height, originalImage.Metadata.DecodedImageFormat?.Name ?? "Unknown"); - - // Calcular dimensões mantendo aspect ratio - var (newWidth, newHeight) = CalculateResizeDimensions( - originalImage.Width, originalImage.Height, TARGET_SIZE); - - _logger.LogDebug("Calculated resize dimensions: {NewWidth}x{NewHeight}px (target: {TargetSize}x{TargetSize}px)", - newWidth, newHeight, TARGET_SIZE, TARGET_SIZE); - - // Criar imagem com fundo branco - using var processedImage = new Image(TARGET_SIZE, TARGET_SIZE); - - // Preencher com fundo branco - processedImage.Mutate(ctx => ctx.BackgroundColor(SixLabors.ImageSharp.Color.White)); - - // Redimensionar a imagem original mantendo aspect ratio - originalImage.Mutate(ctx => ctx.Resize(newWidth, newHeight)); - - // Calcular posição para centralizar a imagem - var x = (TARGET_SIZE - newWidth) / 2; - var y = (TARGET_SIZE - newHeight) / 2; - - _logger.LogDebug("Centering image at position: x={X}, y={Y}", x, y); - - // Desenhar a imagem centralizada sobre o fundo branco - processedImage.Mutate(ctx => ctx.DrawImage(originalImage, new Point(x, y), 1f)); - - // Converter para JPEG com compressão otimizada - using var outputStream = new MemoryStream(); - var encoder = new JpegEncoder() - { - Quality = 85 // 85% qualidade - }; - - processedImage.SaveAsJpeg(outputStream, encoder); - var result = outputStream.ToArray(); - - _logger.LogDebug("Image processing completed: Output size={Size}KB", result.Length / 1024); - return result; - } - catch (Exception ex) - { - _logger.LogError(ex, "Failed to process image. Input size: {Size} bytes, Exception type: {ExceptionType}", - originalBytes.Length, ex.GetType().Name); - throw; - } - }); - } - - private static (int width, int height) CalculateResizeDimensions(int originalWidth, int originalHeight, int targetSize) - { - var ratio = Math.Min((double)targetSize / originalWidth, (double)targetSize / originalHeight); - return ((int)(originalWidth * ratio), (int)(originalHeight * ratio)); - } - - private async Task ValidateImageResolution(byte[] imageBytes) - { - await Task.Run(() => - { - try - { - _logger.LogDebug("Validating image resolution for {Size} bytes", imageBytes.Length); - - using var image = Image.Load(imageBytes); - - _logger.LogDebug("Image loaded successfully: {Width}x{Height}px, Format: {Format}", - image.Width, image.Height, image.Metadata.DecodedImageFormat?.Name ?? "Unknown"); - - if (image.Width > MAX_RESOLUTION || image.Height > MAX_RESOLUTION) - { - _logger.LogWarning("Image resolution too high: {Width}x{Height}px (max: {MaxResolution}x{MaxResolution}px)", - image.Width, image.Height, MAX_RESOLUTION, MAX_RESOLUTION); - - throw new ArgumentException( - $"Resolução muito alta. Máximo permitido: {MAX_RESOLUTION}x{MAX_RESOLUTION}px. " + - $"Sua imagem: {image.Width}x{image.Height}px"); - } - } - catch (Exception ex) when (!(ex is ArgumentException)) - { - _logger.LogError(ex, "Failed to validate image resolution. Image size: {Size} bytes, Exception type: {ExceptionType}", - imageBytes.Length, ex.GetType().Name); - - // Log mais detalhes sobre o tipo de erro - var errorDetails = ex switch - { - OutOfMemoryException => "Imagem muito grande para processar", - UnknownImageFormatException => "Formato de imagem não suportado", - InvalidImageContentException => "Conteúdo de imagem inválido", - _ => $"Erro inesperado: {ex.GetType().Name}" - }; - - throw new ArgumentException($"Arquivo de imagem inválido ou corrompido. {errorDetails}"); - } - }); - } -} +using MongoDB.Bson; +using MongoDB.Driver; +using MongoDB.Driver.GridFS; +using SixLabors.ImageSharp; +using SixLabors.ImageSharp.Processing; +using SixLabors.ImageSharp.Formats.Jpeg; + +namespace BCards.Web.Services; + +public class GridFSImageStorage : IImageStorageService +{ + private readonly IMongoDatabase _database; + private readonly GridFSBucket _gridFS; + private readonly ILogger _logger; + + private const int TARGET_SIZE = 400; + private const int MAX_FILE_SIZE = 2 * 1024 * 1024; // 2MB - optimal for mobile uploads + private const int MAX_RESOLUTION = 4000; // 4000x4000px máximo (16MP) - adequado para celulares modernos + private static readonly string[] ALLOWED_TYPES = { "image/jpeg", "image/jpg", "image/png", "image/gif" }; + + public GridFSImageStorage(IMongoDatabase database, ILogger logger) + { + _database = database; + _gridFS = new GridFSBucket(database, new GridFSBucketOptions + { + BucketName = "profile_images" + }); + _logger = logger; + } + + public async Task SaveImageAsync(byte[] imageBytes, string fileName, string contentType) + { + try + { + _logger.LogInformation("Starting image upload: FileName={FileName}, ContentType={ContentType}, Size={Size}KB", + fileName, contentType, imageBytes?.Length / 1024 ?? 0); + + // Validações + if (imageBytes == null || imageBytes.Length == 0) + { + _logger.LogWarning("Image upload failed: null or empty image bytes"); + throw new ArgumentException("Image bytes cannot be null or empty"); + } + + if (imageBytes.Length > MAX_FILE_SIZE) + { + _logger.LogWarning("Image upload failed: file too large {Size}KB (max: {MaxSize}KB)", + imageBytes.Length / 1024, MAX_FILE_SIZE / 1024); + throw new ArgumentException($"Arquivo muito grande. Tamanho máximo permitido: {MAX_FILE_SIZE / (1024 * 1024)}MB"); + } + + if (!ALLOWED_TYPES.Contains(contentType.ToLower())) + { + _logger.LogWarning("Image upload failed: invalid content type {ContentType}", contentType); + throw new ArgumentException($"Tipo de arquivo {contentType} não permitido"); + } + + // Validar resolução da imagem + _logger.LogDebug("Starting image resolution validation"); + await ValidateImageResolution(imageBytes); + _logger.LogDebug("Image resolution validation completed successfully"); + + // Processar e redimensionar imagem + _logger.LogDebug("Starting image processing"); + var processedImage = await ProcessImageAsync(imageBytes); + _logger.LogInformation("Image processed successfully: OriginalSize={OriginalSize}KB, ProcessedSize={ProcessedSize}KB", + imageBytes.Length / 1024, processedImage.Length / 1024); + + // Metadata + var options = new GridFSUploadOptions + { + Metadata = new BsonDocument + { + { "originalFileName", fileName }, + { "contentType", "image/jpeg" }, // Sempre JPEG após processamento + { "uploadDate", DateTime.UtcNow }, + { "originalSize", imageBytes.Length }, + { "processedSize", processedImage.Length }, + { "dimensions", $"{TARGET_SIZE}x{TARGET_SIZE}" }, + { "version", "1.0" } + } + }; + + // Nome único para o arquivo + var uniqueFileName = $"profile_{DateTime.UtcNow:yyyyMMdd_HHmmss}_{Guid.NewGuid():N}.jpg"; + + // Upload para GridFS + var fileId = await _gridFS.UploadFromBytesAsync(uniqueFileName, processedImage, options); + + _logger.LogInformation("Image uploaded successfully: {FileId}, Size: {Size}KB", + fileId, processedImage.Length / 1024); + + return fileId.ToString(); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error uploading image: {FileName}", fileName); + throw; + } + } + + public async Task GetImageAsync(string imageId) + { + try + { + if (string.IsNullOrEmpty(imageId)) + return null; + + if (!ObjectId.TryParse(imageId, out var objectId)) + { + _logger.LogWarning("Invalid ObjectId format: {ImageId}", imageId); + return null; + } + + var imageBytes = await _gridFS.DownloadAsBytesAsync(objectId); + return imageBytes; + } + catch (GridFSFileNotFoundException) + { + _logger.LogWarning("Image not found: {ImageId}", imageId); + return null; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error retrieving image: {ImageId}", imageId); + return null; + } + } + + public async Task DeleteImageAsync(string imageId) + { + try + { + if (string.IsNullOrEmpty(imageId)) + return false; + + if (!ObjectId.TryParse(imageId, out var objectId)) + return false; + + await _gridFS.DeleteAsync(objectId); + _logger.LogInformation("Image deleted successfully: {ImageId}", imageId); + return true; + } + catch (GridFSFileNotFoundException) + { + _logger.LogWarning("Attempted to delete non-existent image: {ImageId}", imageId); + return false; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error deleting image: {ImageId}", imageId); + return false; + } + } + + public async Task ImageExistsAsync(string imageId) + { + try + { + if (string.IsNullOrEmpty(imageId)) + return false; + + if (!ObjectId.TryParse(imageId, out var objectId)) + return false; + + var filter = Builders.Filter.Eq("_id", objectId); + var fileInfo = await _gridFS.Find(filter).FirstOrDefaultAsync(); + + return fileInfo != null; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error checking image existence: {ImageId}", imageId); + return false; + } + } + + private async Task ProcessImageAsync(byte[] originalBytes) + { + return await Task.Run(() => + { + try + { + _logger.LogDebug("Loading original image for processing"); + using var originalImage = Image.Load(originalBytes); + + _logger.LogDebug("Original image loaded: {Width}x{Height}px, Format: {Format}", + originalImage.Width, originalImage.Height, originalImage.Metadata.DecodedImageFormat?.Name ?? "Unknown"); + + // Calcular dimensões mantendo aspect ratio + var (newWidth, newHeight) = CalculateResizeDimensions( + originalImage.Width, originalImage.Height, TARGET_SIZE); + + _logger.LogDebug("Calculated resize dimensions: {NewWidth}x{NewHeight}px (target: {TargetSize}x{TargetSize}px)", + newWidth, newHeight, TARGET_SIZE, TARGET_SIZE); + + // Criar imagem com fundo branco + using var processedImage = new Image(TARGET_SIZE, TARGET_SIZE); + + // Preencher com fundo branco + processedImage.Mutate(ctx => ctx.BackgroundColor(SixLabors.ImageSharp.Color.White)); + + // Redimensionar a imagem original mantendo aspect ratio + originalImage.Mutate(ctx => ctx.Resize(newWidth, newHeight)); + + // Calcular posição para centralizar a imagem + var x = (TARGET_SIZE - newWidth) / 2; + var y = (TARGET_SIZE - newHeight) / 2; + + _logger.LogDebug("Centering image at position: x={X}, y={Y}", x, y); + + // Desenhar a imagem centralizada sobre o fundo branco + processedImage.Mutate(ctx => ctx.DrawImage(originalImage, new Point(x, y), 1f)); + + // Converter para JPEG com compressão otimizada + using var outputStream = new MemoryStream(); + var encoder = new JpegEncoder() + { + Quality = 85 // 85% qualidade + }; + + processedImage.SaveAsJpeg(outputStream, encoder); + var result = outputStream.ToArray(); + + _logger.LogDebug("Image processing completed: Output size={Size}KB", result.Length / 1024); + return result; + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to process image. Input size: {Size} bytes, Exception type: {ExceptionType}", + originalBytes.Length, ex.GetType().Name); + throw; + } + }); + } + + private static (int width, int height) CalculateResizeDimensions(int originalWidth, int originalHeight, int targetSize) + { + var ratio = Math.Min((double)targetSize / originalWidth, (double)targetSize / originalHeight); + return ((int)(originalWidth * ratio), (int)(originalHeight * ratio)); + } + + private async Task ValidateImageResolution(byte[] imageBytes) + { + await Task.Run(() => + { + try + { + _logger.LogDebug("Validating image resolution for {Size} bytes", imageBytes.Length); + + using var image = Image.Load(imageBytes); + + _logger.LogDebug("Image loaded successfully: {Width}x{Height}px, Format: {Format}", + image.Width, image.Height, image.Metadata.DecodedImageFormat?.Name ?? "Unknown"); + + if (image.Width > MAX_RESOLUTION || image.Height > MAX_RESOLUTION) + { + _logger.LogWarning("Image resolution too high: {Width}x{Height}px (max: {MaxResolution}x{MaxResolution}px)", + image.Width, image.Height, MAX_RESOLUTION, MAX_RESOLUTION); + + throw new ArgumentException( + $"Resolução muito alta. Máximo permitido: {MAX_RESOLUTION}x{MAX_RESOLUTION}px. " + + $"Sua imagem: {image.Width}x{image.Height}px"); + } + } + catch (Exception ex) when (!(ex is ArgumentException)) + { + _logger.LogError(ex, "Failed to validate image resolution. Image size: {Size} bytes, Exception type: {ExceptionType}", + imageBytes.Length, ex.GetType().Name); + + // Log mais detalhes sobre o tipo de erro + var errorDetails = ex switch + { + OutOfMemoryException => "Imagem muito grande para processar", + UnknownImageFormatException => "Formato de imagem não suportado", + InvalidImageContentException => "Conteúdo de imagem inválido", + _ => $"Erro inesperado: {ex.GetType().Name}" + }; + + throw new ArgumentException($"Arquivo de imagem inválido ou corrompido. {errorDetails}"); + } + }); + } +} diff --git a/src/BCards.Web/Services/IEmailService.cs b/src/BCards.Web/Services/IEmailService.cs index b64737c..90b99b2 100644 --- a/src/BCards.Web/Services/IEmailService.cs +++ b/src/BCards.Web/Services/IEmailService.cs @@ -1,8 +1,8 @@ -namespace BCards.Web.Services; - -public interface IEmailService -{ - Task SendModerationStatusAsync(string userEmail, string userName, string pageTitle, string status, string? reason = null, string? previewUrl = null); - Task SendModeratorNotificationAsync(string pageId, string pageTitle, string planType, string userName); - Task SendEmailAsync(string to, string subject, string htmlContent); +namespace BCards.Web.Services; + +public interface IEmailService +{ + Task SendModerationStatusAsync(string userEmail, string userName, string pageTitle, string status, string? reason = null, string? previewUrl = null); + Task SendModeratorNotificationAsync(string pageId, string pageTitle, string planType, string userName); + Task SendEmailAsync(string to, string subject, string htmlContent); } \ No newline at end of file diff --git a/src/BCards.Web/Services/IImageStorageService.cs b/src/BCards.Web/Services/IImageStorageService.cs index 0641a71..69b94f6 100644 --- a/src/BCards.Web/Services/IImageStorageService.cs +++ b/src/BCards.Web/Services/IImageStorageService.cs @@ -1,34 +1,34 @@ -namespace BCards.Web.Services; - -public interface IImageStorageService -{ - /// - /// Salva uma imagem no storage, com redimensionamento automático para 400x400px - /// - /// Bytes da imagem original - /// Nome original do arquivo - /// Tipo de conteúdo da imagem - /// ID único da imagem salva - Task SaveImageAsync(byte[] imageBytes, string fileName, string contentType); - - /// - /// Recupera os bytes de uma imagem pelo ID - /// - /// ID da imagem - /// Bytes da imagem ou null se não encontrada - Task GetImageAsync(string imageId); - - /// - /// Remove uma imagem do storage - /// - /// ID da imagem - /// True se removida com sucesso - Task DeleteImageAsync(string imageId); - - /// - /// Verifica se uma imagem existe no storage - /// - /// ID da imagem - /// True se a imagem existe - Task ImageExistsAsync(string imageId); +namespace BCards.Web.Services; + +public interface IImageStorageService +{ + /// + /// Salva uma imagem no storage, com redimensionamento automático para 400x400px + /// + /// Bytes da imagem original + /// Nome original do arquivo + /// Tipo de conteúdo da imagem + /// ID único da imagem salva + Task SaveImageAsync(byte[] imageBytes, string fileName, string contentType); + + /// + /// Recupera os bytes de uma imagem pelo ID + /// + /// ID da imagem + /// Bytes da imagem ou null se não encontrada + Task GetImageAsync(string imageId); + + /// + /// Remove uma imagem do storage + /// + /// ID da imagem + /// True se removida com sucesso + Task DeleteImageAsync(string imageId); + + /// + /// Verifica se uma imagem existe no storage + /// + /// ID da imagem + /// True se a imagem existe + Task ImageExistsAsync(string imageId); } \ No newline at end of file diff --git a/src/BCards.Web/Services/ILivePageService.cs b/src/BCards.Web/Services/ILivePageService.cs index 9d56595..9685645 100644 --- a/src/BCards.Web/Services/ILivePageService.cs +++ b/src/BCards.Web/Services/ILivePageService.cs @@ -1,14 +1,14 @@ -using BCards.Web.Models; - -namespace BCards.Web.Services; - -public interface ILivePageService -{ - Task GetByCategoryAndSlugAsync(string category, string slug); - Task> GetAllActiveAsync(); - Task GetLivePageFromUserPageId(string userPageId); - Task SyncFromUserPageAsync(string userPageId); - Task DeleteByOriginalPageIdAsync(string originalPageId); - Task IncrementViewAsync(string livePageId); - Task IncrementLinkClickAsync(string livePageId, int linkIndex); +using BCards.Web.Models; + +namespace BCards.Web.Services; + +public interface ILivePageService +{ + Task GetByCategoryAndSlugAsync(string category, string slug); + Task> GetAllActiveAsync(); + Task GetLivePageFromUserPageId(string userPageId); + Task SyncFromUserPageAsync(string userPageId); + Task DeleteByOriginalPageIdAsync(string originalPageId); + Task IncrementViewAsync(string livePageId); + Task IncrementLinkClickAsync(string livePageId, int linkIndex); } \ No newline at end of file diff --git a/src/BCards.Web/Services/IModerationAuthService.cs b/src/BCards.Web/Services/IModerationAuthService.cs index cf59677..54ac9f8 100644 --- a/src/BCards.Web/Services/IModerationAuthService.cs +++ b/src/BCards.Web/Services/IModerationAuthService.cs @@ -1,11 +1,11 @@ -using System.Security.Claims; - -namespace BCards.Web.Services -{ - public interface IModerationAuthService - { - bool IsUserModerator(ClaimsPrincipal user); - bool IsEmailModerator(string email); - List GetModeratorEmails(); - } -} +using System.Security.Claims; + +namespace BCards.Web.Services +{ + public interface IModerationAuthService + { + bool IsUserModerator(ClaimsPrincipal user); + bool IsEmailModerator(string email); + List GetModeratorEmails(); + } +} diff --git a/src/BCards.Web/Services/IModerationService.cs b/src/BCards.Web/Services/IModerationService.cs index 916cb30..f202d25 100644 --- a/src/BCards.Web/Services/IModerationService.cs +++ b/src/BCards.Web/Services/IModerationService.cs @@ -1,19 +1,19 @@ -using BCards.Web.Models; - -namespace BCards.Web.Services; - -public interface IModerationService -{ - Task GeneratePreviewTokenAsync(string pageId); - Task ValidatePreviewTokenAsync(string pageId, string token); - Task> GetPendingModerationAsync(int skip = 0, int take = 20, string? filter = null); - Task GetPageForModerationAsync(string pageId); - Task ApprovePageAsync(string pageId, string moderatorId, string? notes = null); - Task RejectPageAsync(string pageId, string moderatorId, string reason, List issues); - Task CanUserCreatePageAsync(string userId); - Task IncrementPreviewViewAsync(string pageId); - Task> GetModerationStatsAsync(); - Task> GetModerationHistoryAsync(int skip = 0, int take = 20); - Task GetPageByPreviewTokenAsync(string token); - Task DeleteForModerationAsync(string pageId); +using BCards.Web.Models; + +namespace BCards.Web.Services; + +public interface IModerationService +{ + Task GeneratePreviewTokenAsync(string pageId); + Task ValidatePreviewTokenAsync(string pageId, string token); + Task> GetPendingModerationAsync(int skip = 0, int take = 20, string? filter = null); + Task GetPageForModerationAsync(string pageId); + Task ApprovePageAsync(string pageId, string moderatorId, string? notes = null); + Task RejectPageAsync(string pageId, string moderatorId, string reason, List issues); + Task CanUserCreatePageAsync(string userId); + Task IncrementPreviewViewAsync(string pageId); + Task> GetModerationStatsAsync(); + Task> GetModerationHistoryAsync(int skip = 0, int take = 20); + Task GetPageByPreviewTokenAsync(string token); + Task DeleteForModerationAsync(string pageId); } \ No newline at end of file diff --git a/src/BCards.Web/Services/IOAuthHealthService.cs b/src/BCards.Web/Services/IOAuthHealthService.cs index f87bc50..bfeedff 100644 --- a/src/BCards.Web/Services/IOAuthHealthService.cs +++ b/src/BCards.Web/Services/IOAuthHealthService.cs @@ -1,20 +1,20 @@ -namespace BCards.Web.Services; - -public interface IOAuthHealthService -{ - Task CheckOAuthProvidersAsync(); - Task IsGoogleAvailableAsync(); - Task IsMicrosoftAvailableAsync(); - Task LogOAuthStatusAsync(); -} - -public class OAuthHealthStatus -{ - public bool GoogleAvailable { get; set; } - public bool MicrosoftAvailable { get; set; } - public bool AllProvidersHealthy => GoogleAvailable && MicrosoftAvailable; - public bool AnyProviderHealthy => GoogleAvailable || MicrosoftAvailable; - public string GoogleStatus => GoogleAvailable ? "online" : "offline"; - public string MicrosoftStatus => MicrosoftAvailable ? "online" : "offline"; - public DateTime CheckedAt { get; set; } = DateTime.UtcNow; +namespace BCards.Web.Services; + +public interface IOAuthHealthService +{ + Task CheckOAuthProvidersAsync(); + Task IsGoogleAvailableAsync(); + Task IsMicrosoftAvailableAsync(); + Task LogOAuthStatusAsync(); +} + +public class OAuthHealthStatus +{ + public bool GoogleAvailable { get; set; } + public bool MicrosoftAvailable { get; set; } + public bool AllProvidersHealthy => GoogleAvailable && MicrosoftAvailable; + public bool AnyProviderHealthy => GoogleAvailable || MicrosoftAvailable; + public string GoogleStatus => GoogleAvailable ? "online" : "offline"; + public string MicrosoftStatus => MicrosoftAvailable ? "online" : "offline"; + public DateTime CheckedAt { get; set; } = DateTime.UtcNow; } \ No newline at end of file diff --git a/src/BCards.Web/Services/IOpenGraphService.cs b/src/BCards.Web/Services/IOpenGraphService.cs index 8a05748..7663a38 100644 --- a/src/BCards.Web/Services/IOpenGraphService.cs +++ b/src/BCards.Web/Services/IOpenGraphService.cs @@ -1,10 +1,10 @@ -using BCards.Web.Models; - -namespace BCards.Web.Services; - -public interface IOpenGraphService -{ - Task ExtractDataAsync(string url, string userId); - Task IsRateLimitedAsync(string userId); - Task GetCachedDataAsync(string url); +using BCards.Web.Models; + +namespace BCards.Web.Services; + +public interface IOpenGraphService +{ + Task ExtractDataAsync(string url, string userId); + Task IsRateLimitedAsync(string userId); + Task GetCachedDataAsync(string url); } \ No newline at end of file diff --git a/src/BCards.Web/Services/IPaymentService.cs b/src/BCards.Web/Services/IPaymentService.cs index a30d84c..e9ffe46 100644 --- a/src/BCards.Web/Services/IPaymentService.cs +++ b/src/BCards.Web/Services/IPaymentService.cs @@ -1,27 +1,27 @@ -using BCards.Web.Models; -using Stripe; - -namespace BCards.Web.Services; - -public interface IPaymentService -{ - Task CreateCheckoutSessionAsync(string userId, string planType, string returnUrl, string cancelUrl); - Task CreateOrGetCustomerAsync(string userId, string email, string name); - Task HandleWebhookAsync(string requestBody, string signature); - Task> GetPricesAsync(); - Task CancelSubscriptionAsync(string subscriptionId); - Task UpdateSubscriptionAsync(string subscriptionId, string newPriceId); - Task GetPlanLimitationsAsync(string planType); - - // Novos métodos para gerenciamento de assinatura - Task GetSubscriptionDetailsAsync(string userId); - Task> GetPaymentHistoryAsync(string userId); - Task CreatePortalSessionAsync(string customerId, string returnUrl); - - // Métodos para cancelamento com diferentes políticas - Task CancelSubscriptionImmediatelyAsync(string subscriptionId, bool refund = false); - Task CancelSubscriptionAtPeriodEndAsync(string subscriptionId); - Task ReactivateSubscriptionAsync(string subscriptionId); - Task CreatePartialRefundAsync(string subscriptionId, decimal refundAmount); - Task<(bool CanRefundFull, bool CanRefundPartial, decimal RefundAmount)> CalculateRefundAsync(string subscriptionId); +using BCards.Web.Models; +using Stripe; + +namespace BCards.Web.Services; + +public interface IPaymentService +{ + Task CreateCheckoutSessionAsync(string userId, string planType, string returnUrl, string cancelUrl); + Task CreateOrGetCustomerAsync(string userId, string email, string name); + Task HandleWebhookAsync(string requestBody, string signature); + Task> GetPricesAsync(); + Task CancelSubscriptionAsync(string subscriptionId); + Task UpdateSubscriptionAsync(string subscriptionId, string newPriceId); + Task GetPlanLimitationsAsync(string planType); + + // Novos métodos para gerenciamento de assinatura + Task GetSubscriptionDetailsAsync(string userId); + Task> GetPaymentHistoryAsync(string userId); + Task CreatePortalSessionAsync(string customerId, string returnUrl); + + // Métodos para cancelamento com diferentes políticas + Task CancelSubscriptionImmediatelyAsync(string subscriptionId, bool refund = false); + Task CancelSubscriptionAtPeriodEndAsync(string subscriptionId); + Task ReactivateSubscriptionAsync(string subscriptionId); + Task CreatePartialRefundAsync(string subscriptionId, decimal refundAmount); + Task<(bool CanRefundFull, bool CanRefundPartial, decimal RefundAmount)> CalculateRefundAsync(string subscriptionId); } \ No newline at end of file diff --git a/src/BCards.Web/Services/IPlanConfigurationService.cs b/src/BCards.Web/Services/IPlanConfigurationService.cs index 7632d52..084a912 100644 --- a/src/BCards.Web/Services/IPlanConfigurationService.cs +++ b/src/BCards.Web/Services/IPlanConfigurationService.cs @@ -1,57 +1,57 @@ -using BCards.Web.Models; - -namespace BCards.Web.Services; - -public interface IPlanConfigurationService -{ - /// - /// Mapeia um PriceId do Stripe para o PlanType correspondente - /// - PlanType GetPlanTypeFromPriceId(string priceId); - - /// - /// Mapeia um PriceId do Stripe para o nome string do plano - /// - string GetPlanNameFromPriceId(string priceId); - - /// - /// Obtém as limitações de um plano baseado no PlanType - /// - PlanLimitations GetPlanLimitations(PlanType planType); - - /// - /// Obtém o PriceId de um plano (mensal por padrão) - /// - string GetPriceId(PlanType planType, bool yearly = false); - - /// - /// Obtém o preço de um plano - /// - decimal GetPlanPrice(PlanType planType, bool yearly = false); - - /// - /// Verifica se um plano é anual baseado no PriceId - /// - bool IsYearlyPlan(string priceId); - - /// - /// Obtém todas as configurações de um plano pelo nome da seção - /// - PlanConfiguration? GetPlanConfiguration(string planSectionName); -} - -public class PlanConfiguration -{ - public string Name { get; set; } = string.Empty; - public string PriceId { get; set; } = string.Empty; - public decimal Price { get; set; } - public int MaxPages { get; set; } - public int MaxLinks { get; set; } - public bool AllowPremiumThemes { get; set; } - public bool AllowProductLinks { get; set; } - public bool AllowAnalytics { get; set; } - public bool? SpecialModeration { get; set; } - public List Features { get; set; } = new(); - public string Interval { get; set; } = "month"; - public PlanType BasePlanType { get; set; } +using BCards.Web.Models; + +namespace BCards.Web.Services; + +public interface IPlanConfigurationService +{ + /// + /// Mapeia um PriceId do Stripe para o PlanType correspondente + /// + PlanType GetPlanTypeFromPriceId(string priceId); + + /// + /// Mapeia um PriceId do Stripe para o nome string do plano + /// + string GetPlanNameFromPriceId(string priceId); + + /// + /// Obtém as limitações de um plano baseado no PlanType + /// + PlanLimitations GetPlanLimitations(PlanType planType); + + /// + /// Obtém o PriceId de um plano (mensal por padrão) + /// + string GetPriceId(PlanType planType, bool yearly = false); + + /// + /// Obtém o preço de um plano + /// + decimal GetPlanPrice(PlanType planType, bool yearly = false); + + /// + /// Verifica se um plano é anual baseado no PriceId + /// + bool IsYearlyPlan(string priceId); + + /// + /// Obtém todas as configurações de um plano pelo nome da seção + /// + PlanConfiguration? GetPlanConfiguration(string planSectionName); +} + +public class PlanConfiguration +{ + public string Name { get; set; } = string.Empty; + public string PriceId { get; set; } = string.Empty; + public decimal Price { get; set; } + public int MaxPages { get; set; } + public int MaxLinks { get; set; } + public bool AllowPremiumThemes { get; set; } + public bool AllowProductLinks { get; set; } + public bool AllowAnalytics { get; set; } + public bool? SpecialModeration { get; set; } + public List Features { get; set; } = new(); + public string Interval { get; set; } = "month"; + public PlanType BasePlanType { get; set; } } \ No newline at end of file diff --git a/src/BCards.Web/Services/IThemeService.cs b/src/BCards.Web/Services/IThemeService.cs index e07f08d..ddedaaf 100644 --- a/src/BCards.Web/Services/IThemeService.cs +++ b/src/BCards.Web/Services/IThemeService.cs @@ -1,14 +1,14 @@ -using BCards.Web.Models; - -namespace BCards.Web.Services; - -public interface IThemeService -{ - Task> GetAvailableThemesAsync(); - Task GetThemeByIdAsync(string themeId); - Task GetThemeByNameAsync(string themeName); - Task GenerateCustomCssAsync(PageTheme theme); - Task GenerateThemeCSSAsync(PageTheme theme, UserPage page); - Task InitializeDefaultThemesAsync(); - PageTheme GetDefaultTheme(); +using BCards.Web.Models; + +namespace BCards.Web.Services; + +public interface IThemeService +{ + Task> GetAvailableThemesAsync(); + Task GetThemeByIdAsync(string themeId); + Task GetThemeByNameAsync(string themeName); + Task GenerateCustomCssAsync(PageTheme theme); + Task GenerateThemeCSSAsync(PageTheme theme, UserPage page); + Task InitializeDefaultThemesAsync(); + PageTheme GetDefaultTheme(); } \ No newline at end of file diff --git a/src/BCards.Web/Services/LivePageService.cs b/src/BCards.Web/Services/LivePageService.cs index e376e1f..877b333 100644 --- a/src/BCards.Web/Services/LivePageService.cs +++ b/src/BCards.Web/Services/LivePageService.cs @@ -1,118 +1,118 @@ -using BCards.Web.Models; -using BCards.Web.Repositories; -using BCards.Web.ViewModels; - -namespace BCards.Web.Services; - -public class LivePageService : ILivePageService -{ - private readonly ILivePageRepository _livePageRepository; - private readonly IUserPageRepository _userPageRepository; - private readonly ILogger _logger; - - public LivePageService( - ILivePageRepository livePageRepository, - IUserPageRepository userPageRepository, - ILogger logger) - { - _livePageRepository = livePageRepository; - _userPageRepository = userPageRepository; - _logger = logger; - } - - public async Task GetByCategoryAndSlugAsync(string category, string slug) - { - return await _livePageRepository.GetByCategoryAndSlugAsync(category, slug); - } - - public async Task> GetAllActiveAsync() - { - return await _livePageRepository.GetAllActiveAsync(); - } - - public async Task GetLivePageFromUserPageId(string userPageId) - { - return await _livePageRepository.GetByOriginalPageIdAsync(userPageId); - } - - public async Task SyncFromUserPageAsync(string userPageId) - { - var userPage = await _userPageRepository.GetByIdAsync(userPageId); - if (userPage == null) - throw new InvalidOperationException($"UserPage {userPageId} not found"); - - if (userPage.Status != PageStatus.Active) - throw new InvalidOperationException("UserPage must be Active to sync to LivePage"); - - // Verificar se já existe LivePage para este UserPage - var existingLivePage = await _livePageRepository.GetByOriginalPageIdAsync(userPageId); - - var livePage = new LivePage - { - OriginalPageId = userPageId, - UserId = userPage.UserId, - Category = userPage.Category, - Slug = userPage.Slug, - DisplayName = userPage.DisplayName, - Bio = userPage.Bio, - ProfileImageId = userPage.ProfileImageId, - BusinessType = userPage.BusinessType, - Theme = userPage.Theme, - Links = userPage.Links, - SeoSettings = userPage.SeoSettings, - Language = userPage.Language, - Analytics = new LivePageAnalytics - { - TotalViews = existingLivePage?.Analytics?.TotalViews ?? 0, - TotalClicks = existingLivePage?.Analytics?.TotalClicks ?? 0, - LastViewedAt = existingLivePage?.Analytics?.LastViewedAt - }, - PublishedAt = userPage.ApprovedAt ?? DateTime.UtcNow - }; - - if (existingLivePage != null) - { - // Atualizar existente - livePage.Id = existingLivePage.Id; - livePage.CreatedAt = existingLivePage.CreatedAt; - _logger.LogInformation("Updating existing LivePage {LivePageId} from UserPage {UserPageId}", livePage.Id, userPageId); - return await _livePageRepository.UpdateAsync(livePage); - } - else - { - // Criar nova - _logger.LogInformation("Creating new LivePage from UserPage {UserPageId}", userPageId); - return await _livePageRepository.CreateAsync(livePage); - } - } - - public async Task DeleteByOriginalPageIdAsync(string originalPageId) - { - _logger.LogInformation("Deleting LivePage for UserPage {UserPageId}", originalPageId); - return await _livePageRepository.DeleteByOriginalPageIdAsync(originalPageId); - } - - public async Task IncrementViewAsync(string livePageId) - { - try - { - await _livePageRepository.IncrementViewAsync(livePageId); - } - catch (Exception ex) - { - _logger.LogError(ex, "Failed to increment view for LivePage {LivePageId}", livePageId); - } - } - - public async Task IncrementLinkClickAsync(string livePageId, int linkIndex) - { - try - { - await _livePageRepository.IncrementLinkClickAsync(livePageId, linkIndex); - } - catch (Exception ex) - { - _logger.LogError(ex, "Failed to increment click for LivePage {LivePageId} link {LinkIndex}", livePageId, linkIndex); - } - } +using BCards.Web.Models; +using BCards.Web.Repositories; +using BCards.Web.ViewModels; + +namespace BCards.Web.Services; + +public class LivePageService : ILivePageService +{ + private readonly ILivePageRepository _livePageRepository; + private readonly IUserPageRepository _userPageRepository; + private readonly ILogger _logger; + + public LivePageService( + ILivePageRepository livePageRepository, + IUserPageRepository userPageRepository, + ILogger logger) + { + _livePageRepository = livePageRepository; + _userPageRepository = userPageRepository; + _logger = logger; + } + + public async Task GetByCategoryAndSlugAsync(string category, string slug) + { + return await _livePageRepository.GetByCategoryAndSlugAsync(category, slug); + } + + public async Task> GetAllActiveAsync() + { + return await _livePageRepository.GetAllActiveAsync(); + } + + public async Task GetLivePageFromUserPageId(string userPageId) + { + return await _livePageRepository.GetByOriginalPageIdAsync(userPageId); + } + + public async Task SyncFromUserPageAsync(string userPageId) + { + var userPage = await _userPageRepository.GetByIdAsync(userPageId); + if (userPage == null) + throw new InvalidOperationException($"UserPage {userPageId} not found"); + + if (userPage.Status != PageStatus.Active) + throw new InvalidOperationException("UserPage must be Active to sync to LivePage"); + + // Verificar se já existe LivePage para este UserPage + var existingLivePage = await _livePageRepository.GetByOriginalPageIdAsync(userPageId); + + var livePage = new LivePage + { + OriginalPageId = userPageId, + UserId = userPage.UserId, + Category = userPage.Category, + Slug = userPage.Slug, + DisplayName = userPage.DisplayName, + Bio = userPage.Bio, + ProfileImageId = userPage.ProfileImageId, + BusinessType = userPage.BusinessType, + Theme = userPage.Theme, + Links = userPage.Links, + SeoSettings = userPage.SeoSettings, + Language = userPage.Language, + Analytics = new LivePageAnalytics + { + TotalViews = existingLivePage?.Analytics?.TotalViews ?? 0, + TotalClicks = existingLivePage?.Analytics?.TotalClicks ?? 0, + LastViewedAt = existingLivePage?.Analytics?.LastViewedAt + }, + PublishedAt = userPage.ApprovedAt ?? DateTime.UtcNow + }; + + if (existingLivePage != null) + { + // Atualizar existente + livePage.Id = existingLivePage.Id; + livePage.CreatedAt = existingLivePage.CreatedAt; + _logger.LogInformation("Updating existing LivePage {LivePageId} from UserPage {UserPageId}", livePage.Id, userPageId); + return await _livePageRepository.UpdateAsync(livePage); + } + else + { + // Criar nova + _logger.LogInformation("Creating new LivePage from UserPage {UserPageId}", userPageId); + return await _livePageRepository.CreateAsync(livePage); + } + } + + public async Task DeleteByOriginalPageIdAsync(string originalPageId) + { + _logger.LogInformation("Deleting LivePage for UserPage {UserPageId}", originalPageId); + return await _livePageRepository.DeleteByOriginalPageIdAsync(originalPageId); + } + + public async Task IncrementViewAsync(string livePageId) + { + try + { + await _livePageRepository.IncrementViewAsync(livePageId); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to increment view for LivePage {LivePageId}", livePageId); + } + } + + public async Task IncrementLinkClickAsync(string livePageId, int linkIndex) + { + try + { + await _livePageRepository.IncrementLinkClickAsync(livePageId, linkIndex); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to increment click for LivePage {LivePageId} link {LinkIndex}", livePageId, linkIndex); + } + } } \ No newline at end of file diff --git a/src/BCards.Web/Services/ModerationAuthService.cs b/src/BCards.Web/Services/ModerationAuthService.cs index 7a9ffcc..0678ca8 100644 --- a/src/BCards.Web/Services/ModerationAuthService.cs +++ b/src/BCards.Web/Services/ModerationAuthService.cs @@ -1,39 +1,39 @@ -using BCards.Web.Configuration; -using Microsoft.Extensions.Options; -using System.Security.Claims; - -namespace BCards.Web.Services -{ - public class ModerationAuthService : IModerationAuthService - { - private readonly ModerationSettings _settings; - - public ModerationAuthService(IOptions settings) - { - _settings = settings.Value; - } - - public bool IsUserModerator(ClaimsPrincipal user) - { - if (!user.Identity?.IsAuthenticated == true) - return false; - - var email = user.FindFirst(ClaimTypes.Email)?.Value; - return IsEmailModerator(email); - } - - public bool IsEmailModerator(string? email) - { - if (string.IsNullOrWhiteSpace(email)) - return false; - - return _settings.ModeratorEmails - .Any(moderatorEmail => moderatorEmail.Equals(email, StringComparison.OrdinalIgnoreCase)); - } - - public List GetModeratorEmails() - { - return _settings.ModeratorEmails.ToList(); - } - } -} +using BCards.Web.Configuration; +using Microsoft.Extensions.Options; +using System.Security.Claims; + +namespace BCards.Web.Services +{ + public class ModerationAuthService : IModerationAuthService + { + private readonly ModerationSettings _settings; + + public ModerationAuthService(IOptions settings) + { + _settings = settings.Value; + } + + public bool IsUserModerator(ClaimsPrincipal user) + { + if (!user.Identity?.IsAuthenticated == true) + return false; + + var email = user.FindFirst(ClaimTypes.Email)?.Value; + return IsEmailModerator(email); + } + + public bool IsEmailModerator(string? email) + { + if (string.IsNullOrWhiteSpace(email)) + return false; + + return _settings.ModeratorEmails + .Any(moderatorEmail => moderatorEmail.Equals(email, StringComparison.OrdinalIgnoreCase)); + } + + public List GetModeratorEmails() + { + return _settings.ModeratorEmails.ToList(); + } + } +} diff --git a/src/BCards.Web/Services/ModerationService.cs b/src/BCards.Web/Services/ModerationService.cs index 31c4c29..9590215 100644 --- a/src/BCards.Web/Services/ModerationService.cs +++ b/src/BCards.Web/Services/ModerationService.cs @@ -1,287 +1,287 @@ -using BCards.Web.Models; -using BCards.Web.Repositories; -using BCards.Web.ViewModels; -using MongoDB.Driver; - -namespace BCards.Web.Services; - -public class ModerationService : IModerationService -{ - private readonly IUserPageRepository _userPageRepository; - private readonly IUserRepository _userRepository; - private readonly ILivePageService _livePageService; - private readonly ILogger _logger; - - public ModerationService( - IUserPageRepository userPageRepository, - IUserRepository userRepository, - ILivePageService livePageService, - ILogger logger) - { - _userPageRepository = userPageRepository; - _userRepository = userRepository; - _livePageService = livePageService; - _logger = logger; - } - - public async Task GeneratePreviewTokenAsync(string pageId) - { - var token = Guid.NewGuid().ToString("N")[..16]; - var expiry = DateTime.UtcNow.AddMinutes(5); // Token válido por 5 minutos - - // LOG ANTES da busca - _logger.LogInformation("Generating token for page {PageId} - searching page in DB", pageId); - - var page = await _userPageRepository.GetByIdAsync(pageId); - - // LOG TOKEN ANTERIOR - _logger.LogInformation("Page {PageId} - Old token: {OldToken}, New token: {NewToken}", - pageId, page.PreviewToken, token); - - page.PreviewToken = token; - page.PreviewTokenExpiry = expiry; - page.PreviewViewCount = 0; - - // LOG ANTES do update - _logger.LogInformation("Updating page {PageId} with new token {Token}", pageId, token); - - await _userPageRepository.UpdateAsync(page); - - // LOG APÓS update - _logger.LogInformation("Successfully updated page {PageId} with token {Token}", pageId, token); - - return token; - } - - public async Task ValidatePreviewTokenAsync(string pageId, string token) - { - var page = await _userPageRepository.GetByIdAsync(pageId); - if (page == null) - return false; - - var isValid = page.PreviewToken == token && - page.PreviewTokenExpiry > DateTime.UtcNow && - page.PreviewViewCount < 50; - - return isValid; - } - - - public async Task> GetPendingModerationAsync(int skip = 0, int take = 20, string? filter = null) - { - var filterBuilder = Builders.Filter; - var baseFilter = filterBuilder.Eq(p => p.Status, PageStatus.PendingModeration); - - FilterDefinition finalFilter = baseFilter; - - // Aplicar filtro de moderação especial - if (!string.IsNullOrEmpty(filter)) - { - switch (filter.ToLower()) - { - case "special": - // Filtrar apenas páginas com moderação especial (Premium + Afiliados) - var specialFilter = filterBuilder.Eq("planLimitations.specialModeration", true); - finalFilter = filterBuilder.And(baseFilter, specialFilter); - break; - case "normal": - // Filtrar apenas páginas sem moderação especial - var normalFilter = filterBuilder.Or( - filterBuilder.Eq("planLimitations.specialModeration", false), - filterBuilder.Exists("planLimitations.specialModeration", false) - ); - finalFilter = filterBuilder.And(baseFilter, normalFilter); - break; - default: - // "all" ou qualquer outro valor: sem filtro adicional - break; - } - } - - // Ordenar por moderação especial primeiro (SLA reduzido), depois por data - var sort = Builders.Sort - .Descending("planLimitations.specialModeration") - .Ascending(p => p.CreatedAt); - - var pages = await _userPageRepository.GetManyAsync(finalFilter, sort, skip, take); - return pages.ToList(); - } - - public async Task GetPageForModerationAsync(string pageId) - { - var page = await _userPageRepository.GetByIdAsync(pageId); - if (page?.Status != PageStatus.PendingModeration) - return null; - - return page; - } - - public async Task DeleteForModerationAsync(string pageId) - { - var page = await _userPageRepository.GetByIdAsync(pageId); - await _userPageRepository.DeleteAsync(pageId); - } - - public async Task ApprovePageAsync(string pageId, string moderatorId, string? notes = null) - { - var page = await _userPageRepository.GetByIdAsync(pageId); - if (page == null) - throw new ArgumentException("Page not found"); - - var moderationEntry = new ModerationHistory - { - Attempt = page.ModerationAttempts + 1, - Status = "approved", - ModeratorId = moderatorId, - Date = DateTime.UtcNow, - Reason = notes - }; - - page.ModerationHistory.Add(moderationEntry); - - var update = Builders.Update - .Set(p => p.Status, PageStatus.Active) - .Set(p => p.ApprovedAt, DateTime.UtcNow) - .Set(p => p.ModerationAttempts, page.ModerationAttempts + 1) - .Set(p => p.ModerationHistory, page.ModerationHistory) - .Set(p => p.PublishedAt, DateTime.UtcNow) - .Unset(p => p.PreviewToken) - .Unset(p => p.PreviewTokenExpiry); - - await _userPageRepository.UpdateAsync(pageId, update); - _logger.LogInformation("Page {PageId} approved by moderator {ModeratorId}", pageId, moderatorId); - - // 🔥 NOVA FUNCIONALIDADE: Sincronizar para LivePage - try - { - await _livePageService.SyncFromUserPageAsync(pageId); - _logger.LogInformation("Page {PageId} synced to LivePages successfully", pageId); - } - catch (Exception ex) - { - _logger.LogError(ex, "Failed to sync page {PageId} to LivePages. Approval completed but sync failed.", pageId); - // Não falhar a aprovação se sync falhar - } - } - - public async Task RejectPageAsync(string pageId, string moderatorId, string reason, List issues) - { - var page = await _userPageRepository.GetByIdAsync(pageId); - if (page == null) - throw new ArgumentException("Page not found"); - - var moderationEntry = new ModerationHistory - { - Attempt = page.ModerationAttempts + 1, - Status = "rejected", - ModeratorId = moderatorId, - Date = DateTime.UtcNow, - Reason = reason, - Issues = issues - }; - - page.ModerationHistory.Add(moderationEntry); - - var newStatus = page.ModerationAttempts >= 2 ? PageStatus.Rejected : PageStatus.Inactive; - var userScoreDeduction = Math.Min(20, page.UserScore / 5); - - var update = Builders.Update - .Set(p => p.Status, newStatus) - .Set(p => p.ModerationAttempts, page.ModerationAttempts + 1) - .Set(p => p.ModerationHistory, page.ModerationHistory) - .Set(p => p.UserScore, Math.Max(0, page.UserScore - userScoreDeduction)) - .Unset(p => p.PreviewToken) - .Unset(p => p.PreviewTokenExpiry); - - await _userPageRepository.UpdateAsync(pageId, update); - _logger.LogInformation("Page {PageId} rejected by moderator {ModeratorId}. Reason: {Reason}", - pageId, moderatorId, reason); - - // Remover da LivePages se existir - try - { - await _livePageService.DeleteByOriginalPageIdAsync(pageId); - _logger.LogInformation("LivePage removed for rejected UserPage {PageId}", pageId); - } - catch (Exception ex) - { - _logger.LogError(ex, "Failed to remove LivePage for UserPage {PageId}", pageId); - } - } - - public async Task CanUserCreatePageAsync(string userId) - { - var user = await _userRepository.GetByIdAsync(userId); - if (user == null) - return false; - - //var rejectedPages = userPages.Count(p => p.Status == PageStatus.Rejected); - - var filter = Builders.Filter.Eq(p => p.UserId, userId); - filter &= Builders.Filter.Eq(p => p.Status, PageStatus.Rejected); - - var rejectedPages = await _userPageRepository.CountAsync(filter); - - // Usuários com mais de 2 páginas rejeitadas não podem criar novas - return rejectedPages < 2; - } - - public async Task IncrementPreviewViewAsync(string pageId) - { - var page = await _userPageRepository.GetByIdAsync(pageId); - if (page == null || page.PreviewViewCount >= 50) - return false; - - var update = Builders.Update - .Inc(p => p.PreviewViewCount, 1); - - await _userPageRepository.UpdateAsync(pageId, update); - return true; - } - - public async Task> GetModerationStatsAsync() - { - var stats = new Dictionary(); - - var pendingCount = await _userPageRepository.CountAsync( - Builders.Filter.Eq(p => p.Status, PageStatus.PendingModeration)); - - var approvedToday = await _userPageRepository.CountAsync( - Builders.Filter.And( - Builders.Filter.Eq(p => p.Status, PageStatus.Active), - Builders.Filter.Gte(p => p.ApprovedAt, DateTime.UtcNow.Date))); - - var rejectedToday = await _userPageRepository.CountAsync( - Builders.Filter.And( - Builders.Filter.Eq(p => p.Status, PageStatus.Rejected), - Builders.Filter.Gte(p => p.UpdatedAt, DateTime.UtcNow.Date))); - - stats["pending"] = (int)pendingCount; - stats["approvedToday"] = (int)approvedToday; - stats["rejectedToday"] = (int)rejectedToday; - - return stats; - } - - public async Task> GetModerationHistoryAsync(int skip = 0, int take = 20) - { - var filter = Builders.Filter.Or( - Builders.Filter.Eq(p => p.Status, PageStatus.Active), - Builders.Filter.Eq(p => p.Status, PageStatus.Rejected)); - - var sort = Builders.Sort.Descending(p => p.UpdatedAt); - - var pages = await _userPageRepository.GetManyAsync(filter, sort, skip, take); - return pages.ToList(); - } - - public async Task GetPageByPreviewTokenAsync(string token) - { - var filter = Builders.Filter.And( - Builders.Filter.Eq(p => p.PreviewToken, token), - Builders.Filter.Gt(p => p.PreviewTokenExpiry, DateTime.UtcNow)); - - var pages = await _userPageRepository.GetManyAsync(filter); - return pages.FirstOrDefault(); - } +using BCards.Web.Models; +using BCards.Web.Repositories; +using BCards.Web.ViewModels; +using MongoDB.Driver; + +namespace BCards.Web.Services; + +public class ModerationService : IModerationService +{ + private readonly IUserPageRepository _userPageRepository; + private readonly IUserRepository _userRepository; + private readonly ILivePageService _livePageService; + private readonly ILogger _logger; + + public ModerationService( + IUserPageRepository userPageRepository, + IUserRepository userRepository, + ILivePageService livePageService, + ILogger logger) + { + _userPageRepository = userPageRepository; + _userRepository = userRepository; + _livePageService = livePageService; + _logger = logger; + } + + public async Task GeneratePreviewTokenAsync(string pageId) + { + var token = Guid.NewGuid().ToString("N")[..16]; + var expiry = DateTime.UtcNow.AddMinutes(5); // Token válido por 5 minutos + + // LOG ANTES da busca + _logger.LogInformation("Generating token for page {PageId} - searching page in DB", pageId); + + var page = await _userPageRepository.GetByIdAsync(pageId); + + // LOG TOKEN ANTERIOR + _logger.LogInformation("Page {PageId} - Old token: {OldToken}, New token: {NewToken}", + pageId, page.PreviewToken, token); + + page.PreviewToken = token; + page.PreviewTokenExpiry = expiry; + page.PreviewViewCount = 0; + + // LOG ANTES do update + _logger.LogInformation("Updating page {PageId} with new token {Token}", pageId, token); + + await _userPageRepository.UpdateAsync(page); + + // LOG APÓS update + _logger.LogInformation("Successfully updated page {PageId} with token {Token}", pageId, token); + + return token; + } + + public async Task ValidatePreviewTokenAsync(string pageId, string token) + { + var page = await _userPageRepository.GetByIdAsync(pageId); + if (page == null) + return false; + + var isValid = page.PreviewToken == token && + page.PreviewTokenExpiry > DateTime.UtcNow && + page.PreviewViewCount < 50; + + return isValid; + } + + + public async Task> GetPendingModerationAsync(int skip = 0, int take = 20, string? filter = null) + { + var filterBuilder = Builders.Filter; + var baseFilter = filterBuilder.Eq(p => p.Status, PageStatus.PendingModeration); + + FilterDefinition finalFilter = baseFilter; + + // Aplicar filtro de moderação especial + if (!string.IsNullOrEmpty(filter)) + { + switch (filter.ToLower()) + { + case "special": + // Filtrar apenas páginas com moderação especial (Premium + Afiliados) + var specialFilter = filterBuilder.Eq("planLimitations.specialModeration", true); + finalFilter = filterBuilder.And(baseFilter, specialFilter); + break; + case "normal": + // Filtrar apenas páginas sem moderação especial + var normalFilter = filterBuilder.Or( + filterBuilder.Eq("planLimitations.specialModeration", false), + filterBuilder.Exists("planLimitations.specialModeration", false) + ); + finalFilter = filterBuilder.And(baseFilter, normalFilter); + break; + default: + // "all" ou qualquer outro valor: sem filtro adicional + break; + } + } + + // Ordenar por moderação especial primeiro (SLA reduzido), depois por data + var sort = Builders.Sort + .Descending("planLimitations.specialModeration") + .Ascending(p => p.CreatedAt); + + var pages = await _userPageRepository.GetManyAsync(finalFilter, sort, skip, take); + return pages.ToList(); + } + + public async Task GetPageForModerationAsync(string pageId) + { + var page = await _userPageRepository.GetByIdAsync(pageId); + if (page?.Status != PageStatus.PendingModeration) + return null; + + return page; + } + + public async Task DeleteForModerationAsync(string pageId) + { + var page = await _userPageRepository.GetByIdAsync(pageId); + await _userPageRepository.DeleteAsync(pageId); + } + + public async Task ApprovePageAsync(string pageId, string moderatorId, string? notes = null) + { + var page = await _userPageRepository.GetByIdAsync(pageId); + if (page == null) + throw new ArgumentException("Page not found"); + + var moderationEntry = new ModerationHistory + { + Attempt = page.ModerationAttempts + 1, + Status = "approved", + ModeratorId = moderatorId, + Date = DateTime.UtcNow, + Reason = notes + }; + + page.ModerationHistory.Add(moderationEntry); + + var update = Builders.Update + .Set(p => p.Status, PageStatus.Active) + .Set(p => p.ApprovedAt, DateTime.UtcNow) + .Set(p => p.ModerationAttempts, page.ModerationAttempts + 1) + .Set(p => p.ModerationHistory, page.ModerationHistory) + .Set(p => p.PublishedAt, DateTime.UtcNow) + .Unset(p => p.PreviewToken) + .Unset(p => p.PreviewTokenExpiry); + + await _userPageRepository.UpdateAsync(pageId, update); + _logger.LogInformation("Page {PageId} approved by moderator {ModeratorId}", pageId, moderatorId); + + // 🔥 NOVA FUNCIONALIDADE: Sincronizar para LivePage + try + { + await _livePageService.SyncFromUserPageAsync(pageId); + _logger.LogInformation("Page {PageId} synced to LivePages successfully", pageId); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to sync page {PageId} to LivePages. Approval completed but sync failed.", pageId); + // Não falhar a aprovação se sync falhar + } + } + + public async Task RejectPageAsync(string pageId, string moderatorId, string reason, List issues) + { + var page = await _userPageRepository.GetByIdAsync(pageId); + if (page == null) + throw new ArgumentException("Page not found"); + + var moderationEntry = new ModerationHistory + { + Attempt = page.ModerationAttempts + 1, + Status = "rejected", + ModeratorId = moderatorId, + Date = DateTime.UtcNow, + Reason = reason, + Issues = issues + }; + + page.ModerationHistory.Add(moderationEntry); + + var newStatus = page.ModerationAttempts >= 2 ? PageStatus.Rejected : PageStatus.Inactive; + var userScoreDeduction = Math.Min(20, page.UserScore / 5); + + var update = Builders.Update + .Set(p => p.Status, newStatus) + .Set(p => p.ModerationAttempts, page.ModerationAttempts + 1) + .Set(p => p.ModerationHistory, page.ModerationHistory) + .Set(p => p.UserScore, Math.Max(0, page.UserScore - userScoreDeduction)) + .Unset(p => p.PreviewToken) + .Unset(p => p.PreviewTokenExpiry); + + await _userPageRepository.UpdateAsync(pageId, update); + _logger.LogInformation("Page {PageId} rejected by moderator {ModeratorId}. Reason: {Reason}", + pageId, moderatorId, reason); + + // Remover da LivePages se existir + try + { + await _livePageService.DeleteByOriginalPageIdAsync(pageId); + _logger.LogInformation("LivePage removed for rejected UserPage {PageId}", pageId); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to remove LivePage for UserPage {PageId}", pageId); + } + } + + public async Task CanUserCreatePageAsync(string userId) + { + var user = await _userRepository.GetByIdAsync(userId); + if (user == null) + return false; + + //var rejectedPages = userPages.Count(p => p.Status == PageStatus.Rejected); + + var filter = Builders.Filter.Eq(p => p.UserId, userId); + filter &= Builders.Filter.Eq(p => p.Status, PageStatus.Rejected); + + var rejectedPages = await _userPageRepository.CountAsync(filter); + + // Usuários com mais de 2 páginas rejeitadas não podem criar novas + return rejectedPages < 2; + } + + public async Task IncrementPreviewViewAsync(string pageId) + { + var page = await _userPageRepository.GetByIdAsync(pageId); + if (page == null || page.PreviewViewCount >= 50) + return false; + + var update = Builders.Update + .Inc(p => p.PreviewViewCount, 1); + + await _userPageRepository.UpdateAsync(pageId, update); + return true; + } + + public async Task> GetModerationStatsAsync() + { + var stats = new Dictionary(); + + var pendingCount = await _userPageRepository.CountAsync( + Builders.Filter.Eq(p => p.Status, PageStatus.PendingModeration)); + + var approvedToday = await _userPageRepository.CountAsync( + Builders.Filter.And( + Builders.Filter.Eq(p => p.Status, PageStatus.Active), + Builders.Filter.Gte(p => p.ApprovedAt, DateTime.UtcNow.Date))); + + var rejectedToday = await _userPageRepository.CountAsync( + Builders.Filter.And( + Builders.Filter.Eq(p => p.Status, PageStatus.Rejected), + Builders.Filter.Gte(p => p.UpdatedAt, DateTime.UtcNow.Date))); + + stats["pending"] = (int)pendingCount; + stats["approvedToday"] = (int)approvedToday; + stats["rejectedToday"] = (int)rejectedToday; + + return stats; + } + + public async Task> GetModerationHistoryAsync(int skip = 0, int take = 20) + { + var filter = Builders.Filter.Or( + Builders.Filter.Eq(p => p.Status, PageStatus.Active), + Builders.Filter.Eq(p => p.Status, PageStatus.Rejected)); + + var sort = Builders.Sort.Descending(p => p.UpdatedAt); + + var pages = await _userPageRepository.GetManyAsync(filter, sort, skip, take); + return pages.ToList(); + } + + public async Task GetPageByPreviewTokenAsync(string token) + { + var filter = Builders.Filter.And( + Builders.Filter.Eq(p => p.PreviewToken, token), + Builders.Filter.Gt(p => p.PreviewTokenExpiry, DateTime.UtcNow)); + + var pages = await _userPageRepository.GetManyAsync(filter); + return pages.FirstOrDefault(); + } } \ No newline at end of file diff --git a/src/BCards.Web/Services/OAuthHealthService.cs b/src/BCards.Web/Services/OAuthHealthService.cs index 270139c..bc12c02 100644 --- a/src/BCards.Web/Services/OAuthHealthService.cs +++ b/src/BCards.Web/Services/OAuthHealthService.cs @@ -1,127 +1,127 @@ -using System.Net; - -namespace BCards.Web.Services; - -public class OAuthHealthService : IOAuthHealthService -{ - private readonly HttpClient _httpClient; - private readonly ILogger _logger; - private static OAuthHealthStatus? _cachedStatus; - private static DateTime _lastCheck = DateTime.MinValue; - private static readonly TimeSpan CacheTimeout = TimeSpan.FromMinutes(1); // Reduzido para 1 minuto - - public OAuthHealthService(HttpClient httpClient, ILogger logger) - { - _httpClient = httpClient; - _logger = logger; - - // IMPORTANTE: Não modificar o HttpClient aqui para evitar conflitos - // O timeout já foi configurado no Program.cs - } - - public async Task CheckOAuthProvidersAsync() - { - // Usar cache se ainda válido - if (_cachedStatus != null && DateTime.UtcNow - _lastCheck < CacheTimeout) - { - return _cachedStatus; - } - - var status = new OAuthHealthStatus(); - - var tasks = new[] - { - CheckProviderAsync("Google", "https://accounts.google.com"), - CheckProviderAsync("Microsoft", "https://login.microsoftonline.com") - }; - - var results = await Task.WhenAll(tasks); - status.GoogleAvailable = results[0]; - status.MicrosoftAvailable = results[1]; - - // Cache o resultado - _cachedStatus = status; - _lastCheck = DateTime.UtcNow; - - return status; - } - - public async Task IsGoogleAvailableAsync() - { - var status = await CheckOAuthProvidersAsync(); - return status.GoogleAvailable; - } - - public async Task IsMicrosoftAvailableAsync() - { - var status = await CheckOAuthProvidersAsync(); - return status.MicrosoftAvailable; - } - - public async Task LogOAuthStatusAsync() - { - var status = await CheckOAuthProvidersAsync(); - - if (!status.GoogleAvailable) - { - _logger.LogWarning("🟡 OAuth Provider Google está OFFLINE - usuários não conseguem fazer login com Google"); - } - - if (!status.MicrosoftAvailable) - { - _logger.LogWarning("🟡 OAuth Provider Microsoft está OFFLINE - usuários não conseguem fazer login com Microsoft"); - } - - if (status.AllProvidersHealthy) - { - _logger.LogInformation("✅ Todos os OAuth providers estão funcionando normalmente"); - } - else if (!status.AnyProviderHealthy) - { - _logger.LogError("🔴 CRÍTICO: TODOS os OAuth providers estão offline - login completamente indisponível!"); - } - } - - private async Task CheckProviderAsync(string provider, string url) - { - try - { - using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(8)); // Aumentei para 8 segundos - using var response = await _httpClient.GetAsync(url, cts.Token); - - // Aceitar tanto 200 quanto 302/redirects como saudável - var isHealthy = response.IsSuccessStatusCode || - response.StatusCode == System.Net.HttpStatusCode.Found || - response.StatusCode == System.Net.HttpStatusCode.Moved || - response.StatusCode == System.Net.HttpStatusCode.MovedPermanently; - - if (!isHealthy) - { - _logger.LogWarning("OAuth provider {Provider} retornou status {StatusCode}", - provider, (int)response.StatusCode); - } else { - _logger.LogDebug("OAuth provider {Provider} está saudável: {StatusCode}", - provider, (int)response.StatusCode); - } - - return isHealthy; - } - catch (TaskCanceledException) - { - _logger.LogWarning("OAuth provider {Provider} timeout após 8 segundos", provider); - return false; - } - catch (HttpRequestException ex) - { - _logger.LogWarning("OAuth provider {Provider} erro de conexão: {Error}", provider, ex.Message); - return false; - } - catch (Exception ex) - { - _logger.LogWarning(ex, "Erro ao verificar OAuth provider {Provider} - assumindo disponível para não bloquear login", provider); - // MUDANÇA CRÍTICA: Se houver erro desconhecido, assumir que está disponível - // para não bloquear login dos usuários - return true; - } - } +using System.Net; + +namespace BCards.Web.Services; + +public class OAuthHealthService : IOAuthHealthService +{ + private readonly HttpClient _httpClient; + private readonly ILogger _logger; + private static OAuthHealthStatus? _cachedStatus; + private static DateTime _lastCheck = DateTime.MinValue; + private static readonly TimeSpan CacheTimeout = TimeSpan.FromMinutes(1); // Reduzido para 1 minuto + + public OAuthHealthService(HttpClient httpClient, ILogger logger) + { + _httpClient = httpClient; + _logger = logger; + + // IMPORTANTE: Não modificar o HttpClient aqui para evitar conflitos + // O timeout já foi configurado no Program.cs + } + + public async Task CheckOAuthProvidersAsync() + { + // Usar cache se ainda válido + if (_cachedStatus != null && DateTime.UtcNow - _lastCheck < CacheTimeout) + { + return _cachedStatus; + } + + var status = new OAuthHealthStatus(); + + var tasks = new[] + { + CheckProviderAsync("Google", "https://accounts.google.com"), + CheckProviderAsync("Microsoft", "https://login.microsoftonline.com") + }; + + var results = await Task.WhenAll(tasks); + status.GoogleAvailable = results[0]; + status.MicrosoftAvailable = results[1]; + + // Cache o resultado + _cachedStatus = status; + _lastCheck = DateTime.UtcNow; + + return status; + } + + public async Task IsGoogleAvailableAsync() + { + var status = await CheckOAuthProvidersAsync(); + return status.GoogleAvailable; + } + + public async Task IsMicrosoftAvailableAsync() + { + var status = await CheckOAuthProvidersAsync(); + return status.MicrosoftAvailable; + } + + public async Task LogOAuthStatusAsync() + { + var status = await CheckOAuthProvidersAsync(); + + if (!status.GoogleAvailable) + { + _logger.LogWarning("🟡 OAuth Provider Google está OFFLINE - usuários não conseguem fazer login com Google"); + } + + if (!status.MicrosoftAvailable) + { + _logger.LogWarning("🟡 OAuth Provider Microsoft está OFFLINE - usuários não conseguem fazer login com Microsoft"); + } + + if (status.AllProvidersHealthy) + { + _logger.LogInformation("✅ Todos os OAuth providers estão funcionando normalmente"); + } + else if (!status.AnyProviderHealthy) + { + _logger.LogError("🔴 CRÍTICO: TODOS os OAuth providers estão offline - login completamente indisponível!"); + } + } + + private async Task CheckProviderAsync(string provider, string url) + { + try + { + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(8)); // Aumentei para 8 segundos + using var response = await _httpClient.GetAsync(url, cts.Token); + + // Aceitar tanto 200 quanto 302/redirects como saudável + var isHealthy = response.IsSuccessStatusCode || + response.StatusCode == System.Net.HttpStatusCode.Found || + response.StatusCode == System.Net.HttpStatusCode.Moved || + response.StatusCode == System.Net.HttpStatusCode.MovedPermanently; + + if (!isHealthy) + { + _logger.LogWarning("OAuth provider {Provider} retornou status {StatusCode}", + provider, (int)response.StatusCode); + } else { + _logger.LogDebug("OAuth provider {Provider} está saudável: {StatusCode}", + provider, (int)response.StatusCode); + } + + return isHealthy; + } + catch (TaskCanceledException) + { + _logger.LogWarning("OAuth provider {Provider} timeout após 8 segundos", provider); + return false; + } + catch (HttpRequestException ex) + { + _logger.LogWarning("OAuth provider {Provider} erro de conexão: {Error}", provider, ex.Message); + return false; + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Erro ao verificar OAuth provider {Provider} - assumindo disponível para não bloquear login", provider); + // MUDANÇA CRÍTICA: Se houver erro desconhecido, assumir que está disponível + // para não bloquear login dos usuários + return true; + } + } } \ No newline at end of file diff --git a/src/BCards.Web/Services/OpenGraphService.cs b/src/BCards.Web/Services/OpenGraphService.cs index 9451ec3..f8c24fe 100644 --- a/src/BCards.Web/Services/OpenGraphService.cs +++ b/src/BCards.Web/Services/OpenGraphService.cs @@ -1,299 +1,299 @@ -using BCards.Web.Models; -using BCards.Web.Utils; -using Microsoft.Extensions.Caching.Memory; -using MongoDB.Driver; -using HtmlAgilityPack; -using System.Text.RegularExpressions; -using System.Security.Cryptography; -using System.Text; - -namespace BCards.Web.Services; - -public class OpenGraphService : IOpenGraphService -{ - private readonly IMemoryCache _cache; - private readonly ILogger _logger; - private readonly HttpClient _httpClient; - private readonly IMongoCollection _ogCache; - - public OpenGraphService( - IMemoryCache cache, - ILogger logger, - HttpClient httpClient, - IMongoDatabase database) - { - _cache = cache; - _logger = logger; - _httpClient = httpClient; - _ogCache = database.GetCollection("openGraphCache"); - - // Configure HttpClient - _httpClient.DefaultRequestHeaders.Clear(); - //_httpClient.DefaultRequestHeaders.Add("User-Agent", - // "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36"); - _httpClient.DefaultRequestHeaders.Add("User-Agent", - "facebookexternalhit/1.1 (+http://www.facebook.com/externalhit_uatext.php)"); - _httpClient.Timeout = TimeSpan.FromSeconds(10); - } - - public async Task ExtractDataAsync(string url, string userId) - { - // 1. Validar domínio - if (!AllowedDomains.IsAllowed(url)) - { - _logger.LogWarning("Tentativa de extração de domínio não permitido: {Url} pelo usuário {UserId}", url, userId); - throw new InvalidOperationException("Domínio não permitido. Use apenas e-commerces conhecidos e seguros."); - } - - // 2. Verificar rate limit (1 request por minuto por usuário) - var rateLimitKey = $"og_rate_{userId}"; - if (_cache.TryGetValue(rateLimitKey, out _)) - { - _logger.LogWarning("Rate limit excedido para usuário {UserId}", userId); - throw new InvalidOperationException("Aguarde 1 minuto antes de extrair dados de outro produto."); - } - - // 3. Verificar cache no MongoDB - var urlHash = GenerateUrlHash(url); - var cachedData = await GetCachedDataAsync(url); - - if (cachedData != null && cachedData.ExpiresAt > DateTime.UtcNow) - { - _logger.LogInformation("Retornando dados do cache MongoDB para URL: {Url}", url); - return new OpenGraphData - { - Title = cachedData.Title, - Description = cachedData.Description, - Image = cachedData.Image, - Price = cachedData.Price, - Currency = cachedData.Currency, - IsValid = cachedData.IsValid, - ErrorMessage = cachedData.ErrorMessage - }; - } - - // 4. Extrair dados da URL - var extractedData = await ExtractFromUrlAsync(url); - - // 5. Salvar no cache MongoDB - await SaveToCacheAsync(url, urlHash, extractedData); - - // 6. Aplicar rate limit (1 minuto) - _cache.Set(rateLimitKey, true, TimeSpan.FromMinutes(1)); - - _logger.LogInformation("Dados extraídos com sucesso para URL: {Url}", url); - return extractedData; - } - - public Task IsRateLimitedAsync(string userId) - { - var rateLimitKey = $"og_rate_{userId}"; - return Task.FromResult(_cache.TryGetValue(rateLimitKey, out _)); - } - - public async Task GetCachedDataAsync(string url) - { - var urlHash = GenerateUrlHash(url); - return await _ogCache - .Find(x => x.UrlHash == urlHash && x.ExpiresAt > DateTime.UtcNow) - .FirstOrDefaultAsync(); - } - - private async Task ExtractFromUrlAsync(string url) - { - try - { - _logger.LogInformation("Iniciando extração de dados para URL: {Url}", url); - - var response = await _httpClient.GetAsync(url); - response.EnsureSuccessStatusCode(); - - var html = await response.Content.ReadAsStringAsync(); - var doc = new HtmlDocument(); - doc.LoadHtml(html); - - var title = GetMetaContent(doc, "og:title", "title") ?? GetTitleFromHTML(doc); - var description = GetMetaContent(doc, "og:description", "description"); - var image = GetMetaContent(doc, "og:image"); - var price = GetMetaContent(doc, "og:price:amount") ?? ExtractPriceFromHTML(html, doc); - var currency = GetMetaContent(doc, "og:price:currency") ?? "BRL"; - - // Limpar e validar dados - title = CleanText(title); - description = CleanText(description); - price = CleanPrice(price); - image = ValidateImageUrl(image, url); - - var isValid = !string.IsNullOrEmpty(title); - - return new OpenGraphData - { - Title = title, - Description = description, - Image = image, - Price = price, - Currency = currency, - IsValid = isValid - }; - } - catch (Exception ex) - { - _logger.LogWarning(ex, "Falha ao extrair dados de {Url}", url); - return new OpenGraphData - { - IsValid = false, - ErrorMessage = $"Erro ao processar a página: {ex.Message}" - }; - } - } - - private string? GetMetaContent(HtmlDocument doc, params string[] properties) - { - foreach (var property in properties) - { - var meta = doc.DocumentNode - .SelectSingleNode($"//meta[@property='{property}' or @name='{property}' or @itemprop='{property}']"); - - var content = meta?.GetAttributeValue("content", null); - if (!string.IsNullOrWhiteSpace(content)) - return content; - } - return null; - } - - private string? GetTitleFromHTML(HtmlDocument doc) - { - var titleNode = doc.DocumentNode.SelectSingleNode("//title"); - return titleNode?.InnerText?.Trim(); - } - - private string? ExtractPriceFromHTML(string html, HtmlDocument doc) - { - // Regex patterns para encontrar preços em diferentes formatos - var pricePatterns = new[] - { - @"R\$\s*[\d\.,]+", - @"BRL\s*[\d\.,]+", - @"[\$]\s*[\d\.,]+", - @"price[^>]*>([^<]*[\d\.,]+[^<]*)<", - @"valor[^>]*>([^<]*[\d\.,]+[^<]*)<", - @"preço[^>]*>([^<]*[\d\.,]+[^<]*)<" - }; - - foreach (var pattern in pricePatterns) - { - var match = Regex.Match(html, pattern, RegexOptions.IgnoreCase); - if (match.Success) - { - return match.Value; - } - } - - // Tentar encontrar por seletores específicos - var priceSelectors = new[] - { - ".price", ".valor", ".preco", "[data-price]", ".price-current", - ".price-value", ".product-price", ".sale-price" - }; - - foreach (var selector in priceSelectors) - { - var priceNode = doc.DocumentNode.SelectSingleNode($"//*[contains(@class, '{selector.Replace(".", "")}')]"); - if (priceNode != null) - { - var priceText = priceNode.InnerText?.Trim(); - if (Regex.IsMatch(priceText ?? "", @"[\d\.,]+")) - { - return priceText; - } - } - } - - return null; - } - - private string CleanText(string? text) - { - if (string.IsNullOrWhiteSpace(text)) - return string.Empty; - - return Regex.Replace(text.Trim(), @"\s+", " "); - } - - private string CleanPrice(string? price) - { - if (string.IsNullOrWhiteSpace(price)) - return string.Empty; - - // Limpar e formatar preço - var cleanPrice = Regex.Replace(price, @"[^\d\.,R\$]", " ").Trim(); - return Regex.Replace(cleanPrice, @"\s+", " "); - } - - private string ValidateImageUrl(string? imageUrl, string baseUrl) - { - if (string.IsNullOrWhiteSpace(imageUrl)) - return string.Empty; - - try - { - // Se for URL relativa, converter para absoluta - if (imageUrl.StartsWith("/")) - { - var baseUri = new Uri(baseUrl); - return $"{baseUri.Scheme}://{baseUri.Host}{imageUrl}"; - } - - // Validar se é uma URL válida - if (Uri.TryCreate(imageUrl, UriKind.Absolute, out var uri)) - { - return uri.ToString(); - } - } - catch (Exception ex) - { - _logger.LogWarning(ex, "Erro ao validar URL da imagem: {ImageUrl}", imageUrl); - } - - return string.Empty; - } - - private string GenerateUrlHash(string url) - { - using var sha256 = SHA256.Create(); - var hashBytes = sha256.ComputeHash(Encoding.UTF8.GetBytes(url.ToLowerInvariant())); - return Convert.ToBase64String(hashBytes); - } - - private async Task SaveToCacheAsync(string url, string urlHash, OpenGraphData data) - { - try - { - var cacheItem = new OpenGraphCache - { - Url = url, - UrlHash = urlHash, - Title = data.Title, - Description = data.Description, - Image = data.Image, - Price = data.Price, - Currency = data.Currency, - IsValid = data.IsValid, - ErrorMessage = data.ErrorMessage, - CachedAt = DateTime.UtcNow, - ExpiresAt = data.IsValid ? DateTime.UtcNow.AddHours(24) : DateTime.UtcNow.AddHours(1) - }; - - // Upsert no MongoDB - await _ogCache.ReplaceOneAsync( - x => x.UrlHash == urlHash, - cacheItem, - new ReplaceOptions { IsUpsert = true } - ); - } - catch (Exception ex) - { - _logger.LogError(ex, "Erro ao salvar cache para URL: {Url}", url); - } - } +using BCards.Web.Models; +using BCards.Web.Utils; +using Microsoft.Extensions.Caching.Memory; +using MongoDB.Driver; +using HtmlAgilityPack; +using System.Text.RegularExpressions; +using System.Security.Cryptography; +using System.Text; + +namespace BCards.Web.Services; + +public class OpenGraphService : IOpenGraphService +{ + private readonly IMemoryCache _cache; + private readonly ILogger _logger; + private readonly HttpClient _httpClient; + private readonly IMongoCollection _ogCache; + + public OpenGraphService( + IMemoryCache cache, + ILogger logger, + HttpClient httpClient, + IMongoDatabase database) + { + _cache = cache; + _logger = logger; + _httpClient = httpClient; + _ogCache = database.GetCollection("openGraphCache"); + + // Configure HttpClient + _httpClient.DefaultRequestHeaders.Clear(); + //_httpClient.DefaultRequestHeaders.Add("User-Agent", + // "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36"); + _httpClient.DefaultRequestHeaders.Add("User-Agent", + "facebookexternalhit/1.1 (+http://www.facebook.com/externalhit_uatext.php)"); + _httpClient.Timeout = TimeSpan.FromSeconds(10); + } + + public async Task ExtractDataAsync(string url, string userId) + { + // 1. Validar domínio + if (!AllowedDomains.IsAllowed(url)) + { + _logger.LogWarning("Tentativa de extração de domínio não permitido: {Url} pelo usuário {UserId}", url, userId); + throw new InvalidOperationException("Domínio não permitido. Use apenas e-commerces conhecidos e seguros."); + } + + // 2. Verificar rate limit (1 request por minuto por usuário) + var rateLimitKey = $"og_rate_{userId}"; + if (_cache.TryGetValue(rateLimitKey, out _)) + { + _logger.LogWarning("Rate limit excedido para usuário {UserId}", userId); + throw new InvalidOperationException("Aguarde 1 minuto antes de extrair dados de outro produto."); + } + + // 3. Verificar cache no MongoDB + var urlHash = GenerateUrlHash(url); + var cachedData = await GetCachedDataAsync(url); + + if (cachedData != null && cachedData.ExpiresAt > DateTime.UtcNow) + { + _logger.LogInformation("Retornando dados do cache MongoDB para URL: {Url}", url); + return new OpenGraphData + { + Title = cachedData.Title, + Description = cachedData.Description, + Image = cachedData.Image, + Price = cachedData.Price, + Currency = cachedData.Currency, + IsValid = cachedData.IsValid, + ErrorMessage = cachedData.ErrorMessage + }; + } + + // 4. Extrair dados da URL + var extractedData = await ExtractFromUrlAsync(url); + + // 5. Salvar no cache MongoDB + await SaveToCacheAsync(url, urlHash, extractedData); + + // 6. Aplicar rate limit (1 minuto) + _cache.Set(rateLimitKey, true, TimeSpan.FromMinutes(1)); + + _logger.LogInformation("Dados extraídos com sucesso para URL: {Url}", url); + return extractedData; + } + + public Task IsRateLimitedAsync(string userId) + { + var rateLimitKey = $"og_rate_{userId}"; + return Task.FromResult(_cache.TryGetValue(rateLimitKey, out _)); + } + + public async Task GetCachedDataAsync(string url) + { + var urlHash = GenerateUrlHash(url); + return await _ogCache + .Find(x => x.UrlHash == urlHash && x.ExpiresAt > DateTime.UtcNow) + .FirstOrDefaultAsync(); + } + + private async Task ExtractFromUrlAsync(string url) + { + try + { + _logger.LogInformation("Iniciando extração de dados para URL: {Url}", url); + + var response = await _httpClient.GetAsync(url); + response.EnsureSuccessStatusCode(); + + var html = await response.Content.ReadAsStringAsync(); + var doc = new HtmlDocument(); + doc.LoadHtml(html); + + var title = GetMetaContent(doc, "og:title", "title") ?? GetTitleFromHTML(doc); + var description = GetMetaContent(doc, "og:description", "description"); + var image = GetMetaContent(doc, "og:image"); + var price = GetMetaContent(doc, "og:price:amount") ?? ExtractPriceFromHTML(html, doc); + var currency = GetMetaContent(doc, "og:price:currency") ?? "BRL"; + + // Limpar e validar dados + title = CleanText(title); + description = CleanText(description); + price = CleanPrice(price); + image = ValidateImageUrl(image, url); + + var isValid = !string.IsNullOrEmpty(title); + + return new OpenGraphData + { + Title = title, + Description = description, + Image = image, + Price = price, + Currency = currency, + IsValid = isValid + }; + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Falha ao extrair dados de {Url}", url); + return new OpenGraphData + { + IsValid = false, + ErrorMessage = $"Erro ao processar a página: {ex.Message}" + }; + } + } + + private string? GetMetaContent(HtmlDocument doc, params string[] properties) + { + foreach (var property in properties) + { + var meta = doc.DocumentNode + .SelectSingleNode($"//meta[@property='{property}' or @name='{property}' or @itemprop='{property}']"); + + var content = meta?.GetAttributeValue("content", null); + if (!string.IsNullOrWhiteSpace(content)) + return content; + } + return null; + } + + private string? GetTitleFromHTML(HtmlDocument doc) + { + var titleNode = doc.DocumentNode.SelectSingleNode("//title"); + return titleNode?.InnerText?.Trim(); + } + + private string? ExtractPriceFromHTML(string html, HtmlDocument doc) + { + // Regex patterns para encontrar preços em diferentes formatos + var pricePatterns = new[] + { + @"R\$\s*[\d\.,]+", + @"BRL\s*[\d\.,]+", + @"[\$]\s*[\d\.,]+", + @"price[^>]*>([^<]*[\d\.,]+[^<]*)<", + @"valor[^>]*>([^<]*[\d\.,]+[^<]*)<", + @"preço[^>]*>([^<]*[\d\.,]+[^<]*)<" + }; + + foreach (var pattern in pricePatterns) + { + var match = Regex.Match(html, pattern, RegexOptions.IgnoreCase); + if (match.Success) + { + return match.Value; + } + } + + // Tentar encontrar por seletores específicos + var priceSelectors = new[] + { + ".price", ".valor", ".preco", "[data-price]", ".price-current", + ".price-value", ".product-price", ".sale-price" + }; + + foreach (var selector in priceSelectors) + { + var priceNode = doc.DocumentNode.SelectSingleNode($"//*[contains(@class, '{selector.Replace(".", "")}')]"); + if (priceNode != null) + { + var priceText = priceNode.InnerText?.Trim(); + if (Regex.IsMatch(priceText ?? "", @"[\d\.,]+")) + { + return priceText; + } + } + } + + return null; + } + + private string CleanText(string? text) + { + if (string.IsNullOrWhiteSpace(text)) + return string.Empty; + + return Regex.Replace(text.Trim(), @"\s+", " "); + } + + private string CleanPrice(string? price) + { + if (string.IsNullOrWhiteSpace(price)) + return string.Empty; + + // Limpar e formatar preço + var cleanPrice = Regex.Replace(price, @"[^\d\.,R\$]", " ").Trim(); + return Regex.Replace(cleanPrice, @"\s+", " "); + } + + private string ValidateImageUrl(string? imageUrl, string baseUrl) + { + if (string.IsNullOrWhiteSpace(imageUrl)) + return string.Empty; + + try + { + // Se for URL relativa, converter para absoluta + if (imageUrl.StartsWith("/")) + { + var baseUri = new Uri(baseUrl); + return $"{baseUri.Scheme}://{baseUri.Host}{imageUrl}"; + } + + // Validar se é uma URL válida + if (Uri.TryCreate(imageUrl, UriKind.Absolute, out var uri)) + { + return uri.ToString(); + } + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Erro ao validar URL da imagem: {ImageUrl}", imageUrl); + } + + return string.Empty; + } + + private string GenerateUrlHash(string url) + { + using var sha256 = SHA256.Create(); + var hashBytes = sha256.ComputeHash(Encoding.UTF8.GetBytes(url.ToLowerInvariant())); + return Convert.ToBase64String(hashBytes); + } + + private async Task SaveToCacheAsync(string url, string urlHash, OpenGraphData data) + { + try + { + var cacheItem = new OpenGraphCache + { + Url = url, + UrlHash = urlHash, + Title = data.Title, + Description = data.Description, + Image = data.Image, + Price = data.Price, + Currency = data.Currency, + IsValid = data.IsValid, + ErrorMessage = data.ErrorMessage, + CachedAt = DateTime.UtcNow, + ExpiresAt = data.IsValid ? DateTime.UtcNow.AddHours(24) : DateTime.UtcNow.AddHours(1) + }; + + // Upsert no MongoDB + await _ogCache.ReplaceOneAsync( + x => x.UrlHash == urlHash, + cacheItem, + new ReplaceOptions { IsUpsert = true } + ); + } + catch (Exception ex) + { + _logger.LogError(ex, "Erro ao salvar cache para URL: {Url}", url); + } + } } \ No newline at end of file diff --git a/src/BCards.Web/Services/PaymentService.cs b/src/BCards.Web/Services/PaymentService.cs index a9e8a18..29124bb 100644 --- a/src/BCards.Web/Services/PaymentService.cs +++ b/src/BCards.Web/Services/PaymentService.cs @@ -1,561 +1,561 @@ -using BCards.Web.Configuration; -using BCards.Web.Models; -using BCards.Web.Repositories; -using Microsoft.Extensions.Options; -using Stripe; -using Stripe.Checkout; -using Stripe.BillingPortal; -using System.Numerics; - -namespace BCards.Web.Services; - -public class PaymentService : IPaymentService -{ - private readonly StripeSettings _stripeSettings; - private readonly IUserRepository _userRepository; - private readonly ISubscriptionRepository _subscriptionRepository; - private readonly IConfiguration _configuration; - private readonly IPlanConfigurationService _planConfigurationService; - - public PaymentService( - IOptions stripeSettings, - IUserRepository userRepository, - ISubscriptionRepository subscriptionRepository, - IConfiguration configuration, - IPlanConfigurationService planConfigurationService) - { - _stripeSettings = stripeSettings.Value; - _userRepository = userRepository; - _subscriptionRepository = subscriptionRepository; - _configuration = configuration; - _planConfigurationService = planConfigurationService; - - StripeConfiguration.ApiKey = _stripeSettings.SecretKey; - } - - public async Task CreateCheckoutSessionAsync(string userId, string planType, string returnUrl, string cancelUrl) - { - var user = await _userRepository.GetByIdAsync(userId); - if (user == null) throw new InvalidOperationException("User not found"); - - var planConfig = _configuration.GetSection($"Plans:{planType}"); - var priceId = planConfig["PriceId"]; - - if (string.IsNullOrEmpty(priceId)) - throw new InvalidOperationException($"Price ID not found for plan: {planType}"); - - var customer = await CreateOrGetCustomerAsync(userId, user.Email, user.Name); - - var options = new Stripe.Checkout.SessionCreateOptions - { - PaymentMethodTypes = new List { "card" }, - Mode = "subscription", - Customer = customer.Id, - LineItems = new List - { - new() - { - Price = priceId, - Quantity = 1 - } - }, - SuccessUrl = returnUrl, - CancelUrl = cancelUrl, - Metadata = new Dictionary - { - { "user_id", userId }, - { "plan_type", planType } - } - }; - - var service = new Stripe.Checkout.SessionService(); - var session = await service.CreateAsync(options); - - return session.Url; - } - - public async Task CreateOrGetCustomerAsync(string userId, string email, string name) - { - var user = await _userRepository.GetByIdAsync(userId); - - if (!string.IsNullOrEmpty(user?.StripeCustomerId)) - { - var customerService = new CustomerService(); - try - { - return await customerService.GetAsync(user.StripeCustomerId); - } - catch (StripeException) - { - // Customer doesn't exist, create new one - } - } - - // Create new customer - var options = new CustomerCreateOptions - { - Email = email, - Name = name, - Metadata = new Dictionary - { - { "user_id", userId } - } - }; - - var service = new CustomerService(); - var customer = await service.CreateAsync(options); - - // Update user with customer ID - if (user != null) - { - user.StripeCustomerId = customer.Id; - await _userRepository.UpdateAsync(user); - } - - return customer; - } - - public async Task HandleWebhookAsync(string requestBody, string signature) - { - try - { - var stripeEvent = EventUtility.ConstructEvent(requestBody, signature, _stripeSettings.WebhookSecret, throwOnApiVersionMismatch: false); - - switch (stripeEvent.Type) - { - case "checkout.session.completed": - var session = stripeEvent.Data.Object as Stripe.Checkout.Session; - await HandleCheckoutSessionCompletedAsync(session!); - break; - - case "invoice.finalized": - var invoice = stripeEvent.Data.Object as Invoice; - await HandleInvoicePaymentSucceededAsync(invoice!); - break; - - case "customer.subscription.updated": - case "customer.subscription.deleted": - var subscription = stripeEvent.Data.Object as Stripe.Subscription; - await HandleSubscriptionUpdatedAsync(subscription!); - break; - } - - return stripeEvent.Data.Object as Stripe.Subscription ?? new Stripe.Subscription(); - } - catch (StripeException ex) - { - throw new InvalidOperationException($"Webhook signature verification failed: {ex.Message}"); - } - } - - public async Task> GetPricesAsync() - { - var service = new PriceService(); - var options = new PriceListOptions - { - Active = true, - Type = "recurring" - }; - - var prices = await service.ListAsync(options); - return prices.Data; - } - - public async Task CancelSubscriptionAsync(string subscriptionId) - { - var service = new SubscriptionService(); - var options = new SubscriptionUpdateOptions - { - CancelAtPeriodEnd = true - }; - - var subscription = await service.UpdateAsync(subscriptionId, options); - - // Update local subscription - var localSubscription = await _subscriptionRepository.GetByStripeSubscriptionIdAsync(subscriptionId); - if (localSubscription != null) - { - localSubscription.CancelAtPeriodEnd = true; - await _subscriptionRepository.UpdateAsync(localSubscription); - } - - return subscription.CancelAtPeriodEnd; - } - - public async Task UpdateSubscriptionAsync(string subscriptionId, string newPriceId) - { - var service = new SubscriptionService(); - var subscription = await service.GetAsync(subscriptionId); - - var options = new SubscriptionUpdateOptions - { - Items = new List - { - new() - { - Id = subscription.Items.Data[0].Id, - Price = newPriceId - } - } - }; - - return await service.UpdateAsync(subscriptionId, options); - } - - public Task GetPlanLimitationsAsync(string planType) - { - // Mapear string planType para PlanType enum - var planTypeEnum = planType.ToLower() switch - { - "basic" or "basicyearly" => PlanType.Basic, - "professional" or "professionalyearly" => PlanType.Professional, - "premium" or "premiumyearly" => PlanType.Premium, - "premiumaffiliate" or "premiumaffiliateyearly" => PlanType.PremiumAffiliate, - _ => PlanType.Trial - }; - - var limitations = _planConfigurationService.GetPlanLimitations(planTypeEnum); - return Task.FromResult(limitations); - } - - private async Task HandleCheckoutSessionCompletedAsync(Stripe.Checkout.Session session) - { - var userId = session.Metadata["user_id"]; - var planType = session.Metadata["plan_type"]; - - var subscriptionService = new SubscriptionService(); - var stripeSubscription = await subscriptionService.GetAsync(session.SubscriptionId); - - var service = new SubscriptionItemService(); - var subItem = service.Get(stripeSubscription.Items.Data[0].Id); - - var limitations = await GetPlanLimitationsAsync(planType); - - var subscription = new Models.Subscription - { - UserId = userId, - StripeSubscriptionId = session.SubscriptionId, - PlanType = planType, - Status = stripeSubscription.Status, - CurrentPeriodStart = subItem.CurrentPeriodStart, - CurrentPeriodEnd = subItem.CurrentPeriodEnd, - MaxLinks = limitations.MaxLinks, - AllowCustomThemes = limitations.AllowCustomThemes, - AllowAnalytics = limitations.AllowAnalytics, - AllowCustomDomain = limitations.AllowCustomDomain, - AllowMultipleDomains = limitations.AllowMultipleDomains, - PrioritySupport = limitations.PrioritySupport - }; - - await _subscriptionRepository.CreateAsync(subscription); - - // Update user - var user = await _userRepository.GetByIdAsync(userId); - if (user != null) - { - user.CurrentPlan = planType; - user.SubscriptionStatus = "active"; - await _userRepository.UpdateAsync(user); - } - } - - private async Task HandleInvoicePaymentSucceededAsync(Invoice invoice) - { - var subscriptionId = GetSubscriptionId(invoice); - var subscription = await _subscriptionRepository.GetByStripeSubscriptionIdAsync(subscriptionId); - if (subscription != null) - { - subscription.Status = "active"; - await _subscriptionRepository.UpdateAsync(subscription); - } - } - - private async Task HandleSubscriptionUpdatedAsync(Stripe.Subscription stripeSubscription) - { - var subscription = await _subscriptionRepository.GetByStripeSubscriptionIdAsync(stripeSubscription.Id); - if (subscription != null) - { - var service = new SubscriptionItemService(); - var subItem = service.Get(stripeSubscription.Items.Data[0].Id); - - subscription.Status = stripeSubscription.Status; - subscription.CurrentPeriodStart = subItem.CurrentPeriodStart; - subscription.CurrentPeriodEnd = subItem.CurrentPeriodEnd; - subscription.CancelAtPeriodEnd = stripeSubscription.CancelAtPeriodEnd; - - await _subscriptionRepository.UpdateAsync(subscription); - - // Update user status - var user = await _userRepository.GetByIdAsync(subscription.UserId); - if (user != null) - { - user.SubscriptionStatus = stripeSubscription.Status; - if (stripeSubscription.Status != "active") - { - // Quando assinatura não está ativa, usuário volta para o plano trial (gratuito) - user.CurrentPlan = "trial"; - } - await _userRepository.UpdateAsync(user); - } - } - } - - public async Task GetSubscriptionDetailsAsync(string userId) - { - var user = await _userRepository.GetByIdAsync(userId); - if (user == null || string.IsNullOrEmpty(user.StripeCustomerId)) - return null; - - var subscription = await _subscriptionRepository.GetByUserIdAsync(userId); - if (subscription == null || string.IsNullOrEmpty(subscription.StripeSubscriptionId)) - return null; - - try - { - var service = new SubscriptionService(); - return await service.GetAsync(subscription.StripeSubscriptionId); - } - catch (StripeException) - { - return null; - } - } - - public async Task> GetPaymentHistoryAsync(string userId) - { - var user = await _userRepository.GetByIdAsync(userId); - if (user == null || string.IsNullOrEmpty(user.StripeCustomerId)) - return new List(); - - try - { - var service = new InvoiceService(); - var options = new InvoiceListOptions - { - Customer = user.StripeCustomerId, - Limit = 50, // Últimas 50 faturas - Status = "paid" - }; - - var invoices = await service.ListAsync(options); - return invoices.Data; - } - catch (StripeException) - { - return new List(); - } - } - - public async Task CreatePortalSessionAsync(string customerId, string returnUrl) - { - try - { - var options = new Stripe.BillingPortal.SessionCreateOptions - { - Customer = customerId, - ReturnUrl = returnUrl - }; - - var service = new Stripe.BillingPortal.SessionService(); - var session = await service.CreateAsync(options); - return session.Url; - } - catch (StripeException ex) - { - throw new InvalidOperationException($"Erro ao criar sessão do portal: {ex.Message}"); - } - } - - // Métodos de cancelamento com diferentes políticas - public async Task CancelSubscriptionImmediatelyAsync(string subscriptionId, bool refund = false) - { - try - { - var service = new SubscriptionService(); - - if (refund) - { - // Para reembolso completo, apenas cancela - reembolso deve ser feito manualmente via Stripe Dashboard - await service.CancelAsync(subscriptionId); - } - else - { - await service.CancelAsync(subscriptionId); - } - - // Atualizar subscription local - var localSubscription = await _subscriptionRepository.GetByStripeSubscriptionIdAsync(subscriptionId); - if (localSubscription != null) - { - localSubscription.Status = "cancelled"; - localSubscription.UpdatedAt = DateTime.UtcNow; - await _subscriptionRepository.UpdateAsync(localSubscription); - } - - return true; - } - catch (StripeException) - { - return false; - } - } - - public async Task CancelSubscriptionAtPeriodEndAsync(string subscriptionId) - { - try - { - var service = new SubscriptionService(); - var options = new SubscriptionUpdateOptions - { - CancelAtPeriodEnd = true - }; - - await service.UpdateAsync(subscriptionId, options); - - // Atualizar subscription local - var localSubscription = await _subscriptionRepository.GetByStripeSubscriptionIdAsync(subscriptionId); - if (localSubscription != null) - { - localSubscription.CancelAtPeriodEnd = true; - localSubscription.UpdatedAt = DateTime.UtcNow; - await _subscriptionRepository.UpdateAsync(localSubscription); - } - - return true; - } - catch (StripeException) - { - return false; - } - } - - public async Task ReactivateSubscriptionAsync(string subscriptionId) - { - try - { - var service = new SubscriptionService(); - var options = new SubscriptionUpdateOptions - { - CancelAtPeriodEnd = false // Reativar removendo o agendamento de cancelamento - }; - - await service.UpdateAsync(subscriptionId, options); - - // Atualizar subscription local - var localSubscription = await _subscriptionRepository.GetByStripeSubscriptionIdAsync(subscriptionId); - if (localSubscription != null) - { - localSubscription.CancelAtPeriodEnd = false; - localSubscription.UpdatedAt = DateTime.UtcNow; - await _subscriptionRepository.UpdateAsync(localSubscription); - } - - return true; - } - catch (StripeException) - { - return false; - } - } - - public async Task CreatePartialRefundAsync(string subscriptionId, decimal refundAmount) - { - // NOTA: Para planos de assinatura, reembolsos devem ser processados manualmente via Stripe Dashboard - // Este método retorna null para indicar que o reembolso precisa ser feito manualmente - await Task.CompletedTask; - throw new InvalidOperationException("Reembolsos parciais para assinaturas devem ser processados manualmente via Stripe Dashboard ou Portal de Cobrança"); - } - - public async Task<(bool CanRefundFull, bool CanRefundPartial, decimal RefundAmount)> CalculateRefundAsync(string subscriptionId) - { - try - { - var service = new SubscriptionService(); - var subscription = await service.GetAsync(subscriptionId); - - // Obter datas via SubscriptionItem - var subscriptionItemService = new SubscriptionItemService(); - var subItem = await subscriptionItemService.GetAsync(subscription.Items.Data[0].Id); - - var daysSinceStart = (DateTime.UtcNow - subItem.CurrentPeriodStart).TotalDays; - var totalDays = (subItem.CurrentPeriodEnd - subItem.CurrentPeriodStart).TotalDays; - var daysRemaining = totalDays - daysSinceStart; - - // Direito de arrependimento (7 dias) - bool canRefundFull = daysSinceStart <= 7; - - // Reembolso proporcional para planos anuais - bool canRefundPartial = false; - decimal refundAmount = 0; - - if (!canRefundFull && daysRemaining > 0) - { - // Verificar se é plano anual (pela duração do período) - bool isYearlyPlan = totalDays > 300; // Aproximadamente 1 ano - - if (isYearlyPlan) - { - canRefundPartial = true; - - // Buscar valor pago na última fatura - var invoiceService = new InvoiceService(); - var invoices = await invoiceService.ListAsync(new InvoiceListOptions - { - Subscription = subscriptionId, - Status = "paid", - Limit = 1 - }); - - if (invoices.Data.Any()) - { - var latestInvoice = invoices.Data.First(); - var totalPaid = (decimal)latestInvoice.AmountPaid / 100; // Converter de centavos - - // Calcular proporção do tempo restante - var proportionRemaining = daysRemaining / totalDays; - refundAmount = totalPaid * (decimal)proportionRemaining; - refundAmount = Math.Round(refundAmount, 2); - } - } - } - - return (canRefundFull, canRefundPartial, refundAmount); - } - catch (StripeException) - { - return (false, false, 0); - } - } - - private async Task CreateFullRefundAsync(string? chargeId) - { - // NOTA: Para planos de assinatura, reembolsos devem ser processados manualmente - await Task.CompletedTask; - return null; - } - - private string GetSubscriptionId(Invoice? invoice) - { - if (invoice!=null) - { - var subscriptionLineItem = invoice.Lines?.Data - .FirstOrDefault(line => - !string.IsNullOrEmpty(line.SubscriptionId) || - line.Subscription != null - ); - - string subscriptionId = null; - - if (subscriptionLineItem != null) - { - // Tenta obter o ID da assinatura de duas formas diferentes - subscriptionId = subscriptionLineItem.SubscriptionId - ?? subscriptionLineItem.Subscription?.Id; - } - - return subscriptionId; - } - - return null; - } +using BCards.Web.Configuration; +using BCards.Web.Models; +using BCards.Web.Repositories; +using Microsoft.Extensions.Options; +using Stripe; +using Stripe.Checkout; +using Stripe.BillingPortal; +using System.Numerics; + +namespace BCards.Web.Services; + +public class PaymentService : IPaymentService +{ + private readonly StripeSettings _stripeSettings; + private readonly IUserRepository _userRepository; + private readonly ISubscriptionRepository _subscriptionRepository; + private readonly IConfiguration _configuration; + private readonly IPlanConfigurationService _planConfigurationService; + + public PaymentService( + IOptions stripeSettings, + IUserRepository userRepository, + ISubscriptionRepository subscriptionRepository, + IConfiguration configuration, + IPlanConfigurationService planConfigurationService) + { + _stripeSettings = stripeSettings.Value; + _userRepository = userRepository; + _subscriptionRepository = subscriptionRepository; + _configuration = configuration; + _planConfigurationService = planConfigurationService; + + StripeConfiguration.ApiKey = _stripeSettings.SecretKey; + } + + public async Task CreateCheckoutSessionAsync(string userId, string planType, string returnUrl, string cancelUrl) + { + var user = await _userRepository.GetByIdAsync(userId); + if (user == null) throw new InvalidOperationException("User not found"); + + var planConfig = _configuration.GetSection($"Plans:{planType}"); + var priceId = planConfig["PriceId"]; + + if (string.IsNullOrEmpty(priceId)) + throw new InvalidOperationException($"Price ID not found for plan: {planType}"); + + var customer = await CreateOrGetCustomerAsync(userId, user.Email, user.Name); + + var options = new Stripe.Checkout.SessionCreateOptions + { + PaymentMethodTypes = new List { "card" }, + Mode = "subscription", + Customer = customer.Id, + LineItems = new List + { + new() + { + Price = priceId, + Quantity = 1 + } + }, + SuccessUrl = returnUrl, + CancelUrl = cancelUrl, + Metadata = new Dictionary + { + { "user_id", userId }, + { "plan_type", planType } + } + }; + + var service = new Stripe.Checkout.SessionService(); + var session = await service.CreateAsync(options); + + return session.Url; + } + + public async Task CreateOrGetCustomerAsync(string userId, string email, string name) + { + var user = await _userRepository.GetByIdAsync(userId); + + if (!string.IsNullOrEmpty(user?.StripeCustomerId)) + { + var customerService = new CustomerService(); + try + { + return await customerService.GetAsync(user.StripeCustomerId); + } + catch (StripeException) + { + // Customer doesn't exist, create new one + } + } + + // Create new customer + var options = new CustomerCreateOptions + { + Email = email, + Name = name, + Metadata = new Dictionary + { + { "user_id", userId } + } + }; + + var service = new CustomerService(); + var customer = await service.CreateAsync(options); + + // Update user with customer ID + if (user != null) + { + user.StripeCustomerId = customer.Id; + await _userRepository.UpdateAsync(user); + } + + return customer; + } + + public async Task HandleWebhookAsync(string requestBody, string signature) + { + try + { + var stripeEvent = EventUtility.ConstructEvent(requestBody, signature, _stripeSettings.WebhookSecret, throwOnApiVersionMismatch: false); + + switch (stripeEvent.Type) + { + case "checkout.session.completed": + var session = stripeEvent.Data.Object as Stripe.Checkout.Session; + await HandleCheckoutSessionCompletedAsync(session!); + break; + + case "invoice.finalized": + var invoice = stripeEvent.Data.Object as Invoice; + await HandleInvoicePaymentSucceededAsync(invoice!); + break; + + case "customer.subscription.updated": + case "customer.subscription.deleted": + var subscription = stripeEvent.Data.Object as Stripe.Subscription; + await HandleSubscriptionUpdatedAsync(subscription!); + break; + } + + return stripeEvent.Data.Object as Stripe.Subscription ?? new Stripe.Subscription(); + } + catch (StripeException ex) + { + throw new InvalidOperationException($"Webhook signature verification failed: {ex.Message}"); + } + } + + public async Task> GetPricesAsync() + { + var service = new PriceService(); + var options = new PriceListOptions + { + Active = true, + Type = "recurring" + }; + + var prices = await service.ListAsync(options); + return prices.Data; + } + + public async Task CancelSubscriptionAsync(string subscriptionId) + { + var service = new SubscriptionService(); + var options = new SubscriptionUpdateOptions + { + CancelAtPeriodEnd = true + }; + + var subscription = await service.UpdateAsync(subscriptionId, options); + + // Update local subscription + var localSubscription = await _subscriptionRepository.GetByStripeSubscriptionIdAsync(subscriptionId); + if (localSubscription != null) + { + localSubscription.CancelAtPeriodEnd = true; + await _subscriptionRepository.UpdateAsync(localSubscription); + } + + return subscription.CancelAtPeriodEnd; + } + + public async Task UpdateSubscriptionAsync(string subscriptionId, string newPriceId) + { + var service = new SubscriptionService(); + var subscription = await service.GetAsync(subscriptionId); + + var options = new SubscriptionUpdateOptions + { + Items = new List + { + new() + { + Id = subscription.Items.Data[0].Id, + Price = newPriceId + } + } + }; + + return await service.UpdateAsync(subscriptionId, options); + } + + public Task GetPlanLimitationsAsync(string planType) + { + // Mapear string planType para PlanType enum + var planTypeEnum = planType.ToLower() switch + { + "basic" or "basicyearly" => PlanType.Basic, + "professional" or "professionalyearly" => PlanType.Professional, + "premium" or "premiumyearly" => PlanType.Premium, + "premiumaffiliate" or "premiumaffiliateyearly" => PlanType.PremiumAffiliate, + _ => PlanType.Trial + }; + + var limitations = _planConfigurationService.GetPlanLimitations(planTypeEnum); + return Task.FromResult(limitations); + } + + private async Task HandleCheckoutSessionCompletedAsync(Stripe.Checkout.Session session) + { + var userId = session.Metadata["user_id"]; + var planType = session.Metadata["plan_type"]; + + var subscriptionService = new SubscriptionService(); + var stripeSubscription = await subscriptionService.GetAsync(session.SubscriptionId); + + var service = new SubscriptionItemService(); + var subItem = service.Get(stripeSubscription.Items.Data[0].Id); + + var limitations = await GetPlanLimitationsAsync(planType); + + var subscription = new Models.Subscription + { + UserId = userId, + StripeSubscriptionId = session.SubscriptionId, + PlanType = planType, + Status = stripeSubscription.Status, + CurrentPeriodStart = subItem.CurrentPeriodStart, + CurrentPeriodEnd = subItem.CurrentPeriodEnd, + MaxLinks = limitations.MaxLinks, + AllowCustomThemes = limitations.AllowCustomThemes, + AllowAnalytics = limitations.AllowAnalytics, + AllowCustomDomain = limitations.AllowCustomDomain, + AllowMultipleDomains = limitations.AllowMultipleDomains, + PrioritySupport = limitations.PrioritySupport + }; + + await _subscriptionRepository.CreateAsync(subscription); + + // Update user + var user = await _userRepository.GetByIdAsync(userId); + if (user != null) + { + user.CurrentPlan = planType; + user.SubscriptionStatus = "active"; + await _userRepository.UpdateAsync(user); + } + } + + private async Task HandleInvoicePaymentSucceededAsync(Invoice invoice) + { + var subscriptionId = GetSubscriptionId(invoice); + var subscription = await _subscriptionRepository.GetByStripeSubscriptionIdAsync(subscriptionId); + if (subscription != null) + { + subscription.Status = "active"; + await _subscriptionRepository.UpdateAsync(subscription); + } + } + + private async Task HandleSubscriptionUpdatedAsync(Stripe.Subscription stripeSubscription) + { + var subscription = await _subscriptionRepository.GetByStripeSubscriptionIdAsync(stripeSubscription.Id); + if (subscription != null) + { + var service = new SubscriptionItemService(); + var subItem = service.Get(stripeSubscription.Items.Data[0].Id); + + subscription.Status = stripeSubscription.Status; + subscription.CurrentPeriodStart = subItem.CurrentPeriodStart; + subscription.CurrentPeriodEnd = subItem.CurrentPeriodEnd; + subscription.CancelAtPeriodEnd = stripeSubscription.CancelAtPeriodEnd; + + await _subscriptionRepository.UpdateAsync(subscription); + + // Update user status + var user = await _userRepository.GetByIdAsync(subscription.UserId); + if (user != null) + { + user.SubscriptionStatus = stripeSubscription.Status; + if (stripeSubscription.Status != "active") + { + // Quando assinatura não está ativa, usuário volta para o plano trial (gratuito) + user.CurrentPlan = "trial"; + } + await _userRepository.UpdateAsync(user); + } + } + } + + public async Task GetSubscriptionDetailsAsync(string userId) + { + var user = await _userRepository.GetByIdAsync(userId); + if (user == null || string.IsNullOrEmpty(user.StripeCustomerId)) + return null; + + var subscription = await _subscriptionRepository.GetByUserIdAsync(userId); + if (subscription == null || string.IsNullOrEmpty(subscription.StripeSubscriptionId)) + return null; + + try + { + var service = new SubscriptionService(); + return await service.GetAsync(subscription.StripeSubscriptionId); + } + catch (StripeException) + { + return null; + } + } + + public async Task> GetPaymentHistoryAsync(string userId) + { + var user = await _userRepository.GetByIdAsync(userId); + if (user == null || string.IsNullOrEmpty(user.StripeCustomerId)) + return new List(); + + try + { + var service = new InvoiceService(); + var options = new InvoiceListOptions + { + Customer = user.StripeCustomerId, + Limit = 50, // Últimas 50 faturas + Status = "paid" + }; + + var invoices = await service.ListAsync(options); + return invoices.Data; + } + catch (StripeException) + { + return new List(); + } + } + + public async Task CreatePortalSessionAsync(string customerId, string returnUrl) + { + try + { + var options = new Stripe.BillingPortal.SessionCreateOptions + { + Customer = customerId, + ReturnUrl = returnUrl + }; + + var service = new Stripe.BillingPortal.SessionService(); + var session = await service.CreateAsync(options); + return session.Url; + } + catch (StripeException ex) + { + throw new InvalidOperationException($"Erro ao criar sessão do portal: {ex.Message}"); + } + } + + // Métodos de cancelamento com diferentes políticas + public async Task CancelSubscriptionImmediatelyAsync(string subscriptionId, bool refund = false) + { + try + { + var service = new SubscriptionService(); + + if (refund) + { + // Para reembolso completo, apenas cancela - reembolso deve ser feito manualmente via Stripe Dashboard + await service.CancelAsync(subscriptionId); + } + else + { + await service.CancelAsync(subscriptionId); + } + + // Atualizar subscription local + var localSubscription = await _subscriptionRepository.GetByStripeSubscriptionIdAsync(subscriptionId); + if (localSubscription != null) + { + localSubscription.Status = "cancelled"; + localSubscription.UpdatedAt = DateTime.UtcNow; + await _subscriptionRepository.UpdateAsync(localSubscription); + } + + return true; + } + catch (StripeException) + { + return false; + } + } + + public async Task CancelSubscriptionAtPeriodEndAsync(string subscriptionId) + { + try + { + var service = new SubscriptionService(); + var options = new SubscriptionUpdateOptions + { + CancelAtPeriodEnd = true + }; + + await service.UpdateAsync(subscriptionId, options); + + // Atualizar subscription local + var localSubscription = await _subscriptionRepository.GetByStripeSubscriptionIdAsync(subscriptionId); + if (localSubscription != null) + { + localSubscription.CancelAtPeriodEnd = true; + localSubscription.UpdatedAt = DateTime.UtcNow; + await _subscriptionRepository.UpdateAsync(localSubscription); + } + + return true; + } + catch (StripeException) + { + return false; + } + } + + public async Task ReactivateSubscriptionAsync(string subscriptionId) + { + try + { + var service = new SubscriptionService(); + var options = new SubscriptionUpdateOptions + { + CancelAtPeriodEnd = false // Reativar removendo o agendamento de cancelamento + }; + + await service.UpdateAsync(subscriptionId, options); + + // Atualizar subscription local + var localSubscription = await _subscriptionRepository.GetByStripeSubscriptionIdAsync(subscriptionId); + if (localSubscription != null) + { + localSubscription.CancelAtPeriodEnd = false; + localSubscription.UpdatedAt = DateTime.UtcNow; + await _subscriptionRepository.UpdateAsync(localSubscription); + } + + return true; + } + catch (StripeException) + { + return false; + } + } + + public async Task CreatePartialRefundAsync(string subscriptionId, decimal refundAmount) + { + // NOTA: Para planos de assinatura, reembolsos devem ser processados manualmente via Stripe Dashboard + // Este método retorna null para indicar que o reembolso precisa ser feito manualmente + await Task.CompletedTask; + throw new InvalidOperationException("Reembolsos parciais para assinaturas devem ser processados manualmente via Stripe Dashboard ou Portal de Cobrança"); + } + + public async Task<(bool CanRefundFull, bool CanRefundPartial, decimal RefundAmount)> CalculateRefundAsync(string subscriptionId) + { + try + { + var service = new SubscriptionService(); + var subscription = await service.GetAsync(subscriptionId); + + // Obter datas via SubscriptionItem + var subscriptionItemService = new SubscriptionItemService(); + var subItem = await subscriptionItemService.GetAsync(subscription.Items.Data[0].Id); + + var daysSinceStart = (DateTime.UtcNow - subItem.CurrentPeriodStart).TotalDays; + var totalDays = (subItem.CurrentPeriodEnd - subItem.CurrentPeriodStart).TotalDays; + var daysRemaining = totalDays - daysSinceStart; + + // Direito de arrependimento (7 dias) + bool canRefundFull = daysSinceStart <= 7; + + // Reembolso proporcional para planos anuais + bool canRefundPartial = false; + decimal refundAmount = 0; + + if (!canRefundFull && daysRemaining > 0) + { + // Verificar se é plano anual (pela duração do período) + bool isYearlyPlan = totalDays > 300; // Aproximadamente 1 ano + + if (isYearlyPlan) + { + canRefundPartial = true; + + // Buscar valor pago na última fatura + var invoiceService = new InvoiceService(); + var invoices = await invoiceService.ListAsync(new InvoiceListOptions + { + Subscription = subscriptionId, + Status = "paid", + Limit = 1 + }); + + if (invoices.Data.Any()) + { + var latestInvoice = invoices.Data.First(); + var totalPaid = (decimal)latestInvoice.AmountPaid / 100; // Converter de centavos + + // Calcular proporção do tempo restante + var proportionRemaining = daysRemaining / totalDays; + refundAmount = totalPaid * (decimal)proportionRemaining; + refundAmount = Math.Round(refundAmount, 2); + } + } + } + + return (canRefundFull, canRefundPartial, refundAmount); + } + catch (StripeException) + { + return (false, false, 0); + } + } + + private async Task CreateFullRefundAsync(string? chargeId) + { + // NOTA: Para planos de assinatura, reembolsos devem ser processados manualmente + await Task.CompletedTask; + return null; + } + + private string GetSubscriptionId(Invoice? invoice) + { + if (invoice!=null) + { + var subscriptionLineItem = invoice.Lines?.Data + .FirstOrDefault(line => + !string.IsNullOrEmpty(line.SubscriptionId) || + line.Subscription != null + ); + + string subscriptionId = null; + + if (subscriptionLineItem != null) + { + // Tenta obter o ID da assinatura de duas formas diferentes + subscriptionId = subscriptionLineItem.SubscriptionId + ?? subscriptionLineItem.Subscription?.Id; + } + + return subscriptionId; + } + + return null; + } } \ No newline at end of file diff --git a/src/BCards.Web/Services/PlanConfigurationService.cs b/src/BCards.Web/Services/PlanConfigurationService.cs index 979e6fa..1b73523 100644 --- a/src/BCards.Web/Services/PlanConfigurationService.cs +++ b/src/BCards.Web/Services/PlanConfigurationService.cs @@ -1,228 +1,228 @@ -using BCards.Web.Models; - -namespace BCards.Web.Services; - -public class PlanConfigurationService : IPlanConfigurationService -{ - private readonly IConfiguration _configuration; - private readonly Dictionary _plans; - private readonly Dictionary _priceIdToPlanName; - private readonly Dictionary _priceIdToPlanType; - - public PlanConfigurationService(IConfiguration configuration) - { - _configuration = configuration; - _plans = LoadPlansFromConfiguration(); - _priceIdToPlanName = BuildPriceIdToPlanNameMap(); - _priceIdToPlanType = BuildPriceIdToPlanTypeMap(); - } - - public PlanType GetPlanTypeFromPriceId(string priceId) - { - if (string.IsNullOrEmpty(priceId)) - return PlanType.Trial; - - return _priceIdToPlanType.TryGetValue(priceId, out var planType) ? planType : PlanType.Trial; - } - - public string GetPlanNameFromPriceId(string priceId) - { - if (string.IsNullOrEmpty(priceId)) - return "Trial"; - - return _priceIdToPlanName.TryGetValue(priceId, out var planName) ? planName : "Trial"; - } - - public PlanLimitations GetPlanLimitations(PlanType planType) - { - return planType switch - { - PlanType.Trial => new PlanLimitations - { - MaxLinks = 3, - AllowCustomThemes = false, - AllowAnalytics = false, - AllowCustomDomain = false, - AllowMultipleDomains = false, - PrioritySupport = false, - AllowProductLinks = false, - MaxProductLinks = 0, - PlanType = "trial" - }, - PlanType.Basic => new PlanLimitations - { - MaxLinks = GetConfigValue(PlanType.Basic, "MaxLinks", 8), - AllowCustomThemes = GetConfigValue(PlanType.Basic, "AllowPremiumThemes", false), - AllowAnalytics = GetConfigValue(PlanType.Basic, "AllowAnalytics", true), - AllowCustomDomain = true, - AllowMultipleDomains = false, - PrioritySupport = false, - AllowProductLinks = GetConfigValue(PlanType.Basic, "AllowProductLinks", false), - MaxProductLinks = 0, - PlanType = "basic" - }, - PlanType.Professional => new PlanLimitations - { - MaxLinks = GetConfigValue(PlanType.Professional, "MaxLinks", 20), - AllowCustomThemes = GetConfigValue(PlanType.Professional, "AllowPremiumThemes", false), - AllowAnalytics = GetConfigValue(PlanType.Professional, "AllowAnalytics", true), - AllowCustomDomain = true, - AllowMultipleDomains = false, - PrioritySupport = false, - AllowProductLinks = GetConfigValue(PlanType.Professional, "AllowProductLinks", false), - MaxProductLinks = 0, - PlanType = "professional" - }, - PlanType.Premium => new PlanLimitations - { - MaxLinks = GetConfigValue(PlanType.Premium, "MaxLinks", -1), - AllowCustomThemes = GetConfigValue(PlanType.Premium, "AllowPremiumThemes", true), - AllowAnalytics = GetConfigValue(PlanType.Premium, "AllowAnalytics", true), - AllowCustomDomain = true, - AllowMultipleDomains = true, - PrioritySupport = true, - AllowProductLinks = GetConfigValue(PlanType.Premium, "AllowProductLinks", false), - MaxProductLinks = 0, - PlanType = "premium" - }, - PlanType.PremiumAffiliate => new PlanLimitations - { - MaxLinks = GetConfigValue(PlanType.PremiumAffiliate, "MaxLinks", -1), - AllowCustomThemes = GetConfigValue(PlanType.PremiumAffiliate, "AllowPremiumThemes", true), - AllowAnalytics = GetConfigValue(PlanType.PremiumAffiliate, "AllowAnalytics", true), - AllowCustomDomain = true, - AllowMultipleDomains = true, - PrioritySupport = true, - AllowProductLinks = GetConfigValue(PlanType.PremiumAffiliate, "AllowProductLinks", true), - MaxProductLinks = 10, - PlanType = "premiumaffiliate" - }, - _ => new PlanLimitations { PlanType = "trial" } - }; - } - - public string GetPriceId(PlanType planType, bool yearly = false) - { - var planName = planType switch - { - PlanType.Basic => yearly ? "BasicYearly" : "Basic", - PlanType.Professional => yearly ? "ProfessionalYearly" : "Professional", - PlanType.Premium => yearly ? "PremiumYearly" : "Premium", - PlanType.PremiumAffiliate => yearly ? "PremiumAffiliateYearly" : "PremiumAffiliate", - _ => "Trial" - }; - - return _plans.TryGetValue(planName, out var config) ? config.PriceId : string.Empty; - } - - public decimal GetPlanPrice(PlanType planType, bool yearly = false) - { - var planName = planType switch - { - PlanType.Basic => yearly ? "BasicYearly" : "Basic", - PlanType.Professional => yearly ? "ProfessionalYearly" : "Professional", - PlanType.Premium => yearly ? "PremiumYearly" : "Premium", - PlanType.PremiumAffiliate => yearly ? "PremiumAffiliateYearly" : "PremiumAffiliate", - _ => "Trial" - }; - - return _plans.TryGetValue(planName, out var config) ? config.Price : 0; - } - - public bool IsYearlyPlan(string priceId) - { - if (string.IsNullOrEmpty(priceId) || !_priceIdToPlanName.TryGetValue(priceId, out var planName)) - return false; - - return _plans.TryGetValue(planName, out var config) && config.Interval == "year"; - } - - public PlanConfiguration? GetPlanConfiguration(string planSectionName) - { - return _plans.TryGetValue(planSectionName, out var config) ? config : null; - } - - private Dictionary LoadPlansFromConfiguration() - { - var plans = new Dictionary(); - var plansSection = _configuration.GetSection("Plans"); - - foreach (var planSection in plansSection.GetChildren()) - { - var config = new PlanConfiguration(); - planSection.Bind(config); - - // Mapear o nome da seção para PlanType base - config.BasePlanType = planSection.Key switch - { - "Basic" or "BasicYearly" => PlanType.Basic, - "Professional" or "ProfessionalYearly" => PlanType.Professional, - "Premium" or "PremiumYearly" => PlanType.Premium, - "PremiumAffiliate" or "PremiumAffiliateYearly" => PlanType.PremiumAffiliate, - _ => PlanType.Trial - }; - - plans[planSection.Key] = config; - } - - return plans; - } - - private Dictionary BuildPriceIdToPlanNameMap() - { - var map = new Dictionary(); - - foreach (var kvp in _plans) - { - if (!string.IsNullOrEmpty(kvp.Value.PriceId)) - { - map[kvp.Value.PriceId] = kvp.Key; - } - } - - return map; - } - - private Dictionary BuildPriceIdToPlanTypeMap() - { - var map = new Dictionary(); - - foreach (var kvp in _plans) - { - if (!string.IsNullOrEmpty(kvp.Value.PriceId)) - { - map[kvp.Value.PriceId] = kvp.Value.BasePlanType; - } - } - - return map; - } - - private T GetConfigValue(PlanType planType, string propertyName, T defaultValue) - { - // Buscar primeira nas configurações mensais, depois anuais - var monthlyPlan = planType switch - { - PlanType.Basic => "Basic", - PlanType.Professional => "Professional", - PlanType.Premium => "Premium", - PlanType.PremiumAffiliate => "PremiumAffiliate", - _ => null - }; - - if (monthlyPlan != null && _plans.TryGetValue(monthlyPlan, out var config)) - { - var property = typeof(PlanConfiguration).GetProperty(propertyName); - if (property != null) - { - var value = property.GetValue(config); - if (value != null && value is T typedValue) - { - return typedValue; - } - } - } - - return defaultValue; - } +using BCards.Web.Models; + +namespace BCards.Web.Services; + +public class PlanConfigurationService : IPlanConfigurationService +{ + private readonly IConfiguration _configuration; + private readonly Dictionary _plans; + private readonly Dictionary _priceIdToPlanName; + private readonly Dictionary _priceIdToPlanType; + + public PlanConfigurationService(IConfiguration configuration) + { + _configuration = configuration; + _plans = LoadPlansFromConfiguration(); + _priceIdToPlanName = BuildPriceIdToPlanNameMap(); + _priceIdToPlanType = BuildPriceIdToPlanTypeMap(); + } + + public PlanType GetPlanTypeFromPriceId(string priceId) + { + if (string.IsNullOrEmpty(priceId)) + return PlanType.Trial; + + return _priceIdToPlanType.TryGetValue(priceId, out var planType) ? planType : PlanType.Trial; + } + + public string GetPlanNameFromPriceId(string priceId) + { + if (string.IsNullOrEmpty(priceId)) + return "Trial"; + + return _priceIdToPlanName.TryGetValue(priceId, out var planName) ? planName : "Trial"; + } + + public PlanLimitations GetPlanLimitations(PlanType planType) + { + return planType switch + { + PlanType.Trial => new PlanLimitations + { + MaxLinks = 3, + AllowCustomThemes = false, + AllowAnalytics = false, + AllowCustomDomain = false, + AllowMultipleDomains = false, + PrioritySupport = false, + AllowProductLinks = false, + MaxProductLinks = 0, + PlanType = "trial" + }, + PlanType.Basic => new PlanLimitations + { + MaxLinks = GetConfigValue(PlanType.Basic, "MaxLinks", 8), + AllowCustomThemes = GetConfigValue(PlanType.Basic, "AllowPremiumThemes", false), + AllowAnalytics = GetConfigValue(PlanType.Basic, "AllowAnalytics", true), + AllowCustomDomain = true, + AllowMultipleDomains = false, + PrioritySupport = false, + AllowProductLinks = GetConfigValue(PlanType.Basic, "AllowProductLinks", false), + MaxProductLinks = 0, + PlanType = "basic" + }, + PlanType.Professional => new PlanLimitations + { + MaxLinks = GetConfigValue(PlanType.Professional, "MaxLinks", 20), + AllowCustomThemes = GetConfigValue(PlanType.Professional, "AllowPremiumThemes", false), + AllowAnalytics = GetConfigValue(PlanType.Professional, "AllowAnalytics", true), + AllowCustomDomain = true, + AllowMultipleDomains = false, + PrioritySupport = false, + AllowProductLinks = GetConfigValue(PlanType.Professional, "AllowProductLinks", false), + MaxProductLinks = 0, + PlanType = "professional" + }, + PlanType.Premium => new PlanLimitations + { + MaxLinks = GetConfigValue(PlanType.Premium, "MaxLinks", -1), + AllowCustomThemes = GetConfigValue(PlanType.Premium, "AllowPremiumThemes", true), + AllowAnalytics = GetConfigValue(PlanType.Premium, "AllowAnalytics", true), + AllowCustomDomain = true, + AllowMultipleDomains = true, + PrioritySupport = true, + AllowProductLinks = GetConfigValue(PlanType.Premium, "AllowProductLinks", false), + MaxProductLinks = 0, + PlanType = "premium" + }, + PlanType.PremiumAffiliate => new PlanLimitations + { + MaxLinks = GetConfigValue(PlanType.PremiumAffiliate, "MaxLinks", -1), + AllowCustomThemes = GetConfigValue(PlanType.PremiumAffiliate, "AllowPremiumThemes", true), + AllowAnalytics = GetConfigValue(PlanType.PremiumAffiliate, "AllowAnalytics", true), + AllowCustomDomain = true, + AllowMultipleDomains = true, + PrioritySupport = true, + AllowProductLinks = GetConfigValue(PlanType.PremiumAffiliate, "AllowProductLinks", true), + MaxProductLinks = 10, + PlanType = "premiumaffiliate" + }, + _ => new PlanLimitations { PlanType = "trial" } + }; + } + + public string GetPriceId(PlanType planType, bool yearly = false) + { + var planName = planType switch + { + PlanType.Basic => yearly ? "BasicYearly" : "Basic", + PlanType.Professional => yearly ? "ProfessionalYearly" : "Professional", + PlanType.Premium => yearly ? "PremiumYearly" : "Premium", + PlanType.PremiumAffiliate => yearly ? "PremiumAffiliateYearly" : "PremiumAffiliate", + _ => "Trial" + }; + + return _plans.TryGetValue(planName, out var config) ? config.PriceId : string.Empty; + } + + public decimal GetPlanPrice(PlanType planType, bool yearly = false) + { + var planName = planType switch + { + PlanType.Basic => yearly ? "BasicYearly" : "Basic", + PlanType.Professional => yearly ? "ProfessionalYearly" : "Professional", + PlanType.Premium => yearly ? "PremiumYearly" : "Premium", + PlanType.PremiumAffiliate => yearly ? "PremiumAffiliateYearly" : "PremiumAffiliate", + _ => "Trial" + }; + + return _plans.TryGetValue(planName, out var config) ? config.Price : 0; + } + + public bool IsYearlyPlan(string priceId) + { + if (string.IsNullOrEmpty(priceId) || !_priceIdToPlanName.TryGetValue(priceId, out var planName)) + return false; + + return _plans.TryGetValue(planName, out var config) && config.Interval == "year"; + } + + public PlanConfiguration? GetPlanConfiguration(string planSectionName) + { + return _plans.TryGetValue(planSectionName, out var config) ? config : null; + } + + private Dictionary LoadPlansFromConfiguration() + { + var plans = new Dictionary(); + var plansSection = _configuration.GetSection("Plans"); + + foreach (var planSection in plansSection.GetChildren()) + { + var config = new PlanConfiguration(); + planSection.Bind(config); + + // Mapear o nome da seção para PlanType base + config.BasePlanType = planSection.Key switch + { + "Basic" or "BasicYearly" => PlanType.Basic, + "Professional" or "ProfessionalYearly" => PlanType.Professional, + "Premium" or "PremiumYearly" => PlanType.Premium, + "PremiumAffiliate" or "PremiumAffiliateYearly" => PlanType.PremiumAffiliate, + _ => PlanType.Trial + }; + + plans[planSection.Key] = config; + } + + return plans; + } + + private Dictionary BuildPriceIdToPlanNameMap() + { + var map = new Dictionary(); + + foreach (var kvp in _plans) + { + if (!string.IsNullOrEmpty(kvp.Value.PriceId)) + { + map[kvp.Value.PriceId] = kvp.Key; + } + } + + return map; + } + + private Dictionary BuildPriceIdToPlanTypeMap() + { + var map = new Dictionary(); + + foreach (var kvp in _plans) + { + if (!string.IsNullOrEmpty(kvp.Value.PriceId)) + { + map[kvp.Value.PriceId] = kvp.Value.BasePlanType; + } + } + + return map; + } + + private T GetConfigValue(PlanType planType, string propertyName, T defaultValue) + { + // Buscar primeira nas configurações mensais, depois anuais + var monthlyPlan = planType switch + { + PlanType.Basic => "Basic", + PlanType.Professional => "Professional", + PlanType.Premium => "Premium", + PlanType.PremiumAffiliate => "PremiumAffiliate", + _ => null + }; + + if (monthlyPlan != null && _plans.TryGetValue(monthlyPlan, out var config)) + { + var property = typeof(PlanConfiguration).GetProperty(propertyName); + if (property != null) + { + var value = property.GetValue(config); + if (value != null && value is T typedValue) + { + return typedValue; + } + } + } + + return defaultValue; + } } \ No newline at end of file diff --git a/src/BCards.Web/Services/SeoService.cs b/src/BCards.Web/Services/SeoService.cs index b4f1812..d2e50be 100644 --- a/src/BCards.Web/Services/SeoService.cs +++ b/src/BCards.Web/Services/SeoService.cs @@ -1,79 +1,79 @@ -using BCards.Web.Models; - -namespace BCards.Web.Services; - -public class SeoService : ISeoService -{ - private readonly string _baseUrl; - - public SeoService(IConfiguration configuration) - { - _baseUrl = configuration["BaseUrl"] ?? "https://bcards.site"; - } - - public SeoSettings GenerateSeoSettings(UserPage userPage, Category category) - { - return new SeoSettings - { - Title = GeneratePageTitle(userPage, category), - Description = GeneratePageDescription(userPage, category), - Keywords = GenerateKeywords(userPage, category), - OgTitle = GeneratePageTitle(userPage, category), - OgDescription = GeneratePageDescription(userPage, category), - OgImage = !string.IsNullOrEmpty(userPage.ProfileImageId) ? userPage.ProfileImageUrl : $"{_baseUrl}/images/default-og.png", - CanonicalUrl = GenerateCanonicalUrl(userPage), - TwitterCard = "summary_large_image" - }; - } - - public string GeneratePageTitle(UserPage userPage, Category category) - { - var businessTypeText = userPage.BusinessType == "company" ? "Empresa" : "Profissional"; - return $"{userPage.DisplayName} - {businessTypeText} de {category.Name} | BCards"; - } - - public string GeneratePageDescription(UserPage userPage, Category category) - { - if (!string.IsNullOrEmpty(userPage.Bio)) - { - var bio = userPage.Bio.Length > 150 ? userPage.Bio[..147] + "..." : userPage.Bio; - return $"{bio} Conheça mais sobre {userPage.DisplayName}, {category.Name.ToLower()}."; - } - - var businessTypeText = userPage.BusinessType == "company" ? "empresa" : "profissional"; - return $"Conheça {userPage.DisplayName}, {businessTypeText} especializado em {category.Name.ToLower()}. Acesse os links e entre em contato."; - } - - public List GenerateKeywords(UserPage userPage, Category category) - { - var keywords = new List - { - userPage.DisplayName.ToLower(), - category.Name.ToLower(), - userPage.BusinessType == "company" ? "empresa" : "profissional" - }; - - // Add category SEO keywords - keywords.AddRange(category.SeoKeywords); - - // Add business type specific keywords - if (userPage.BusinessType == "company") - { - keywords.AddRange(new[] { "empresa", "negócio", "serviços", "contato" }); - } - else - { - keywords.AddRange(new[] { "profissional", "especialista", "consultor", "contato" }); - } - - // Add location-based keywords if available - keywords.AddRange(new[] { "brasil", "br", "online", "digital" }); - - return keywords.Distinct().ToList(); - } - - public string GenerateCanonicalUrl(UserPage userPage) - { - return $"{_baseUrl}/{userPage.Category}/{userPage.Slug}"; - } +using BCards.Web.Models; + +namespace BCards.Web.Services; + +public class SeoService : ISeoService +{ + private readonly string _baseUrl; + + public SeoService(IConfiguration configuration) + { + _baseUrl = configuration["BaseUrl"] ?? "https://bcards.site"; + } + + public SeoSettings GenerateSeoSettings(UserPage userPage, Category category) + { + return new SeoSettings + { + Title = GeneratePageTitle(userPage, category), + Description = GeneratePageDescription(userPage, category), + Keywords = GenerateKeywords(userPage, category), + OgTitle = GeneratePageTitle(userPage, category), + OgDescription = GeneratePageDescription(userPage, category), + OgImage = !string.IsNullOrEmpty(userPage.ProfileImageId) ? userPage.ProfileImageUrl : $"{_baseUrl}/images/default-og.png", + CanonicalUrl = GenerateCanonicalUrl(userPage), + TwitterCard = "summary_large_image" + }; + } + + public string GeneratePageTitle(UserPage userPage, Category category) + { + var businessTypeText = userPage.BusinessType == "company" ? "Empresa" : "Profissional"; + return $"{userPage.DisplayName} - {businessTypeText} de {category.Name} | BCards"; + } + + public string GeneratePageDescription(UserPage userPage, Category category) + { + if (!string.IsNullOrEmpty(userPage.Bio)) + { + var bio = userPage.Bio.Length > 150 ? userPage.Bio[..147] + "..." : userPage.Bio; + return $"{bio} Conheça mais sobre {userPage.DisplayName}, {category.Name.ToLower()}."; + } + + var businessTypeText = userPage.BusinessType == "company" ? "empresa" : "profissional"; + return $"Conheça {userPage.DisplayName}, {businessTypeText} especializado em {category.Name.ToLower()}. Acesse os links e entre em contato."; + } + + public List GenerateKeywords(UserPage userPage, Category category) + { + var keywords = new List + { + userPage.DisplayName.ToLower(), + category.Name.ToLower(), + userPage.BusinessType == "company" ? "empresa" : "profissional" + }; + + // Add category SEO keywords + keywords.AddRange(category.SeoKeywords); + + // Add business type specific keywords + if (userPage.BusinessType == "company") + { + keywords.AddRange(new[] { "empresa", "negócio", "serviços", "contato" }); + } + else + { + keywords.AddRange(new[] { "profissional", "especialista", "consultor", "contato" }); + } + + // Add location-based keywords if available + keywords.AddRange(new[] { "brasil", "br", "online", "digital" }); + + return keywords.Distinct().ToList(); + } + + public string GenerateCanonicalUrl(UserPage userPage) + { + return $"{_baseUrl}/{userPage.Category}/{userPage.Slug}"; + } } \ No newline at end of file diff --git a/src/BCards.Web/Services/ThemeService.cs b/src/BCards.Web/Services/ThemeService.cs index 3f56a68..fd467c5 100644 --- a/src/BCards.Web/Services/ThemeService.cs +++ b/src/BCards.Web/Services/ThemeService.cs @@ -1,345 +1,345 @@ -using BCards.Web.Models; -using MongoDB.Bson; -using MongoDB.Driver; -using System.Text; - -namespace BCards.Web.Services; - -public class ThemeService : IThemeService -{ - private readonly IMongoCollection _themes; - - public ThemeService(IMongoDatabase database) - { - _themes = database.GetCollection("themes"); - } - - public async Task> GetAvailableThemesAsync() - { - return await _themes.Find(x => x.IsActive).ToListAsync(); - } - - public async Task GetThemeByIdAsync(string themeId) - { - return await _themes.Find(x => x.Id == themeId && x.IsActive).FirstOrDefaultAsync(); - } - - public async Task GetThemeByNameAsync(string themeName) - { - var theme = await _themes.Find(x => x.Name.ToLower() == themeName.ToLower() && x.IsActive).FirstOrDefaultAsync(); - return theme ?? GetDefaultTheme(); - } - - public Task GenerateCustomCssAsync(PageTheme theme) - { - var css = $@" - :root {{ - --primary-color: {theme.PrimaryColor}; - --secondary-color: {theme.SecondaryColor}; - --background-color: {theme.BackgroundColor}; - --text-color: {theme.TextColor}; - }} - - .user-page {{ - background-color: var(--background-color); - color: var(--text-color); - {(!string.IsNullOrEmpty(theme.BackgroundImage) ? $"background-image: url('{theme.BackgroundImage}');" : "")} - background-size: cover; - background-position: center; - background-attachment: fixed; - }} - - .profile-card {{ - background-color: rgba(255, 255, 255, 0.95); - backdrop-filter: blur(10px); - border-radius: 20px; - padding: 2rem; - box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1); - }} - - .profile-image {{ - width: 120px; - height: 120px; - border-radius: 50%; - border: 4px solid var(--primary-color); - object-fit: cover; - }} - - .profile-name {{ - color: var(--primary-color); - font-size: 2rem; - font-weight: 600; - margin-bottom: 0.5rem; - }} - - .profile-bio {{ - color: var(--text-color); - opacity: 0.8; - margin-bottom: 2rem; - }} - - .link-button {{ - background-color: var(--primary-color); - color: white; - border: none; - padding: 1rem 2rem; - border-radius: 50px; - text-decoration: none; - display: block; - margin-bottom: 1rem; - text-align: center; - font-weight: 500; - transition: all 0.3s ease; - box-shadow: 0 4px 15px rgba(0, 0, 0, 0.1); - }} - - .link-button:hover {{ - background-color: var(--secondary-color); - transform: translateY(-2px); - box-shadow: 0 6px 20px rgba(0, 0, 0, 0.15); - color: white; - text-decoration: none; - }} - - .link-title {{ - font-size: 1.1rem; - margin-bottom: 0.25rem; - }} - - .link-description {{ - font-size: 0.9rem; - opacity: 0.9; - }} - - @media (max-width: 768px) {{ - .profile-card {{ - padding: 1.5rem; - margin: 1rem; - }} - - .profile-image {{ - width: 100px; - height: 100px; - }} - - .profile-name {{ - font-size: 1.75rem; - }} - - .link-button {{ - padding: 0.875rem 1.5rem; - }} - }} - "; - - return Task.FromResult(css); - } - - public async Task GenerateThemeCSSAsync(PageTheme theme, UserPage page) - { - var css = new StringBuilder(); - - // CSS base com variáveis do tema - css.AppendLine($":root {{"); - css.AppendLine($" --primary-color: {theme.PrimaryColor};"); - css.AppendLine($" --secondary-color: {theme.SecondaryColor};"); - css.AppendLine($" --background-color: {theme.BackgroundColor};"); - css.AppendLine($" --text-color: {theme.TextColor};"); - css.AppendLine($"}}"); - - // CSS específico por tema - switch (theme.Name?.ToLower()) - { - case "minimalista": - css.AppendLine(GetMinimalistCSS()); - break; - case "corporativo": - css.AppendLine(GetCorporateCSS()); - break; - case "dark mode": - css.AppendLine(GetDarkCSS()); - break; - case "natureza": - css.AppendLine(GetNatureCSS()); - break; - case "vibrante": - css.AppendLine(GetVibrantCSS()); - break; - default: - css.AppendLine(await GenerateCustomCssAsync(theme)); - break; - } - - return css.ToString(); - } - - private string GetMinimalistCSS() => @" - .profile-card { - background: white; - box-shadow: 0 2px 10px rgba(0,0,0,0.1); - border-radius: 12px; - } - .link-button { - background: var(--primary-color); - border-radius: 8px; - } - "; - - private string GetCorporateCSS() => @" - .user-page { - background: linear-gradient(135deg, #f8fafc 0%, #e2e8f0 100%); - } - .profile-card { - background: white; - box-shadow: 0 4px 20px rgba(0,0,0,0.1); - border: 1px solid #e2e8f0; - } - .link-button { - background: var(--primary-color); - border-radius: 6px; - font-weight: 600; - } - "; - - private string GetDarkCSS() => @" - .user-page { - background: linear-gradient(135deg, #1a1a1a 0%, #2d2d2d 100%); - } - .profile-card { - background: rgba(255,255,255,0.1); - backdrop-filter: blur(15px); - border: 1px solid rgba(255,255,255,0.2); - color: #f9fafb; - } - .link-button { - background: var(--primary-color); - box-shadow: 0 4px 15px rgba(0,0,0,0.3); - } - .profile-name, .profile-bio { - color: #f9fafb; - } - "; - - private string GetNatureCSS() => @" - .user-page { - background: linear-gradient(135deg, #f0fdf4 0%, #dcfce7 100%); - background-image: url('data:image/svg+xml,'); - } - .profile-card { - background: rgba(255,255,255,0.9); - backdrop-filter: blur(10px); - border: 1px solid rgba(34, 197, 94, 0.2); - } - .link-button { - background: linear-gradient(135deg, var(--primary-color), var(--secondary-color)); - border-radius: 25px; - } - "; - - private string GetVibrantCSS() => @" - .user-page { - background: linear-gradient(135deg, #fef2f2 0%, #fee2e2 50%, #fecaca 100%); - } - .profile-card { - background: rgba(255,255,255,0.95); - box-shadow: 0 8px 32px rgba(220, 38, 38, 0.2); - border: 2px solid rgba(220, 38, 38, 0.1); - } - .link-button { - background: linear-gradient(135deg, var(--primary-color), var(--secondary-color)); - border-radius: 30px; - transform: perspective(1000px) rotateX(0deg); - transition: all 0.3s ease; - } - .link-button:hover { - transform: perspective(1000px) rotateX(-5deg) translateY(-5px); - box-shadow: 0 15px 30px rgba(220, 38, 38, 0.3); - } - "; - - public async Task InitializeDefaultThemesAsync() - { - var existingThemes = await _themes.Find(x => x.IsActive).ToListAsync(); - if (existingThemes.Any()) return; - - var defaultThemes = new[] - { - new PageTheme - { - Id = ObjectId.GenerateNewId().ToString(), - Name = "Minimalista", - PrimaryColor = "#2563eb", - SecondaryColor = "#1d4ed8", - BackgroundColor = "#ffffff", - TextColor = "#1f2937", - IsPremium = false, - CssTemplate = "minimal" - }, - new PageTheme - { - Id = ObjectId.GenerateNewId().ToString(), - Name = "Dark Mode", - PrimaryColor = "#10b981", - SecondaryColor = "#059669", - BackgroundColor = "#111827", - TextColor = "#f9fafb", - IsPremium = false, - CssTemplate = "dark" - }, - new PageTheme - { - Id = ObjectId.GenerateNewId().ToString(), - Name = "Natureza", - PrimaryColor = "#16a34a", - SecondaryColor = "#15803d", - BackgroundColor = "#f0fdf4", - TextColor = "#166534", - BackgroundImage = "/images/themes/nature-bg.jpg", - IsPremium = false, - CssTemplate = "nature" - }, - new PageTheme - { - Id = ObjectId.GenerateNewId().ToString(), - Name = "Corporativo", - PrimaryColor = "#1e40af", - SecondaryColor = "#1e3a8a", - BackgroundColor = "#f8fafc", - TextColor = "#0f172a", - IsPremium = false, - CssTemplate = "corporate" - }, - new PageTheme - { - Id = ObjectId.GenerateNewId().ToString(), - Name = "Vibrante", - PrimaryColor = "#dc2626", - SecondaryColor = "#b91c1c", - BackgroundColor = "#fef2f2", - TextColor = "#7f1d1d", - IsPremium = true, - CssTemplate = "vibrant" - } - }; - - foreach (var theme in defaultThemes) - { - await _themes.InsertOneAsync(theme); - } - } - - public PageTheme GetDefaultTheme() - { - return new PageTheme - { - Id = ObjectId.GenerateNewId().ToString(), - Name = "Padrão", - PrimaryColor = "#2563eb", - SecondaryColor = "#1d4ed8", - BackgroundColor = "#ffffff", - TextColor = "#1f2937", - IsPremium = false, - CssTemplate = "default" - }; - } +using BCards.Web.Models; +using MongoDB.Bson; +using MongoDB.Driver; +using System.Text; + +namespace BCards.Web.Services; + +public class ThemeService : IThemeService +{ + private readonly IMongoCollection _themes; + + public ThemeService(IMongoDatabase database) + { + _themes = database.GetCollection("themes"); + } + + public async Task> GetAvailableThemesAsync() + { + return await _themes.Find(x => x.IsActive).ToListAsync(); + } + + public async Task GetThemeByIdAsync(string themeId) + { + return await _themes.Find(x => x.Id == themeId && x.IsActive).FirstOrDefaultAsync(); + } + + public async Task GetThemeByNameAsync(string themeName) + { + var theme = await _themes.Find(x => x.Name.ToLower() == themeName.ToLower() && x.IsActive).FirstOrDefaultAsync(); + return theme ?? GetDefaultTheme(); + } + + public Task GenerateCustomCssAsync(PageTheme theme) + { + var css = $@" + :root {{ + --primary-color: {theme.PrimaryColor}; + --secondary-color: {theme.SecondaryColor}; + --background-color: {theme.BackgroundColor}; + --text-color: {theme.TextColor}; + }} + + .user-page {{ + background-color: var(--background-color); + color: var(--text-color); + {(!string.IsNullOrEmpty(theme.BackgroundImage) ? $"background-image: url('{theme.BackgroundImage}');" : "")} + background-size: cover; + background-position: center; + background-attachment: fixed; + }} + + .profile-card {{ + background-color: rgba(255, 255, 255, 0.95); + backdrop-filter: blur(10px); + border-radius: 20px; + padding: 2rem; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1); + }} + + .profile-image {{ + width: 120px; + height: 120px; + border-radius: 50%; + border: 4px solid var(--primary-color); + object-fit: cover; + }} + + .profile-name {{ + color: var(--primary-color); + font-size: 2rem; + font-weight: 600; + margin-bottom: 0.5rem; + }} + + .profile-bio {{ + color: var(--text-color); + opacity: 0.8; + margin-bottom: 2rem; + }} + + .link-button {{ + background-color: var(--primary-color); + color: white; + border: none; + padding: 1rem 2rem; + border-radius: 50px; + text-decoration: none; + display: block; + margin-bottom: 1rem; + text-align: center; + font-weight: 500; + transition: all 0.3s ease; + box-shadow: 0 4px 15px rgba(0, 0, 0, 0.1); + }} + + .link-button:hover {{ + background-color: var(--secondary-color); + transform: translateY(-2px); + box-shadow: 0 6px 20px rgba(0, 0, 0, 0.15); + color: white; + text-decoration: none; + }} + + .link-title {{ + font-size: 1.1rem; + margin-bottom: 0.25rem; + }} + + .link-description {{ + font-size: 0.9rem; + opacity: 0.9; + }} + + @media (max-width: 768px) {{ + .profile-card {{ + padding: 1.5rem; + margin: 1rem; + }} + + .profile-image {{ + width: 100px; + height: 100px; + }} + + .profile-name {{ + font-size: 1.75rem; + }} + + .link-button {{ + padding: 0.875rem 1.5rem; + }} + }} + "; + + return Task.FromResult(css); + } + + public async Task GenerateThemeCSSAsync(PageTheme theme, UserPage page) + { + var css = new StringBuilder(); + + // CSS base com variáveis do tema + css.AppendLine($":root {{"); + css.AppendLine($" --primary-color: {theme.PrimaryColor};"); + css.AppendLine($" --secondary-color: {theme.SecondaryColor};"); + css.AppendLine($" --background-color: {theme.BackgroundColor};"); + css.AppendLine($" --text-color: {theme.TextColor};"); + css.AppendLine($"}}"); + + // CSS específico por tema + switch (theme.Name?.ToLower()) + { + case "minimalista": + css.AppendLine(GetMinimalistCSS()); + break; + case "corporativo": + css.AppendLine(GetCorporateCSS()); + break; + case "dark mode": + css.AppendLine(GetDarkCSS()); + break; + case "natureza": + css.AppendLine(GetNatureCSS()); + break; + case "vibrante": + css.AppendLine(GetVibrantCSS()); + break; + default: + css.AppendLine(await GenerateCustomCssAsync(theme)); + break; + } + + return css.ToString(); + } + + private string GetMinimalistCSS() => @" + .profile-card { + background: white; + box-shadow: 0 2px 10px rgba(0,0,0,0.1); + border-radius: 12px; + } + .link-button { + background: var(--primary-color); + border-radius: 8px; + } + "; + + private string GetCorporateCSS() => @" + .user-page { + background: linear-gradient(135deg, #f8fafc 0%, #e2e8f0 100%); + } + .profile-card { + background: white; + box-shadow: 0 4px 20px rgba(0,0,0,0.1); + border: 1px solid #e2e8f0; + } + .link-button { + background: var(--primary-color); + border-radius: 6px; + font-weight: 600; + } + "; + + private string GetDarkCSS() => @" + .user-page { + background: linear-gradient(135deg, #1a1a1a 0%, #2d2d2d 100%); + } + .profile-card { + background: rgba(255,255,255,0.1); + backdrop-filter: blur(15px); + border: 1px solid rgba(255,255,255,0.2); + color: #f9fafb; + } + .link-button { + background: var(--primary-color); + box-shadow: 0 4px 15px rgba(0,0,0,0.3); + } + .profile-name, .profile-bio { + color: #f9fafb; + } + "; + + private string GetNatureCSS() => @" + .user-page { + background: linear-gradient(135deg, #f0fdf4 0%, #dcfce7 100%); + background-image: url('data:image/svg+xml,'); + } + .profile-card { + background: rgba(255,255,255,0.9); + backdrop-filter: blur(10px); + border: 1px solid rgba(34, 197, 94, 0.2); + } + .link-button { + background: linear-gradient(135deg, var(--primary-color), var(--secondary-color)); + border-radius: 25px; + } + "; + + private string GetVibrantCSS() => @" + .user-page { + background: linear-gradient(135deg, #fef2f2 0%, #fee2e2 50%, #fecaca 100%); + } + .profile-card { + background: rgba(255,255,255,0.95); + box-shadow: 0 8px 32px rgba(220, 38, 38, 0.2); + border: 2px solid rgba(220, 38, 38, 0.1); + } + .link-button { + background: linear-gradient(135deg, var(--primary-color), var(--secondary-color)); + border-radius: 30px; + transform: perspective(1000px) rotateX(0deg); + transition: all 0.3s ease; + } + .link-button:hover { + transform: perspective(1000px) rotateX(-5deg) translateY(-5px); + box-shadow: 0 15px 30px rgba(220, 38, 38, 0.3); + } + "; + + public async Task InitializeDefaultThemesAsync() + { + var existingThemes = await _themes.Find(x => x.IsActive).ToListAsync(); + if (existingThemes.Any()) return; + + var defaultThemes = new[] + { + new PageTheme + { + Id = ObjectId.GenerateNewId().ToString(), + Name = "Minimalista", + PrimaryColor = "#2563eb", + SecondaryColor = "#1d4ed8", + BackgroundColor = "#ffffff", + TextColor = "#1f2937", + IsPremium = false, + CssTemplate = "minimal" + }, + new PageTheme + { + Id = ObjectId.GenerateNewId().ToString(), + Name = "Dark Mode", + PrimaryColor = "#10b981", + SecondaryColor = "#059669", + BackgroundColor = "#111827", + TextColor = "#f9fafb", + IsPremium = false, + CssTemplate = "dark" + }, + new PageTheme + { + Id = ObjectId.GenerateNewId().ToString(), + Name = "Natureza", + PrimaryColor = "#16a34a", + SecondaryColor = "#15803d", + BackgroundColor = "#f0fdf4", + TextColor = "#166534", + BackgroundImage = "/images/themes/nature-bg.jpg", + IsPremium = false, + CssTemplate = "nature" + }, + new PageTheme + { + Id = ObjectId.GenerateNewId().ToString(), + Name = "Corporativo", + PrimaryColor = "#1e40af", + SecondaryColor = "#1e3a8a", + BackgroundColor = "#f8fafc", + TextColor = "#0f172a", + IsPremium = false, + CssTemplate = "corporate" + }, + new PageTheme + { + Id = ObjectId.GenerateNewId().ToString(), + Name = "Vibrante", + PrimaryColor = "#dc2626", + SecondaryColor = "#b91c1c", + BackgroundColor = "#fef2f2", + TextColor = "#7f1d1d", + IsPremium = true, + CssTemplate = "vibrant" + } + }; + + foreach (var theme in defaultThemes) + { + await _themes.InsertOneAsync(theme); + } + } + + public PageTheme GetDefaultTheme() + { + return new PageTheme + { + Id = ObjectId.GenerateNewId().ToString(), + Name = "Padrão", + PrimaryColor = "#2563eb", + SecondaryColor = "#1d4ed8", + BackgroundColor = "#ffffff", + TextColor = "#1f2937", + IsPremium = false, + CssTemplate = "default" + }; + } } \ No newline at end of file diff --git a/src/BCards.Web/Services/TrialExpirationService.cs b/src/BCards.Web/Services/TrialExpirationService.cs index c020a31..ff553d0 100644 --- a/src/BCards.Web/Services/TrialExpirationService.cs +++ b/src/BCards.Web/Services/TrialExpirationService.cs @@ -1,271 +1,271 @@ -using BCards.Web.Models; -using BCards.Web.Repositories; -using BCards.Web.ViewModels; -using MongoDB.Driver; - -namespace BCards.Web.Services; - -public class TrialExpirationService : BackgroundService -{ - private readonly IServiceProvider _serviceProvider; - private readonly ILogger _logger; - private readonly TimeSpan _checkInterval = TimeSpan.FromHours(1); // Check every hour - - public TrialExpirationService( - IServiceProvider serviceProvider, - ILogger logger) - { - _serviceProvider = serviceProvider; - _logger = logger; - } - - protected override async Task ExecuteAsync(CancellationToken stoppingToken) - { - _logger.LogInformation("TrialExpirationService started"); - - while (!stoppingToken.IsCancellationRequested) - { - try - { - await ProcessTrialExpirationsAsync(); - - // Verificar cancelamento antes de fazer delay - if (stoppingToken.IsCancellationRequested) - break; - - await Task.Delay(_checkInterval, stoppingToken); - } - catch (OperationCanceledException) - { - // Cancelamento normal - não é erro - _logger.LogInformation("TrialExpirationService is being cancelled"); - break; - } - catch (Exception ex) - { - _logger.LogError(ex, "Error processing trial expirations"); - - // Verificar cancelamento antes de fazer delay de erro - if (stoppingToken.IsCancellationRequested) - break; - - try - { - await Task.Delay(TimeSpan.FromMinutes(5), stoppingToken); // Wait 5 minutes on error - } - catch (OperationCanceledException) - { - // Cancelamento durante delay de erro - também é normal - _logger.LogInformation("TrialExpirationService cancelled during error delay"); - break; - } - } - } - - _logger.LogInformation("TrialExpirationService stopped"); - } - - private async Task ProcessTrialExpirationsAsync() - { - try - { - using var scope = _serviceProvider.CreateScope(); - var subscriptionRepository = scope.ServiceProvider.GetRequiredService(); - var userPageRepository = scope.ServiceProvider.GetRequiredService(); - var userRepository = scope.ServiceProvider.GetRequiredService(); - - _logger.LogInformation("Checking for expired trials..."); - - // Process trial expirations - var trialSubscriptions = await subscriptionRepository.GetTrialSubscriptionsAsync(); - var now = DateTime.UtcNow; - - _logger.LogInformation($"Found {trialSubscriptions.Count} trial subscriptions to process"); - - foreach (var subscription in trialSubscriptions) - { - try - { - var user = await userRepository.GetByIdAsync(subscription.UserId); - if (user == null) - { - _logger.LogWarning($"User not found for subscription {subscription.Id}"); - continue; - } - - var daysUntilExpiration = (subscription.CurrentPeriodEnd - now).TotalDays; - - if (daysUntilExpiration <= 0) - { - // Trial expired - deactivate page - _logger.LogInformation($"Trial expired for user {user.Email}"); - await HandleTrialExpiredAsync(user, subscription, userPageRepository); - } - else if (daysUntilExpiration <= 2 && !user.NotifiedOfExpiration) - { - // Trial expiring soon - send notification - _logger.LogInformation($"Trial expiring in {daysUntilExpiration:F1} days for user {user.Email}"); - await SendExpirationWarningAsync(user, subscription, daysUntilExpiration); - - // Mark as notified - user.NotifiedOfExpiration = true; - await userRepository.UpdateAsync(user); - } - } - catch (Exception ex) - { - _logger.LogError(ex, $"Error processing trial for subscription {subscription.Id}"); - } - } - - _logger.LogInformation("Finished checking trial expirations"); - - // Process permanent deletions (pages deleted for more than 30 days) - await ProcessPermanentDeletionsAsync(userPageRepository); - } - catch (Exception ex) - { - _logger.LogError(ex, "Critical error in ProcessTrialExpirationsAsync"); - throw; // Re-throw para ser tratado pelo ExecuteAsync - } - } - - private async Task HandleTrialExpiredAsync( - User user, - Subscription subscription, - IUserPageRepository userPageRepository) - { - // Mark user page as expired (logical deletion) - var userPage = await userPageRepository.GetByUserIdAsync(user.Id); - if (userPage != null) - { - userPage.Status = PageStatus.Expired; - userPage.DeletedAt = DateTime.UtcNow; - userPage.DeletionReason = "trial_expired"; - userPage.UpdatedAt = DateTime.UtcNow; - await userPageRepository.UpdateAsync(userPage); - } - - // Update subscription status - subscription.Status = "expired"; - subscription.UpdatedAt = DateTime.UtcNow; - - using var scope = _serviceProvider.CreateScope(); - var subscriptionRepository = scope.ServiceProvider.GetRequiredService(); - await subscriptionRepository.UpdateAsync(subscription); - - // Send expiration email - await SendTrialExpiredEmailAsync(user); - - _logger.LogInformation($"Deactivated trial page for user {user.Email}"); - } - - private async Task SendExpirationWarningAsync( - User user, - Subscription subscription, - double daysRemaining) - { - // TODO: Implement email service - // For now, just log - _logger.LogInformation($"Should send expiration warning to {user.Email} - {daysRemaining:F1} days remaining"); - - // Example email content: - var subject = "Seu trial do BCards expira em breve!"; - var message = $@" - Olá {user.Name}, - - Seu trial gratuito do BCards expira em {Math.Ceiling(daysRemaining)} dia(s). - - Para continuar usando sua página de links, escolha um de nossos planos: - - • Básico - R$ 9,90/mês - • Profissional - R$ 24,90/mês - • Premium - R$ 29,90/mês - - Acesse: {GetUpgradeUrl()} - - Equipe BCards - "; - - // TODO: Send actual email when email service is implemented - await Task.CompletedTask; - } - - private async Task SendTrialExpiredEmailAsync(User user) - { - // TODO: Implement email service - _logger.LogInformation($"Should send trial expired email to {user.Email}"); - - var subject = "Seu trial do BCards expirou"; - var message = $@" - Olá {user.Name}, - - Seu trial gratuito do BCards expirou e sua página foi temporariamente desativada. - - Para reativar sua página, escolha um de nossos planos: - - • Básico - R$ 9,90/mês - 5 links, analytics básicos - • Profissional - R$ 24,90/mês - 15 links, todos os temas, analytics avançados - • Premium - R$ 29,90/mês - Links ilimitados, temas premium, analytics completos - - Seus dados estão seguros e serão restaurados assim que você escolher um plano. - - Acesse: {GetUpgradeUrl()} - - Equipe BCards - "; - - // TODO: Send actual email when email service is implemented - await Task.CompletedTask; - } - - private string GetUpgradeUrl() - { - // TODO: Get from configuration - return "https://bcards.com.br/pricing"; - } - - private async Task ProcessPermanentDeletionsAsync(IUserPageRepository userPageRepository) - { - try - { - _logger.LogInformation("Checking for pages to permanently delete..."); - - // Find pages that have been logically deleted for more than 30 days - var cutoffDate = DateTime.UtcNow.AddDays(-30); - - // Get all expired pages older than 30 days - var filter = MongoDB.Driver.Builders.Filter.And( - MongoDB.Driver.Builders.Filter.Eq(p => p.Status, PageStatus.Expired), - MongoDB.Driver.Builders.Filter.Ne(p => p.DeletedAt, null), - MongoDB.Driver.Builders.Filter.Lt(p => p.DeletedAt, cutoffDate) - ); - - var pagesToDelete = await userPageRepository.GetManyAsync(filter); - - _logger.LogInformation($"Found {pagesToDelete.Count} pages to permanently delete"); - - foreach (var page in pagesToDelete) - { - try - { - _logger.LogInformation($"Permanently deleting page {page.Id} ({page.DisplayName}) - deleted at {page.DeletedAt}"); - await userPageRepository.DeleteAsync(page.Id); - } - catch (Exception ex) - { - _logger.LogError(ex, $"Error permanently deleting page {page.Id}"); - } - } - - if (pagesToDelete.Count > 0) - { - _logger.LogInformation($"Permanently deleted {pagesToDelete.Count} pages"); - } - } - catch (Exception ex) - { - _logger.LogError(ex, "Error processing permanent deletions"); - } - } +using BCards.Web.Models; +using BCards.Web.Repositories; +using BCards.Web.ViewModels; +using MongoDB.Driver; + +namespace BCards.Web.Services; + +public class TrialExpirationService : BackgroundService +{ + private readonly IServiceProvider _serviceProvider; + private readonly ILogger _logger; + private readonly TimeSpan _checkInterval = TimeSpan.FromHours(1); // Check every hour + + public TrialExpirationService( + IServiceProvider serviceProvider, + ILogger logger) + { + _serviceProvider = serviceProvider; + _logger = logger; + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + _logger.LogInformation("TrialExpirationService started"); + + while (!stoppingToken.IsCancellationRequested) + { + try + { + await ProcessTrialExpirationsAsync(); + + // Verificar cancelamento antes de fazer delay + if (stoppingToken.IsCancellationRequested) + break; + + await Task.Delay(_checkInterval, stoppingToken); + } + catch (OperationCanceledException) + { + // Cancelamento normal - não é erro + _logger.LogInformation("TrialExpirationService is being cancelled"); + break; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error processing trial expirations"); + + // Verificar cancelamento antes de fazer delay de erro + if (stoppingToken.IsCancellationRequested) + break; + + try + { + await Task.Delay(TimeSpan.FromMinutes(5), stoppingToken); // Wait 5 minutes on error + } + catch (OperationCanceledException) + { + // Cancelamento durante delay de erro - também é normal + _logger.LogInformation("TrialExpirationService cancelled during error delay"); + break; + } + } + } + + _logger.LogInformation("TrialExpirationService stopped"); + } + + private async Task ProcessTrialExpirationsAsync() + { + try + { + using var scope = _serviceProvider.CreateScope(); + var subscriptionRepository = scope.ServiceProvider.GetRequiredService(); + var userPageRepository = scope.ServiceProvider.GetRequiredService(); + var userRepository = scope.ServiceProvider.GetRequiredService(); + + _logger.LogInformation("Checking for expired trials..."); + + // Process trial expirations + var trialSubscriptions = await subscriptionRepository.GetTrialSubscriptionsAsync(); + var now = DateTime.UtcNow; + + _logger.LogInformation($"Found {trialSubscriptions.Count} trial subscriptions to process"); + + foreach (var subscription in trialSubscriptions) + { + try + { + var user = await userRepository.GetByIdAsync(subscription.UserId); + if (user == null) + { + _logger.LogWarning($"User not found for subscription {subscription.Id}"); + continue; + } + + var daysUntilExpiration = (subscription.CurrentPeriodEnd - now).TotalDays; + + if (daysUntilExpiration <= 0) + { + // Trial expired - deactivate page + _logger.LogInformation($"Trial expired for user {user.Email}"); + await HandleTrialExpiredAsync(user, subscription, userPageRepository); + } + else if (daysUntilExpiration <= 2 && !user.NotifiedOfExpiration) + { + // Trial expiring soon - send notification + _logger.LogInformation($"Trial expiring in {daysUntilExpiration:F1} days for user {user.Email}"); + await SendExpirationWarningAsync(user, subscription, daysUntilExpiration); + + // Mark as notified + user.NotifiedOfExpiration = true; + await userRepository.UpdateAsync(user); + } + } + catch (Exception ex) + { + _logger.LogError(ex, $"Error processing trial for subscription {subscription.Id}"); + } + } + + _logger.LogInformation("Finished checking trial expirations"); + + // Process permanent deletions (pages deleted for more than 30 days) + await ProcessPermanentDeletionsAsync(userPageRepository); + } + catch (Exception ex) + { + _logger.LogError(ex, "Critical error in ProcessTrialExpirationsAsync"); + throw; // Re-throw para ser tratado pelo ExecuteAsync + } + } + + private async Task HandleTrialExpiredAsync( + User user, + Subscription subscription, + IUserPageRepository userPageRepository) + { + // Mark user page as expired (logical deletion) + var userPage = await userPageRepository.GetByUserIdAsync(user.Id); + if (userPage != null) + { + userPage.Status = PageStatus.Expired; + userPage.DeletedAt = DateTime.UtcNow; + userPage.DeletionReason = "trial_expired"; + userPage.UpdatedAt = DateTime.UtcNow; + await userPageRepository.UpdateAsync(userPage); + } + + // Update subscription status + subscription.Status = "expired"; + subscription.UpdatedAt = DateTime.UtcNow; + + using var scope = _serviceProvider.CreateScope(); + var subscriptionRepository = scope.ServiceProvider.GetRequiredService(); + await subscriptionRepository.UpdateAsync(subscription); + + // Send expiration email + await SendTrialExpiredEmailAsync(user); + + _logger.LogInformation($"Deactivated trial page for user {user.Email}"); + } + + private async Task SendExpirationWarningAsync( + User user, + Subscription subscription, + double daysRemaining) + { + // TODO: Implement email service + // For now, just log + _logger.LogInformation($"Should send expiration warning to {user.Email} - {daysRemaining:F1} days remaining"); + + // Example email content: + var subject = "Seu trial do BCards expira em breve!"; + var message = $@" + Olá {user.Name}, + + Seu trial gratuito do BCards expira em {Math.Ceiling(daysRemaining)} dia(s). + + Para continuar usando sua página de links, escolha um de nossos planos: + + • Básico - R$ 9,90/mês + • Profissional - R$ 24,90/mês + • Premium - R$ 29,90/mês + + Acesse: {GetUpgradeUrl()} + + Equipe BCards + "; + + // TODO: Send actual email when email service is implemented + await Task.CompletedTask; + } + + private async Task SendTrialExpiredEmailAsync(User user) + { + // TODO: Implement email service + _logger.LogInformation($"Should send trial expired email to {user.Email}"); + + var subject = "Seu trial do BCards expirou"; + var message = $@" + Olá {user.Name}, + + Seu trial gratuito do BCards expirou e sua página foi temporariamente desativada. + + Para reativar sua página, escolha um de nossos planos: + + • Básico - R$ 9,90/mês - 5 links, analytics básicos + • Profissional - R$ 24,90/mês - 15 links, todos os temas, analytics avançados + • Premium - R$ 29,90/mês - Links ilimitados, temas premium, analytics completos + + Seus dados estão seguros e serão restaurados assim que você escolher um plano. + + Acesse: {GetUpgradeUrl()} + + Equipe BCards + "; + + // TODO: Send actual email when email service is implemented + await Task.CompletedTask; + } + + private string GetUpgradeUrl() + { + // TODO: Get from configuration + return "https://bcards.com.br/pricing"; + } + + private async Task ProcessPermanentDeletionsAsync(IUserPageRepository userPageRepository) + { + try + { + _logger.LogInformation("Checking for pages to permanently delete..."); + + // Find pages that have been logically deleted for more than 30 days + var cutoffDate = DateTime.UtcNow.AddDays(-30); + + // Get all expired pages older than 30 days + var filter = MongoDB.Driver.Builders.Filter.And( + MongoDB.Driver.Builders.Filter.Eq(p => p.Status, PageStatus.Expired), + MongoDB.Driver.Builders.Filter.Ne(p => p.DeletedAt, null), + MongoDB.Driver.Builders.Filter.Lt(p => p.DeletedAt, cutoffDate) + ); + + var pagesToDelete = await userPageRepository.GetManyAsync(filter); + + _logger.LogInformation($"Found {pagesToDelete.Count} pages to permanently delete"); + + foreach (var page in pagesToDelete) + { + try + { + _logger.LogInformation($"Permanently deleting page {page.Id} ({page.DisplayName}) - deleted at {page.DeletedAt}"); + await userPageRepository.DeleteAsync(page.Id); + } + catch (Exception ex) + { + _logger.LogError(ex, $"Error permanently deleting page {page.Id}"); + } + } + + if (pagesToDelete.Count > 0) + { + _logger.LogInformation($"Permanently deleted {pagesToDelete.Count} pages"); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Error processing permanent deletions"); + } + } } \ No newline at end of file diff --git a/src/BCards.Web/Services/UserPageService.cs b/src/BCards.Web/Services/UserPageService.cs index 8c5065a..4982005 100644 --- a/src/BCards.Web/Services/UserPageService.cs +++ b/src/BCards.Web/Services/UserPageService.cs @@ -1,261 +1,261 @@ -using BCards.Web.Models; -using BCards.Web.Repositories; -using System.Text.RegularExpressions; -using System.Globalization; -using System.Text; -using BCards.Web.Utils; - -namespace BCards.Web.Services; - -public class UserPageService : IUserPageService -{ - private readonly IUserPageRepository _userPageRepository; - private readonly IUserRepository _userRepository; - private readonly ISubscriptionRepository _subscriptionRepository; - private readonly ILivePageRepository _livePageRepository; - - public UserPageService( - IUserPageRepository userPageRepository, - IUserRepository userRepository, - ISubscriptionRepository subscriptionRepository, - ILivePageRepository livePageRepository) - { - _userPageRepository = userPageRepository; - _userRepository = userRepository; - _subscriptionRepository = subscriptionRepository; - _livePageRepository = livePageRepository; - } - - public async Task GetPageAsync(string category, string slug) - { - return await _userPageRepository.GetBySlugAsync(category, slug); - } - - public async Task GetUserPageAsync(string userId) - { - return await _userPageRepository.GetByUserIdAsync(userId); - } - - public async Task GetPageByIdAsync(string id) - { - return await _userPageRepository.GetByIdAsync(id); - } - - public async Task> GetUserPagesAsync(string userId) - { - return await _userPageRepository.GetByUserIdAllAsync(userId); - } - - public async Task> GetActivePagesAsync() - { - return await _userPageRepository.GetActivePagesAsync(); - } - - public async Task CreatePageAsync(UserPage userPage) - { - userPage.Slug = await GenerateSlugAsync(userPage.Category, userPage.DisplayName); - return await _userPageRepository.CreateAsync(userPage); - } - - public async Task UpdatePageAsync(UserPage userPage) - { - return await _userPageRepository.UpdateAsync(userPage); - } - - public async Task DeletePageAsync(string id) - { - await _userPageRepository.DeleteAsync(id); - } - - public async Task ValidateSlugAsync(string category, string slug, string? excludeId = null) - { - if (string.IsNullOrWhiteSpace(slug) || string.IsNullOrWhiteSpace(category)) - return false; - - if (!IsValidSlugFormat(slug)) - return false; - - return !await _userPageRepository.SlugExistsAsync(category, slug, excludeId); - } - - public async Task GenerateSlugAsync(string category, string name) - { - var slug = SlugHelper.CreateSlug(GenerateSlug(name)); - var originalSlug = slug; - var counter = 1; - - while (await _userPageRepository.SlugExistsAsync(category, slug)) - { - slug = $"{originalSlug}-{counter}"; - counter++; - } - - return slug; - } - - public async Task CanCreateLinksAsync(string userId, int newLinksCount = 1) - { - var userPage = await _userPageRepository.GetByUserIdAsync(userId); - if (userPage == null) return true; // New page - - var subscription = await _subscriptionRepository.GetByUserIdAsync(userId); - var maxLinks = subscription?.MaxLinks ?? 5; // Default free plan - - if (maxLinks == -1) return true; // Unlimited - - var currentLinksCount = userPage.Links?.Count(l => l.IsActive) ?? 0; - return (currentLinksCount + newLinksCount) <= maxLinks; - } - - public async Task RecordPageViewAsync(string pageId, string? referrer = null, string? userAgent = null) - { - var page = await _userPageRepository.GetByIdAsync(pageId); - if (page?.PlanLimitations.AllowAnalytics != true) return; - - var analytics = page.Analytics; - analytics.TotalViews++; - analytics.LastViewedAt = DateTime.UtcNow; - - // Monthly stats - var monthKey = DateTime.UtcNow.ToString("yyyy-MM"); - if (analytics.MonthlyViews.ContainsKey(monthKey)) - analytics.MonthlyViews[monthKey]++; - else - analytics.MonthlyViews[monthKey] = 1; - - // Referrer stats - if (!string.IsNullOrEmpty(referrer)) - { - var domain = ExtractDomain(referrer); - if (!string.IsNullOrEmpty(domain)) - { - if (analytics.TopReferrers.ContainsKey(domain)) - analytics.TopReferrers[domain]++; - else - analytics.TopReferrers[domain] = 1; - } - } - - // Device stats (simplified) - if (!string.IsNullOrEmpty(userAgent)) - { - var deviceType = GetDeviceType(userAgent); - if (analytics.DeviceStats.ContainsKey(deviceType)) - analytics.DeviceStats[deviceType]++; - else - analytics.DeviceStats[deviceType] = 1; - } - - await _userPageRepository.UpdateAnalyticsAsync(pageId, analytics); - } - - public async Task RecordLinkClickAsync(string pageId, int linkIndex) - { - var livePageExists = await _livePageRepository.GetByIdAsync(pageId); - if (livePageExists == null) return; - - var page = await _userPageRepository.GetByIdAsync(livePageExists.OriginalPageId); - var livepage = (LivePage)livePageExists; - - if (linkIndex >= 0 && linkIndex < livepage.Links.Count) - { - livepage.Links[linkIndex].Clicks++; - page.Links[linkIndex].Clicks++; - } - - var analyticsLive = livepage.Analytics; - analyticsLive.TotalClicks++; - - var analytics = page.Analytics; - analytics.TotalClicks++; - - // Monthly clicks - var monthKey = DateTime.UtcNow.ToString("yyyy-MM"); - if (analytics.MonthlyClicks.ContainsKey(monthKey)) - analytics.MonthlyClicks[monthKey]++; - else - analytics.MonthlyClicks[monthKey] = 1; - - await _livePageRepository.UpdateAsync(livepage); - await _userPageRepository.UpdateAsync(page); - } - - public async Task> GetRecentPagesAsync(int limit = 10) - { - return await _userPageRepository.GetRecentPagesAsync(limit); - } - - public async Task> GetPagesByCategoryAsync(string category, int limit = 20) - { - return await _userPageRepository.GetByCategoryAsync(category, limit); - } - - private static string GenerateSlug(string text) - { - if (string.IsNullOrWhiteSpace(text)) - return string.Empty; - - // Remove acentos - text = RemoveDiacritics(text); - - // Converter para minúsculas - text = text.ToLowerInvariant(); - - // Substituir espaços e caracteres especiais por hífens - text = Regex.Replace(text, @"[^a-z0-9\s-]", ""); - text = Regex.Replace(text, @"[\s-]+", "-"); - - // Remover hífens do início e fim - text = text.Trim('-'); - - return text; - } - - private static string RemoveDiacritics(string text) - { - var normalizedString = text.Normalize(NormalizationForm.FormD); - var stringBuilder = new StringBuilder(); - - foreach (var c in normalizedString) - { - var unicodeCategory = CharUnicodeInfo.GetUnicodeCategory(c); - if (unicodeCategory != UnicodeCategory.NonSpacingMark) - { - stringBuilder.Append(c); - } - } - - return stringBuilder.ToString().Normalize(NormalizationForm.FormC); - } - - private static bool IsValidSlugFormat(string slug) - { - return Regex.IsMatch(slug, @"^[a-z0-9-]+$") && !slug.StartsWith('-') && !slug.EndsWith('-'); - } - - private static string ExtractDomain(string url) - { - try - { - var uri = new Uri(url); - return uri.Host; - } - catch - { - return string.Empty; - } - } - - private static string GetDeviceType(string userAgent) - { - userAgent = userAgent.ToLowerInvariant(); - - if (userAgent.Contains("mobile") || userAgent.Contains("android") || userAgent.Contains("iphone")) - return "mobile"; - - if (userAgent.Contains("tablet") || userAgent.Contains("ipad")) - return "tablet"; - - return "desktop"; - } +using BCards.Web.Models; +using BCards.Web.Repositories; +using System.Text.RegularExpressions; +using System.Globalization; +using System.Text; +using BCards.Web.Utils; + +namespace BCards.Web.Services; + +public class UserPageService : IUserPageService +{ + private readonly IUserPageRepository _userPageRepository; + private readonly IUserRepository _userRepository; + private readonly ISubscriptionRepository _subscriptionRepository; + private readonly ILivePageRepository _livePageRepository; + + public UserPageService( + IUserPageRepository userPageRepository, + IUserRepository userRepository, + ISubscriptionRepository subscriptionRepository, + ILivePageRepository livePageRepository) + { + _userPageRepository = userPageRepository; + _userRepository = userRepository; + _subscriptionRepository = subscriptionRepository; + _livePageRepository = livePageRepository; + } + + public async Task GetPageAsync(string category, string slug) + { + return await _userPageRepository.GetBySlugAsync(category, slug); + } + + public async Task GetUserPageAsync(string userId) + { + return await _userPageRepository.GetByUserIdAsync(userId); + } + + public async Task GetPageByIdAsync(string id) + { + return await _userPageRepository.GetByIdAsync(id); + } + + public async Task> GetUserPagesAsync(string userId) + { + return await _userPageRepository.GetByUserIdAllAsync(userId); + } + + public async Task> GetActivePagesAsync() + { + return await _userPageRepository.GetActivePagesAsync(); + } + + public async Task CreatePageAsync(UserPage userPage) + { + userPage.Slug = await GenerateSlugAsync(userPage.Category, userPage.DisplayName); + return await _userPageRepository.CreateAsync(userPage); + } + + public async Task UpdatePageAsync(UserPage userPage) + { + return await _userPageRepository.UpdateAsync(userPage); + } + + public async Task DeletePageAsync(string id) + { + await _userPageRepository.DeleteAsync(id); + } + + public async Task ValidateSlugAsync(string category, string slug, string? excludeId = null) + { + if (string.IsNullOrWhiteSpace(slug) || string.IsNullOrWhiteSpace(category)) + return false; + + if (!IsValidSlugFormat(slug)) + return false; + + return !await _userPageRepository.SlugExistsAsync(category, slug, excludeId); + } + + public async Task GenerateSlugAsync(string category, string name) + { + var slug = SlugHelper.CreateSlug(GenerateSlug(name)); + var originalSlug = slug; + var counter = 1; + + while (await _userPageRepository.SlugExistsAsync(category, slug)) + { + slug = $"{originalSlug}-{counter}"; + counter++; + } + + return slug; + } + + public async Task CanCreateLinksAsync(string userId, int newLinksCount = 1) + { + var userPage = await _userPageRepository.GetByUserIdAsync(userId); + if (userPage == null) return true; // New page + + var subscription = await _subscriptionRepository.GetByUserIdAsync(userId); + var maxLinks = subscription?.MaxLinks ?? 5; // Default free plan + + if (maxLinks == -1) return true; // Unlimited + + var currentLinksCount = userPage.Links?.Count(l => l.IsActive) ?? 0; + return (currentLinksCount + newLinksCount) <= maxLinks; + } + + public async Task RecordPageViewAsync(string pageId, string? referrer = null, string? userAgent = null) + { + var page = await _userPageRepository.GetByIdAsync(pageId); + if (page?.PlanLimitations.AllowAnalytics != true) return; + + var analytics = page.Analytics; + analytics.TotalViews++; + analytics.LastViewedAt = DateTime.UtcNow; + + // Monthly stats + var monthKey = DateTime.UtcNow.ToString("yyyy-MM"); + if (analytics.MonthlyViews.ContainsKey(monthKey)) + analytics.MonthlyViews[monthKey]++; + else + analytics.MonthlyViews[monthKey] = 1; + + // Referrer stats + if (!string.IsNullOrEmpty(referrer)) + { + var domain = ExtractDomain(referrer); + if (!string.IsNullOrEmpty(domain)) + { + if (analytics.TopReferrers.ContainsKey(domain)) + analytics.TopReferrers[domain]++; + else + analytics.TopReferrers[domain] = 1; + } + } + + // Device stats (simplified) + if (!string.IsNullOrEmpty(userAgent)) + { + var deviceType = GetDeviceType(userAgent); + if (analytics.DeviceStats.ContainsKey(deviceType)) + analytics.DeviceStats[deviceType]++; + else + analytics.DeviceStats[deviceType] = 1; + } + + await _userPageRepository.UpdateAnalyticsAsync(pageId, analytics); + } + + public async Task RecordLinkClickAsync(string pageId, int linkIndex) + { + var livePageExists = await _livePageRepository.GetByIdAsync(pageId); + if (livePageExists == null) return; + + var page = await _userPageRepository.GetByIdAsync(livePageExists.OriginalPageId); + var livepage = (LivePage)livePageExists; + + if (linkIndex >= 0 && linkIndex < livepage.Links.Count) + { + livepage.Links[linkIndex].Clicks++; + page.Links[linkIndex].Clicks++; + } + + var analyticsLive = livepage.Analytics; + analyticsLive.TotalClicks++; + + var analytics = page.Analytics; + analytics.TotalClicks++; + + // Monthly clicks + var monthKey = DateTime.UtcNow.ToString("yyyy-MM"); + if (analytics.MonthlyClicks.ContainsKey(monthKey)) + analytics.MonthlyClicks[monthKey]++; + else + analytics.MonthlyClicks[monthKey] = 1; + + await _livePageRepository.UpdateAsync(livepage); + await _userPageRepository.UpdateAsync(page); + } + + public async Task> GetRecentPagesAsync(int limit = 10) + { + return await _userPageRepository.GetRecentPagesAsync(limit); + } + + public async Task> GetPagesByCategoryAsync(string category, int limit = 20) + { + return await _userPageRepository.GetByCategoryAsync(category, limit); + } + + private static string GenerateSlug(string text) + { + if (string.IsNullOrWhiteSpace(text)) + return string.Empty; + + // Remove acentos + text = RemoveDiacritics(text); + + // Converter para minúsculas + text = text.ToLowerInvariant(); + + // Substituir espaços e caracteres especiais por hífens + text = Regex.Replace(text, @"[^a-z0-9\s-]", ""); + text = Regex.Replace(text, @"[\s-]+", "-"); + + // Remover hífens do início e fim + text = text.Trim('-'); + + return text; + } + + private static string RemoveDiacritics(string text) + { + var normalizedString = text.Normalize(NormalizationForm.FormD); + var stringBuilder = new StringBuilder(); + + foreach (var c in normalizedString) + { + var unicodeCategory = CharUnicodeInfo.GetUnicodeCategory(c); + if (unicodeCategory != UnicodeCategory.NonSpacingMark) + { + stringBuilder.Append(c); + } + } + + return stringBuilder.ToString().Normalize(NormalizationForm.FormC); + } + + private static bool IsValidSlugFormat(string slug) + { + return Regex.IsMatch(slug, @"^[a-z0-9-]+$") && !slug.StartsWith('-') && !slug.EndsWith('-'); + } + + private static string ExtractDomain(string url) + { + try + { + var uri = new Uri(url); + return uri.Host; + } + catch + { + return string.Empty; + } + } + + private static string GetDeviceType(string userAgent) + { + userAgent = userAgent.ToLowerInvariant(); + + if (userAgent.Contains("mobile") || userAgent.Contains("android") || userAgent.Contains("iphone")) + return "mobile"; + + if (userAgent.Contains("tablet") || userAgent.Contains("ipad")) + return "tablet"; + + return "desktop"; + } } \ No newline at end of file diff --git a/src/BCards.Web/StartStripeCLI.bat b/src/BCards.Web/StartStripeCLI.bat index 2878f06..ed6ad36 100644 --- a/src/BCards.Web/StartStripeCLI.bat +++ b/src/BCards.Web/StartStripeCLI.bat @@ -1 +1 @@ - stripe listen --forward-to localhost:49178/webhook/stripe + stripe listen --forward-to localhost:49178/webhook/stripe diff --git a/src/BCards.Web/Utils/AllowedDomains.cs b/src/BCards.Web/Utils/AllowedDomains.cs index 17cec15..67b70f2 100644 --- a/src/BCards.Web/Utils/AllowedDomains.cs +++ b/src/BCards.Web/Utils/AllowedDomains.cs @@ -1,69 +1,69 @@ -namespace BCards.Web.Utils; - -public static class AllowedDomains -{ - public static readonly HashSet EcommerceWhitelist = new(StringComparer.OrdinalIgnoreCase) - { - // Principais E-commerces Brasileiros - "mercadolivre.com.br", "mercadolibre.com", - "amazon.com.br", "amazon.com", - "magazineluiza.com.br", "magalu.com.br", - "americanas.com", "submarino.com.br", - "extra.com.br", "pontofrio.com.br", - "casasbahia.com.br", "casas.com.br", - "shopee.com.br", "shopee.com", "s.shopee.com.br", - "aliexpress.com", "aliexpress.us", - "netshoes.com.br", "centauro.com.br", - "dafiti.com.br", "kanui.com.br", - "fastshop.com.br", "kabum.com.br", - "pichau.com.br", "terabyteshop.com.br", - - // Marketplaces Internacionais Seguros - "ebay.com", "etsy.com", "walmart.com", - "target.com", "bestbuy.com", - - // E-commerces de Moda - "zara.com", "hm.com", "gap.com", - "uniqlo.com", "forever21.com", - - // Livrarias e Educação - "saraiva.com.br", "livrariacultura.com.br", - "estantevirtual.com.br", - - // Casa e Decoração - "mobly.com.br", "tok-stok.com.br", - "westwing.com.br", "madeiramadeira.com.br" - }; - - public static bool IsAllowed(string url) - { - try - { - var uri = new Uri(url); - var domain = uri.Host.ToLowerInvariant(); - - // Remove "www." se existir - if (domain.StartsWith("www.")) - domain = domain.Substring(4); - - return EcommerceWhitelist.Contains(domain); - } - catch - { - return false; - } - } - - public static string GetDomainFromUrl(string url) - { - try - { - var uri = new Uri(url); - return uri.Host.ToLowerInvariant().Replace("www.", ""); - } - catch - { - return string.Empty; - } - } +namespace BCards.Web.Utils; + +public static class AllowedDomains +{ + public static readonly HashSet EcommerceWhitelist = new(StringComparer.OrdinalIgnoreCase) + { + // Principais E-commerces Brasileiros + "mercadolivre.com.br", "mercadolibre.com", + "amazon.com.br", "amazon.com", + "magazineluiza.com.br", "magalu.com.br", + "americanas.com", "submarino.com.br", + "extra.com.br", "pontofrio.com.br", + "casasbahia.com.br", "casas.com.br", + "shopee.com.br", "shopee.com", "s.shopee.com.br", + "aliexpress.com", "aliexpress.us", + "netshoes.com.br", "centauro.com.br", + "dafiti.com.br", "kanui.com.br", + "fastshop.com.br", "kabum.com.br", + "pichau.com.br", "terabyteshop.com.br", + + // Marketplaces Internacionais Seguros + "ebay.com", "etsy.com", "walmart.com", + "target.com", "bestbuy.com", + + // E-commerces de Moda + "zara.com", "hm.com", "gap.com", + "uniqlo.com", "forever21.com", + + // Livrarias e Educação + "saraiva.com.br", "livrariacultura.com.br", + "estantevirtual.com.br", + + // Casa e Decoração + "mobly.com.br", "tok-stok.com.br", + "westwing.com.br", "madeiramadeira.com.br" + }; + + public static bool IsAllowed(string url) + { + try + { + var uri = new Uri(url); + var domain = uri.Host.ToLowerInvariant(); + + // Remove "www." se existir + if (domain.StartsWith("www.")) + domain = domain.Substring(4); + + return EcommerceWhitelist.Contains(domain); + } + catch + { + return false; + } + } + + public static string GetDomainFromUrl(string url) + { + try + { + var uri = new Uri(url); + return uri.Host.ToLowerInvariant().Replace("www.", ""); + } + catch + { + return string.Empty; + } + } } \ No newline at end of file diff --git a/src/BCards.Web/Utils/ConditionalRequiredAttribute.cs b/src/BCards.Web/Utils/ConditionalRequiredAttribute.cs index d989d1e..fc7c15c 100644 --- a/src/BCards.Web/Utils/ConditionalRequiredAttribute.cs +++ b/src/BCards.Web/Utils/ConditionalRequiredAttribute.cs @@ -1,72 +1,72 @@ -using BCards.Web.Models; -using BCards.Web.ViewModels; -using Microsoft.AspNetCore.Mvc.ModelBinding; -using System.ComponentModel.DataAnnotations; - -// Atributo de validação customizado para links -public class ConditionalRequiredAttribute : ValidationAttribute -{ - private readonly string _dependentProperty; - private readonly object _targetValue; - - public ConditionalRequiredAttribute(string dependentProperty, object targetValue) - { - _dependentProperty = dependentProperty; - _targetValue = targetValue; - } - - protected override ValidationResult IsValid(object value, ValidationContext validationContext) - { - var dependentProperty = validationContext.ObjectType.GetProperty(_dependentProperty); - if (dependentProperty == null) - return ValidationResult.Success; - - var dependentValue = dependentProperty.GetValue(validationContext.ObjectInstance); - - // Se o valor dependente não é o target, não valida - if (!Equals(dependentValue, _targetValue)) - return ValidationResult.Success; - - // Se é o target value e o campo está vazio, retorna erro - if (value == null || string.IsNullOrWhiteSpace(value.ToString())) - return new ValidationResult(ErrorMessage ?? $"{validationContext.DisplayName} é obrigatório."); - - return ValidationResult.Success; - } -} - -// Método de extensão para validação personalizada no Controller -public static class ModelStateExtensions -{ - public static void ValidateLinks(this ModelStateDictionary modelState, List links) - { - for (int i = 0; i < links.Count; i++) - { - var link = links[i]; - - // Validação condicional baseada no tipo - if (link.Type == LinkType.Product) - { - // Para links de produto, ProductTitle é obrigatório - if (string.IsNullOrWhiteSpace(link.ProductTitle)) - { - modelState.AddModelError($"Links[{i}].ProductTitle", "Título do produto é obrigatório"); - } - - // Title pode ser vazio para links de produto (será preenchido automaticamente) - modelState.Remove($"Links[{i}].Title"); - } - else - { - // Para links normais, Title é obrigatório - if (string.IsNullOrWhiteSpace(link.Title)) - { - modelState.AddModelError($"Links[{i}].Title", "Título é obrigatório"); - } - - // Campos de produto podem ser vazios para links normais - modelState.Remove($"Links[{i}].ProductTitle"); - } - } - } +using BCards.Web.Models; +using BCards.Web.ViewModels; +using Microsoft.AspNetCore.Mvc.ModelBinding; +using System.ComponentModel.DataAnnotations; + +// Atributo de validação customizado para links +public class ConditionalRequiredAttribute : ValidationAttribute +{ + private readonly string _dependentProperty; + private readonly object _targetValue; + + public ConditionalRequiredAttribute(string dependentProperty, object targetValue) + { + _dependentProperty = dependentProperty; + _targetValue = targetValue; + } + + protected override ValidationResult IsValid(object value, ValidationContext validationContext) + { + var dependentProperty = validationContext.ObjectType.GetProperty(_dependentProperty); + if (dependentProperty == null) + return ValidationResult.Success; + + var dependentValue = dependentProperty.GetValue(validationContext.ObjectInstance); + + // Se o valor dependente não é o target, não valida + if (!Equals(dependentValue, _targetValue)) + return ValidationResult.Success; + + // Se é o target value e o campo está vazio, retorna erro + if (value == null || string.IsNullOrWhiteSpace(value.ToString())) + return new ValidationResult(ErrorMessage ?? $"{validationContext.DisplayName} é obrigatório."); + + return ValidationResult.Success; + } +} + +// Método de extensão para validação personalizada no Controller +public static class ModelStateExtensions +{ + public static void ValidateLinks(this ModelStateDictionary modelState, List links) + { + for (int i = 0; i < links.Count; i++) + { + var link = links[i]; + + // Validação condicional baseada no tipo + if (link.Type == LinkType.Product) + { + // Para links de produto, ProductTitle é obrigatório + if (string.IsNullOrWhiteSpace(link.ProductTitle)) + { + modelState.AddModelError($"Links[{i}].ProductTitle", "Título do produto é obrigatório"); + } + + // Title pode ser vazio para links de produto (será preenchido automaticamente) + modelState.Remove($"Links[{i}].Title"); + } + else + { + // Para links normais, Title é obrigatório + if (string.IsNullOrWhiteSpace(link.Title)) + { + modelState.AddModelError($"Links[{i}].Title", "Título é obrigatório"); + } + + // Campos de produto podem ser vazios para links normais + modelState.Remove($"Links[{i}].ProductTitle"); + } + } + } } \ No newline at end of file diff --git a/src/BCards.Web/Utils/ModerationMenuViewComponent.cs b/src/BCards.Web/Utils/ModerationMenuViewComponent.cs index de21305..3ca2d61 100644 --- a/src/BCards.Web/Utils/ModerationMenuViewComponent.cs +++ b/src/BCards.Web/Utils/ModerationMenuViewComponent.cs @@ -1,25 +1,25 @@ -using BCards.Web.Models; -using BCards.Web.Services; -using Microsoft.AspNetCore.Mvc; - -namespace BCards.Web.Utils -{ - public class ModerationMenuViewComponent : ViewComponent - { - private readonly IModerationAuthService _moderationAuth; - - public ModerationMenuViewComponent(IModerationAuthService moderationAuth) - { - _moderationAuth = moderationAuth; - } - - public IViewComponentResult Invoke() - { - var user = HttpContext.User; - var isModerator = user.Identity?.IsAuthenticated == true && - _moderationAuth.IsUserModerator(user); - - return View(isModerator); - } - } -} +using BCards.Web.Models; +using BCards.Web.Services; +using Microsoft.AspNetCore.Mvc; + +namespace BCards.Web.Utils +{ + public class ModerationMenuViewComponent : ViewComponent + { + private readonly IModerationAuthService _moderationAuth; + + public ModerationMenuViewComponent(IModerationAuthService moderationAuth) + { + _moderationAuth = moderationAuth; + } + + public IViewComponentResult Invoke() + { + var user = HttpContext.User; + var isModerator = user.Identity?.IsAuthenticated == true && + _moderationAuth.IsUserModerator(user); + + return View(isModerator); + } + } +} diff --git a/src/BCards.Web/Utils/SlugHelper.cs b/src/BCards.Web/Utils/SlugHelper.cs index c97460f..43bd923 100644 --- a/src/BCards.Web/Utils/SlugHelper.cs +++ b/src/BCards.Web/Utils/SlugHelper.cs @@ -1,116 +1,116 @@ -using System.Globalization; -using System.Text.RegularExpressions; -using System.Text; - -namespace BCards.Web.Utils -{ - public static class SlugHelper - { - /// - /// Remove acentos e caracteres especiais, criando um slug limpo - /// - public static string RemoveAccents(string text) - { - if (string.IsNullOrWhiteSpace(text)) - return string.Empty; - - // Normalizar para NFD (decompor caracteres acentuados) - var normalizedString = text.Normalize(NormalizationForm.FormD); - var stringBuilder = new StringBuilder(); - - // Filtrar apenas caracteres que não são marcas diacríticas - foreach (var c in normalizedString) - { - var unicodeCategory = CharUnicodeInfo.GetUnicodeCategory(c); - if (unicodeCategory != UnicodeCategory.NonSpacingMark) - { - stringBuilder.Append(c); - } - } - - return stringBuilder.ToString().Normalize(NormalizationForm.FormC); - } - - /// - /// Cria um slug limpo e URL-friendly - /// - public static string CreateSlug(string text) - { - if (string.IsNullOrWhiteSpace(text)) - return string.Empty; - - // 1. Remover acentos - var slug = RemoveAccents(text); - - // 2. Converter para minúsculas - slug = slug.ToLowerInvariant(); - - // 3. Substituir espaços e caracteres especiais por hífen - slug = Regex.Replace(slug, @"[^a-z0-9\s-]", ""); - - // 4. Substituir múltiplos espaços por hífen único - slug = Regex.Replace(slug, @"[\s-]+", "-"); - - // 5. Remover hífens do início e fim - slug = slug.Trim('-'); - - // 6. Limitar tamanho (opcional) - if (slug.Length > 50) - slug = slug.Substring(0, 50).TrimEnd('-'); - - return slug; - } - - /// - /// Cria uma categoria limpa (sem acentos, minúscula) - /// - public static string CreateCategorySlug(string category) - { - if (string.IsNullOrWhiteSpace(category)) - return string.Empty; - - var slug = RemoveAccents(category); - slug = slug.ToLowerInvariant(); - slug = Regex.Replace(slug, @"[^a-z0-9]", ""); - - return slug; - } - - /// - /// Dicionário de conversões comuns para categorias brasileiras - /// - private static readonly Dictionary CategoryMappings = new() - { - { "saúde", "saude" }, - { "educação", "educacao" }, - { "tecnologia", "tecnologia" }, - { "negócios", "negocios" }, - { "serviços", "servicos" }, - { "alimentação", "alimentacao" }, - { "construção", "construcao" }, - { "automóveis", "automoveis" }, - { "beleza", "beleza" }, - { "esportes", "esportes" }, - { "música", "musica" }, - { "fotografia", "fotografia" } - }; - - /// - /// Converte categoria com mapeamento personalizado - /// - public static string ConvertCategory(string category) - { - if (string.IsNullOrWhiteSpace(category)) - return string.Empty; - - var lowerCategory = category.ToLowerInvariant().Trim(); - - // Verificar mapeamento direto - if (CategoryMappings.ContainsKey(lowerCategory)) - return CategoryMappings[lowerCategory]; - - // Fallback para conversão automática - return CreateCategorySlug(category); - } - } -} +using System.Globalization; +using System.Text.RegularExpressions; +using System.Text; + +namespace BCards.Web.Utils +{ + public static class SlugHelper + { + /// + /// Remove acentos e caracteres especiais, criando um slug limpo + /// + public static string RemoveAccents(string text) + { + if (string.IsNullOrWhiteSpace(text)) + return string.Empty; + + // Normalizar para NFD (decompor caracteres acentuados) + var normalizedString = text.Normalize(NormalizationForm.FormD); + var stringBuilder = new StringBuilder(); + + // Filtrar apenas caracteres que não são marcas diacríticas + foreach (var c in normalizedString) + { + var unicodeCategory = CharUnicodeInfo.GetUnicodeCategory(c); + if (unicodeCategory != UnicodeCategory.NonSpacingMark) + { + stringBuilder.Append(c); + } + } + + return stringBuilder.ToString().Normalize(NormalizationForm.FormC); + } + + /// + /// Cria um slug limpo e URL-friendly + /// + public static string CreateSlug(string text) + { + if (string.IsNullOrWhiteSpace(text)) + return string.Empty; + + // 1. Remover acentos + var slug = RemoveAccents(text); + + // 2. Converter para minúsculas + slug = slug.ToLowerInvariant(); + + // 3. Substituir espaços e caracteres especiais por hífen + slug = Regex.Replace(slug, @"[^a-z0-9\s-]", ""); + + // 4. Substituir múltiplos espaços por hífen único + slug = Regex.Replace(slug, @"[\s-]+", "-"); + + // 5. Remover hífens do início e fim + slug = slug.Trim('-'); + + // 6. Limitar tamanho (opcional) + if (slug.Length > 50) + slug = slug.Substring(0, 50).TrimEnd('-'); + + return slug; + } + + /// + /// Cria uma categoria limpa (sem acentos, minúscula) + /// + public static string CreateCategorySlug(string category) + { + if (string.IsNullOrWhiteSpace(category)) + return string.Empty; + + var slug = RemoveAccents(category); + slug = slug.ToLowerInvariant(); + slug = Regex.Replace(slug, @"[^a-z0-9]", ""); + + return slug; + } + + /// + /// Dicionário de conversões comuns para categorias brasileiras + /// + private static readonly Dictionary CategoryMappings = new() + { + { "saúde", "saude" }, + { "educação", "educacao" }, + { "tecnologia", "tecnologia" }, + { "negócios", "negocios" }, + { "serviços", "servicos" }, + { "alimentação", "alimentacao" }, + { "construção", "construcao" }, + { "automóveis", "automoveis" }, + { "beleza", "beleza" }, + { "esportes", "esportes" }, + { "música", "musica" }, + { "fotografia", "fotografia" } + }; + + /// + /// Converte categoria com mapeamento personalizado + /// + public static string ConvertCategory(string category) + { + if (string.IsNullOrWhiteSpace(category)) + return string.Empty; + + var lowerCategory = category.ToLowerInvariant().Trim(); + + // Verificar mapeamento direto + if (CategoryMappings.ContainsKey(lowerCategory)) + return CategoryMappings[lowerCategory]; + + // Fallback para conversão automática + return CreateCategorySlug(category); + } + } +} diff --git a/src/BCards.Web/Utils/ViewExtensions.cs b/src/BCards.Web/Utils/ViewExtensions.cs index a37922d..39e668d 100644 --- a/src/BCards.Web/Utils/ViewExtensions.cs +++ b/src/BCards.Web/Utils/ViewExtensions.cs @@ -1,14 +1,14 @@ -using BCards.Web.Services; -using System.Security.Claims; - -namespace BCards.Web.Utils -{ - public static class ViewExtensions - { - public static bool IsModerator(this ClaimsPrincipal user, IServiceProvider services) - { - var moderationAuth = services.GetRequiredService(); - return moderationAuth.IsUserModerator(user); - } - } -} +using BCards.Web.Services; +using System.Security.Claims; + +namespace BCards.Web.Utils +{ + public static class ViewExtensions + { + public static bool IsModerator(this ClaimsPrincipal user, IServiceProvider services) + { + var moderationAuth = services.GetRequiredService(); + return moderationAuth.IsUserModerator(user); + } + } +} diff --git a/src/BCards.Web/ViewModels/CancelSubscriptionViewModel.cs b/src/BCards.Web/ViewModels/CancelSubscriptionViewModel.cs index b235d71..4e66a8f 100644 --- a/src/BCards.Web/ViewModels/CancelSubscriptionViewModel.cs +++ b/src/BCards.Web/ViewModels/CancelSubscriptionViewModel.cs @@ -1,18 +1,18 @@ -namespace BCards.Web.ViewModels; - -public class CancelSubscriptionViewModel -{ - public string SubscriptionId { get; set; } = string.Empty; - public string PlanName { get; set; } = string.Empty; - public DateTime CurrentPeriodEnd { get; set; } - public bool CanRefundFull { get; set; } - public bool CanRefundPartial { get; set; } - public decimal RefundAmount { get; set; } - public int DaysRemaining { get; set; } -} - -public class CancelSubscriptionRequest -{ - public string SubscriptionId { get; set; } = string.Empty; - public string CancelType { get; set; } = "at_period_end"; // immediate_with_refund, immediate_no_refund, partial_refund, at_period_end +namespace BCards.Web.ViewModels; + +public class CancelSubscriptionViewModel +{ + public string SubscriptionId { get; set; } = string.Empty; + public string PlanName { get; set; } = string.Empty; + public DateTime CurrentPeriodEnd { get; set; } + public bool CanRefundFull { get; set; } + public bool CanRefundPartial { get; set; } + public decimal RefundAmount { get; set; } + public int DaysRemaining { get; set; } +} + +public class CancelSubscriptionRequest +{ + public string SubscriptionId { get; set; } = string.Empty; + public string CancelType { get; set; } = "at_period_end"; // immediate_with_refund, immediate_no_refund, partial_refund, at_period_end } \ No newline at end of file diff --git a/src/BCards.Web/ViewModels/CreatePageViewModel.cs b/src/BCards.Web/ViewModels/CreatePageViewModel.cs index 338b7a3..968c767 100644 --- a/src/BCards.Web/ViewModels/CreatePageViewModel.cs +++ b/src/BCards.Web/ViewModels/CreatePageViewModel.cs @@ -1,50 +1,50 @@ -using System.ComponentModel.DataAnnotations; - -namespace BCards.Web.ViewModels; - -public class CreatePageViewModel -{ - [Required(ErrorMessage = "Nome é obrigatório")] - [StringLength(50, ErrorMessage = "Nome deve ter no máximo 50 caracteres")] - public string DisplayName { get; set; } = string.Empty; - - [Required(ErrorMessage = "Categoria é obrigatória")] - public string Category { get; set; } = string.Empty; - - [Required(ErrorMessage = "Tipo de negócio é obrigatório")] - public string BusinessType { get; set; } = "individual"; - - [StringLength(200, ErrorMessage = "Bio deve ter no máximo 200 caracteres")] - public string Bio { get; set; } = string.Empty; - - [Required(ErrorMessage = "Tema é obrigatório")] - public string SelectedTheme { get; set; } = "minimalist"; - - public string WhatsAppNumber { get; set; } = string.Empty; - - public string FacebookUrl { get; set; } = string.Empty; - - public string TwitterUrl { get; set; } = string.Empty; - - public string InstagramUrl { get; set; } = string.Empty; - - public List Links { get; set; } = new(); - - public string Slug { get; set; } = string.Empty; -} - -public class CreateLinkViewModel -{ - [Required(ErrorMessage = "Título é obrigatório")] - [StringLength(50, ErrorMessage = "Título deve ter no máximo 50 caracteres")] - public string Title { get; set; } = string.Empty; - - [Required(ErrorMessage = "URL é obrigatória")] - [Url(ErrorMessage = "URL inválida")] - public string Url { get; set; } = string.Empty; - - [StringLength(100, ErrorMessage = "Descrição deve ter no máximo 100 caracteres")] - public string Description { get; set; } = string.Empty; - - public string Icon { get; set; } = string.Empty; +using System.ComponentModel.DataAnnotations; + +namespace BCards.Web.ViewModels; + +public class CreatePageViewModel +{ + [Required(ErrorMessage = "Nome é obrigatório")] + [StringLength(50, ErrorMessage = "Nome deve ter no máximo 50 caracteres")] + public string DisplayName { get; set; } = string.Empty; + + [Required(ErrorMessage = "Categoria é obrigatória")] + public string Category { get; set; } = string.Empty; + + [Required(ErrorMessage = "Tipo de negócio é obrigatório")] + public string BusinessType { get; set; } = "individual"; + + [StringLength(200, ErrorMessage = "Bio deve ter no máximo 200 caracteres")] + public string Bio { get; set; } = string.Empty; + + [Required(ErrorMessage = "Tema é obrigatório")] + public string SelectedTheme { get; set; } = "minimalist"; + + public string WhatsAppNumber { get; set; } = string.Empty; + + public string FacebookUrl { get; set; } = string.Empty; + + public string TwitterUrl { get; set; } = string.Empty; + + public string InstagramUrl { get; set; } = string.Empty; + + public List Links { get; set; } = new(); + + public string Slug { get; set; } = string.Empty; +} + +public class CreateLinkViewModel +{ + [Required(ErrorMessage = "Título é obrigatório")] + [StringLength(50, ErrorMessage = "Título deve ter no máximo 50 caracteres")] + public string Title { get; set; } = string.Empty; + + [Required(ErrorMessage = "URL é obrigatória")] + [Url(ErrorMessage = "URL inválida")] + public string Url { get; set; } = string.Empty; + + [StringLength(100, ErrorMessage = "Descrição deve ter no máximo 100 caracteres")] + public string Description { get; set; } = string.Empty; + + public string Icon { get; set; } = string.Empty; } \ No newline at end of file diff --git a/src/BCards.Web/ViewModels/ManagePageViewModel.cs b/src/BCards.Web/ViewModels/ManagePageViewModel.cs index c8fc4b5..25c00ac 100644 --- a/src/BCards.Web/ViewModels/ManagePageViewModel.cs +++ b/src/BCards.Web/ViewModels/ManagePageViewModel.cs @@ -1,143 +1,143 @@ -using System.ComponentModel.DataAnnotations; -using BCards.Web.Models; - -namespace BCards.Web.ViewModels; - -public class ManagePageViewModel -{ - public string Id { get; set; } = string.Empty; - public bool IsNewPage { get; set; } = true; - - [Required(ErrorMessage = "Nome é obrigatório")] - [StringLength(50, ErrorMessage = "Nome deve ter no máximo 50 caracteres")] - public string DisplayName { get; set; } = string.Empty; - - [Required(ErrorMessage = "Categoria é obrigatória")] - public string Category { get; set; } = string.Empty; - - [Required(ErrorMessage = "Tipo de negócio é obrigatório")] - public string BusinessType { get; set; } = "individual"; - - [StringLength(200, ErrorMessage = "Bio deve ter no máximo 200 caracteres")] - public string Bio { get; set; } = string.Empty; - - public string Slug { get; set; } = string.Empty; - - [Required(ErrorMessage = "Tema é obrigatório")] - public string SelectedTheme { get; set; } = "minimalist"; - - public string WhatsAppNumber { get; set; } = string.Empty; - - public string FacebookUrl { get; set; } = string.Empty; - - public string TwitterUrl { get; set; } = string.Empty; - - public string InstagramUrl { get; set; } = string.Empty; - - public List Links { get; set; } = new(); - - // Profile image fields - public string? ProfileImageId { get; set; } - public IFormFile? ProfileImageFile { get; set; } - - // Data for dropdowns and selections - public List AvailableCategories { get; set; } = new(); - public List AvailableThemes { get; set; } = new(); - - // Plan limitations - public int MaxLinksAllowed { get; set; } = 3; - public bool AllowProductLinks { get; set; } = false; - public bool CanUseTheme(string themeName) => AvailableThemes.Any(t => t.Name.ToLower() == themeName.ToLower()); - - /// - /// URL da imagem de perfil ou imagem padrão se não houver upload - /// - public string ProfileImageUrl => !string.IsNullOrEmpty(ProfileImageId) - ? $"/api/image/{ProfileImageId}" - : "/images/default-avatar.svg"; -} - -public class ManageLinkViewModel -{ - public string Id { get; set; } = "new"; - - [Required(ErrorMessage = "Título é obrigatório")] - [StringLength(200, ErrorMessage = "Título deve ter no máximo 50 caracteres")] - public string Title { get; set; } = string.Empty; - - [Required(ErrorMessage = "URL é obrigatória")] - [Url(ErrorMessage = "URL inválida")] - public string Url { get; set; } = string.Empty; - - [StringLength(3000, ErrorMessage = "Descrição deve ter no máximo 100 caracteres")] - public string Description { get; set; } = string.Empty; - - public string Icon { get; set; } = string.Empty; - public int Order { get; set; } = 0; - public bool IsActive { get; set; } = true; - - // Campos para Links de Produto - public LinkType Type { get; set; } = LinkType.Normal; - - [StringLength(200, ErrorMessage = "Título do produto deve ter no máximo 100 caracteres")] - public string ProductTitle { get; set; } = string.Empty; - - public string ProductImage { get; set; } = string.Empty; - - [StringLength(50, ErrorMessage = "Preço deve ter no máximo 50 caracteres")] - public string? ProductPrice { get; set; } = string.Empty; - - [StringLength(3000, ErrorMessage = "Descrição do produto deve ter no máximo 200 caracteres")] - public string ProductDescription { get; set; } = string.Empty; - - public DateTime? ProductDataCachedAt { get; set; } -} - -public class DashboardViewModel -{ - public User CurrentUser { get; set; } = new(); - public List UserPages { get; set; } = new(); - public PlanInfo CurrentPlan { get; set; } = new(); - public bool CanCreateNewPage { get; set; } = false; - public int DaysRemaining { get; set; } = 0; -} - -public class UserPageSummary -{ - public string Id { get; set; } = string.Empty; - public string DisplayName { get; set; } = string.Empty; - public string Slug { get; set; } = string.Empty; - public string Category { get; set; } = string.Empty; - public PageStatus Status { get; set; } = PageStatus.Active; - public long TotalClicks { get; set; } = 0; - public long TotalViews { get; set; } = 0; - public DateTime CreatedAt { get; set; } = DateTime.UtcNow; - public string? PreviewToken { get; set; } = string.Empty; - public string PublicUrl => $"/page/{Category.ToLower()}/{Slug.ToLower()}?preview={PreviewToken}"; - public PageStatus? LastModerationStatus { get; set; } = PageStatus.PendingModeration; - public string? Motive { get; set; } = string.Empty; -} - -public class PlanInfo -{ - public PlanType Type { get; set; } = PlanType.Trial; - public string Name { get; set; } = string.Empty; - public int MaxPages { get; set; } = 1; - public int MaxLinksPerPage { get; set; } = 3; - public int DurationDays { get; set; } = 7; - public decimal Price { get; set; } = 0; - public bool AllowsAnalytics { get; set; } = false; - public bool AllowsCustomThemes { get; set; } = false; -} - -public enum PageStatus -{ - Active, // Funcionando normalmente - Expired, // Trial vencido -> 301 redirect - PendingPayment, // Pagamento atrasado -> aviso na página - Inactive, // Pausada pelo usuário - PendingModeration = 4, // Aguardando moderação - Rejected = 5, // Rejeitada na moderação - Creating = 6, // Em desenvolvimento/criação - Approved = 7 // Aprovada +using System.ComponentModel.DataAnnotations; +using BCards.Web.Models; + +namespace BCards.Web.ViewModels; + +public class ManagePageViewModel +{ + public string Id { get; set; } = string.Empty; + public bool IsNewPage { get; set; } = true; + + [Required(ErrorMessage = "Nome é obrigatório")] + [StringLength(50, ErrorMessage = "Nome deve ter no máximo 50 caracteres")] + public string DisplayName { get; set; } = string.Empty; + + [Required(ErrorMessage = "Categoria é obrigatória")] + public string Category { get; set; } = string.Empty; + + [Required(ErrorMessage = "Tipo de negócio é obrigatório")] + public string BusinessType { get; set; } = "individual"; + + [StringLength(200, ErrorMessage = "Bio deve ter no máximo 200 caracteres")] + public string Bio { get; set; } = string.Empty; + + public string Slug { get; set; } = string.Empty; + + [Required(ErrorMessage = "Tema é obrigatório")] + public string SelectedTheme { get; set; } = "minimalist"; + + public string WhatsAppNumber { get; set; } = string.Empty; + + public string FacebookUrl { get; set; } = string.Empty; + + public string TwitterUrl { get; set; } = string.Empty; + + public string InstagramUrl { get; set; } = string.Empty; + + public List Links { get; set; } = new(); + + // Profile image fields + public string? ProfileImageId { get; set; } + public IFormFile? ProfileImageFile { get; set; } + + // Data for dropdowns and selections + public List AvailableCategories { get; set; } = new(); + public List AvailableThemes { get; set; } = new(); + + // Plan limitations + public int MaxLinksAllowed { get; set; } = 3; + public bool AllowProductLinks { get; set; } = false; + public bool CanUseTheme(string themeName) => AvailableThemes.Any(t => t.Name.ToLower() == themeName.ToLower()); + + /// + /// URL da imagem de perfil ou imagem padrão se não houver upload + /// + public string ProfileImageUrl => !string.IsNullOrEmpty(ProfileImageId) + ? $"/api/image/{ProfileImageId}" + : "/images/default-avatar.svg"; +} + +public class ManageLinkViewModel +{ + public string Id { get; set; } = "new"; + + [Required(ErrorMessage = "Título é obrigatório")] + [StringLength(200, ErrorMessage = "Título deve ter no máximo 50 caracteres")] + public string Title { get; set; } = string.Empty; + + [Required(ErrorMessage = "URL é obrigatória")] + [Url(ErrorMessage = "URL inválida")] + public string Url { get; set; } = string.Empty; + + [StringLength(3000, ErrorMessage = "Descrição deve ter no máximo 100 caracteres")] + public string Description { get; set; } = string.Empty; + + public string Icon { get; set; } = string.Empty; + public int Order { get; set; } = 0; + public bool IsActive { get; set; } = true; + + // Campos para Links de Produto + public LinkType Type { get; set; } = LinkType.Normal; + + [StringLength(200, ErrorMessage = "Título do produto deve ter no máximo 100 caracteres")] + public string ProductTitle { get; set; } = string.Empty; + + public string ProductImage { get; set; } = string.Empty; + + [StringLength(50, ErrorMessage = "Preço deve ter no máximo 50 caracteres")] + public string? ProductPrice { get; set; } = string.Empty; + + [StringLength(3000, ErrorMessage = "Descrição do produto deve ter no máximo 200 caracteres")] + public string ProductDescription { get; set; } = string.Empty; + + public DateTime? ProductDataCachedAt { get; set; } +} + +public class DashboardViewModel +{ + public User CurrentUser { get; set; } = new(); + public List UserPages { get; set; } = new(); + public PlanInfo CurrentPlan { get; set; } = new(); + public bool CanCreateNewPage { get; set; } = false; + public int DaysRemaining { get; set; } = 0; +} + +public class UserPageSummary +{ + public string Id { get; set; } = string.Empty; + public string DisplayName { get; set; } = string.Empty; + public string Slug { get; set; } = string.Empty; + public string Category { get; set; } = string.Empty; + public PageStatus Status { get; set; } = PageStatus.Active; + public long TotalClicks { get; set; } = 0; + public long TotalViews { get; set; } = 0; + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + public string? PreviewToken { get; set; } = string.Empty; + public string PublicUrl => $"/page/{Category.ToLower()}/{Slug.ToLower()}?preview={PreviewToken}"; + public PageStatus? LastModerationStatus { get; set; } = PageStatus.PendingModeration; + public string? Motive { get; set; } = string.Empty; +} + +public class PlanInfo +{ + public PlanType Type { get; set; } = PlanType.Trial; + public string Name { get; set; } = string.Empty; + public int MaxPages { get; set; } = 1; + public int MaxLinksPerPage { get; set; } = 3; + public int DurationDays { get; set; } = 7; + public decimal Price { get; set; } = 0; + public bool AllowsAnalytics { get; set; } = false; + public bool AllowsCustomThemes { get; set; } = false; +} + +public enum PageStatus +{ + Active, // Funcionando normalmente + Expired, // Trial vencido -> 301 redirect + PendingPayment, // Pagamento atrasado -> aviso na página + Inactive, // Pausada pelo usuário + PendingModeration = 4, // Aguardando moderação + Rejected = 5, // Rejeitada na moderação + Creating = 6, // Em desenvolvimento/criação + Approved = 7 // Aprovada } \ No newline at end of file diff --git a/src/BCards.Web/ViewModels/ManageSubscriptionViewModel.cs b/src/BCards.Web/ViewModels/ManageSubscriptionViewModel.cs index 674abd3..82d3b9a 100644 --- a/src/BCards.Web/ViewModels/ManageSubscriptionViewModel.cs +++ b/src/BCards.Web/ViewModels/ManageSubscriptionViewModel.cs @@ -1,86 +1,86 @@ -using BCards.Web.Models; -using Stripe; - -namespace BCards.Web.ViewModels; - -public class ManageSubscriptionViewModel -{ - public User User { get; set; } = new(); - public Stripe.Subscription? StripeSubscription { get; set; } - public Models.Subscription? LocalSubscription { get; set; } - public List PaymentHistory { get; set; } = new(); - public List AvailablePlans { get; set; } = new(); - public string? ErrorMessage { get; set; } - public string? SuccessMessage { get; set; } - - // Propriedades calculadas - public bool HasActiveSubscription => StripeSubscription?.Status == "active"; - public bool CanUpgrade => HasActiveSubscription && User.CurrentPlan != "premium"; - public bool CanDowngrade => HasActiveSubscription && User.CurrentPlan != "basic"; - public bool WillCancelAtPeriodEnd => StripeSubscription?.CancelAtPeriodEnd == true; - public string? StripeSubscriptionId => StripeSubscription?.Id; - - public DateTime? CurrentPeriodEnd { get; set; } - public DateTime? NextBillingDate => !WillCancelAtPeriodEnd ? CurrentPeriodEnd : null; - - public decimal? MonthlyAmount => StripeSubscription?.Items?.Data?.FirstOrDefault()?.Price?.UnitAmount / 100m; - public string? Currency => StripeSubscription?.Items?.Data?.FirstOrDefault()?.Price?.Currency?.ToUpper(); - - public string StatusDisplayName => (StripeSubscription?.Status) switch - { - "active" => "Ativa", - "past_due" => "Em atraso", - "canceled" => "Cancelada", - "unpaid" => "Não paga", - "incomplete" => "Incompleta", - "incomplete_expired" => "Expirada", - "trialing" => "Em período de teste", - _ => "Desconhecido" - }; - - public string PlanDisplayName => User.CurrentPlan switch - { - "Basic" => "Básico", - "Professional" => "Profissional", - "Premium" => "Premium", - "PremiumAffiliate" => "Premium+Afiliados", - _ => "Gratuito" - }; -} - -public class AvailablePlanViewModel -{ - public string PlanType { get; set; } = string.Empty; - public string DisplayName { get; set; } = string.Empty; - public decimal Price { get; set; } - public string PriceId { get; set; } = string.Empty; - public int MaxLinks { get; set; } - public bool AllowCustomThemes { get; set; } - public bool AllowAnalytics { get; set; } - public bool AllowCustomDomain { get; set; } - public bool AllowProductLinks { get; set; } - public bool IsCurrentPlan { get; set; } - public bool IsUpgrade { get; set; } - public bool IsDowngrade { get; set; } - public List Features { get; set; } = new(); -} - -public class PaymentHistoryItemViewModel -{ - public string InvoiceId { get; set; } = string.Empty; - public DateTime Date { get; set; } - public decimal Amount { get; set; } - public string Currency { get; set; } = string.Empty; - public string Status { get; set; } = string.Empty; - public string Description { get; set; } = string.Empty; - public string? ReceiptUrl { get; set; } - - public string StatusDisplayName => Status switch - { - "paid" => "Pago", - "open" => "Em aberto", - "void" => "Cancelado", - "uncollectible" => "Incobrável", - _ => "Desconhecido" - }; +using BCards.Web.Models; +using Stripe; + +namespace BCards.Web.ViewModels; + +public class ManageSubscriptionViewModel +{ + public User User { get; set; } = new(); + public Stripe.Subscription? StripeSubscription { get; set; } + public Models.Subscription? LocalSubscription { get; set; } + public List PaymentHistory { get; set; } = new(); + public List AvailablePlans { get; set; } = new(); + public string? ErrorMessage { get; set; } + public string? SuccessMessage { get; set; } + + // Propriedades calculadas + public bool HasActiveSubscription => StripeSubscription?.Status == "active"; + public bool CanUpgrade => HasActiveSubscription && User.CurrentPlan != "premium"; + public bool CanDowngrade => HasActiveSubscription && User.CurrentPlan != "basic"; + public bool WillCancelAtPeriodEnd => StripeSubscription?.CancelAtPeriodEnd == true; + public string? StripeSubscriptionId => StripeSubscription?.Id; + + public DateTime? CurrentPeriodEnd { get; set; } + public DateTime? NextBillingDate => !WillCancelAtPeriodEnd ? CurrentPeriodEnd : null; + + public decimal? MonthlyAmount => StripeSubscription?.Items?.Data?.FirstOrDefault()?.Price?.UnitAmount / 100m; + public string? Currency => StripeSubscription?.Items?.Data?.FirstOrDefault()?.Price?.Currency?.ToUpper(); + + public string StatusDisplayName => (StripeSubscription?.Status) switch + { + "active" => "Ativa", + "past_due" => "Em atraso", + "canceled" => "Cancelada", + "unpaid" => "Não paga", + "incomplete" => "Incompleta", + "incomplete_expired" => "Expirada", + "trialing" => "Em período de teste", + _ => "Desconhecido" + }; + + public string PlanDisplayName => User.CurrentPlan switch + { + "Basic" => "Básico", + "Professional" => "Profissional", + "Premium" => "Premium", + "PremiumAffiliate" => "Premium+Afiliados", + _ => "Gratuito" + }; +} + +public class AvailablePlanViewModel +{ + public string PlanType { get; set; } = string.Empty; + public string DisplayName { get; set; } = string.Empty; + public decimal Price { get; set; } + public string PriceId { get; set; } = string.Empty; + public int MaxLinks { get; set; } + public bool AllowCustomThemes { get; set; } + public bool AllowAnalytics { get; set; } + public bool AllowCustomDomain { get; set; } + public bool AllowProductLinks { get; set; } + public bool IsCurrentPlan { get; set; } + public bool IsUpgrade { get; set; } + public bool IsDowngrade { get; set; } + public List Features { get; set; } = new(); +} + +public class PaymentHistoryItemViewModel +{ + public string InvoiceId { get; set; } = string.Empty; + public DateTime Date { get; set; } + public decimal Amount { get; set; } + public string Currency { get; set; } = string.Empty; + public string Status { get; set; } = string.Empty; + public string Description { get; set; } = string.Empty; + public string? ReceiptUrl { get; set; } + + public string StatusDisplayName => Status switch + { + "paid" => "Pago", + "open" => "Em aberto", + "void" => "Cancelado", + "uncollectible" => "Incobrável", + _ => "Desconhecido" + }; } \ No newline at end of file diff --git a/src/BCards.Web/ViewModels/ModerationViewModel.cs b/src/BCards.Web/ViewModels/ModerationViewModel.cs index 2cd8fbc..5e0decb 100644 --- a/src/BCards.Web/ViewModels/ModerationViewModel.cs +++ b/src/BCards.Web/ViewModels/ModerationViewModel.cs @@ -1,83 +1,83 @@ -using BCards.Web.Models; - -namespace BCards.Web.ViewModels; - -public class ModerationDashboardViewModel -{ - public List PendingPages { get; set; } = new(); - public Dictionary Stats { get; set; } = new(); - public int CurrentPage { get; set; } = 1; - public int PageSize { get; set; } = 20; - public bool HasNextPage { get; set; } = false; - public bool HasPreviousPage => CurrentPage > 1; - public string CurrentFilter { get; set; } = "all"; -} - -public class ModerationPageViewModel -{ - public string Id { get; set; } = string.Empty; - public string DisplayName { get; set; } = string.Empty; - public string Category { get; set; } = string.Empty; - public string Slug { get; set; } = string.Empty; - public DateTime CreatedAt { get; set; } - public string Status { get; set; } = string.Empty; - public int ModerationAttempts { get; set; } - public string PlanType { get; set; } = string.Empty; - public string? PreviewUrl { get; set; } - public DateTime? ApprovedAt { get; set; } - public ModerationHistory? LastModerationEntry { get; set; } - - public string PriorityLabel => PlanType.ToLower() switch - { - "premium" => "ALTA", - "professional" => "ALTA", - "basic" => "MÉDIA", - _ => "BAIXA" - }; - - public string PriorityColor => PlanType.ToLower() switch - { - "premium" => "danger", - "professional" => "warning", - "basic" => "info", - _ => "secondary" - }; -} - -public class ModerationReviewViewModel -{ - public UserPage Page { get; set; } = new(); - public User User { get; set; } = new(); - public string? PreviewUrl { get; set; } - public List ModerationCriteria { get; set; } = new(); -} - -public class ModerationHistoryViewModel -{ - public List Pages { get; set; } = new(); - public int CurrentPage { get; set; } = 1; - public int PageSize { get; set; } = 20; - public bool HasNextPage { get; set; } = false; - public bool HasPreviousPage => CurrentPage > 1; -} - -public class ModerationCriterion -{ - public string Category { get; set; } = string.Empty; - public List Items { get; set; } = new(); -} - -public class PendingPageViewModel -{ - public string Id { get; set; } = ""; - public string DisplayName { get; set; } = ""; - public string Slug { get; set; } = ""; - public string Category { get; set; } = ""; - public string PlanType { get; set; } = ""; - public DateTime CreatedAt { get; set; } - public int ModerationAttempts { get; set; } - public string PreviewUrl { get; set; } = ""; - public string PriorityLabel { get; set; } = ""; - public string PriorityColor { get; set; } = ""; - public bool IsSpecialModeration { get; set; } = false; -} +using BCards.Web.Models; + +namespace BCards.Web.ViewModels; + +public class ModerationDashboardViewModel +{ + public List PendingPages { get; set; } = new(); + public Dictionary Stats { get; set; } = new(); + public int CurrentPage { get; set; } = 1; + public int PageSize { get; set; } = 20; + public bool HasNextPage { get; set; } = false; + public bool HasPreviousPage => CurrentPage > 1; + public string CurrentFilter { get; set; } = "all"; +} + +public class ModerationPageViewModel +{ + public string Id { get; set; } = string.Empty; + public string DisplayName { get; set; } = string.Empty; + public string Category { get; set; } = string.Empty; + public string Slug { get; set; } = string.Empty; + public DateTime CreatedAt { get; set; } + public string Status { get; set; } = string.Empty; + public int ModerationAttempts { get; set; } + public string PlanType { get; set; } = string.Empty; + public string? PreviewUrl { get; set; } + public DateTime? ApprovedAt { get; set; } + public ModerationHistory? LastModerationEntry { get; set; } + + public string PriorityLabel => PlanType.ToLower() switch + { + "premium" => "ALTA", + "professional" => "ALTA", + "basic" => "MÉDIA", + _ => "BAIXA" + }; + + public string PriorityColor => PlanType.ToLower() switch + { + "premium" => "danger", + "professional" => "warning", + "basic" => "info", + _ => "secondary" + }; +} + +public class ModerationReviewViewModel +{ + public UserPage Page { get; set; } = new(); + public User User { get; set; } = new(); + public string? PreviewUrl { get; set; } + public List ModerationCriteria { get; set; } = new(); +} + +public class ModerationHistoryViewModel +{ + public List Pages { get; set; } = new(); + public int CurrentPage { get; set; } = 1; + public int PageSize { get; set; } = 20; + public bool HasNextPage { get; set; } = false; + public bool HasPreviousPage => CurrentPage > 1; +} + +public class ModerationCriterion +{ + public string Category { get; set; } = string.Empty; + public List Items { get; set; } = new(); +} + +public class PendingPageViewModel +{ + public string Id { get; set; } = ""; + public string DisplayName { get; set; } = ""; + public string Slug { get; set; } = ""; + public string Category { get; set; } = ""; + public string PlanType { get; set; } = ""; + public DateTime CreatedAt { get; set; } + public int ModerationAttempts { get; set; } + public string PreviewUrl { get; set; } = ""; + public string PriorityLabel { get; set; } = ""; + public string PriorityColor { get; set; } = ""; + public bool IsSpecialModeration { get; set; } = false; +} diff --git a/src/BCards.Web/Views/Admin/CreatePage.cshtml b/src/BCards.Web/Views/Admin/CreatePage.cshtml index fd057ca..f2eb3c5 100644 --- a/src/BCards.Web/Views/Admin/CreatePage.cshtml +++ b/src/BCards.Web/Views/Admin/CreatePage.cshtml @@ -1,618 +1,618 @@ -@model BCards.Web.ViewModels.CreatePageViewModel -@{ - ViewData["Title"] = "Criar Página"; - Layout = "_Layout"; -} - -
-
-
-
-
-

- - Criar Sua Página de Links -

-
- -
- -
-
-
- -
- - -
-
- 1 - Informações Básicas -
- -
-
-
- - - -
-
- -
-
- - - -
-
-
- -
-
-
- - -
-
- -
-
- -
- page/ - categoria - / - - -
- URL gerada automaticamente -
-
-
- -
- - - -
-
- - -
-
- 2 - Escolha Seu Tema Visual -
- -
- @foreach (var theme in ViewBag.Themes as List ?? new List()) - { -
-
-
-
-
-
@theme.Name
-
- -
-
- @theme.Name - @if (theme.IsPremium) - { - Premium - } -
-
-
- } -
- - -
- - -
-
- 3 - Links Principais -
- - - - -
- - -
-
- 4 - Redes Sociais -
- -
-
-
- - - -
-
- -
-
- - - -
-
-
- -
-
-
- - - -
-
- -
-
- - - -
-
-
-
- - -
-
- 5 - Preview e Finalização -
- -
-
-
- -
-
-
- -
-

Sua página estará disponível em:

- page/categoria/seu-slug -
-
- - -
- - - - - -
-
-
-
-
-
-
- - - - - -@section Scripts { - @{await Html.RenderPartialAsync("_ValidationScriptsPartial");} +@model BCards.Web.ViewModels.CreatePageViewModel +@{ + ViewData["Title"] = "Criar Página"; + Layout = "_Layout"; +} + +
+
+
+
+
+

+ + Criar Sua Página de Links +

+
+ +
+ +
+
+
+ +
+ + +
+
+ 1 + Informações Básicas +
+ +
+
+
+ + + +
+
+ +
+
+ + + +
+
+
+ +
+
+
+ + +
+
+ +
+
+ +
+ page/ + categoria + / + + +
+ URL gerada automaticamente +
+
+
+ +
+ + + +
+
+ + +
+
+ 2 + Escolha Seu Tema Visual +
+ +
+ @foreach (var theme in ViewBag.Themes as List ?? new List()) + { +
+
+
+
+
+
@theme.Name
+
+ +
+
+ @theme.Name + @if (theme.IsPremium) + { + Premium + } +
+
+
+ } +
+ + +
+ + +
+
+ 3 + Links Principais +
+ + + + +
+ + +
+
+ 4 + Redes Sociais +
+ +
+
+
+ + + +
+
+ +
+
+ + + +
+
+
+ +
+
+
+ + + +
+
+ +
+
+ + + +
+
+
+
+ + +
+
+ 5 + Preview e Finalização +
+ +
+
+
+ +
+
+
+ +
+

Sua página estará disponível em:

+ page/categoria/seu-slug +
+
+ + +
+ + + + + +
+
+
+
+
+
+
+ + + + + +@section Scripts { + @{await Html.RenderPartialAsync("_ValidationScriptsPartial");} } \ No newline at end of file diff --git a/src/BCards.Web/Views/Admin/Dashboard.cshtml b/src/BCards.Web/Views/Admin/Dashboard.cshtml index 364083a..436230e 100644 --- a/src/BCards.Web/Views/Admin/Dashboard.cshtml +++ b/src/BCards.Web/Views/Admin/Dashboard.cshtml @@ -1,554 +1,554 @@ -@model BCards.Web.ViewModels.DashboardViewModel -@{ - ViewData["Title"] = "Dashboard - BCards"; - Layout = "_Layout"; - var pageInCreation = Model.UserPages.FirstOrDefault(p => (p.LastModerationStatus ?? p.Status) == BCards.Web.ViewModels.PageStatus.Creating); -} - -
-
-
-
-
-

Olá, @Model.CurrentUser.Name!

-

Gerencie suas páginas profissionais

-
-
- @if (!string.IsNullOrEmpty(Model.CurrentUser.ProfileImage)) - { - @Model.CurrentUser.Name - } -
-
- - - @if (pageInCreation != null) - { -
-
-

- -

-
-
- Você pode editar e testar sua página "@pageInCreation.DisplayName" quantas vezes quiser. - Ao terminar, use os botões no card da página para enviá-la para moderação. -
Dica: Ao segurar o dedo sobre um botão, uma dica será exibida. -
-
-
-
- } - - -
- @foreach (var pageItem in Model.UserPages) - { -
-
-
-
- @(pageItem.DisplayName) -
- @Html.AntiForgeryToken() - -
-
-

@(pageItem.Category)/@(pageItem.Slug)

- -
- @switch (pageItem.LastModerationStatus ?? pageItem.Status) - { - case BCards.Web.ViewModels.PageStatus.Active: - Ativa - break; - case BCards.Web.ViewModels.PageStatus.PendingModeration: - Aguardando Moderação - break; - case BCards.Web.ViewModels.PageStatus.Rejected: - Rejeitada - break; - case BCards.Web.ViewModels.PageStatus.Expired: - Trial Expirado - break; - case BCards.Web.ViewModels.PageStatus.Creating: - Em Criação - break; - default: - @pageItem.Status - break; - } -
- - @* Exibir histórico de rejeição se existir *@ - @if (pageItem.Status == BCards.Web.ViewModels.PageStatus.Rejected && !string.IsNullOrEmpty(pageItem.Motive)) - { -
-
- -
- Motivo da rejeição:
- @pageItem.Motive -
-
-
- } - - @if (Model.CurrentPlan.AllowsAnalytics) - { -
-
-
@(pageItem.TotalViews)
-
Visualizações
-
-
-
@(pageItem.TotalClicks)
-
Cliques
-
-
- } -
- -
-
- } - - - @if (Model.CanCreateNewPage) - { -
-
-
-
- -
Criar Nova Página
- Começar -
-
-
-
- } - else if (!Model.UserPages.Any()) - { -
-
-
-
🚀
-

Crie sua primeira página!

-

Comece criando sua página profissional personalizada com seus links organizados.

- Criar Minha Página -
-
-
- } - else - { -
-
- -
- Limite atingido! Você já criou o máximo de @Model.CurrentPlan.MaxPages página(s) para seu plano atual. - Fazer upgrade -
-
-
- } -
-
- -
- -
-
-
-

- -

-
-
-
-
@Model.CurrentPlan.Name
- @if (Model.CurrentPlan.Type == BCards.Web.Models.PlanType.Trial) - { -

@Model.DaysRemaining dia(s) restante(s)

- } - else - { -

R$ @Model.CurrentPlan.Price.ToString("F2")/mês

- } -
-
- Páginas - @Model.UserPages.Count/@Model.CurrentPlan.MaxPages -
-
- @{ - var pagesPercentage = Model.CurrentPlan.MaxPages > 0 ? (double)Model.UserPages.Count / Model.CurrentPlan.MaxPages * 100 : 0; - } -
-
-
-
Links por página: @(Model.CurrentPlan.MaxLinksPerPage == int.MaxValue ? "Ilimitado" : Model.CurrentPlan.MaxLinksPerPage.ToString()) -
-
Analytics: @(Model.CurrentPlan.AllowsAnalytics ? "✅" : "❌")
-
Temas premium: @(Model.CurrentPlan.AllowsCustomThemes ? "✅" : "❌")
- @if (Model.CurrentPlan.Type == BCards.Web.Models.PlanType.Trial) - { - Fazer Upgrade - } - else - { - Gerenciar Assinatura - } -
-
-
-
- - - @if (Model.CurrentPlan.AllowsAnalytics && Model.UserPages.Any()) - { -
-
Estatísticas Gerais
-
-
-
-
@Model.UserPages.Sum(p => p.TotalViews)
- Total de Visualizações -
-
-
@Model.UserPages.Sum(p => p.TotalClicks)
- Total de Cliques -
-
- @if (Model.UserPages.Sum(p => p.TotalViews) > 0) - { -
-
-
@((Model.UserPages.Sum(p => p.TotalClicks) * 100.0 / Model.UserPages.Sum(p => p.TotalViews)).ToString("F1"))%
- Taxa de Cliques -
- } -
-
- } -
-
💡 Dicas
-
-
    -
  • Use uma bio clara e objetiva
  • -
  • Organize seus links por importância
  • -
  • Escolha URLs fáceis de lembrar
  • -
  • Atualize regularmente seus links
  • -
  • Monitore suas estatísticas
  • -
-
-
-
-
-
- -@section Scripts { - -} - -@if (TempData["Success"] != null) -{ -
- -
-} - -@if (TempData["Error"] != null) -{ -
- -
+@model BCards.Web.ViewModels.DashboardViewModel +@{ + ViewData["Title"] = "Dashboard - BCards"; + Layout = "_Layout"; + var pageInCreation = Model.UserPages.FirstOrDefault(p => (p.LastModerationStatus ?? p.Status) == BCards.Web.ViewModels.PageStatus.Creating); +} + +
+
+
+
+
+

Olá, @Model.CurrentUser.Name!

+

Gerencie suas páginas profissionais

+
+
+ @if (!string.IsNullOrEmpty(Model.CurrentUser.ProfileImage)) + { + @Model.CurrentUser.Name + } +
+
+ + + @if (pageInCreation != null) + { +
+
+

+ +

+
+
+ Você pode editar e testar sua página "@pageInCreation.DisplayName" quantas vezes quiser. + Ao terminar, use os botões no card da página para enviá-la para moderação. +
Dica: Ao segurar o dedo sobre um botão, uma dica será exibida. +
+
+
+
+ } + + +
+ @foreach (var pageItem in Model.UserPages) + { +
+
+
+
+ @(pageItem.DisplayName) +
+ @Html.AntiForgeryToken() + +
+
+

@(pageItem.Category)/@(pageItem.Slug)

+ +
+ @switch (pageItem.LastModerationStatus ?? pageItem.Status) + { + case BCards.Web.ViewModels.PageStatus.Active: + Ativa + break; + case BCards.Web.ViewModels.PageStatus.PendingModeration: + Aguardando Moderação + break; + case BCards.Web.ViewModels.PageStatus.Rejected: + Rejeitada + break; + case BCards.Web.ViewModels.PageStatus.Expired: + Trial Expirado + break; + case BCards.Web.ViewModels.PageStatus.Creating: + Em Criação + break; + default: + @pageItem.Status + break; + } +
+ + @* Exibir histórico de rejeição se existir *@ + @if (pageItem.Status == BCards.Web.ViewModels.PageStatus.Rejected && !string.IsNullOrEmpty(pageItem.Motive)) + { +
+
+ +
+ Motivo da rejeição:
+ @pageItem.Motive +
+
+
+ } + + @if (Model.CurrentPlan.AllowsAnalytics) + { +
+
+
@(pageItem.TotalViews)
+
Visualizações
+
+
+
@(pageItem.TotalClicks)
+
Cliques
+
+
+ } +
+ +
+
+ } + + + @if (Model.CanCreateNewPage) + { +
+
+
+
+ +
Criar Nova Página
+ Começar +
+
+
+
+ } + else if (!Model.UserPages.Any()) + { +
+
+
+
🚀
+

Crie sua primeira página!

+

Comece criando sua página profissional personalizada com seus links organizados.

+ Criar Minha Página +
+
+
+ } + else + { +
+
+ +
+ Limite atingido! Você já criou o máximo de @Model.CurrentPlan.MaxPages página(s) para seu plano atual. + Fazer upgrade +
+
+
+ } +
+
+ +
+ +
+
+
+

+ +

+
+
+
+
@Model.CurrentPlan.Name
+ @if (Model.CurrentPlan.Type == BCards.Web.Models.PlanType.Trial) + { +

@Model.DaysRemaining dia(s) restante(s)

+ } + else + { +

R$ @Model.CurrentPlan.Price.ToString("F2")/mês

+ } +
+
+ Páginas + @Model.UserPages.Count/@Model.CurrentPlan.MaxPages +
+
+ @{ + var pagesPercentage = Model.CurrentPlan.MaxPages > 0 ? (double)Model.UserPages.Count / Model.CurrentPlan.MaxPages * 100 : 0; + } +
+
+
+
Links por página: @(Model.CurrentPlan.MaxLinksPerPage == int.MaxValue ? "Ilimitado" : Model.CurrentPlan.MaxLinksPerPage.ToString()) +
+
Analytics: @(Model.CurrentPlan.AllowsAnalytics ? "✅" : "❌")
+
Temas premium: @(Model.CurrentPlan.AllowsCustomThemes ? "✅" : "❌")
+ @if (Model.CurrentPlan.Type == BCards.Web.Models.PlanType.Trial) + { + Fazer Upgrade + } + else + { + Gerenciar Assinatura + } +
+
+
+
+ + + @if (Model.CurrentPlan.AllowsAnalytics && Model.UserPages.Any()) + { +
+
Estatísticas Gerais
+
+
+
+
@Model.UserPages.Sum(p => p.TotalViews)
+ Total de Visualizações +
+
+
@Model.UserPages.Sum(p => p.TotalClicks)
+ Total de Cliques +
+
+ @if (Model.UserPages.Sum(p => p.TotalViews) > 0) + { +
+
+
@((Model.UserPages.Sum(p => p.TotalClicks) * 100.0 / Model.UserPages.Sum(p => p.TotalViews)).ToString("F1"))%
+ Taxa de Cliques +
+ } +
+
+ } +
+
💡 Dicas
+
+
    +
  • Use uma bio clara e objetiva
  • +
  • Organize seus links por importância
  • +
  • Escolha URLs fáceis de lembrar
  • +
  • Atualize regularmente seus links
  • +
  • Monitore suas estatísticas
  • +
+
+
+
+
+
+ +@section Scripts { + +} + +@if (TempData["Success"] != null) +{ +
+ +
+} + +@if (TempData["Error"] != null) +{ +
+ +
} \ No newline at end of file diff --git a/src/BCards.Web/Views/Home/Index.cshtml b/src/BCards.Web/Views/Home/Index.cshtml index 6360955..6370a40 100644 --- a/src/BCards.Web/Views/Home/Index.cshtml +++ b/src/BCards.Web/Views/Home/Index.cshtml @@ -1,182 +1,182 @@ -@{ - //var isPreview = ViewBag.IsPreview as bool? ?? false; - ViewData["Title"] = "BCards - Crie sua bio / links Profissional"; - var categories = ViewBag.Categories as List ?? new List(); - var recentPages = ViewBag.RecentPages as List ?? new List(); - //Layout = isPreview ? "_Layout" : "_UserPageLayout"; - Layout = "_Layout"; -} - -
-
-
-
-

- Crie sua página profissional em minutos -

-

- A melhor alternativa ao para ter uma página de links simples. - Criada para profissionais e empresas no Brasil. - Organize todos os seus links em uma página única e profissional. -

-
- @if (User.Identity?.IsAuthenticated == true) - { - - Acessar Dashboard - - } - else - { - - Começar Grátis - - } - - Ver Planos - -
-
-
- Exemplo de página BCards -
-
-
-
- -
- - @if (categories.Any()) - { -
-

Categorias Populares

-
- @foreach (var category in categories.Take(8)) - { - - } -
-
- } - - -
-

Por que escolher o BCards?

-
-
-
-
- 🎨 -
-
Temas Profissionais
-

Escolha entre diversos temas profissionais ou personalize as cores da sua página.

-
-
-
-
-
- 📊 -
-
Analytics Avançado
-

Acompanhe quantas pessoas visitaram sua página e clicaram nos seus links.

-
-
-
-
-
- 🔗 -
-
URLs Organizadas
-

Suas URLs são organizadas por categoria: bcards.site/corretor/seu-nome

-
-
-
-
- - - @if (recentPages.Any()) - { -
-

Profissionais que confiam no BCards

-
- @foreach (var page in recentPages) - { -
-
-
- @if (!string.IsNullOrEmpty(page.ProfileImageId)) - { - @(page.DisplayName) - } - else - { -
- -
- } -
@(page.DisplayName)
- @(page.Category) - -
-
-
- } -
-
- } - - -
-
-

Pronto para começar?

-

- Crie sua página profissional agora mesmo e comece a organizar seus links. -

- @if (User.Identity?.IsAuthenticated == true) - { - - Criar Minha Página - - } - else - { - - Começar Grátis - - } -
-
-
- -@section Styles { - +@{ + //var isPreview = ViewBag.IsPreview as bool? ?? false; + ViewData["Title"] = "BCards - Crie sua bio / links Profissional"; + var categories = ViewBag.Categories as List ?? new List(); + var recentPages = ViewBag.RecentPages as List ?? new List(); + //Layout = isPreview ? "_Layout" : "_UserPageLayout"; + Layout = "_Layout"; +} + +
+
+
+
+

+ Crie sua página profissional em minutos +

+

+ A melhor alternativa ao para ter uma página de links simples. + Criada para profissionais e empresas no Brasil. + Organize todos os seus links em uma página única e profissional. +

+
+ @if (User.Identity?.IsAuthenticated == true) + { + + Acessar Dashboard + + } + else + { + + Começar Grátis + + } + + Ver Planos + +
+
+
+ Exemplo de página BCards +
+
+
+
+ +
+ + @if (categories.Any()) + { +
+

Categorias Populares

+
+ @foreach (var category in categories.Take(8)) + { + + } +
+
+ } + + +
+

Por que escolher o BCards?

+
+
+
+
+ 🎨 +
+
Temas Profissionais
+

Escolha entre diversos temas profissionais ou personalize as cores da sua página.

+
+
+
+
+
+ 📊 +
+
Analytics Avançado
+

Acompanhe quantas pessoas visitaram sua página e clicaram nos seus links.

+
+
+
+
+
+ 🔗 +
+
URLs Organizadas
+

Suas URLs são organizadas por categoria: bcards.site/corretor/seu-nome

+
+
+
+
+ + + @if (recentPages.Any()) + { +
+

Profissionais que confiam no BCards

+
+ @foreach (var page in recentPages) + { +
+
+
+ @if (!string.IsNullOrEmpty(page.ProfileImageId)) + { + @(page.DisplayName) + } + else + { +
+ +
+ } +
@(page.DisplayName)
+ @(page.Category) + +
+
+
+ } +
+
+ } + + +
+
+

Pronto para começar?

+

+ Crie sua página profissional agora mesmo e comece a organizar seus links. +

+ @if (User.Identity?.IsAuthenticated == true) + { + + Criar Minha Página + + } + else + { + + Começar Grátis + + } +
+
+
+ +@section Styles { + } \ No newline at end of file diff --git a/src/BCards.Web/Views/Home/Pricing.cshtml b/src/BCards.Web/Views/Home/Pricing.cshtml index 64f0863..a68cf9a 100644 --- a/src/BCards.Web/Views/Home/Pricing.cshtml +++ b/src/BCards.Web/Views/Home/Pricing.cshtml @@ -1,598 +1,598 @@ -@{ - ViewData["Title"] = "Planos e Preços - BCards"; - //var isPreview = ViewBag.IsPreview as bool? ?? false; - //Layout = isPreview ? "_Layout" : "_UserPageLayout"; - Layout = "_Layout"; -} - -
-
-

Escolha o plano ideal para você

-

Comece grátis e faça upgrade quando precisar de mais recursos

- - -
-
- - - - - -
-
-
- -
- -
-
-
-
Trial Gratuito
-
- R$ 0 - /7 dias -
-
-
-
    -
  • - - Até 3 links -
  • -
  • - - 1 tema básico -
  • -
  • - - 7 dias grátis -
  • -
  • - - Analytics -
  • -
  • - - Página rápida -
  • -
-
- -
-
- - -
-
-
-
Básico
-
-
- R$ 5,90 - /mês -
-
- R$ 59,00 - /ano -
- Economize R$ 11,80 (2 meses grátis) -
-
-
-
-
    -
  • - - 3 páginas -
  • -
  • - - 8 links por página -
  • -
  • - - 20 temas básicos -
  • -
  • - - Analytics simples -
  • -
  • - - URL personalizada -
  • -
  • - - Temas premium -
  • -
  • - - Links de produto -
  • -
-
- -
-
- - -
-
-
-
Profissional
-
-
- R$ 12,90 - /mês -
-
- R$ 129,00 - /ano -
- Economize R$ 25,80 (2 meses grátis) -
-
-
-
-
    -
  • - - 5 páginas -
  • -
  • - - 20 links por página -
  • -
  • - - 20 temas básicos -
  • -
  • - - Analytics avançado -
  • -
  • - - URL personalizada -
  • -
  • - - Temas premium -
  • -
  • - - Links de produto -
  • -
-
- -
-
- - -
-
-
- Mais Popular -
-
-
Premium
-
-
- R$ 19,90 - /mês -
-
- R$ 199,00 - /ano -
- Economize R$ 39,80 (2 meses grátis) -
-
- Melhor custo-benefício! -
-
-
    -
  • - - 15 páginas -
  • -
  • - - Links ilimitados -
  • -
  • - - 40 temas* -
  • -
  • - - Analytics completo -
  • -
  • - - URL personalizada -
  • -
  • - - Suporte prioritário -
  • -
  • - - Links de produto -
  • -
-
- * 20 temas básicos + 20 temas premium exclusivos -
-
- -
-
- - -
-
-
- Novo! -
-
-
Premium + Afiliados
-
-
- R$ 29,90 - /mês -
-
- R$ 299,00 - /ano -
- Economize R$ 59,80 (2 meses grátis) -
-
- Para monetização! -
-
-
    -
  • - - 15 páginas -
  • -
  • - - Links ilimitados -
  • -
  • - - 40 temas* -
  • -
  • - - Links de produto -
  • -
  • - - Moderação plus -
  • -
  • - - Suporte prioritário -
  • -
  • - - 10 links afiliados -
  • -
-
- * 20 temas básicos + 20 temas premium exclusivos -
-
- -
-
-
- - -
-

Compare todos os recursos

-
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
RecursosTrialBásicoProfissionalPremiumPremium + Afiliados
Páginas1351515
Links por página3820IlimitadoIlimitado
Temas disponíveis1 básico20 básicos20 básicos40 (básicos + premium)40 (básicos + premium)
AnalyticsSimplesAvançadoCompletoCompleto
Suporte por email
URL personalizada
Suporte prioritário
Links de produto
Moderação especial
-
-
- - -
-

Perguntas Frequentes

-
-
-
-
-

- -

-
-
- Sim! Você pode cancelar sua assinatura a qualquer momento. O cancelamento será aplicado no final do período de cobrança atual. -
-
-
- -
-

- -

-
-
- Nos planos anuais você economiza 2 meses! Pague 10 meses e use por 12 meses. O desconto é aplicado automaticamente na cobrança anual. -
-
-
- -
-

- -

-
-
- Se você atingir o limite de links do seu plano, será sugerido fazer upgrade. Seus links existentes continuarão funcionando normalmente. -
-
-
-
-
-
-
-
- -@if (TempData["Success"] != null) -{ -
- -
-} - -@if (TempData["Error"] != null) -{ -
- -
-} - -@if (TempData["Info"] != null) -{ -
- -
-} - - \ No newline at end of file diff --git a/src/BCards.Web/Views/Moderation/Dashboard.cshtml b/src/BCards.Web/Views/Moderation/Dashboard.cshtml index f14c8b7..d84b771 100644 --- a/src/BCards.Web/Views/Moderation/Dashboard.cshtml +++ b/src/BCards.Web/Views/Moderation/Dashboard.cshtml @@ -1,94 +1,94 @@ -@using BCards.Web.ViewModels -@model ModerationDashboardViewModel -@{ - ViewData["Title"] = "Dashboard de Moderação"; - Layout = "_Layout"; -} - -
-

Dashboard de Moderação

- -
-

Sistema de Moderação

-

Páginas pendentes: @Model.PendingPages.Count

-
- - @if (Model.PendingPages.Any()) - { -
-
-
Páginas Pendentes
-
- - -
-
-
-
- - - - - - - - - - - @foreach (var pageItem in Model.PendingPages) - { - - - - - - - } - -
NomeCategoriaCriada emAções
- @pageItem.DisplayName - @if (pageItem.IsSpecialModeration) - { - - SLA Reduzido - - } - @pageItem.Category@pageItem.CreatedAt.ToString("dd/MM/yyyy") - - Moderar - -
-
-
-
- } - else - { -
-

✅ Nenhuma página pendente!

-

Todas as páginas foram processadas.

-
- } -
- - \ No newline at end of file diff --git a/src/BCards.Web/Views/Moderation/History.cshtml b/src/BCards.Web/Views/Moderation/History.cshtml index 7b1a113..518234e 100644 --- a/src/BCards.Web/Views/Moderation/History.cshtml +++ b/src/BCards.Web/Views/Moderation/History.cshtml @@ -1,83 +1,83 @@ -@using BCards.Web.ViewModels -@model ModerationHistoryViewModel -@{ - ViewData["Title"] = "Histórico de Moderação"; - Layout = "_Layout"; -} - -
-
-

Histórico de Moderação

- - Voltar - -
- -
-

Histórico

-

Páginas processadas: @Model.Pages.Count

-
- - @if (Model.Pages.Any()) - { -
-
-
Páginas Processadas
-
-
-
- - - - - - - - - - - @foreach (var pageItem in Model.Pages) - { - - - - - - - } - -
StatusNomeCategoriaProcessada em
- @if (pageItem.Status == "Active") - { - Aprovada - } - else if (pageItem.Status == "Rejected") - { - Rejeitada - } - else - { - @pageItem.Status - } - @pageItem.DisplayName@pageItem.Category - @if (pageItem.ApprovedAt.HasValue) - { - @pageItem.ApprovedAt.Value.ToString("dd/MM/yyyy") - } - else - { - Pendente - } -
-
-
-
- } - else - { -
-

📋 Nenhum histórico ainda

-

Ainda não há páginas processadas.

-
- } +@using BCards.Web.ViewModels +@model ModerationHistoryViewModel +@{ + ViewData["Title"] = "Histórico de Moderação"; + Layout = "_Layout"; +} + +
+
+

Histórico de Moderação

+ + Voltar + +
+ +
+

Histórico

+

Páginas processadas: @Model.Pages.Count

+
+ + @if (Model.Pages.Any()) + { +
+
+
Páginas Processadas
+
+
+
+ + + + + + + + + + + @foreach (var pageItem in Model.Pages) + { + + + + + + + } + +
StatusNomeCategoriaProcessada em
+ @if (pageItem.Status == "Active") + { + Aprovada + } + else if (pageItem.Status == "Rejected") + { + Rejeitada + } + else + { + @pageItem.Status + } + @pageItem.DisplayName@pageItem.Category + @if (pageItem.ApprovedAt.HasValue) + { + @pageItem.ApprovedAt.Value.ToString("dd/MM/yyyy") + } + else + { + Pendente + } +
+
+
+
+ } + else + { +
+

📋 Nenhum histórico ainda

+

Ainda não há páginas processadas.

+
+ }
\ No newline at end of file diff --git a/src/BCards.Web/Views/Moderation/Review.cshtml b/src/BCards.Web/Views/Moderation/Review.cshtml index f860759..1e81ad2 100644 --- a/src/BCards.Web/Views/Moderation/Review.cshtml +++ b/src/BCards.Web/Views/Moderation/Review.cshtml @@ -1,267 +1,267 @@ -@using BCards.Web.ViewModels -@model ModerationReviewViewModel -@{ - ViewData["Title"] = "Revisar Página"; - Layout = "_Layout"; -} - -
-
-
-
-

Moderar Página

- - Voltar - -
-
-
- -
- -
-
-
-
Informações da Página
-
-
-
- Nome: @Model.Page.DisplayName -
-
- Categoria: - @Model.Page.Category -
-
- Slug: @Model.Page.Slug -
-
- Tipo: @Model.Page.BusinessType -
-
- Plano: - @Model.Page.PlanLimitations.PlanType -
-
- Criado em: @Model.Page.CreatedAt.ToString("dd/MM/yyyy HH:mm") -
-
- Tentativas: @Model.Page.ModerationAttempts -
-
- Total de Links: @Model.Page.Links.Count -
-
-
- - -
-
-
Informações do Usuário
-
-
-
- Nome: @Model.User.Name -
-
- Email: @Model.User.Email -
-
- Score: @Model.Page.UserScore -
-
- Membro desde: @Model.User.CreatedAt.ToString("dd/MM/yyyy") -
-
-
- - - @if (!string.IsNullOrEmpty(Model.PreviewUrl)) - { -
-
-
Preview da Página
-
-
- - Abrir Preview - - - Visualizações: @Model.Page.PreviewViewCount/50 - -
-
- } -
- - -
-
-
-
Conteúdo da Página
-
-
- @if (!string.IsNullOrEmpty(Model.Page.Bio)) - { -
- Biografia: -

@Model.Page.Bio

-
- } - -
- Links (@Model.Page.Links.Count): -
- @foreach (var link in Model.Page.Links.OrderBy(l => l.Order)) - { -
-
-
-
- @link.Title - @if (link.Type == LinkType.Product) - { - Produto - } -
- @link.Url - @if (!string.IsNullOrEmpty(link.Description)) - { -
- @link.Description - } -
-
- - - -
-
-
-
- } -
-
-
-
- - -
-
-
Critérios de Moderação
-
-
-
- @foreach (var criterion in Model.ModerationCriteria) - { -
-
🚫 @criterion.Category
- @foreach (var item in criterion.Items) - { -
- - -
- } -
- } -
-
-
- - - @if (Model.Page.ModerationHistory.Any()) - { -
-
-
Histórico de Moderação
-
-
- @foreach (var history in Model.Page.ModerationHistory.OrderByDescending(h => h.Date)) - { -
-
- Tentativa @history.Attempt - @history.Date.ToString("dd/MM/yyyy HH:mm") -
-
- - @(history.Status == "approved" ? "Aprovada" : "Rejeitada") - -
- @if (!string.IsNullOrEmpty(history.Reason)) - { -
- Motivo: @history.Reason -
- } - @if (history.Issues.Any()) - { -
- Problemas: -
    - @foreach (var issue in history.Issues) - { -
  • @issue
  • - } -
-
- } -
- } -
-
- } - - -
-
-
Ações de Moderação
-
-
-
-
-
- @Html.AntiForgeryToken() -
- - -
- -
-
-
-
- @Html.AntiForgeryToken() -
- - -
- - -
-
-
-
-
-
-
-
- -@section Scripts { - +@using BCards.Web.ViewModels +@model ModerationReviewViewModel +@{ + ViewData["Title"] = "Revisar Página"; + Layout = "_Layout"; +} + +
+
+
+
+

Moderar Página

+ + Voltar + +
+
+
+ +
+ +
+
+
+
Informações da Página
+
+
+
+ Nome: @Model.Page.DisplayName +
+
+ Categoria: + @Model.Page.Category +
+
+ Slug: @Model.Page.Slug +
+
+ Tipo: @Model.Page.BusinessType +
+
+ Plano: + @Model.Page.PlanLimitations.PlanType +
+
+ Criado em: @Model.Page.CreatedAt.ToString("dd/MM/yyyy HH:mm") +
+
+ Tentativas: @Model.Page.ModerationAttempts +
+
+ Total de Links: @Model.Page.Links.Count +
+
+
+ + +
+
+
Informações do Usuário
+
+
+
+ Nome: @Model.User.Name +
+
+ Email: @Model.User.Email +
+
+ Score: @Model.Page.UserScore +
+
+ Membro desde: @Model.User.CreatedAt.ToString("dd/MM/yyyy") +
+
+
+ + + @if (!string.IsNullOrEmpty(Model.PreviewUrl)) + { +
+
+
Preview da Página
+
+
+ + Abrir Preview + + + Visualizações: @Model.Page.PreviewViewCount/50 + +
+
+ } +
+ + +
+
+
+
Conteúdo da Página
+
+
+ @if (!string.IsNullOrEmpty(Model.Page.Bio)) + { +
+ Biografia: +

@Model.Page.Bio

+
+ } + +
+ Links (@Model.Page.Links.Count): +
+ @foreach (var link in Model.Page.Links.OrderBy(l => l.Order)) + { +
+
+
+
+ @link.Title + @if (link.Type == LinkType.Product) + { + Produto + } +
+ @link.Url + @if (!string.IsNullOrEmpty(link.Description)) + { +
+ @link.Description + } +
+
+ + + +
+
+
+
+ } +
+
+
+
+ + +
+
+
Critérios de Moderação
+
+
+
+ @foreach (var criterion in Model.ModerationCriteria) + { +
+
🚫 @criterion.Category
+ @foreach (var item in criterion.Items) + { +
+ + +
+ } +
+ } +
+
+
+ + + @if (Model.Page.ModerationHistory.Any()) + { +
+
+
Histórico de Moderação
+
+
+ @foreach (var history in Model.Page.ModerationHistory.OrderByDescending(h => h.Date)) + { +
+
+ Tentativa @history.Attempt + @history.Date.ToString("dd/MM/yyyy HH:mm") +
+
+ + @(history.Status == "approved" ? "Aprovada" : "Rejeitada") + +
+ @if (!string.IsNullOrEmpty(history.Reason)) + { +
+ Motivo: @history.Reason +
+ } + @if (history.Issues.Any()) + { +
+ Problemas: +
    + @foreach (var issue in history.Issues) + { +
  • @issue
  • + } +
+
+ } +
+ } +
+
+ } + + +
+
+
Ações de Moderação
+
+
+
+
+
+ @Html.AntiForgeryToken() +
+ + +
+ +
+
+
+
+ @Html.AntiForgeryToken() +
+ + +
+ + +
+
+
+
+
+
+
+
+ +@section Scripts { + } \ No newline at end of file diff --git a/src/BCards.Web/Views/Payment/ManageSubscription.cshtml b/src/BCards.Web/Views/Payment/ManageSubscription.cshtml index f3f191d..6892b7e 100644 --- a/src/BCards.Web/Views/Payment/ManageSubscription.cshtml +++ b/src/BCards.Web/Views/Payment/ManageSubscription.cshtml @@ -1,302 +1,302 @@ -@using System.Globalization -@model BCards.Web.ViewModels.ManageSubscriptionViewModel -@{ - ViewData["Title"] = "Gerenciar Assinatura"; - Layout = "_Layout"; -} - -
-
-
- - -
-

- - Gerenciar Assinatura -

- - - Voltar ao Dashboard - -
- - - @if (!string.IsNullOrEmpty(Model.ErrorMessage)) - { - - } - - @if (!string.IsNullOrEmpty(Model.SuccessMessage)) - { - - } - - @if (!string.IsNullOrEmpty(TempData["Error"]?.ToString())) - { - - } - - @if (!string.IsNullOrEmpty(TempData["Success"]?.ToString())) - { - - } - - -
-
-
- - Assinatura Atual -
-
-
- @if (Model.HasActiveSubscription) - { -
-
-

Plano @Model.PlanDisplayName

-

- Status: @Model.StatusDisplayName -

- @if (Model.MonthlyAmount.HasValue) - { -

- R$ @Model.MonthlyAmount.Value.ToString("F2") / mês -

- } -
-
- @if (Model.NextBillingDate.HasValue) - { -

- - Próxima cobrança: @Model.NextBillingDate.Value.ToString("dd/MM/yyyy") -

- } - @if (Model.WillCancelAtPeriodEnd) - { -

- - Assinatura será cancelada em @Model.CurrentPeriodEnd?.ToString("dd/MM/yyyy") -

- } -
-
- -
-
- @if (!Model.WillCancelAtPeriodEnd) - { - - - Cancelar Assinatura - - } - else - { -
- - -
- } - -
- -
-
-
- } - else - { -
- -
Nenhuma assinatura ativa
-

Você está usando o plano gratuito. Faça upgrade para desbloquear mais recursos!

- - - Ver Planos - -
- } -
-
- - - @if (Model.HasActiveSubscription && (Model.CanUpgrade || Model.CanDowngrade)) - { -
-
-
- - Alterar Plano -
-
-
-
- @foreach (var plan in Model.AvailablePlans.Where(p => !p.IsCurrentPlan)) - { -
-
-
-
@plan.DisplayName
-

R$ @plan.Price.ToString("F2", new CultureInfo("pt-BR"))

-
    - @foreach (var feature in plan.Features) - { -
  • - - @feature -
  • - } -
- -
- - -
-
-
-
- } -
-
-
- } - - - @if (Model.PaymentHistory.Any()) - { -
-
-
- - Histórico de Pagamentos -
-
-
-
- - - - - - - - - - - - @foreach (var invoice in Model.PaymentHistory.Take(10)) - { - - - - - - - - } - -
DataDescriçãoValorStatusRecibo
@invoice.Created.ToString("dd/MM/yyyy") - @if (!string.IsNullOrEmpty(invoice.Description)) - { - @invoice.Description - } - else - { - Assinatura @Model.PlanDisplayName - } - - R$ @((invoice.AmountPaid / 100m).ToString("F2")) - - Pago - - @if (!string.IsNullOrEmpty(invoice.HostedInvoiceUrl)) - { - - - Ver - - } -
-
-
-
- } -
-
-
- - - - -@section Scripts { - +@using System.Globalization +@model BCards.Web.ViewModels.ManageSubscriptionViewModel +@{ + ViewData["Title"] = "Gerenciar Assinatura"; + Layout = "_Layout"; +} + +
+
+
+ + +
+

+ + Gerenciar Assinatura +

+ + + Voltar ao Dashboard + +
+ + + @if (!string.IsNullOrEmpty(Model.ErrorMessage)) + { + + } + + @if (!string.IsNullOrEmpty(Model.SuccessMessage)) + { + + } + + @if (!string.IsNullOrEmpty(TempData["Error"]?.ToString())) + { + + } + + @if (!string.IsNullOrEmpty(TempData["Success"]?.ToString())) + { + + } + + +
+
+
+ + Assinatura Atual +
+
+
+ @if (Model.HasActiveSubscription) + { +
+
+

Plano @Model.PlanDisplayName

+

+ Status: @Model.StatusDisplayName +

+ @if (Model.MonthlyAmount.HasValue) + { +

+ R$ @Model.MonthlyAmount.Value.ToString("F2") / mês +

+ } +
+
+ @if (Model.NextBillingDate.HasValue) + { +

+ + Próxima cobrança: @Model.NextBillingDate.Value.ToString("dd/MM/yyyy") +

+ } + @if (Model.WillCancelAtPeriodEnd) + { +

+ + Assinatura será cancelada em @Model.CurrentPeriodEnd?.ToString("dd/MM/yyyy") +

+ } +
+
+ +
+
+ @if (!Model.WillCancelAtPeriodEnd) + { + + + Cancelar Assinatura + + } + else + { +
+ + +
+ } + +
+ +
+
+
+ } + else + { +
+ +
Nenhuma assinatura ativa
+

Você está usando o plano gratuito. Faça upgrade para desbloquear mais recursos!

+ + + Ver Planos + +
+ } +
+
+ + + @if (Model.HasActiveSubscription && (Model.CanUpgrade || Model.CanDowngrade)) + { +
+
+
+ + Alterar Plano +
+
+
+
+ @foreach (var plan in Model.AvailablePlans.Where(p => !p.IsCurrentPlan)) + { +
+
+
+
@plan.DisplayName
+

R$ @plan.Price.ToString("F2", new CultureInfo("pt-BR"))

+
    + @foreach (var feature in plan.Features) + { +
  • + + @feature +
  • + } +
+ +
+ + +
+
+
+
+ } +
+
+
+ } + + + @if (Model.PaymentHistory.Any()) + { +
+
+
+ + Histórico de Pagamentos +
+
+
+
+ + + + + + + + + + + + @foreach (var invoice in Model.PaymentHistory.Take(10)) + { + + + + + + + + } + +
DataDescriçãoValorStatusRecibo
@invoice.Created.ToString("dd/MM/yyyy") + @if (!string.IsNullOrEmpty(invoice.Description)) + { + @invoice.Description + } + else + { + Assinatura @Model.PlanDisplayName + } + + R$ @((invoice.AmountPaid / 100m).ToString("F2")) + + Pago + + @if (!string.IsNullOrEmpty(invoice.HostedInvoiceUrl)) + { + + + Ver + + } +
+
+
+
+ } +
+
+
+ + + + +@section Scripts { + } \ No newline at end of file diff --git a/src/BCards.Web/Views/Shared/Components/ModerationMenu/Default.cshtml b/src/BCards.Web/Views/Shared/Components/ModerationMenu/Default.cshtml index ce02e0e..cd90b8d 100644 --- a/src/BCards.Web/Views/Shared/Components/ModerationMenu/Default.cshtml +++ b/src/BCards.Web/Views/Shared/Components/ModerationMenu/Default.cshtml @@ -1,36 +1,36 @@ -@model bool - -@if (Model) -{ - +@model bool + +@if (Model) +{ + } \ No newline at end of file diff --git a/src/BCards.Web/Views/Shared/Error.cshtml b/src/BCards.Web/Views/Shared/Error.cshtml index ffef43f..24bd346 100644 --- a/src/BCards.Web/Views/Shared/Error.cshtml +++ b/src/BCards.Web/Views/Shared/Error.cshtml @@ -1,26 +1,26 @@ -@{ - ViewData["Title"] = "Erro"; -} - - - - - - - Erro - BCards - - - -
-
-
-
-

Oops! Algo deu errado.

-

Ocorreu um erro inesperado. Por favor, tente novamente.

- Voltar ao Início -
-
-
-
- +@{ + ViewData["Title"] = "Erro"; +} + + + + + + + Erro - BCards + + + +
+
+
+
+

Oops! Algo deu errado.

+

Ocorreu um erro inesperado. Por favor, tente novamente.

+ Voltar ao Início +
+
+
+
+ \ No newline at end of file diff --git a/src/BCards.Web/Views/Shared/_Layout.cshtml b/src/BCards.Web/Views/Shared/_Layout.cshtml index 42a35b9..6626dd4 100644 --- a/src/BCards.Web/Views/Shared/_Layout.cshtml +++ b/src/BCards.Web/Views/Shared/_Layout.cshtml @@ -238,10 +238,10 @@ @if (User.Identity?.IsAuthenticated == true) { Comece Agora - } - else - { - Comece Agora + } + else + { + Comece Agora }
diff --git a/src/BCards.Web/Views/Shared/_ThemeStyles.cshtml b/src/BCards.Web/Views/Shared/_ThemeStyles.cshtml index 0838566..eee1dd8 100644 --- a/src/BCards.Web/Views/Shared/_ThemeStyles.cshtml +++ b/src/BCards.Web/Views/Shared/_ThemeStyles.cshtml @@ -1,515 +1,515 @@ -@model BCards.Web.Models.PageTheme - -@{ - var theme = Model ?? new BCards.Web.Models.PageTheme - { - Name = "Padrão", - PrimaryColor = "#2563eb", - SecondaryColor = "#1d4ed8", - BackgroundColor = "#ffffff", - TextColor = "#1f2937" - }; -} - -:root { - --primary-color: @theme.PrimaryColor; - --secondary-color: @theme.SecondaryColor; - --background-color: @theme.BackgroundColor; - --text-color: @theme.TextColor; - --card-bg: rgba(255, 255, 255, 0.95); - --border-color: rgba(0, 0, 0, 0.1); -} - -.user-page { - background-color: var(--background-color); - color: var(--text-color); - min-height: 100vh; - padding: 2rem 0; - -} - -.profile-card { - background-color: var(--card-bg); - backdrop-filter: blur(10px); - border-radius: 20px; - padding: 2rem; - box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1); - border: 1px solid var(--border-color); - max-width: 500px; - margin: 0 auto; -} - -.profile-image-large { - width: 120px; - height: 120px; - border: 4px solid var(--primary-color); - object-fit: cover; - display: block; - margin: 0 auto; -} - -.profile-icon-placeholder { - width: 120px; - height: 120px; - border-radius: 50%; - border: 4px solid var(--primary-color); - background-color: var(--card-bg); - color: var(--primary-color); - display: flex; - align-items: center; - justify-content: center; - font-size: 3rem; - margin: 0 auto; -} - -.profile-name { - color: var(--primary-color); - font-size: 2rem; - font-weight: 600; - margin: 1rem 0 0.5rem 0; -} - -.profile-bio { - color: var(--text-color); - opacity: 0.8; - margin-bottom: 2rem; - font-size: 1.1rem; -} - -/* ========== LINKS CONTAINER ========== */ -.links-container { - margin-bottom: 2rem; -} - -/* ========== UNIVERSAL LINK STYLE (TODOS OS LINKS IGUAIS) ========== */ -.universal-link { - border: 1px solid var(--border-color); - border-radius: 12px; - margin-bottom: 0.75rem; - overflow: hidden; - background-color: var(--card-bg); - transition: all 0.3s ease; -} - -.universal-link:hover { - transform: translateY(-1px); - box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); -} - -.universal-link-header { - background-color: var(--primary-color); - color: white !important; - padding: 0.75rem 1rem; - display: flex; - align-items: center; - justify-content: space-between; - cursor: pointer; - text-decoration: none !important; - transition: background-color 0.3s ease; - position: relative; - font-weight: 500; -} - -.universal-link-header:hover { - background-color: var(--secondary-color); - color: white !important; - text-decoration: none !important; -} - -.universal-link-content { - display: flex; - align-items: center; - flex: 1; - min-width: 0; - text-align: left; -} - -/* Thumbnail para produtos */ -.link-thumbnail { - width: 40px; - height: 40px; - border-radius: 6px; - object-fit: cover; - margin-right: 0.75rem; - flex-shrink: 0; - background-color: rgba(255, 255, 255, 0.2); -} - -/* Ícone para links normais */ -.link-icon { - width: 40px; - height: 40px; - display: flex; - align-items: center; - justify-content: center; - margin-right: 0.75rem; - flex-shrink: 0; - font-size: 1.2rem; - color: white; -} - -.link-text-container { - flex: 1; - min-width: 0; -} - -.link-title { - font-weight: 600; - font-size: 1rem; - color: white; - margin: 0; - line-height: 1.2; - /* Truncate long titles */ - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} - -.link-subtitle { - font-size: 0.85rem; - color: rgba(255, 255, 255, 0.8); - margin: 0; - line-height: 1.1; - /* Truncate long subtitles */ - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} - -/* Seta de expansão */ -.expand-arrow { - background: rgba(255, 255, 255, 0.2); - border: none; - color: white; - border-radius: 6px; - width: 32px; - height: 32px; - display: flex; - align-items: center; - justify-content: center; - cursor: pointer; - transition: all 0.3s ease; - flex-shrink: 0; - margin-left: 0.5rem; -} - -.expand-arrow:hover { - background: rgba(255, 255, 255, 0.3); - transform: scale(1.05); -} - -.expand-arrow i { - font-size: 0.9rem; - transition: transform 0.3s ease; -} - -.expand-arrow.expanded i { - transform: rotate(180deg); -} - -/* Conteúdo expandido */ -.universal-link-details { - padding: 0; - background-color: var(--card-bg); - border-top: 1px solid var(--border-color); - max-height: 0; - overflow: hidden; - transition: max-height 0.3s ease-out, padding 0.3s ease; -} - -.universal-link-details.show { - max-height: 400px; - padding: 1rem; -} - -/* Imagem expandida para produtos */ -.expanded-image { - width: 100%; - max-width: 200px; - height: auto; - border-radius: 8px; - margin-bottom: 1rem; - display: block; - margin-left: auto; - margin-right: auto; - -} - -.expanded-description { - color: var(--text-color); - opacity: 0.8; - font-size: 0.9rem; - line-height: 1.5; - margin-bottom: 0.75rem; - - max-height: 150px; /* Ajuste conforme necessário */ - overflow-y: auto; - padding-right: 0.5rem; /* Espaço para a scrollbar */ - - /* Styling da scrollbar (opcional) */ - scrollbar-width: thin; - scrollbar-color: var(--primary-color) transparent; -} - -.expanded-description::-webkit-scrollbar { - width: 6px; -} - -.expanded-description::-webkit-scrollbar-track { - background: transparent; -} - -.expanded-description::-webkit-scrollbar-thumb { - background-color: var(--primary-color); - border-radius: 3px; -} - -.expanded-price { - color: #28a745; - font-weight: 700; - font-size: 1.1rem; - margin-bottom: 0.75rem; - text-align: center; -} - -.expanded-action { - color: var(--primary-color); - font-size: 0.85rem; - font-weight: 500; - display: flex; - align-items: center; - justify-content: center; - gap: 0.25rem; -} - -/* ========== FOOTER ========== */ -.profile-footer { - margin-top: 2rem; - padding-top: 1rem; - border-top: 1px solid var(--border-color); - text-align: center; -} - -.footer-promo { - background-color: var(--card-bg); - border: 1px solid var(--border-color); - border-radius: 8px; - padding: 1rem; - margin-bottom: 1rem; - cursor: pointer; - transition: all 0.3s ease; -} - -.footer-promo:hover { - transform: translateY(-1px); - box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); -} - -.footer-promo-header { - display: flex; - align-items: center; - justify-content: space-between; - color: var(--primary-color); - font-weight: 600; - font-size: 0.9rem; -} - -.footer-promo-header i { - transition: transform 0.3s ease; -} - -.footer-promo-header.expanded i { - transform: rotate(180deg); -} - -.footer-promo-content { - margin-top: 0.5rem; - max-height: 0; - overflow: hidden; - transition: max-height 0.3s ease-out, margin-top 0.3s ease; - color: var(--text-color); - opacity: 0.8; - font-size: 0.85rem; - line-height: 1.4; -} - -.footer-promo-content.show { - max-height: 200px; - margin-top: 0.75rem; -} - -.footer-promo-button { - background-color: var(--primary-color); - color: white !important; - border: none; - padding: 0.4rem 0.8rem; - border-radius: 6px; - text-decoration: none !important; - font-size: 0.8rem; - display: inline-flex; - align-items: center; - gap: 0.25rem; - margin-top: 0.5rem; - transition: background-color 0.3s ease; -} - -.footer-promo-button:hover { - background-color: var(--secondary-color); - color: white !important; - text-decoration: none !important; -} - -.footer-credits { - font-size: 0.8rem; - color: var(--text-color); - opacity: 0.6; -} - -.footer-credits a { - color: var(--primary-color); - text-decoration: none; - font-weight: 500; -} - -.footer-credits a:hover { - color: var(--secondary-color); - text-decoration: underline; -} - -/* ========== ANIMATIONS ========== */ -@@keyframes slideDown { - from { - max-height: 0; - padding-top: 0; - padding-bottom: 0; - opacity: 0; - } - to { - max-height: 400px; - padding-top: 1rem; - padding-bottom: 1rem; - opacity: 1; - } -} - -/* ========== RESPONSIVE DESIGN ========== */ -@@media (max-width: 768px) { - .user-page { - padding: 1rem 0; - } - - .profile-card { - padding: 1.5rem; - margin: 0 1rem; - border-radius: 15px; - } - - .profile-image-large, - .profile-icon-placeholder { - width: 100px; - height: 100px; - } - - .profile-name { - font-size: 1.75rem; - } - - .universal-link-header { - padding: 0.65rem 0.8rem; - } - - .link-title { - font-size: 0.95rem; - } - - .link-subtitle { - font-size: 0.8rem; - } - - .link-thumbnail, - .link-icon { - width: 36px; - height: 36px; - margin-right: 0.5rem; - } - - .expand-arrow { - width: 28px; - height: 28px; - } - - .expand-arrow i { - font-size: 0.8rem; - } -} - -@@media (max-width: 480px) { - .profile-card { - padding: 1rem; - margin: 0 0.5rem; - } - - .profile-image-large, - .profile-icon-placeholder { - width: 80px; - height: 80px; - } - - .profile-name { - font-size: 1.5rem; - } - - .universal-link-header { - padding: 0.6rem 0.8rem; - } - - .link-title { - font-size: 0.9rem; - } - - .link-subtitle { - font-size: 0.75rem; - } - - .link-thumbnail, - .link-icon { - width: 32px; - height: 32px; - margin-right: 0.5rem; - } - - .expand-arrow { - width: 26px; - height: 26px; - } - - .expanded-image { - max-width: 150px; - } -} - -/* ========== DARK THEME COMPATIBILITY ========== */ -.user-page[data-theme="dark"] .profile-card, -.user-page[data-theme="dark"] .universal-link, -.user-page[data-theme="dark"] .footer-promo { - background-color: rgba(31, 41, 55, 0.95); - border-color: rgba(255, 255, 255, 0.1); -} - -.user-page[data-theme="dark"] .universal-link-details, -.user-page[data-theme="dark"] .footer-promo-content { - background-color: rgba(31, 41, 55, 0.95); - border-color: rgba(255, 255, 255, 0.1); -} - -/* Accessibility */ -.universal-link-header:focus, -.expand-arrow:focus { - outline: 2px solid rgba(255, 255, 255, 0.5); - outline-offset: 2px; -} - -/* Smooth scroll for mobile */ -.universal-link-details { - scroll-behavior: smooth; +@model BCards.Web.Models.PageTheme + +@{ + var theme = Model ?? new BCards.Web.Models.PageTheme + { + Name = "Padrão", + PrimaryColor = "#2563eb", + SecondaryColor = "#1d4ed8", + BackgroundColor = "#ffffff", + TextColor = "#1f2937" + }; +} + +:root { + --primary-color: @theme.PrimaryColor; + --secondary-color: @theme.SecondaryColor; + --background-color: @theme.BackgroundColor; + --text-color: @theme.TextColor; + --card-bg: rgba(255, 255, 255, 0.95); + --border-color: rgba(0, 0, 0, 0.1); +} + +.user-page { + background-color: var(--background-color); + color: var(--text-color); + min-height: 100vh; + padding: 2rem 0; + +} + +.profile-card { + background-color: var(--card-bg); + backdrop-filter: blur(10px); + border-radius: 20px; + padding: 2rem; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1); + border: 1px solid var(--border-color); + max-width: 500px; + margin: 0 auto; +} + +.profile-image-large { + width: 120px; + height: 120px; + border: 4px solid var(--primary-color); + object-fit: cover; + display: block; + margin: 0 auto; +} + +.profile-icon-placeholder { + width: 120px; + height: 120px; + border-radius: 50%; + border: 4px solid var(--primary-color); + background-color: var(--card-bg); + color: var(--primary-color); + display: flex; + align-items: center; + justify-content: center; + font-size: 3rem; + margin: 0 auto; +} + +.profile-name { + color: var(--primary-color); + font-size: 2rem; + font-weight: 600; + margin: 1rem 0 0.5rem 0; +} + +.profile-bio { + color: var(--text-color); + opacity: 0.8; + margin-bottom: 2rem; + font-size: 1.1rem; +} + +/* ========== LINKS CONTAINER ========== */ +.links-container { + margin-bottom: 2rem; +} + +/* ========== UNIVERSAL LINK STYLE (TODOS OS LINKS IGUAIS) ========== */ +.universal-link { + border: 1px solid var(--border-color); + border-radius: 12px; + margin-bottom: 0.75rem; + overflow: hidden; + background-color: var(--card-bg); + transition: all 0.3s ease; +} + +.universal-link:hover { + transform: translateY(-1px); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); +} + +.universal-link-header { + background-color: var(--primary-color); + color: white !important; + padding: 0.75rem 1rem; + display: flex; + align-items: center; + justify-content: space-between; + cursor: pointer; + text-decoration: none !important; + transition: background-color 0.3s ease; + position: relative; + font-weight: 500; +} + +.universal-link-header:hover { + background-color: var(--secondary-color); + color: white !important; + text-decoration: none !important; +} + +.universal-link-content { + display: flex; + align-items: center; + flex: 1; + min-width: 0; + text-align: left; +} + +/* Thumbnail para produtos */ +.link-thumbnail { + width: 40px; + height: 40px; + border-radius: 6px; + object-fit: cover; + margin-right: 0.75rem; + flex-shrink: 0; + background-color: rgba(255, 255, 255, 0.2); +} + +/* Ícone para links normais */ +.link-icon { + width: 40px; + height: 40px; + display: flex; + align-items: center; + justify-content: center; + margin-right: 0.75rem; + flex-shrink: 0; + font-size: 1.2rem; + color: white; +} + +.link-text-container { + flex: 1; + min-width: 0; +} + +.link-title { + font-weight: 600; + font-size: 1rem; + color: white; + margin: 0; + line-height: 1.2; + /* Truncate long titles */ + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.link-subtitle { + font-size: 0.85rem; + color: rgba(255, 255, 255, 0.8); + margin: 0; + line-height: 1.1; + /* Truncate long subtitles */ + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +/* Seta de expansão */ +.expand-arrow { + background: rgba(255, 255, 255, 0.2); + border: none; + color: white; + border-radius: 6px; + width: 32px; + height: 32px; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + transition: all 0.3s ease; + flex-shrink: 0; + margin-left: 0.5rem; +} + +.expand-arrow:hover { + background: rgba(255, 255, 255, 0.3); + transform: scale(1.05); +} + +.expand-arrow i { + font-size: 0.9rem; + transition: transform 0.3s ease; +} + +.expand-arrow.expanded i { + transform: rotate(180deg); +} + +/* Conteúdo expandido */ +.universal-link-details { + padding: 0; + background-color: var(--card-bg); + border-top: 1px solid var(--border-color); + max-height: 0; + overflow: hidden; + transition: max-height 0.3s ease-out, padding 0.3s ease; +} + +.universal-link-details.show { + max-height: 400px; + padding: 1rem; +} + +/* Imagem expandida para produtos */ +.expanded-image { + width: 100%; + max-width: 200px; + height: auto; + border-radius: 8px; + margin-bottom: 1rem; + display: block; + margin-left: auto; + margin-right: auto; + +} + +.expanded-description { + color: var(--text-color); + opacity: 0.8; + font-size: 0.9rem; + line-height: 1.5; + margin-bottom: 0.75rem; + + max-height: 150px; /* Ajuste conforme necessário */ + overflow-y: auto; + padding-right: 0.5rem; /* Espaço para a scrollbar */ + + /* Styling da scrollbar (opcional) */ + scrollbar-width: thin; + scrollbar-color: var(--primary-color) transparent; +} + +.expanded-description::-webkit-scrollbar { + width: 6px; +} + +.expanded-description::-webkit-scrollbar-track { + background: transparent; +} + +.expanded-description::-webkit-scrollbar-thumb { + background-color: var(--primary-color); + border-radius: 3px; +} + +.expanded-price { + color: #28a745; + font-weight: 700; + font-size: 1.1rem; + margin-bottom: 0.75rem; + text-align: center; +} + +.expanded-action { + color: var(--primary-color); + font-size: 0.85rem; + font-weight: 500; + display: flex; + align-items: center; + justify-content: center; + gap: 0.25rem; +} + +/* ========== FOOTER ========== */ +.profile-footer { + margin-top: 2rem; + padding-top: 1rem; + border-top: 1px solid var(--border-color); + text-align: center; +} + +.footer-promo { + background-color: var(--card-bg); + border: 1px solid var(--border-color); + border-radius: 8px; + padding: 1rem; + margin-bottom: 1rem; + cursor: pointer; + transition: all 0.3s ease; +} + +.footer-promo:hover { + transform: translateY(-1px); + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); +} + +.footer-promo-header { + display: flex; + align-items: center; + justify-content: space-between; + color: var(--primary-color); + font-weight: 600; + font-size: 0.9rem; +} + +.footer-promo-header i { + transition: transform 0.3s ease; +} + +.footer-promo-header.expanded i { + transform: rotate(180deg); +} + +.footer-promo-content { + margin-top: 0.5rem; + max-height: 0; + overflow: hidden; + transition: max-height 0.3s ease-out, margin-top 0.3s ease; + color: var(--text-color); + opacity: 0.8; + font-size: 0.85rem; + line-height: 1.4; +} + +.footer-promo-content.show { + max-height: 200px; + margin-top: 0.75rem; +} + +.footer-promo-button { + background-color: var(--primary-color); + color: white !important; + border: none; + padding: 0.4rem 0.8rem; + border-radius: 6px; + text-decoration: none !important; + font-size: 0.8rem; + display: inline-flex; + align-items: center; + gap: 0.25rem; + margin-top: 0.5rem; + transition: background-color 0.3s ease; +} + +.footer-promo-button:hover { + background-color: var(--secondary-color); + color: white !important; + text-decoration: none !important; +} + +.footer-credits { + font-size: 0.8rem; + color: var(--text-color); + opacity: 0.6; +} + +.footer-credits a { + color: var(--primary-color); + text-decoration: none; + font-weight: 500; +} + +.footer-credits a:hover { + color: var(--secondary-color); + text-decoration: underline; +} + +/* ========== ANIMATIONS ========== */ +@@keyframes slideDown { + from { + max-height: 0; + padding-top: 0; + padding-bottom: 0; + opacity: 0; + } + to { + max-height: 400px; + padding-top: 1rem; + padding-bottom: 1rem; + opacity: 1; + } +} + +/* ========== RESPONSIVE DESIGN ========== */ +@@media (max-width: 768px) { + .user-page { + padding: 1rem 0; + } + + .profile-card { + padding: 1.5rem; + margin: 0 1rem; + border-radius: 15px; + } + + .profile-image-large, + .profile-icon-placeholder { + width: 100px; + height: 100px; + } + + .profile-name { + font-size: 1.75rem; + } + + .universal-link-header { + padding: 0.65rem 0.8rem; + } + + .link-title { + font-size: 0.95rem; + } + + .link-subtitle { + font-size: 0.8rem; + } + + .link-thumbnail, + .link-icon { + width: 36px; + height: 36px; + margin-right: 0.5rem; + } + + .expand-arrow { + width: 28px; + height: 28px; + } + + .expand-arrow i { + font-size: 0.8rem; + } +} + +@@media (max-width: 480px) { + .profile-card { + padding: 1rem; + margin: 0 0.5rem; + } + + .profile-image-large, + .profile-icon-placeholder { + width: 80px; + height: 80px; + } + + .profile-name { + font-size: 1.5rem; + } + + .universal-link-header { + padding: 0.6rem 0.8rem; + } + + .link-title { + font-size: 0.9rem; + } + + .link-subtitle { + font-size: 0.75rem; + } + + .link-thumbnail, + .link-icon { + width: 32px; + height: 32px; + margin-right: 0.5rem; + } + + .expand-arrow { + width: 26px; + height: 26px; + } + + .expanded-image { + max-width: 150px; + } +} + +/* ========== DARK THEME COMPATIBILITY ========== */ +.user-page[data-theme="dark"] .profile-card, +.user-page[data-theme="dark"] .universal-link, +.user-page[data-theme="dark"] .footer-promo { + background-color: rgba(31, 41, 55, 0.95); + border-color: rgba(255, 255, 255, 0.1); +} + +.user-page[data-theme="dark"] .universal-link-details, +.user-page[data-theme="dark"] .footer-promo-content { + background-color: rgba(31, 41, 55, 0.95); + border-color: rgba(255, 255, 255, 0.1); +} + +/* Accessibility */ +.universal-link-header:focus, +.expand-arrow:focus { + outline: 2px solid rgba(255, 255, 255, 0.5); + outline-offset: 2px; +} + +/* Smooth scroll for mobile */ +.universal-link-details { + scroll-behavior: smooth; } \ No newline at end of file diff --git a/src/BCards.Web/Views/Shared/_UserPageLayout.cshtml b/src/BCards.Web/Views/Shared/_UserPageLayout.cshtml index 6113d12..e89dd7a 100644 --- a/src/BCards.Web/Views/Shared/_UserPageLayout.cshtml +++ b/src/BCards.Web/Views/Shared/_UserPageLayout.cshtml @@ -1,61 +1,61 @@ -@{ - var seo = ViewBag.SeoSettings as BCards.Web.Models.SeoSettings; -} - - - - - - @(seo?.Title ?? ViewData["Title"]) - - @if (seo != null) - { - - - - - - - - - - - - - - - - - - - - - } - - @await RenderSectionAsync("Head", required: false) - - - - - - - - @await RenderSectionAsync("Styles", required: false) - - - @RenderBody() - - - - @await RenderSectionAsync("Scripts", required: false) - +@{ + var seo = ViewBag.SeoSettings as BCards.Web.Models.SeoSettings; +} + + + + + + @(seo?.Title ?? ViewData["Title"]) + + @if (seo != null) + { + + + + + + + + + + + + + + + + + + + + + } + + @await RenderSectionAsync("Head", required: false) + + + + + + + + @await RenderSectionAsync("Styles", required: false) + + + @RenderBody() + + + + @await RenderSectionAsync("Scripts", required: false) + \ No newline at end of file diff --git a/src/BCards.Web/Views/Subscription/Cancel.cshtml b/src/BCards.Web/Views/Subscription/Cancel.cshtml index 6e15969..f744783 100644 --- a/src/BCards.Web/Views/Subscription/Cancel.cshtml +++ b/src/BCards.Web/Views/Subscription/Cancel.cshtml @@ -1,167 +1,167 @@ -@model BCards.Web.ViewModels.CancelSubscriptionViewModel -@{ - ViewData["Title"] = "Cancelar Assinatura"; - Layout = "_Layout"; -} - -
-
-
-
-
-

- - Cancelar Assinatura -

-
-
-
-
Informações da Assinatura
-

Plano: @Model.PlanName

-

Válido até: @Model.CurrentPeriodEnd.ToString("dd/MM/yyyy")

-

Dias restantes: @Model.DaysRemaining dias

-
- -
Escolha uma opção de cancelamento:
- -
- - -
- - @if (Model.CanRefundFull) - { -
-
-
-
- - -
-
-
-
- } - - - @if (Model.CanRefundPartial && Model.RefundAmount > 0) - { -
-
-
-
- - -
-
-
-
- } - - -
-
-
-
- - -
-
-
-
- - -
-
-
-
- - -
-
-
-
-
- -
-
-
Importante:
-
    -
  • Reembolsos são processados em até 10 dias úteis
  • -
  • O valor retorna para o mesmo cartão usado na compra
  • -
  • Após o cancelamento, suas páginas podem ser desativadas conforme o plano escolhido
  • -
-
-
- -
- - Voltar - - -
-
-
-
-
-
-
- - \ No newline at end of file diff --git a/src/BCards.Web/Views/UserPage/PageRejected.cshtml b/src/BCards.Web/Views/UserPage/PageRejected.cshtml index d807684..074bcb9 100644 --- a/src/BCards.Web/Views/UserPage/PageRejected.cshtml +++ b/src/BCards.Web/Views/UserPage/PageRejected.cshtml @@ -1,30 +1,30 @@ -@{ - ViewData["Title"] = "Página Rejeitada"; - Layout = "_Layout"; -} - -
-
-
-
-
-
- -
-

Página Rejeitada

-

- Esta página foi rejeitada na moderação e não está disponível publicamente. -

-

- O conteúdo não atende aos nossos termos de uso ou padrões de qualidade. -
- Proprietário: Verifique seu painel para mais detalhes -

- - Voltar ao Início - -
-
-
-
+@{ + ViewData["Title"] = "Página Rejeitada"; + Layout = "_Layout"; +} + +
+
+
+
+
+
+ +
+

Página Rejeitada

+

+ Esta página foi rejeitada na moderação e não está disponível publicamente. +

+

+ O conteúdo não atende aos nossos termos de uso ou padrões de qualidade. +
+ Proprietário: Verifique seu painel para mais detalhes +

+ + Voltar ao Início + +
+
+
+
\ No newline at end of file diff --git a/src/BCards.Web/Views/UserPage/PendingModeration.cshtml b/src/BCards.Web/Views/UserPage/PendingModeration.cshtml index ffb091a..0e25f49 100644 --- a/src/BCards.Web/Views/UserPage/PendingModeration.cshtml +++ b/src/BCards.Web/Views/UserPage/PendingModeration.cshtml @@ -1,34 +1,34 @@ -@{ - ViewData["Title"] = "Página em Análise"; - Layout = "_Layout"; -} - -
-
-
-
-
-
- -
-

Página em Análise

-

- Esta página está sendo analisada por nossa equipe de moderação. -

-

- Estamos verificando se o conteúdo segue nossos termos de uso para manter a qualidade da plataforma. -
- Tempo estimado: 3-7 dias úteis -

-
- - Proprietário da página: Verifique seu email para o link de preview -
- - Voltar ao Início - -
-
-
-
+@{ + ViewData["Title"] = "Página em Análise"; + Layout = "_Layout"; +} + +
+
+
+
+
+
+ +
+

Página em Análise

+

+ Esta página está sendo analisada por nossa equipe de moderação. +

+

+ Estamos verificando se o conteúdo segue nossos termos de uso para manter a qualidade da plataforma. +
+ Tempo estimado: 3-7 dias úteis +

+
+ + Proprietário da página: Verifique seu email para o link de preview +
+ + Voltar ao Início + +
+
+
+
\ No newline at end of file diff --git a/src/BCards.Web/Views/UserPage/PreviewExpired.cshtml b/src/BCards.Web/Views/UserPage/PreviewExpired.cshtml index 0bf4ec6..c730b32 100644 --- a/src/BCards.Web/Views/UserPage/PreviewExpired.cshtml +++ b/src/BCards.Web/Views/UserPage/PreviewExpired.cshtml @@ -1,38 +1,38 @@ -@{ - ViewData["Title"] = "Preview Expirado"; - Layout = "_Layout"; -} - -
-
-
-
-
-
- -
-

Preview Expirado

-

- O link de preview que você acessou não é mais válido. -

-

- Isso pode acontecer se: -
- • O link expirou (30 dias) -
- • Excedeu o limite de visualizações (50) -
- • A página já foi processada -

-
- - Proprietário: Acesse seu painel para ver o status atual -
- - Voltar ao Início - -
-
-
-
+@{ + ViewData["Title"] = "Preview Expirado"; + Layout = "_Layout"; +} + +
+
+
+
+
+
+ +
+

Preview Expirado

+

+ O link de preview que você acessou não é mais válido. +

+

+ Isso pode acontecer se: +
+ • O link expirou (30 dias) +
+ • Excedeu o limite de visualizações (50) +
+ • A página já foi processada +

+
+ + Proprietário: Acesse seu painel para ver o status atual +
+ + Voltar ao Início + +
+
+
+
\ No newline at end of file diff --git a/src/BCards.Web/Views/_ViewImports.cshtml b/src/BCards.Web/Views/_ViewImports.cshtml index 654eb76..b38d17b 100644 --- a/src/BCards.Web/Views/_ViewImports.cshtml +++ b/src/BCards.Web/Views/_ViewImports.cshtml @@ -1,3 +1,3 @@ -@using BCards.Web -@using BCards.Web.Models +@using BCards.Web +@using BCards.Web.Models @addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers \ No newline at end of file diff --git a/src/BCards.Web/appsettings.Development.json b/src/BCards.Web/appsettings.Development.json index ccf425a..454d945 100644 --- a/src/BCards.Web/appsettings.Development.json +++ b/src/BCards.Web/appsettings.Development.json @@ -1,25 +1,25 @@ -{ - "Logging": { - "LogLevel": { - "Default": "Debug", - "System": "Information", - "Microsoft": "Information", - "Microsoft.AspNetCore": "Warning" - } - }, - "Stripe": { - "PublishableKey": "pk_test_51RjUmIBMIadsOxJVP4bWc54pHEOSf5km1hpOkOBSoGVoKxI46N4KSWtevpXCSq68OjFazBuXmPJGBwZ1KDN5MNJy003lj1YmAS", - "SecretKey": "sk_test_51RjUmIBMIadsOxJVeqsMFxnZ8ePR7d8IbnaF4sAwBVJv9rrfODPEQ2C9fF3beoABpITdfzEk0ZDzGTTQfvKv63xI00PeZoABGO", - "WebhookSecret": "whsec_8d189c137ff170ab5e62498003512b9d073e2db50c50ed7d8712b7ef11a37543", - "Environment": "test" - }, - "Serilog": { - "OpenSearchUrl": "http://192.168.0.100:9200", - }, - "DetailedErrors": true, - "MongoDb": { - "ConnectionString": "mongodb://localhost:27017", - "DatabaseName": "BCardsDB_Dev" - }, - "BaseUrl": "https://localhost:49178" +{ + "Logging": { + "LogLevel": { + "Default": "Debug", + "System": "Information", + "Microsoft": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "Stripe": { + "PublishableKey": "pk_test_51RjUmIBMIadsOxJVP4bWc54pHEOSf5km1hpOkOBSoGVoKxI46N4KSWtevpXCSq68OjFazBuXmPJGBwZ1KDN5MNJy003lj1YmAS", + "SecretKey": "sk_test_51RjUmIBMIadsOxJVeqsMFxnZ8ePR7d8IbnaF4sAwBVJv9rrfODPEQ2C9fF3beoABpITdfzEk0ZDzGTTQfvKv63xI00PeZoABGO", + "WebhookSecret": "whsec_8d189c137ff170ab5e62498003512b9d073e2db50c50ed7d8712b7ef11a37543", + "Environment": "test" + }, + "Serilog": { + "OpenSearchUrl": "http://192.168.0.100:9200", + }, + "DetailedErrors": true, + "MongoDb": { + "ConnectionString": "mongodb://localhost:27017", + "DatabaseName": "BCardsDB_Dev" + }, + "BaseUrl": "https://localhost:49178" } \ No newline at end of file diff --git a/src/BCards.Web/appsettings.Release.json b/src/BCards.Web/appsettings.Release.json index 5f8e8bb..3283740 100644 --- a/src/BCards.Web/appsettings.Release.json +++ b/src/BCards.Web/appsettings.Release.json @@ -1,17 +1,17 @@ -{ - "Logging": { - "LogLevel": { - "Default": "Information", - "Microsoft.AspNetCore": "Warning" - } - }, - "Serilog": { - "SeqUrl": "http://seq.internal:5341", - "ApiKey": "YOUR_PRODUCTION_API_KEY" - }, - "MongoDb": { - "ConnectionString": "mongodb://192.168.0.100:27017", - "DatabaseName": "BCardsDB_Staging" - }, - "BaseUrl": "http://192.168.0.100:8080" -} +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "Serilog": { + "SeqUrl": "http://seq.internal:5341", + "ApiKey": "YOUR_PRODUCTION_API_KEY" + }, + "MongoDb": { + "ConnectionString": "mongodb://192.168.0.100:27017", + "DatabaseName": "BCardsDB_Staging" + }, + "BaseUrl": "http://192.168.0.100:8080" +} diff --git a/src/BCards.Web/appsettings.Testing.json b/src/BCards.Web/appsettings.Testing.json index ccf425a..454d945 100644 --- a/src/BCards.Web/appsettings.Testing.json +++ b/src/BCards.Web/appsettings.Testing.json @@ -1,25 +1,25 @@ -{ - "Logging": { - "LogLevel": { - "Default": "Debug", - "System": "Information", - "Microsoft": "Information", - "Microsoft.AspNetCore": "Warning" - } - }, - "Stripe": { - "PublishableKey": "pk_test_51RjUmIBMIadsOxJVP4bWc54pHEOSf5km1hpOkOBSoGVoKxI46N4KSWtevpXCSq68OjFazBuXmPJGBwZ1KDN5MNJy003lj1YmAS", - "SecretKey": "sk_test_51RjUmIBMIadsOxJVeqsMFxnZ8ePR7d8IbnaF4sAwBVJv9rrfODPEQ2C9fF3beoABpITdfzEk0ZDzGTTQfvKv63xI00PeZoABGO", - "WebhookSecret": "whsec_8d189c137ff170ab5e62498003512b9d073e2db50c50ed7d8712b7ef11a37543", - "Environment": "test" - }, - "Serilog": { - "OpenSearchUrl": "http://192.168.0.100:9200", - }, - "DetailedErrors": true, - "MongoDb": { - "ConnectionString": "mongodb://localhost:27017", - "DatabaseName": "BCardsDB_Dev" - }, - "BaseUrl": "https://localhost:49178" +{ + "Logging": { + "LogLevel": { + "Default": "Debug", + "System": "Information", + "Microsoft": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "Stripe": { + "PublishableKey": "pk_test_51RjUmIBMIadsOxJVP4bWc54pHEOSf5km1hpOkOBSoGVoKxI46N4KSWtevpXCSq68OjFazBuXmPJGBwZ1KDN5MNJy003lj1YmAS", + "SecretKey": "sk_test_51RjUmIBMIadsOxJVeqsMFxnZ8ePR7d8IbnaF4sAwBVJv9rrfODPEQ2C9fF3beoABpITdfzEk0ZDzGTTQfvKv63xI00PeZoABGO", + "WebhookSecret": "whsec_8d189c137ff170ab5e62498003512b9d073e2db50c50ed7d8712b7ef11a37543", + "Environment": "test" + }, + "Serilog": { + "OpenSearchUrl": "http://192.168.0.100:9200", + }, + "DetailedErrors": true, + "MongoDb": { + "ConnectionString": "mongodb://localhost:27017", + "DatabaseName": "BCardsDB_Dev" + }, + "BaseUrl": "https://localhost:49178" } \ No newline at end of file diff --git a/src/BCards.Web/appsettings.json b/src/BCards.Web/appsettings.json index e14273a..a4c2cf0 100644 --- a/src/BCards.Web/appsettings.json +++ b/src/BCards.Web/appsettings.json @@ -1,157 +1,157 @@ -{ - "Logging": { - "LogLevel": { - "Default": "Information", - "Microsoft.AspNetCore": "Warning", - "Microsoft.Hosting.Lifetime": "Information" - } - }, - "Serilog": { - "OpenSearchUrl": "https://localhost:9201", - "OpenSearchUsername": "admin", - "OpenSearchPassword": "C4rn31r0#13" - }, - "AllowedHosts": "*", - "Stripe": { - "PublishableKey": "", - "SecretKey": "", - "WebhookSecret": "", - "Environment": "test" - }, - "Plans": { - "Basic": { - "Name": "Básico", - "PriceId": "price_1RycPaBMIadsOxJVKioZZofK", - "Price": 5.90, - "MaxPages": 3, - "MaxLinks": 8, - "AllowPremiumThemes": false, - "AllowProductLinks": false, - "AllowAnalytics": true, - "Features": [ "URL Personalizada", "20 temas básicos", "Analytics simples" ], - "Interval": "month" - }, - "Professional": { - "Name": "Profissional", - "PriceId": "price_1RycQmBMIadsOxJVGqjVMaOj", - "Price": 12.90, - "MaxPages": 5, - "MaxLinks": 20, - "AllowPremiumThemes": false, - "AllowProductLinks": false, - "AllowAnalytics": true, - "Features": [ "URL Personalizada", "20 temas básicos", "Analytics avançado" ], - "Interval": "month" - }, - "Premium": { - "Name": "Premium", - "PriceId": "price_1RycRUBMIadsOxJVkxGOh3uu", - "Price": 19.90, - "MaxPages": 15, - "MaxLinks": -1, - "AllowPremiumThemes": true, - "AllowProductLinks": false, - "AllowAnalytics": true, - "SpecialModeration": false, - "Features": [ "URL Personalizada", "40 temas (básicos + premium)", "Suporte prioritário", "Links ilimitados" ], - "Interval": "month" - }, - "PremiumAffiliate": { - "Name": "Premium+Afiliados", - "PriceId": "price_1RycTaBMIadsOxJVeDLseXQq", - "Price": 29.90, - "MaxPages": 15, - "MaxLinks": -1, - "AllowPremiumThemes": true, - "AllowProductLinks": true, - "AllowAnalytics": true, - "SpecialModeration": true, - "Features": [ "Tudo do Premium", "Links de produto", "Moderação especial", "Até 10 links afiliados" ], - "Interval": "month" - }, - "BasicYearly": { - "Name": "Básico Anual", - "PriceId": "price_1RycWgBMIadsOxJVGdtEeoMS", - "Price": 59.00, - "MaxPages": 3, - "MaxLinks": 8, - "AllowPremiumThemes": false, - "AllowProductLinks": false, - "AllowAnalytics": true, - "Features": [ "URL Personalizada", "20 temas básicos", "Analytics simples", "Economize R$ 11,80 (2 meses grátis)" ], - "Interval": "year" - }, - "ProfessionalYearly": { - "Name": "Profissional Anual", - "PriceId": "price_1RycXdBMIadsOxJV5cNX7dHm", - "Price": 129.00, - "MaxPages": 5, - "MaxLinks": 20, - "AllowPremiumThemes": false, - "AllowProductLinks": false, - "AllowAnalytics": true, - "Features": [ "URL Personalizada", "20 temas básicos", "Analytics avançado", "Economize R$ 25,80 (2 meses grátis)" ], - "Interval": "year" - }, - "PremiumYearly": { - "Name": "Premium Anual", - "PriceId": "price_1RycYnBMIadsOxJVPdKmzy4m", - "Price": 199.00, - "MaxPages": 15, - "MaxLinks": -1, - "AllowPremiumThemes": true, - "AllowProductLinks": false, - "AllowAnalytics": true, - "SpecialModeration": false, - "Features": [ "URL Personalizada", "40 temas (básicos + premium)", "Suporte prioritário", "Links ilimitados", "Economize R$ 39,80 (2 meses grátis)" ], - "Interval": "year" - }, - "PremiumAffiliateYearly": { - "Name": "Premium+Afiliados Anual", - "PriceId": "price_1RycaEBMIadsOxJVEhsdB2Y1", - "Price": 299.00, - "MaxPages": 15, - "MaxLinks": -1, - "AllowPremiumThemes": true, - "AllowProductLinks": true, - "AllowAnalytics": true, - "SpecialModeration": true, - "Features": [ "Tudo do Premium", "Links de produto", "Moderação especial", "Até 10 links afiliados", "Economize R$ 59,80 (2 meses grátis)" ], - "Interval": "year" - } - }, - "MongoDb": { - "ConnectionString": "mongodb://admin:c4rn31r0@129.146.116.218:27017,141.148.162.114:27017/BCardsDB?replicaSet=rs0&authSource=admin", - "DatabaseName": "BCardsDB" - }, - "Authentication": { - "Google": { - "ClientId": "472850008574-nmeepbdt4hunsk5c8krpbdmd3olc4jv6.apps.googleusercontent.com", - "ClientSecret": "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" - }, - "Microsoft": { - "ClientId": "b411606a-e574-4f59-b7cd-10dd941b9fa3", - "ClientSecret": "T0.8Q~an.51iW1H0DVjL2i1bmSK_qTgVQOuEmapK" - } - }, - "Moderation": { - "PriorityTimeframes": { - "Trial": "7.00:00:00", - "Basic": "7.00:00:00", - "Professional": "3.00:00:00", - "Premium": "1.00:00:00" - }, - "MaxAttempts": 3, - "ModeratorEmail": "ricardo.carneiro@jobmaker.com.br", - "ModeratorEmails": [ - "rrcgoncalves@gmail.com", - "rirocarneiro@gmail.com" - ] - }, - "SendGrid": { - "ApiKey": "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", - "FromEmail": "ricardo.carneiro@jobmaker.com.br", - "FromName": "Ricardo Carneiro" - }, - "BaseUrl": "https://bcards.site" +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning", + "Microsoft.Hosting.Lifetime": "Information" + } + }, + "Serilog": { + "OpenSearchUrl": "https://localhost:9201", + "OpenSearchUsername": "admin", + "OpenSearchPassword": "C4rn31r0#13" + }, + "AllowedHosts": "*", + "Stripe": { + "PublishableKey": "", + "SecretKey": "", + "WebhookSecret": "", + "Environment": "test" + }, + "Plans": { + "Basic": { + "Name": "Básico", + "PriceId": "price_1RycPaBMIadsOxJVKioZZofK", + "Price": 5.90, + "MaxPages": 3, + "MaxLinks": 8, + "AllowPremiumThemes": false, + "AllowProductLinks": false, + "AllowAnalytics": true, + "Features": [ "URL Personalizada", "20 temas básicos", "Analytics simples" ], + "Interval": "month" + }, + "Professional": { + "Name": "Profissional", + "PriceId": "price_1RycQmBMIadsOxJVGqjVMaOj", + "Price": 12.90, + "MaxPages": 5, + "MaxLinks": 20, + "AllowPremiumThemes": false, + "AllowProductLinks": false, + "AllowAnalytics": true, + "Features": [ "URL Personalizada", "20 temas básicos", "Analytics avançado" ], + "Interval": "month" + }, + "Premium": { + "Name": "Premium", + "PriceId": "price_1RycRUBMIadsOxJVkxGOh3uu", + "Price": 19.90, + "MaxPages": 15, + "MaxLinks": -1, + "AllowPremiumThemes": true, + "AllowProductLinks": false, + "AllowAnalytics": true, + "SpecialModeration": false, + "Features": [ "URL Personalizada", "40 temas (básicos + premium)", "Suporte prioritário", "Links ilimitados" ], + "Interval": "month" + }, + "PremiumAffiliate": { + "Name": "Premium+Afiliados", + "PriceId": "price_1RycTaBMIadsOxJVeDLseXQq", + "Price": 29.90, + "MaxPages": 15, + "MaxLinks": -1, + "AllowPremiumThemes": true, + "AllowProductLinks": true, + "AllowAnalytics": true, + "SpecialModeration": true, + "Features": [ "Tudo do Premium", "Links de produto", "Moderação especial", "Até 10 links afiliados" ], + "Interval": "month" + }, + "BasicYearly": { + "Name": "Básico Anual", + "PriceId": "price_1RycWgBMIadsOxJVGdtEeoMS", + "Price": 59.00, + "MaxPages": 3, + "MaxLinks": 8, + "AllowPremiumThemes": false, + "AllowProductLinks": false, + "AllowAnalytics": true, + "Features": [ "URL Personalizada", "20 temas básicos", "Analytics simples", "Economize R$ 11,80 (2 meses grátis)" ], + "Interval": "year" + }, + "ProfessionalYearly": { + "Name": "Profissional Anual", + "PriceId": "price_1RycXdBMIadsOxJV5cNX7dHm", + "Price": 129.00, + "MaxPages": 5, + "MaxLinks": 20, + "AllowPremiumThemes": false, + "AllowProductLinks": false, + "AllowAnalytics": true, + "Features": [ "URL Personalizada", "20 temas básicos", "Analytics avançado", "Economize R$ 25,80 (2 meses grátis)" ], + "Interval": "year" + }, + "PremiumYearly": { + "Name": "Premium Anual", + "PriceId": "price_1RycYnBMIadsOxJVPdKmzy4m", + "Price": 199.00, + "MaxPages": 15, + "MaxLinks": -1, + "AllowPremiumThemes": true, + "AllowProductLinks": false, + "AllowAnalytics": true, + "SpecialModeration": false, + "Features": [ "URL Personalizada", "40 temas (básicos + premium)", "Suporte prioritário", "Links ilimitados", "Economize R$ 39,80 (2 meses grátis)" ], + "Interval": "year" + }, + "PremiumAffiliateYearly": { + "Name": "Premium+Afiliados Anual", + "PriceId": "price_1RycaEBMIadsOxJVEhsdB2Y1", + "Price": 299.00, + "MaxPages": 15, + "MaxLinks": -1, + "AllowPremiumThemes": true, + "AllowProductLinks": true, + "AllowAnalytics": true, + "SpecialModeration": true, + "Features": [ "Tudo do Premium", "Links de produto", "Moderação especial", "Até 10 links afiliados", "Economize R$ 59,80 (2 meses grátis)" ], + "Interval": "year" + } + }, + "MongoDb": { + "ConnectionString": "mongodb://admin:c4rn31r0@129.146.116.218:27017,141.148.162.114:27017/BCardsDB?replicaSet=rs0&authSource=admin", + "DatabaseName": "BCardsDB" + }, + "Authentication": { + "Google": { + "ClientId": "472850008574-nmeepbdt4hunsk5c8krpbdmd3olc4jv6.apps.googleusercontent.com", + "ClientSecret": "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" + }, + "Microsoft": { + "ClientId": "b411606a-e574-4f59-b7cd-10dd941b9fa3", + "ClientSecret": "T0.8Q~an.51iW1H0DVjL2i1bmSK_qTgVQOuEmapK" + } + }, + "Moderation": { + "PriorityTimeframes": { + "Trial": "7.00:00:00", + "Basic": "7.00:00:00", + "Professional": "3.00:00:00", + "Premium": "1.00:00:00" + }, + "MaxAttempts": 3, + "ModeratorEmail": "ricardo.carneiro@jobmaker.com.br", + "ModeratorEmails": [ + "rrcgoncalves@gmail.com", + "rirocarneiro@gmail.com" + ] + }, + "SendGrid": { + "ApiKey": "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", + "FromEmail": "ricardo.carneiro@jobmaker.com.br", + "FromName": "Ricardo Carneiro" + }, + "BaseUrl": "https://bcards.site" } \ No newline at end of file diff --git a/src/BCards.Web/wwwroot/ads.txt b/src/BCards.Web/wwwroot/ads.txt index 018faee..fa49e88 100644 --- a/src/BCards.Web/wwwroot/ads.txt +++ b/src/BCards.Web/wwwroot/ads.txt @@ -1 +1 @@ -google.com, pub-3475956393038764, DIRECT, f08c47fec0942fa0 +google.com, pub-3475956393038764, DIRECT, f08c47fec0942fa0 diff --git a/src/BCards.Web/wwwroot/css/userpage.css b/src/BCards.Web/wwwroot/css/userpage.css index 0110d8c..40a90cc 100644 --- a/src/BCards.Web/wwwroot/css/userpage.css +++ b/src/BCards.Web/wwwroot/css/userpage.css @@ -1,307 +1,307 @@ -/* User Page Specific Styles */ -.user-page { - min-height: 100vh; - background-color: #ffffff; - background-size: cover; - background-position: center; - background-attachment: fixed; -} - -.profile-card { - background-color: rgba(255, 255, 255, 0.95); - backdrop-filter: blur(10px); - border-radius: 20px; - padding: 2rem; - box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1); - max-width: 480px; - margin: 0 auto; -} - -.profile-image { - width: 120px; - height: 120px; - border-radius: 50%; - border: 4px solid #007bff; - object-fit: cover; - display: block; - margin: 0 auto; -} - -.profile-image-small { - width: 60px; - height: 60px; - border-radius: 50%; - border: 3px solid #007bff; - object-fit: cover; - flex-shrink: 0; -} - -.profile-header { - padding: 1rem; - background: rgba(255, 255, 255, 0.1); - border-radius: 15px; - backdrop-filter: blur(10px); -} - -.profile-image-placeholder { - width: 120px; - height: 120px; - border-radius: 50%; - border: 4px solid #007bff; - background-color: #f8f9fa; - color: #6c757d; -} - -.profile-name { - color: #007bff; - font-size: 2rem; - font-weight: 600; - margin-bottom: 0.5rem; - text-align: center; -} - -.profile-bio { - color: #6c757d; - opacity: 0.8; - margin-bottom: 2rem; - text-align: center; - line-height: 1.6; -} - -.links-container { - margin-bottom: 2rem; -} - -.link-button { - background-color: #007bff; - color: white; - border: none; - padding: 1rem 2rem; - border-radius: 50px; - text-decoration: none; - display: block; - margin-bottom: 1rem; - text-align: center; - font-weight: 500; - transition: all 0.3s ease; - box-shadow: 0 4px 15px rgba(0, 0, 0, 0.1); - position: relative; - overflow: hidden; -} - -.link-button:hover { - background-color: #0056b3; - transform: translateY(-2px); - box-shadow: 0 6px 20px rgba(0, 0, 0, 0.15); - color: white; - text-decoration: none; -} - -.link-button::before { - content: ''; - position: absolute; - top: 0; - left: -100%; - width: 100%; - height: 100%; - background: linear-gradient(90deg, transparent, rgba(255,255,255,0.2), transparent); - transition: left 0.5s; -} - -.link-button:hover::before { - left: 100%; -} - -.link-icon { - font-size: 1.2rem; - margin-right: 0.5rem; -} - -.link-title { - font-size: 1.1rem; - margin-bottom: 0.25rem; - font-weight: 600; -} - -.link-description { - font-size: 0.9rem; - opacity: 0.9; - font-weight: 400; -} - -.profile-footer { - text-align: center; - border-top: 1px solid rgba(108, 117, 125, 0.2); - padding-top: 1rem; - margin-top: 2rem; -} - -.profile-footer a { - color: #007bff; - font-weight: 500; -} - -.profile-footer a:hover { - text-decoration: underline; -} - -/* Theme Variables (will be overridden by dynamic CSS) */ -:root { - --primary-color: #007bff; - --secondary-color: #0056b3; - --background-color: #ffffff; - --text-color: #212529; - --card-background: rgba(255, 255, 255, 0.95); -} - -/* Responsive Design */ -@media (max-width: 768px) { - .profile-card { - padding: 1.5rem; - margin: 1rem; - border-radius: 15px; - } - - .profile-image, - .profile-image-placeholder { - width: 100px; - height: 100px; - } - - .profile-image-small { - width: 50px; - height: 50px; - } - - .profile-header { - padding: 0.75rem; - } - - .profile-name { - font-size: 1.75rem; - } - - .link-button { - padding: 0.875rem 1.5rem; - font-size: 0.95rem; - } - - .link-title { - font-size: 1rem; - } - - .link-description { - font-size: 0.85rem; - } -} - -@media (max-width: 480px) { - .profile-card { - padding: 1rem; - margin: 0.5rem; - } - - .profile-name { - font-size: 1.5rem; - } - - .link-button { - padding: 0.75rem 1rem; - font-size: 0.9rem; - } -} - -/* Dark theme support */ -@media (prefers-color-scheme: dark) { - .user-page { - background-color: #121212; - color: #ffffff; - } - - .profile-card { - background-color: rgba(33, 37, 41, 0.95); - color: #ffffff; - } - - .profile-bio { - color: #adb5bd; - } - - .profile-footer { - border-top-color: rgba(255, 255, 255, 0.2); - } -} - -/* Print styles */ -@media print { - .user-page { - background: white !important; - color: black !important; - } - - .profile-card { - background: white !important; - color: black !important; - box-shadow: none !important; - border: 1px solid #ccc; - } - - .link-button { - background: white !important; - color: black !important; - border: 1px solid #ccc !important; - page-break-inside: avoid; - } -} - -/* Loading states */ -.link-button.loading { - pointer-events: none; - opacity: 0.7; -} - -.link-button.loading::after { - content: ''; - position: absolute; - top: 50%; - left: 50%; - width: 20px; - height: 20px; - margin: -10px 0 0 -10px; - border: 2px solid rgba(255,255,255,.3); - border-radius: 50%; - border-top-color: #fff; - animation: spin 1s ease-in-out infinite; -} - -@keyframes spin { - to { transform: rotate(360deg); } -} - -/* Accessibility improvements */ -.link-button:focus { - outline: 2px solid #fff; - outline-offset: 2px; -} - -@media (prefers-reduced-motion: reduce) { - .link-button, - .link-button::before { - transition: none; - } - - .link-button.loading::after { - animation: none; - } -} - -/* High contrast mode support */ -@media (prefers-contrast: high) { - .profile-card { - border: 2px solid; - } - - .link-button { - border: 2px solid; - } +/* User Page Specific Styles */ +.user-page { + min-height: 100vh; + background-color: #ffffff; + background-size: cover; + background-position: center; + background-attachment: fixed; +} + +.profile-card { + background-color: rgba(255, 255, 255, 0.95); + backdrop-filter: blur(10px); + border-radius: 20px; + padding: 2rem; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1); + max-width: 480px; + margin: 0 auto; +} + +.profile-image { + width: 120px; + height: 120px; + border-radius: 50%; + border: 4px solid #007bff; + object-fit: cover; + display: block; + margin: 0 auto; +} + +.profile-image-small { + width: 60px; + height: 60px; + border-radius: 50%; + border: 3px solid #007bff; + object-fit: cover; + flex-shrink: 0; +} + +.profile-header { + padding: 1rem; + background: rgba(255, 255, 255, 0.1); + border-radius: 15px; + backdrop-filter: blur(10px); +} + +.profile-image-placeholder { + width: 120px; + height: 120px; + border-radius: 50%; + border: 4px solid #007bff; + background-color: #f8f9fa; + color: #6c757d; +} + +.profile-name { + color: #007bff; + font-size: 2rem; + font-weight: 600; + margin-bottom: 0.5rem; + text-align: center; +} + +.profile-bio { + color: #6c757d; + opacity: 0.8; + margin-bottom: 2rem; + text-align: center; + line-height: 1.6; +} + +.links-container { + margin-bottom: 2rem; +} + +.link-button { + background-color: #007bff; + color: white; + border: none; + padding: 1rem 2rem; + border-radius: 50px; + text-decoration: none; + display: block; + margin-bottom: 1rem; + text-align: center; + font-weight: 500; + transition: all 0.3s ease; + box-shadow: 0 4px 15px rgba(0, 0, 0, 0.1); + position: relative; + overflow: hidden; +} + +.link-button:hover { + background-color: #0056b3; + transform: translateY(-2px); + box-shadow: 0 6px 20px rgba(0, 0, 0, 0.15); + color: white; + text-decoration: none; +} + +.link-button::before { + content: ''; + position: absolute; + top: 0; + left: -100%; + width: 100%; + height: 100%; + background: linear-gradient(90deg, transparent, rgba(255,255,255,0.2), transparent); + transition: left 0.5s; +} + +.link-button:hover::before { + left: 100%; +} + +.link-icon { + font-size: 1.2rem; + margin-right: 0.5rem; +} + +.link-title { + font-size: 1.1rem; + margin-bottom: 0.25rem; + font-weight: 600; +} + +.link-description { + font-size: 0.9rem; + opacity: 0.9; + font-weight: 400; +} + +.profile-footer { + text-align: center; + border-top: 1px solid rgba(108, 117, 125, 0.2); + padding-top: 1rem; + margin-top: 2rem; +} + +.profile-footer a { + color: #007bff; + font-weight: 500; +} + +.profile-footer a:hover { + text-decoration: underline; +} + +/* Theme Variables (will be overridden by dynamic CSS) */ +:root { + --primary-color: #007bff; + --secondary-color: #0056b3; + --background-color: #ffffff; + --text-color: #212529; + --card-background: rgba(255, 255, 255, 0.95); +} + +/* Responsive Design */ +@media (max-width: 768px) { + .profile-card { + padding: 1.5rem; + margin: 1rem; + border-radius: 15px; + } + + .profile-image, + .profile-image-placeholder { + width: 100px; + height: 100px; + } + + .profile-image-small { + width: 50px; + height: 50px; + } + + .profile-header { + padding: 0.75rem; + } + + .profile-name { + font-size: 1.75rem; + } + + .link-button { + padding: 0.875rem 1.5rem; + font-size: 0.95rem; + } + + .link-title { + font-size: 1rem; + } + + .link-description { + font-size: 0.85rem; + } +} + +@media (max-width: 480px) { + .profile-card { + padding: 1rem; + margin: 0.5rem; + } + + .profile-name { + font-size: 1.5rem; + } + + .link-button { + padding: 0.75rem 1rem; + font-size: 0.9rem; + } +} + +/* Dark theme support */ +@media (prefers-color-scheme: dark) { + .user-page { + background-color: #121212; + color: #ffffff; + } + + .profile-card { + background-color: rgba(33, 37, 41, 0.95); + color: #ffffff; + } + + .profile-bio { + color: #adb5bd; + } + + .profile-footer { + border-top-color: rgba(255, 255, 255, 0.2); + } +} + +/* Print styles */ +@media print { + .user-page { + background: white !important; + color: black !important; + } + + .profile-card { + background: white !important; + color: black !important; + box-shadow: none !important; + border: 1px solid #ccc; + } + + .link-button { + background: white !important; + color: black !important; + border: 1px solid #ccc !important; + page-break-inside: avoid; + } +} + +/* Loading states */ +.link-button.loading { + pointer-events: none; + opacity: 0.7; +} + +.link-button.loading::after { + content: ''; + position: absolute; + top: 50%; + left: 50%; + width: 20px; + height: 20px; + margin: -10px 0 0 -10px; + border: 2px solid rgba(255,255,255,.3); + border-radius: 50%; + border-top-color: #fff; + animation: spin 1s ease-in-out infinite; +} + +@keyframes spin { + to { transform: rotate(360deg); } +} + +/* Accessibility improvements */ +.link-button:focus { + outline: 2px solid #fff; + outline-offset: 2px; +} + +@media (prefers-reduced-motion: reduce) { + .link-button, + .link-button::before { + transition: none; + } + + .link-button.loading::after { + animation: none; + } +} + +/* High contrast mode support */ +@media (prefers-contrast: high) { + .profile-card { + border: 2px solid; + } + + .link-button { + border: 2px solid; + } } \ No newline at end of file diff --git a/src/BCards.Web/wwwroot/images/default-avatar.svg b/src/BCards.Web/wwwroot/images/default-avatar.svg index dfa8d80..043ef0c 100644 --- a/src/BCards.Web/wwwroot/images/default-avatar.svg +++ b/src/BCards.Web/wwwroot/images/default-avatar.svg @@ -1,11 +1,11 @@ - - - - - - - - - - + + + + + + + + + + \ No newline at end of file diff --git a/tests/BCards.Tests/BCards.Tests.csproj b/tests/BCards.Tests/BCards.Tests.csproj index f1c2cb5..e9c3447 100644 --- a/tests/BCards.Tests/BCards.Tests.csproj +++ b/tests/BCards.Tests/BCards.Tests.csproj @@ -1,43 +1,43 @@ - - - - net8.0 - enable - enable - false - true - - - - - - - runtime; build; native; contentfiles; analyzers; buildtransitive - all - - - runtime; build; native; contentfiles; analyzers; buildtransitive - all - - - - - - - - - - - - - - - - - - - Always - - - + + + + net8.0 + enable + enable + false + true + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + + + + + + + + + + + Always + + + \ No newline at end of file diff --git a/tests/BCards.Tests/Fixtures/BCardsWebApplicationFactory.cs b/tests/BCards.Tests/Fixtures/BCardsWebApplicationFactory.cs index 561c0f8..df73274 100644 --- a/tests/BCards.Tests/Fixtures/BCardsWebApplicationFactory.cs +++ b/tests/BCards.Tests/Fixtures/BCardsWebApplicationFactory.cs @@ -1,95 +1,95 @@ -using Microsoft.AspNetCore.Hosting; -using Microsoft.AspNetCore.Mvc.Testing; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; -using MongoDB.Driver; -using BCards.Web.Configuration; - -namespace BCards.Tests.Fixtures; - -public class BCardsWebApplicationFactory : WebApplicationFactory where TStartup : class -{ - public IMongoDatabase TestDatabase { get; private set; } = null!; - - protected override void ConfigureWebHost(IWebHostBuilder builder) - { - builder.ConfigureAppConfiguration((context, config) => - { - // Remove existing configuration and add test configuration - config.Sources.Clear(); - config.AddJsonFile("appsettings.Testing.json"); - config.AddEnvironmentVariables(); - }); - - builder.ConfigureServices(services => - { - // Remove the existing MongoDB registration - var mongoDescriptor = services.SingleOrDefault( - d => d.ServiceType == typeof(IMongoDatabase)); - if (mongoDescriptor != null) - { - services.Remove(mongoDescriptor); - } - - var mongoClientDescriptor = services.SingleOrDefault( - d => d.ServiceType == typeof(IMongoClient)); - if (mongoClientDescriptor != null) - { - services.Remove(mongoClientDescriptor); - } - - // Add test MongoDB client and database - services.AddSingleton(serviceProvider => - { - var testConnectionString = "mongodb://localhost:27017"; - return new MongoClient(testConnectionString); - }); - - services.AddScoped(serviceProvider => - { - var client = serviceProvider.GetRequiredService(); - var databaseName = $"bcards_test_{Guid.NewGuid():N}"; - TestDatabase = client.GetDatabase(databaseName); - return TestDatabase; - }); - - // Configure test Stripe settings - services.Configure(options => - { - options.PublishableKey = "pk_test_fake_key_for_testing"; - options.SecretKey = "sk_test_fake_key_for_testing"; - options.WebhookSecret = "whsec_fake_webhook_secret_for_testing"; - }); - }); - - builder.UseEnvironment("Testing"); - - // Suppress logs during testing to reduce noise - builder.ConfigureLogging(logging => - { - logging.ClearProviders(); - logging.AddConsole(); - logging.SetMinimumLevel(LogLevel.Warning); - }); - } - - protected override void Dispose(bool disposing) - { - if (disposing && TestDatabase != null) - { - // Clean up test database - try - { - var client = TestDatabase.Client; - client.DropDatabase(TestDatabase.DatabaseNamespace.DatabaseName); - } - catch (Exception) - { - // Ignore cleanup errors - } - } - - base.Dispose(disposing); - } +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using MongoDB.Driver; +using BCards.Web.Configuration; + +namespace BCards.Tests.Fixtures; + +public class BCardsWebApplicationFactory : WebApplicationFactory where TStartup : class +{ + public IMongoDatabase TestDatabase { get; private set; } = null!; + + protected override void ConfigureWebHost(IWebHostBuilder builder) + { + builder.ConfigureAppConfiguration((context, config) => + { + // Remove existing configuration and add test configuration + config.Sources.Clear(); + config.AddJsonFile("appsettings.Testing.json"); + config.AddEnvironmentVariables(); + }); + + builder.ConfigureServices(services => + { + // Remove the existing MongoDB registration + var mongoDescriptor = services.SingleOrDefault( + d => d.ServiceType == typeof(IMongoDatabase)); + if (mongoDescriptor != null) + { + services.Remove(mongoDescriptor); + } + + var mongoClientDescriptor = services.SingleOrDefault( + d => d.ServiceType == typeof(IMongoClient)); + if (mongoClientDescriptor != null) + { + services.Remove(mongoClientDescriptor); + } + + // Add test MongoDB client and database + services.AddSingleton(serviceProvider => + { + var testConnectionString = "mongodb://localhost:27017"; + return new MongoClient(testConnectionString); + }); + + services.AddScoped(serviceProvider => + { + var client = serviceProvider.GetRequiredService(); + var databaseName = $"bcards_test_{Guid.NewGuid():N}"; + TestDatabase = client.GetDatabase(databaseName); + return TestDatabase; + }); + + // Configure test Stripe settings + services.Configure(options => + { + options.PublishableKey = "pk_test_fake_key_for_testing"; + options.SecretKey = "sk_test_fake_key_for_testing"; + options.WebhookSecret = "whsec_fake_webhook_secret_for_testing"; + }); + }); + + builder.UseEnvironment("Testing"); + + // Suppress logs during testing to reduce noise + builder.ConfigureLogging(logging => + { + logging.ClearProviders(); + logging.AddConsole(); + logging.SetMinimumLevel(LogLevel.Warning); + }); + } + + protected override void Dispose(bool disposing) + { + if (disposing && TestDatabase != null) + { + // Clean up test database + try + { + var client = TestDatabase.Client; + client.DropDatabase(TestDatabase.DatabaseNamespace.DatabaseName); + } + catch (Exception) + { + // Ignore cleanup errors + } + } + + base.Dispose(disposing); + } } \ No newline at end of file diff --git a/tests/BCards.Tests/Fixtures/DatabaseFixture.cs b/tests/BCards.Tests/Fixtures/DatabaseFixture.cs index 420e95d..8a12e9c 100644 --- a/tests/BCards.Tests/Fixtures/DatabaseFixture.cs +++ b/tests/BCards.Tests/Fixtures/DatabaseFixture.cs @@ -1,142 +1,142 @@ -using MongoDB.Driver; -using BCards.Web.Models; -using BCards.Web.Repositories; - -namespace BCards.Tests.Fixtures; - -public class DatabaseFixture : IDisposable -{ - public IMongoDatabase Database { get; } - public IUserRepository UserRepository { get; } - public IUserPageRepository UserPageRepository { get; } - public ICategoryRepository CategoryRepository { get; } - - public DatabaseFixture(IMongoDatabase database) - { - Database = database; - UserRepository = new UserRepository(database); - UserPageRepository = new UserPageRepository(database); - CategoryRepository = new CategoryRepository(database); - - InitializeTestData().Wait(); - } - - private async Task InitializeTestData() - { - // Clear any existing data - await Database.DropCollectionAsync("users"); - await Database.DropCollectionAsync("userpages"); - await Database.DropCollectionAsync("categories"); - - // Initialize test categories - var categories = new List - { - new Category { Id = "tech", Name = "Tecnologia", Description = "Tecnologia e inovação" }, - new Category { Id = "business", Name = "Negócios", Description = "Empresas e negócios" }, - new Category { Id = "personal", Name = "Pessoal", Description = "Páginas pessoais" } - }; - - await CategoryRepository.CreateManyAsync(categories); - } - - public async Task CreateTestUser(PlanType planType = PlanType.Trial, string? email = null) - { - var user = new User - { - Id = Guid.NewGuid().ToString(), - Email = email ?? $"test-{Guid.NewGuid():N}@example.com", - Name = "Test User", - CurrentPlan = planType.ToString(), - CreatedAt = DateTime.UtcNow, - UpdatedAt = DateTime.UtcNow, - IsActive = true - }; - - await UserRepository.CreateAsync(user); - return user; - } - - public async Task CreateTestUserPage(string userId, string category = "tech", int linkCount = 1, int productLinkCount = 0) - { - var userPage = new UserPage - { - Id = Guid.NewGuid().ToString(), - UserId = userId, - DisplayName = "Test Page", - Category = category, - Slug = $"test-page-{Guid.NewGuid():N}", - Bio = "Test page description", - Status = BCards.Web.ViewModels.PageStatus.Active, - Links = new List(), - CreatedAt = DateTime.UtcNow, - UpdatedAt = DateTime.UtcNow - }; - - // Add normal links - for (int i = 0; i < linkCount; i++) - { - userPage.Links.Add(new LinkItem - { - Title = $"Test Link {i + 1}", - Url = $"https://example.com/link{i + 1}", - Description = $"Test link {i + 1} description", - Icon = "fas fa-link", - IsActive = true, - Order = i, - Type = LinkType.Normal - }); - } - - // Add product links - for (int i = 0; i < productLinkCount; i++) - { - userPage.Links.Add(new LinkItem - { - Title = $"Test Product {i + 1}", - Url = $"https://example.com/product{i + 1}", - Description = $"Test product {i + 1} description", - Icon = "fas fa-shopping-cart", - IsActive = true, - Order = linkCount + i, - Type = LinkType.Product, - ProductTitle = $"Product {i + 1}", - ProductPrice = "R$ 99,90", - ProductDescription = $"Amazing product {i + 1}" - }); - } - - await UserPageRepository.CreateAsync(userPage); - return userPage; - } - - public async Task CleanDatabase() - { - var collections = new[] { "users", "userpages", "categories", "livepages" }; - - foreach (var collection in collections) - { - try - { - await Database.DropCollectionAsync(collection); - } - catch (Exception) - { - // Ignore errors when collection doesn't exist - } - } - - await InitializeTestData(); - } - - public void Dispose() - { - try - { - Database.Client.DropDatabase(Database.DatabaseNamespace.DatabaseName); - } - catch (Exception) - { - // Ignore cleanup errors - } - } +using MongoDB.Driver; +using BCards.Web.Models; +using BCards.Web.Repositories; + +namespace BCards.Tests.Fixtures; + +public class DatabaseFixture : IDisposable +{ + public IMongoDatabase Database { get; } + public IUserRepository UserRepository { get; } + public IUserPageRepository UserPageRepository { get; } + public ICategoryRepository CategoryRepository { get; } + + public DatabaseFixture(IMongoDatabase database) + { + Database = database; + UserRepository = new UserRepository(database); + UserPageRepository = new UserPageRepository(database); + CategoryRepository = new CategoryRepository(database); + + InitializeTestData().Wait(); + } + + private async Task InitializeTestData() + { + // Clear any existing data + await Database.DropCollectionAsync("users"); + await Database.DropCollectionAsync("userpages"); + await Database.DropCollectionAsync("categories"); + + // Initialize test categories + var categories = new List + { + new Category { Id = "tech", Name = "Tecnologia", Description = "Tecnologia e inovação" }, + new Category { Id = "business", Name = "Negócios", Description = "Empresas e negócios" }, + new Category { Id = "personal", Name = "Pessoal", Description = "Páginas pessoais" } + }; + + await CategoryRepository.CreateManyAsync(categories); + } + + public async Task CreateTestUser(PlanType planType = PlanType.Trial, string? email = null) + { + var user = new User + { + Id = Guid.NewGuid().ToString(), + Email = email ?? $"test-{Guid.NewGuid():N}@example.com", + Name = "Test User", + CurrentPlan = planType.ToString(), + CreatedAt = DateTime.UtcNow, + UpdatedAt = DateTime.UtcNow, + IsActive = true + }; + + await UserRepository.CreateAsync(user); + return user; + } + + public async Task CreateTestUserPage(string userId, string category = "tech", int linkCount = 1, int productLinkCount = 0) + { + var userPage = new UserPage + { + Id = Guid.NewGuid().ToString(), + UserId = userId, + DisplayName = "Test Page", + Category = category, + Slug = $"test-page-{Guid.NewGuid():N}", + Bio = "Test page description", + Status = BCards.Web.ViewModels.PageStatus.Active, + Links = new List(), + CreatedAt = DateTime.UtcNow, + UpdatedAt = DateTime.UtcNow + }; + + // Add normal links + for (int i = 0; i < linkCount; i++) + { + userPage.Links.Add(new LinkItem + { + Title = $"Test Link {i + 1}", + Url = $"https://example.com/link{i + 1}", + Description = $"Test link {i + 1} description", + Icon = "fas fa-link", + IsActive = true, + Order = i, + Type = LinkType.Normal + }); + } + + // Add product links + for (int i = 0; i < productLinkCount; i++) + { + userPage.Links.Add(new LinkItem + { + Title = $"Test Product {i + 1}", + Url = $"https://example.com/product{i + 1}", + Description = $"Test product {i + 1} description", + Icon = "fas fa-shopping-cart", + IsActive = true, + Order = linkCount + i, + Type = LinkType.Product, + ProductTitle = $"Product {i + 1}", + ProductPrice = "R$ 99,90", + ProductDescription = $"Amazing product {i + 1}" + }); + } + + await UserPageRepository.CreateAsync(userPage); + return userPage; + } + + public async Task CleanDatabase() + { + var collections = new[] { "users", "userpages", "categories", "livepages" }; + + foreach (var collection in collections) + { + try + { + await Database.DropCollectionAsync(collection); + } + catch (Exception) + { + // Ignore errors when collection doesn't exist + } + } + + await InitializeTestData(); + } + + public void Dispose() + { + try + { + Database.Client.DropDatabase(Database.DatabaseNamespace.DatabaseName); + } + catch (Exception) + { + // Ignore cleanup errors + } + } } \ No newline at end of file diff --git a/tests/BCards.Tests/appsettings.Testing.json b/tests/BCards.Tests/appsettings.Testing.json index fd00744..6fb3cd1 100644 --- a/tests/BCards.Tests/appsettings.Testing.json +++ b/tests/BCards.Tests/appsettings.Testing.json @@ -1,39 +1,39 @@ -{ - "ConnectionStrings": { - "DefaultConnection": "mongodb://localhost:27017/bcards_test" - }, - "MongoDb": { - "ConnectionString": "mongodb://localhost:27017", - "DatabaseName": "bcards_test" - }, - "Stripe": { - "PublishableKey": "pk_test_fake_key_for_testing", - "SecretKey": "sk_test_fake_key_for_testing", - "WebhookSecret": "whsec_fake_webhook_secret_for_testing" - }, - "Authentication": { - "Google": { - "ClientId": "fake-google-client-id", - "ClientSecret": "fake-google-client-secret" - }, - "Microsoft": { - "ClientId": "fake-microsoft-client-id", - "ClientSecret": "fake-microsoft-client-secret" - } - }, - "SendGrid": { - "ApiKey": "fake-sendgrid-api-key" - }, - "Moderation": { - "RequireApproval": false, - "AuthKey": "test-moderation-key", - "MaxPendingPages": 1000 - }, - "Logging": { - "LogLevel": { - "Default": "Warning", - "Microsoft.AspNetCore": "Warning", - "BCards": "Information" - } - } +{ + "ConnectionStrings": { + "DefaultConnection": "mongodb://localhost:27017/bcards_test" + }, + "MongoDb": { + "ConnectionString": "mongodb://localhost:27017", + "DatabaseName": "bcards_test" + }, + "Stripe": { + "PublishableKey": "pk_test_fake_key_for_testing", + "SecretKey": "sk_test_fake_key_for_testing", + "WebhookSecret": "whsec_fake_webhook_secret_for_testing" + }, + "Authentication": { + "Google": { + "ClientId": "fake-google-client-id", + "ClientSecret": "fake-google-client-secret" + }, + "Microsoft": { + "ClientId": "fake-microsoft-client-id", + "ClientSecret": "fake-microsoft-client-secret" + } + }, + "SendGrid": { + "ApiKey": "fake-sendgrid-api-key" + }, + "Moderation": { + "RequireApproval": false, + "AuthKey": "test-moderation-key", + "MaxPendingPages": 1000 + }, + "Logging": { + "LogLevel": { + "Default": "Warning", + "Microsoft.AspNetCore": "Warning", + "BCards": "Information" + } + } } \ No newline at end of file