fix: Increase session timeout to 7 days and set SameSite=None for Cloudflare compatibility
All checks were successful
BCards Deployment Pipeline / Run Tests (push) Successful in 4s
BCards Deployment Pipeline / PR Validation (push) Has been skipped
BCards Deployment Pipeline / Build and Push Image (push) Successful in 15m22s
BCards Deployment Pipeline / Deploy to Production (ARM - OCI) (push) Successful in 1m54s
BCards Deployment Pipeline / Deploy to Test (x86 - Local) (push) Has been skipped
BCards Deployment Pipeline / Cleanup Old Resources (push) Has been skipped
BCards Deployment Pipeline / Deployment Summary (push) Successful in 0s
All checks were successful
BCards Deployment Pipeline / Run Tests (push) Successful in 4s
BCards Deployment Pipeline / PR Validation (push) Has been skipped
BCards Deployment Pipeline / Build and Push Image (push) Successful in 15m22s
BCards Deployment Pipeline / Deploy to Production (ARM - OCI) (push) Successful in 1m54s
BCards Deployment Pipeline / Deploy to Test (x86 - Local) (push) Has been skipped
BCards Deployment Pipeline / Cleanup Old Resources (push) Has been skipped
BCards Deployment Pipeline / Deployment Summary (push) Successful in 0s
🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
e043c853b1
commit
3d2ce1f8cf
@ -26,7 +26,8 @@
|
||||
"Bash(sed:*)",
|
||||
"Bash(./clean-build.sh:*)",
|
||||
"Bash(git add:*)",
|
||||
"Bash(scp:*)"
|
||||
"Bash(scp:*)",
|
||||
"Bash(ssh:*)"
|
||||
]
|
||||
},
|
||||
"enableAllProjectMcpServers": false
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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"
|
||||
@ -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 }}"
|
||||
96
BCards.sln
96
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
|
||||
|
||||
490
CLAUDE.md
490
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.
|
||||
@ -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
|
||||
|
||||
76
Dockerfile
76
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"]
|
||||
@ -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"]
|
||||
|
||||
@ -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 <noreply@anthropic.com>"
|
||||
|
||||
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 <noreply@anthropic.com>"
|
||||
|
||||
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! 🎉
|
||||
@ -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 <noreply@anthropic.com>"
|
||||
|
||||
# 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 <noreply@anthropic.com>"
|
||||
|
||||
# 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!** 🎉
|
||||
@ -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"
|
||||
]
|
||||
}
|
||||
902
categorias.json
902
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
|
||||
}
|
||||
]
|
||||
@ -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
|
||||
}
|
||||
@ -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
|
||||
@ -1,369 +1,369 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Deploy script for Release environment with multi-architecture support
|
||||
# Usage: ./deploy-release.sh <IMAGE_TAG>
|
||||
|
||||
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 <IMAGE_TAG>"
|
||||
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/tcp/$mongodb_host/$mongodb_port" 2>/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 <IMAGE_TAG>
|
||||
|
||||
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 <IMAGE_TAG>"
|
||||
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/tcp/$mongodb_host/$mongodb_port" 2>/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 "$@"
|
||||
@ -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/tcp/$MONGODB_HOST/$MONGODB_PORT" 2>/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/tcp/$MONGODB_HOST/$MONGODB_PORT' 2>/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/tcp/$MONGODB_HOST/$MONGODB_PORT" 2>/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/tcp/$MONGODB_HOST/$MONGODB_PORT' 2>/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 "$@"
|
||||
@ -1,45 +1,45 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.9.0" />
|
||||
<PackageReference Include="xunit" Version="2.6.6" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.5.6">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="coverlet.collector" Version="6.0.1">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="8.0.0" />
|
||||
<PackageReference Include="PuppeteerSharp" Version="13.0.2" />
|
||||
<PackageReference Include="MongoDB.Driver" Version="3.4.2" />
|
||||
<PackageReference Include="Testcontainers.MongoDb" Version="3.6.0" />
|
||||
<PackageReference Include="Stripe.net" Version="48.4.0" />
|
||||
<PackageReference Include="FluentAssertions" Version="6.12.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration" Version="8.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="8.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging" Version="8.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Http" Version="8.0.0" />
|
||||
<PackageReference Include="Moq" Version="4.20.70" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\BCards.Web\BCards.Web.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<None Update="appsettings.Testing.json">
|
||||
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
|
||||
</None>
|
||||
</ItemGroup>
|
||||
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.9.0" />
|
||||
<PackageReference Include="xunit" Version="2.6.6" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.5.6">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="coverlet.collector" Version="6.0.1">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="8.0.0" />
|
||||
<PackageReference Include="PuppeteerSharp" Version="13.0.2" />
|
||||
<PackageReference Include="MongoDB.Driver" Version="3.4.2" />
|
||||
<PackageReference Include="Testcontainers.MongoDb" Version="3.6.0" />
|
||||
<PackageReference Include="Stripe.net" Version="48.4.0" />
|
||||
<PackageReference Include="FluentAssertions" Version="6.12.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration" Version="8.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="8.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging" Version="8.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Http" Version="8.0.0" />
|
||||
<PackageReference Include="Moq" Version="4.20.70" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\BCards.Web\BCards.Web.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<None Update="appsettings.Testing.json">
|
||||
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
|
||||
</None>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@ -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<Program>, 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<string, string?>
|
||||
{
|
||||
["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<IMongoClient>(serviceProvider =>
|
||||
{
|
||||
return new MongoClient(_mongoContainer.GetConnectionString());
|
||||
});
|
||||
|
||||
services.AddScoped(serviceProvider =>
|
||||
{
|
||||
var client = serviceProvider.GetRequiredService<IMongoClient>();
|
||||
TestDatabase = client.GetDatabase(TestDatabaseName);
|
||||
return TestDatabase;
|
||||
});
|
||||
|
||||
// Override Stripe settings for testing
|
||||
services.Configure<StripeSettings>(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<IEmailService, MockEmailService>();
|
||||
});
|
||||
|
||||
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<bool> 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<Program>, 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<string, string?>
|
||||
{
|
||||
["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<IMongoClient>(serviceProvider =>
|
||||
{
|
||||
return new MongoClient(_mongoContainer.GetConnectionString());
|
||||
});
|
||||
|
||||
services.AddScoped(serviceProvider =>
|
||||
{
|
||||
var client = serviceProvider.GetRequiredService<IMongoClient>();
|
||||
TestDatabase = client.GetDatabase(TestDatabaseName);
|
||||
return TestDatabase;
|
||||
});
|
||||
|
||||
// Override Stripe settings for testing
|
||||
services.Configure<StripeSettings>(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<IEmailService, MockEmailService>();
|
||||
});
|
||||
|
||||
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<bool> SendEmailAsync(string to, string subject, string htmlContent)
|
||||
{
|
||||
return Task.FromResult(true);
|
||||
}
|
||||
}
|
||||
@ -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<Category>
|
||||
{
|
||||
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<User> 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<UserPage> 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<LinkItem>(),
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
UpdatedAt = DateTime.UtcNow,
|
||||
ModerationAttempts = 0,
|
||||
ModerationHistory = new List<ModerationHistory>()
|
||||
};
|
||||
|
||||
// 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<User> 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<List<UserPage>> GetUserPagesAsync(string userId)
|
||||
{
|
||||
var filter = Builders<UserPage>.Filter.Eq(p => p.UserId, userId);
|
||||
var pages = await UserPageRepository.GetManyAsync(filter);
|
||||
return pages.ToList();
|
||||
}
|
||||
|
||||
public async Task<UserPage?> GetUserPageAsync(string category, string slug)
|
||||
{
|
||||
var filter = Builders<UserPage>.Filter.And(
|
||||
Builders<UserPage>.Filter.Eq(p => p.Category, category),
|
||||
Builders<UserPage>.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<Category>
|
||||
{
|
||||
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<User> 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<UserPage> 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<LinkItem>(),
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
UpdatedAt = DateTime.UtcNow,
|
||||
ModerationAttempts = 0,
|
||||
ModerationHistory = new List<ModerationHistory>()
|
||||
};
|
||||
|
||||
// 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<User> 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<List<UserPage>> GetUserPagesAsync(string userId)
|
||||
{
|
||||
var filter = Builders<UserPage>.Filter.Eq(p => p.UserId, userId);
|
||||
var pages = await UserPageRepository.GetManyAsync(filter);
|
||||
return pages.ToList();
|
||||
}
|
||||
|
||||
public async Task<UserPage?> GetUserPageAsync(string category, string slug)
|
||||
{
|
||||
var filter = Builders<UserPage>.Filter.And(
|
||||
Builders<UserPage>.Filter.Eq(p => p.Category, category),
|
||||
Builders<UserPage>.Filter.Eq(p => p.Slug, slug)
|
||||
);
|
||||
|
||||
var pages = await UserPageRepository.GetManyAsync(filter);
|
||||
return pages.FirstOrDefault();
|
||||
}
|
||||
}
|
||||
@ -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<HttpClient> CreateAuthenticatedClientAsync(
|
||||
WebApplicationFactory<Program> factory,
|
||||
User testUser)
|
||||
{
|
||||
var client = factory.WithWebHostBuilder(builder =>
|
||||
{
|
||||
builder.ConfigureServices(services =>
|
||||
{
|
||||
services.AddAuthentication("Test")
|
||||
.AddScheme<AuthenticationSchemeOptions, TestAuthenticationHandler>(
|
||||
"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<Claim>
|
||||
{
|
||||
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<AuthenticationSchemeOptions>
|
||||
{
|
||||
public TestAuthenticationHandler(IOptionsMonitor<AuthenticationSchemeOptions> options,
|
||||
ILoggerFactory logger, UrlEncoder encoder)
|
||||
: base(options, logger, encoder)
|
||||
{
|
||||
}
|
||||
|
||||
protected override Task<AuthenticateResult> 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<Claim>
|
||||
{
|
||||
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<HttpClient> CreateAuthenticatedClientAsync(
|
||||
WebApplicationFactory<Program> factory,
|
||||
User testUser)
|
||||
{
|
||||
var client = factory.WithWebHostBuilder(builder =>
|
||||
{
|
||||
builder.ConfigureServices(services =>
|
||||
{
|
||||
services.AddAuthentication("Test")
|
||||
.AddScheme<AuthenticationSchemeOptions, TestAuthenticationHandler>(
|
||||
"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<Claim>
|
||||
{
|
||||
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<AuthenticationSchemeOptions>
|
||||
{
|
||||
public TestAuthenticationHandler(IOptionsMonitor<AuthenticationSchemeOptions> options,
|
||||
ILoggerFactory logger, UrlEncoder encoder)
|
||||
: base(options, logger, encoder)
|
||||
{
|
||||
}
|
||||
|
||||
protected override Task<AuthenticateResult> 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<Claim>
|
||||
{
|
||||
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));
|
||||
}
|
||||
}
|
||||
@ -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<Program> 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<string> GetPageContentAsync()
|
||||
{
|
||||
return await Page.GetContentAsync();
|
||||
}
|
||||
|
||||
public async Task<string> GetPageTitleAsync()
|
||||
{
|
||||
return await Page.GetTitleAsync();
|
||||
}
|
||||
|
||||
public async Task<bool> 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<string, string> 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<string> GetElementTextAsync(string selector)
|
||||
{
|
||||
await Page.WaitForSelectorAsync(selector);
|
||||
var element = await Page.QuerySelectorAsync(selector);
|
||||
var text = await Page.EvaluateFunctionAsync<string>("el => el.textContent", element);
|
||||
return text?.Trim() ?? string.Empty;
|
||||
}
|
||||
|
||||
public async Task<string> GetElementValueAsync(string selector)
|
||||
{
|
||||
await Page.WaitForSelectorAsync(selector);
|
||||
var element = await Page.QuerySelectorAsync(selector);
|
||||
var value = await Page.EvaluateFunctionAsync<string>("el => el.value", element);
|
||||
return value ?? string.Empty;
|
||||
}
|
||||
|
||||
public async Task<bool> 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<string> GetCurrentUrlAsync()
|
||||
{
|
||||
return Task.FromResult(Page.Url);
|
||||
}
|
||||
|
||||
public async Task<List<string>> GetAllElementTextsAsync(string selector)
|
||||
{
|
||||
var elements = await Page.QuerySelectorAllAsync(selector);
|
||||
var texts = new List<string>();
|
||||
|
||||
foreach (var element in elements)
|
||||
{
|
||||
var text = await Page.EvaluateFunctionAsync<string>("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<Program> 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<string> GetPageContentAsync()
|
||||
{
|
||||
return await Page.GetContentAsync();
|
||||
}
|
||||
|
||||
public async Task<string> GetPageTitleAsync()
|
||||
{
|
||||
return await Page.GetTitleAsync();
|
||||
}
|
||||
|
||||
public async Task<bool> 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<string, string> 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<string> GetElementTextAsync(string selector)
|
||||
{
|
||||
await Page.WaitForSelectorAsync(selector);
|
||||
var element = await Page.QuerySelectorAsync(selector);
|
||||
var text = await Page.EvaluateFunctionAsync<string>("el => el.textContent", element);
|
||||
return text?.Trim() ?? string.Empty;
|
||||
}
|
||||
|
||||
public async Task<string> GetElementValueAsync(string selector)
|
||||
{
|
||||
await Page.WaitForSelectorAsync(selector);
|
||||
var element = await Page.QuerySelectorAsync(selector);
|
||||
var value = await Page.EvaluateFunctionAsync<string>("el => el.value", element);
|
||||
return value ?? string.Empty;
|
||||
}
|
||||
|
||||
public async Task<bool> 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<string> GetCurrentUrlAsync()
|
||||
{
|
||||
return Task.FromResult(Page.Url);
|
||||
}
|
||||
|
||||
public async Task<List<string>> GetAllElementTextsAsync(string selector)
|
||||
{
|
||||
var elements = await Page.QuerySelectorAllAsync(selector);
|
||||
var texts = new List<string>();
|
||||
|
||||
foreach (var element in elements)
|
||||
{
|
||||
var text = await Page.EvaluateFunctionAsync<string>("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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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 <container-id> 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 <container-id> 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
|
||||
@ -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<BCardsWebApplicationFactory>, 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<IMongoDatabase>();
|
||||
_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<IModerationService>();
|
||||
|
||||
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<IModerationService>();
|
||||
|
||||
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<string> { "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<IModerationService>();
|
||||
|
||||
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<IModerationService>();
|
||||
|
||||
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<IModerationService>();
|
||||
|
||||
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<BCardsWebApplicationFactory>, 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<IMongoDatabase>();
|
||||
_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<IModerationService>();
|
||||
|
||||
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<IModerationService>();
|
||||
|
||||
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<string> { "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<IModerationService>();
|
||||
|
||||
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<IModerationService>();
|
||||
|
||||
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<IModerationService>();
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
@ -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<BCardsWebApplicationFactory>, 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<IMongoDatabase>();
|
||||
_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<BCardsWebApplicationFactory>, 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<IMongoDatabase>();
|
||||
_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");
|
||||
}
|
||||
}
|
||||
@ -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<BCardsWebApplicationFactory>, 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<IMongoDatabase>();
|
||||
_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<BCardsWebApplicationFactory>, 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<IMongoDatabase>();
|
||||
_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();
|
||||
}
|
||||
}
|
||||
@ -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"
|
||||
}
|
||||
@ -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<IModerationAuthService>();
|
||||
|
||||
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<IModerationAuthService>();
|
||||
|
||||
if (!moderationAuth.IsUserModerator(user))
|
||||
{
|
||||
context.Result = new ForbidResult();
|
||||
return;
|
||||
}
|
||||
|
||||
// Adicionar flag para views
|
||||
context.HttpContext.Items["IsModerator"] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,38 +1,38 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<EnableDefaultEmbeddedResourceItems>false</EnableDefaultEmbeddedResourceItems>
|
||||
<RuntimeIdentifiers>linux-x64;linux-arm64</RuntimeIdentifiers>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="MongoDB.Driver" Version="3.4.2" />
|
||||
<PackageReference Include="Stripe.net" Version="48.4.0" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.Google" Version="8.0.4" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.MicrosoftAccount" Version="8.0.4" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Localization" Version="2.2.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Localization" Version="8.0.4" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Mvc.Razor.RuntimeCompilation" Version="8.0.4" />
|
||||
<PackageReference Include="SixLabors.ImageSharp.Web" Version="3.1.0" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.ResponseCaching" Version="2.2.0" />
|
||||
<PackageReference Include="HtmlAgilityPack" Version="1.11.54" />
|
||||
<PackageReference Include="SendGrid" Version="9.29.3" />
|
||||
<PackageReference Include="Serilog.AspNetCore" Version="8.0.0" />
|
||||
<PackageReference Include="Serilog.Sinks.Console" Version="5.0.0" />
|
||||
<PackageReference Include="Serilog.Sinks.OpenSearch" Version="1.3.0" />
|
||||
<PackageReference Include="Serilog.Enrichers.Environment" Version="3.0.0" />
|
||||
<PackageReference Include="Serilog.Enrichers.Process" Version="3.0.0" />
|
||||
<PackageReference Include="Serilog.Enrichers.Thread" Version="4.0.0" />
|
||||
<PackageReference Include="Serilog.Sinks.Async" Version="1.5.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Diagnostics.HealthChecks" Version="8.0.0" />
|
||||
<PackageReference Include="AspNetCore.HealthChecks.MongoDb" Version="7.0.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<EmbeddedResource Include="Resources\**\*.resx" />
|
||||
</ItemGroup>
|
||||
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<EnableDefaultEmbeddedResourceItems>false</EnableDefaultEmbeddedResourceItems>
|
||||
<RuntimeIdentifiers>linux-x64;linux-arm64</RuntimeIdentifiers>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="MongoDB.Driver" Version="3.4.2" />
|
||||
<PackageReference Include="Stripe.net" Version="48.4.0" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.Google" Version="8.0.4" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.MicrosoftAccount" Version="8.0.4" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Localization" Version="2.2.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Localization" Version="8.0.4" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Mvc.Razor.RuntimeCompilation" Version="8.0.4" />
|
||||
<PackageReference Include="SixLabors.ImageSharp.Web" Version="3.1.0" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.ResponseCaching" Version="2.2.0" />
|
||||
<PackageReference Include="HtmlAgilityPack" Version="1.11.54" />
|
||||
<PackageReference Include="SendGrid" Version="9.29.3" />
|
||||
<PackageReference Include="Serilog.AspNetCore" Version="8.0.0" />
|
||||
<PackageReference Include="Serilog.Sinks.Console" Version="5.0.0" />
|
||||
<PackageReference Include="Serilog.Sinks.OpenSearch" Version="1.3.0" />
|
||||
<PackageReference Include="Serilog.Enrichers.Environment" Version="3.0.0" />
|
||||
<PackageReference Include="Serilog.Enrichers.Process" Version="3.0.0" />
|
||||
<PackageReference Include="Serilog.Enrichers.Thread" Version="4.0.0" />
|
||||
<PackageReference Include="Serilog.Sinks.Async" Version="1.5.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Diagnostics.HealthChecks" Version="8.0.0" />
|
||||
<PackageReference Include="AspNetCore.HealthChecks.MongoDb" Version="7.0.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<EmbeddedResource Include="Resources\**\*.resx" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@ -1,10 +1,10 @@
|
||||
namespace BCards.Web.Configuration
|
||||
{
|
||||
public class ModerationSettings
|
||||
{
|
||||
public Dictionary<string, TimeSpan> PriorityTimeframes { get; set; } = new();
|
||||
public int MaxAttempts { get; set; } = 3;
|
||||
public string ModeratorEmail { get; set; } = "";
|
||||
public List<string> ModeratorEmails { get; set; } = new();
|
||||
}
|
||||
}
|
||||
namespace BCards.Web.Configuration
|
||||
{
|
||||
public class ModerationSettings
|
||||
{
|
||||
public Dictionary<string, TimeSpan> PriorityTimeframes { get; set; } = new();
|
||||
public int MaxAttempts { get; set; } = 3;
|
||||
public string ModeratorEmail { get; set; } = "";
|
||||
public List<string> ModeratorEmails { get; set; } = new();
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -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<HealthController> _logger;
|
||||
private readonly IConfiguration _configuration;
|
||||
private static readonly DateTime _startTime = DateTime.UtcNow;
|
||||
|
||||
public HealthController(HealthCheckService healthCheckService, ILogger<HealthController> logger, IConfiguration configuration)
|
||||
{
|
||||
_healthCheckService = healthCheckService;
|
||||
_logger = logger;
|
||||
_configuration = configuration;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Health check simples - retorna apenas status geral
|
||||
/// </summary>
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> 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
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Health check detalhado - formato completo com métricas
|
||||
/// </summary>
|
||||
[HttpGet("detailed")]
|
||||
public async Task<IActionResult> GetDetailedHealth()
|
||||
{
|
||||
var stopwatch = Stopwatch.StartNew();
|
||||
|
||||
try
|
||||
{
|
||||
var healthReport = await _healthCheckService.CheckHealthAsync();
|
||||
stopwatch.Stop();
|
||||
|
||||
var checks = new Dictionary<string, object>();
|
||||
|
||||
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"
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Health check para Uptime Kuma - formato específico
|
||||
/// </summary>
|
||||
[HttpGet("uptime-kuma")]
|
||||
public async Task<IActionResult> 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
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Health checks específicos por serviço
|
||||
/// </summary>
|
||||
[HttpGet("mongodb")]
|
||||
public async Task<IActionResult> GetMongoDbHealth()
|
||||
{
|
||||
return await GetSpecificServiceHealth("mongodb");
|
||||
}
|
||||
|
||||
[HttpGet("stripe")]
|
||||
public async Task<IActionResult> GetStripeHealth()
|
||||
{
|
||||
return await GetSpecificServiceHealth("stripe");
|
||||
}
|
||||
|
||||
[HttpGet("sendgrid")]
|
||||
public async Task<IActionResult> GetSendGridHealth()
|
||||
{
|
||||
return await GetSpecificServiceHealth("sendgrid");
|
||||
}
|
||||
|
||||
[HttpGet("external")]
|
||||
public async Task<IActionResult> GetExternalServicesHealth()
|
||||
{
|
||||
return await GetSpecificServiceHealth("external_services");
|
||||
}
|
||||
|
||||
[HttpGet("resources")]
|
||||
public async Task<IActionResult> GetSystemResourcesHealth()
|
||||
{
|
||||
return await GetSpecificServiceHealth("resources");
|
||||
}
|
||||
|
||||
private async Task<IActionResult> 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<HealthController> _logger;
|
||||
private readonly IConfiguration _configuration;
|
||||
private static readonly DateTime _startTime = DateTime.UtcNow;
|
||||
|
||||
public HealthController(HealthCheckService healthCheckService, ILogger<HealthController> logger, IConfiguration configuration)
|
||||
{
|
||||
_healthCheckService = healthCheckService;
|
||||
_logger = logger;
|
||||
_configuration = configuration;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Health check simples - retorna apenas status geral
|
||||
/// </summary>
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> 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
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Health check detalhado - formato completo com métricas
|
||||
/// </summary>
|
||||
[HttpGet("detailed")]
|
||||
public async Task<IActionResult> GetDetailedHealth()
|
||||
{
|
||||
var stopwatch = Stopwatch.StartNew();
|
||||
|
||||
try
|
||||
{
|
||||
var healthReport = await _healthCheckService.CheckHealthAsync();
|
||||
stopwatch.Stop();
|
||||
|
||||
var checks = new Dictionary<string, object>();
|
||||
|
||||
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"
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Health check para Uptime Kuma - formato específico
|
||||
/// </summary>
|
||||
[HttpGet("uptime-kuma")]
|
||||
public async Task<IActionResult> 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
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Health checks específicos por serviço
|
||||
/// </summary>
|
||||
[HttpGet("mongodb")]
|
||||
public async Task<IActionResult> GetMongoDbHealth()
|
||||
{
|
||||
return await GetSpecificServiceHealth("mongodb");
|
||||
}
|
||||
|
||||
[HttpGet("stripe")]
|
||||
public async Task<IActionResult> GetStripeHealth()
|
||||
{
|
||||
return await GetSpecificServiceHealth("stripe");
|
||||
}
|
||||
|
||||
[HttpGet("sendgrid")]
|
||||
public async Task<IActionResult> GetSendGridHealth()
|
||||
{
|
||||
return await GetSpecificServiceHealth("sendgrid");
|
||||
}
|
||||
|
||||
[HttpGet("external")]
|
||||
public async Task<IActionResult> GetExternalServicesHealth()
|
||||
{
|
||||
return await GetSpecificServiceHealth("external_services");
|
||||
}
|
||||
|
||||
[HttpGet("resources")]
|
||||
public async Task<IActionResult> GetSystemResourcesHealth()
|
||||
{
|
||||
return await GetSpecificServiceHealth("resources");
|
||||
}
|
||||
|
||||
private async Task<IActionResult> 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";
|
||||
}
|
||||
}
|
||||
@ -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> stripeSettings)
|
||||
{
|
||||
_categoryService = categoryService;
|
||||
_userPageService = userPageService;
|
||||
_stripeSettings = stripeSettings.Value;
|
||||
}
|
||||
|
||||
public async Task<IActionResult> 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<IWebHostEnvironment>().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> stripeSettings)
|
||||
{
|
||||
_categoryService = categoryService;
|
||||
_userPageService = userPageService;
|
||||
_stripeSettings = stripeSettings.Value;
|
||||
}
|
||||
|
||||
public async Task<IActionResult> 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<IWebHostEnvironment>().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();
|
||||
}
|
||||
}
|
||||
@ -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<ImageController> _logger;
|
||||
|
||||
public ImageController(IImageStorageService imageStorage, ILogger<ImageController> logger)
|
||||
{
|
||||
_imageStorage = imageStorage;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
[HttpGet("{imageId}")]
|
||||
[ResponseCache(Duration = 3600, Location = ResponseCacheLocation.Any, VaryByQueryKeys = new[] { "imageId" })]
|
||||
public async Task<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<ImageController> _logger;
|
||||
|
||||
public ImageController(IImageStorageService imageStorage, ILogger<ImageController> logger)
|
||||
{
|
||||
_imageStorage = imageStorage;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
[HttpGet("{imageId}")]
|
||||
[ResponseCache(Duration = 3600, Location = ResponseCacheLocation.Any, VaryByQueryKeys = new[] { "imageId" })]
|
||||
public async Task<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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;
|
||||
}
|
||||
}
|
||||
@ -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<LivePageController> _logger;
|
||||
|
||||
public LivePageController(ILivePageService livePageService, ILogger<LivePageController> logger)
|
||||
{
|
||||
_livePageService = livePageService;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
[Route("{category}/{slug}")]
|
||||
[ResponseCache(Duration = 3600, Location = ResponseCacheLocation.Any, VaryByQueryKeys = new string[] { })]
|
||||
public async Task<IActionResult> 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<IActionResult> 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<LivePageController> _logger;
|
||||
|
||||
public LivePageController(ILivePageService livePageService, ILogger<LivePageController> logger)
|
||||
{
|
||||
_livePageService = livePageService;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
[Route("{category}/{slug}")]
|
||||
[ResponseCache(Duration = 3600, Location = ResponseCacheLocation.Any, VaryByQueryKeys = new string[] { })]
|
||||
public async Task<IActionResult> 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<IActionResult> 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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<ModerationController> _logger;
|
||||
|
||||
public ModerationController(
|
||||
IModerationService moderationService,
|
||||
IEmailService emailService,
|
||||
IUserRepository userRepository,
|
||||
ILogger<ModerationController> logger)
|
||||
{
|
||||
_moderationService = moderationService;
|
||||
_emailService = emailService;
|
||||
_userRepository = userRepository;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
[HttpGet("Dashboard")]
|
||||
public async Task<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> Reject(string id, string reason, List<string> 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<IActionResult> 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<ModerationCriterion> GetModerationCriteria()
|
||||
{
|
||||
return new List<ModerationCriterion>
|
||||
{
|
||||
new() { Category = "Conteúdo Proibido", Items = new List<string>
|
||||
{
|
||||
"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<string>
|
||||
{
|
||||
"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<string>
|
||||
{
|
||||
"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<ModerationController> _logger;
|
||||
|
||||
public ModerationController(
|
||||
IModerationService moderationService,
|
||||
IEmailService emailService,
|
||||
IUserRepository userRepository,
|
||||
ILogger<ModerationController> logger)
|
||||
{
|
||||
_moderationService = moderationService;
|
||||
_emailService = emailService;
|
||||
_userRepository = userRepository;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
[HttpGet("Dashboard")]
|
||||
public async Task<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> Reject(string id, string reason, List<string> 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<IActionResult> 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<ModerationCriterion> GetModerationCriteria()
|
||||
{
|
||||
return new List<ModerationCriterion>
|
||||
{
|
||||
new() { Category = "Conteúdo Proibido", Items = new List<string>
|
||||
{
|
||||
"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<string>
|
||||
{
|
||||
"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<string>
|
||||
{
|
||||
"Links funcionais (não quebrados)",
|
||||
"Sites com SSL válido",
|
||||
"Não redirecionamentos maliciosos"
|
||||
}}
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<PlanType>(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<PlanType>(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<IActionResult> 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<IActionResult> 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<IActionResult> 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<AvailablePlanViewModel> GetAvailablePlans(string currentPlan)
|
||||
{
|
||||
var plansConfig = _configuration.GetSection("Plans");
|
||||
var plans = new List<AvailablePlanViewModel>();
|
||||
|
||||
// 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<List<string>>() ?? new List<string>(),
|
||||
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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<PlanType>(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<PlanType>(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<IActionResult> 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<IActionResult> 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<IActionResult> 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<AvailablePlanViewModel> GetAvailablePlans(string currentPlan)
|
||||
{
|
||||
var plansConfig = _configuration.GetSection("Plans");
|
||||
var plans = new List<AvailablePlanViewModel>();
|
||||
|
||||
// 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<List<string>>() ?? new List<string>(),
|
||||
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();
|
||||
}
|
||||
}
|
||||
@ -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<ProductController> _logger;
|
||||
|
||||
public ProductController(
|
||||
IOpenGraphService openGraphService,
|
||||
ILogger<ProductController> logger)
|
||||
{
|
||||
_openGraphService = openGraphService;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
[HttpPost("extract")]
|
||||
public async Task<IActionResult> 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<IActionResult> 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<IActionResult>(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<IActionResult>(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<ProductController> _logger;
|
||||
|
||||
public ProductController(
|
||||
IOpenGraphService openGraphService,
|
||||
ILogger<ProductController> logger)
|
||||
{
|
||||
_openGraphService = openGraphService;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
[HttpPost("extract")]
|
||||
public async Task<IActionResult> 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<IActionResult> 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<IActionResult>(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<IActionResult>(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);
|
||||
}
|
||||
}
|
||||
@ -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<SitemapController> _logger;
|
||||
|
||||
public SitemapController(
|
||||
IUserPageService userPageService,
|
||||
ILivePageService livePageService,
|
||||
ILogger<SitemapController> logger)
|
||||
{
|
||||
_userPageService = userPageService;
|
||||
_livePageService = livePageService;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
[Route("sitemap.xml")]
|
||||
[ResponseCache(Duration = 86400)] // Cache for 24 hours
|
||||
public async Task<IActionResult> 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<SitemapController> _logger;
|
||||
|
||||
public SitemapController(
|
||||
IUserPageService userPageService,
|
||||
ILivePageService livePageService,
|
||||
ILogger<SitemapController> logger)
|
||||
{
|
||||
_userPageService = userPageService;
|
||||
_livePageService = livePageService;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
[Route("sitemap.xml")]
|
||||
[ResponseCache(Duration = 86400)] // Cache for 24 hours
|
||||
public async Task<IActionResult> 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);
|
||||
}
|
||||
}
|
||||
@ -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<SubscriptionController> _logger;
|
||||
|
||||
public SubscriptionController(
|
||||
IPaymentService paymentService,
|
||||
ILogger<SubscriptionController> logger)
|
||||
{
|
||||
_paymentService = paymentService;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
[HttpGet("cancel")]
|
||||
public async Task<IActionResult> 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<IActionResult> 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<IActionResult> 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<SubscriptionController> _logger;
|
||||
|
||||
public SubscriptionController(
|
||||
IPaymentService paymentService,
|
||||
ILogger<SubscriptionController> logger)
|
||||
{
|
||||
_paymentService = paymentService;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
[HttpGet("cancel")]
|
||||
public async Task<IActionResult> 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<IActionResult> 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<IActionResult> 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");
|
||||
}
|
||||
}
|
||||
@ -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<UserPageController> _logger;
|
||||
|
||||
public UserPageController(
|
||||
IUserPageService userPageService,
|
||||
ICategoryService categoryService,
|
||||
ISeoService seoService,
|
||||
IThemeService themeService,
|
||||
IModerationService moderationService,
|
||||
ILogger<UserPageController> 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<IActionResult> 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<IActionResult> RecordClick(string pageId, int linkIndex)
|
||||
{
|
||||
await _userPageService.RecordLinkClickAsync(pageId, linkIndex);
|
||||
return Ok();
|
||||
}
|
||||
|
||||
[Route("preview/{category}/{slug}")]
|
||||
public async Task<IActionResult> 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<UserPageController> _logger;
|
||||
|
||||
public UserPageController(
|
||||
IUserPageService userPageService,
|
||||
ICategoryService categoryService,
|
||||
ISeoService seoService,
|
||||
IThemeService themeService,
|
||||
IModerationService moderationService,
|
||||
ILogger<UserPageController> 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<IActionResult> 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<IActionResult> RecordClick(string pageId, int linkIndex)
|
||||
{
|
||||
await _userPageService.RecordLinkClickAsync(pageId, linkIndex);
|
||||
return Ok();
|
||||
}
|
||||
|
||||
[Route("preview/{category}/{slug}")]
|
||||
public async Task<IActionResult> 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);
|
||||
}
|
||||
|
||||
}
|
||||
@ -1,110 +1,110 @@
|
||||
using Microsoft.Extensions.Diagnostics.HealthChecks;
|
||||
using MongoDB.Driver;
|
||||
using MongoDB.Bson;
|
||||
using Stripe;
|
||||
|
||||
namespace BCards.Web.HealthChecks;
|
||||
|
||||
/// <summary>
|
||||
/// 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)
|
||||
/// </summary>
|
||||
public class CriticalServicesHealthCheck : IHealthCheck
|
||||
{
|
||||
private readonly IMongoDatabase _database;
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly ILogger<CriticalServicesHealthCheck> _logger;
|
||||
|
||||
public CriticalServicesHealthCheck(
|
||||
IMongoDatabase database,
|
||||
HttpClient httpClient,
|
||||
ILogger<CriticalServicesHealthCheck> logger)
|
||||
{
|
||||
_database = database;
|
||||
_httpClient = httpClient;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<HealthCheckResult> CheckHealthAsync(
|
||||
HealthCheckContext context,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var results = new Dictionary<string, object>();
|
||||
var allCritical = true;
|
||||
var criticalFailures = new List<string>();
|
||||
|
||||
// 1. MongoDB Check
|
||||
try
|
||||
{
|
||||
await _database.RunCommandAsync<BsonDocument>(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<string, object>
|
||||
{
|
||||
{ "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;
|
||||
|
||||
/// <summary>
|
||||
/// 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)
|
||||
/// </summary>
|
||||
public class CriticalServicesHealthCheck : IHealthCheck
|
||||
{
|
||||
private readonly IMongoDatabase _database;
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly ILogger<CriticalServicesHealthCheck> _logger;
|
||||
|
||||
public CriticalServicesHealthCheck(
|
||||
IMongoDatabase database,
|
||||
HttpClient httpClient,
|
||||
ILogger<CriticalServicesHealthCheck> logger)
|
||||
{
|
||||
_database = database;
|
||||
_httpClient = httpClient;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<HealthCheckResult> CheckHealthAsync(
|
||||
HealthCheckContext context,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var results = new Dictionary<string, object>();
|
||||
var allCritical = true;
|
||||
var criticalFailures = new List<string>();
|
||||
|
||||
// 1. MongoDB Check
|
||||
try
|
||||
{
|
||||
await _database.RunCommandAsync<BsonDocument>(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<string, object>
|
||||
{
|
||||
{ "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);
|
||||
}
|
||||
}
|
||||
@ -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<MongoDbHealthCheck> _logger;
|
||||
|
||||
public MongoDbHealthCheck(IMongoDatabase database, ILogger<MongoDbHealthCheck> logger)
|
||||
{
|
||||
_database = database;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<HealthCheckResult> CheckHealthAsync(
|
||||
HealthCheckContext context,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var stopwatch = Stopwatch.StartNew();
|
||||
|
||||
try
|
||||
{
|
||||
// Executa ping no MongoDB
|
||||
var command = new BsonDocument("ping", 1);
|
||||
await _database.RunCommandAsync<BsonDocument>(command, cancellationToken: cancellationToken);
|
||||
|
||||
stopwatch.Stop();
|
||||
var duration = stopwatch.ElapsedMilliseconds;
|
||||
|
||||
_logger.LogInformation("MongoDB health check completed successfully in {Duration}ms", duration);
|
||||
|
||||
var data = new Dictionary<string, object>
|
||||
{
|
||||
{ "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<string, object>
|
||||
{
|
||||
{ "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<MongoDbHealthCheck> _logger;
|
||||
|
||||
public MongoDbHealthCheck(IMongoDatabase database, ILogger<MongoDbHealthCheck> logger)
|
||||
{
|
||||
_database = database;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<HealthCheckResult> CheckHealthAsync(
|
||||
HealthCheckContext context,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var stopwatch = Stopwatch.StartNew();
|
||||
|
||||
try
|
||||
{
|
||||
// Executa ping no MongoDB
|
||||
var command = new BsonDocument("ping", 1);
|
||||
await _database.RunCommandAsync<BsonDocument>(command, cancellationToken: cancellationToken);
|
||||
|
||||
stopwatch.Stop();
|
||||
var duration = stopwatch.ElapsedMilliseconds;
|
||||
|
||||
_logger.LogInformation("MongoDB health check completed successfully in {Duration}ms", duration);
|
||||
|
||||
var data = new Dictionary<string, object>
|
||||
{
|
||||
{ "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<string, object>
|
||||
{
|
||||
{ "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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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<OAuthProvidersHealthCheck> _logger;
|
||||
|
||||
public OAuthProvidersHealthCheck(IOAuthHealthService oauthHealthService, ILogger<OAuthProvidersHealthCheck> logger)
|
||||
{
|
||||
_oauthHealthService = oauthHealthService;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<HealthCheckResult> CheckHealthAsync(
|
||||
HealthCheckContext context,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
var status = await _oauthHealthService.CheckOAuthProvidersAsync();
|
||||
|
||||
var data = new Dictionary<string, object>
|
||||
{
|
||||
{ "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<string>();
|
||||
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<OAuthProvidersHealthCheck> _logger;
|
||||
|
||||
public OAuthProvidersHealthCheck(IOAuthHealthService oauthHealthService, ILogger<OAuthProvidersHealthCheck> logger)
|
||||
{
|
||||
_oauthHealthService = oauthHealthService;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<HealthCheckResult> CheckHealthAsync(
|
||||
HealthCheckContext context,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
var status = await _oauthHealthService.CheckOAuthProvidersAsync();
|
||||
|
||||
var data = new Dictionary<string, object>
|
||||
{
|
||||
{ "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<string>();
|
||||
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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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<SendGridHealthCheck> _logger;
|
||||
private readonly IConfiguration _configuration;
|
||||
|
||||
public SendGridHealthCheck(ISendGridClient sendGridClient, ILogger<SendGridHealthCheck> logger, IConfiguration configuration)
|
||||
{
|
||||
_sendGridClient = sendGridClient;
|
||||
_logger = logger;
|
||||
_configuration = configuration;
|
||||
}
|
||||
|
||||
public async Task<HealthCheckResult> 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<string, object>
|
||||
{
|
||||
{ "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<string, object>
|
||||
{
|
||||
{ "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<SendGridHealthCheck> _logger;
|
||||
private readonly IConfiguration _configuration;
|
||||
|
||||
public SendGridHealthCheck(ISendGridClient sendGridClient, ILogger<SendGridHealthCheck> logger, IConfiguration configuration)
|
||||
{
|
||||
_sendGridClient = sendGridClient;
|
||||
_logger = logger;
|
||||
_configuration = configuration;
|
||||
}
|
||||
|
||||
public async Task<HealthCheckResult> 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<string, object>
|
||||
{
|
||||
{ "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<string, object>
|
||||
{
|
||||
{ "status", "unhealthy" },
|
||||
{ "duration", $"{duration}ms" },
|
||||
{ "error", ex.Message }
|
||||
};
|
||||
|
||||
return HealthCheckResult.Unhealthy($"SendGrid connection failed: {ex.Message}", ex, data);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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<StripeHealthCheck> _logger;
|
||||
|
||||
public StripeHealthCheck(IOptions<StripeSettings> stripeSettings, ILogger<StripeHealthCheck> logger)
|
||||
{
|
||||
_stripeSettings = stripeSettings.Value;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<HealthCheckResult> 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<string, object>
|
||||
{
|
||||
{ "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<string, object>
|
||||
{
|
||||
{ "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<string, object>
|
||||
{
|
||||
{ "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<StripeHealthCheck> _logger;
|
||||
|
||||
public StripeHealthCheck(IOptions<StripeSettings> stripeSettings, ILogger<StripeHealthCheck> logger)
|
||||
{
|
||||
_stripeSettings = stripeSettings.Value;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<HealthCheckResult> 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<string, object>
|
||||
{
|
||||
{ "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<string, object>
|
||||
{
|
||||
{ "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<string, object>
|
||||
{
|
||||
{ "status", "unhealthy" },
|
||||
{ "duration", $"{duration}ms" },
|
||||
{ "error", ex.Message }
|
||||
};
|
||||
|
||||
return HealthCheckResult.Unhealthy($"Stripe connection failed: {ex.Message}", ex, data);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,134 +1,134 @@
|
||||
using Microsoft.Extensions.Diagnostics.HealthChecks;
|
||||
using System.Diagnostics;
|
||||
|
||||
namespace BCards.Web.HealthChecks;
|
||||
|
||||
public class SystemResourcesHealthCheck : IHealthCheck
|
||||
{
|
||||
private readonly ILogger<SystemResourcesHealthCheck> _logger;
|
||||
private static readonly DateTime _startTime = DateTime.UtcNow;
|
||||
|
||||
public SystemResourcesHealthCheck(ILogger<SystemResourcesHealthCheck> logger)
|
||||
{
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public Task<HealthCheckResult> 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<string, object>
|
||||
{
|
||||
{ "status", "healthy" },
|
||||
{ "duration", $"{duration}ms" },
|
||||
{ "memory", new Dictionary<string, object>
|
||||
{
|
||||
{ "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<string, object>
|
||||
{
|
||||
{ "id", currentProcess.Id },
|
||||
{ "threads", threadCount },
|
||||
{ "handles", currentProcess.HandleCount },
|
||||
{ "uptime", uptimeString },
|
||||
{ "uptime_seconds", (int)uptime.TotalSeconds }
|
||||
}
|
||||
},
|
||||
{ "system", new Dictionary<string, object>
|
||||
{
|
||||
{ "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<string, object>
|
||||
{
|
||||
{ "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<SystemResourcesHealthCheck> _logger;
|
||||
private static readonly DateTime _startTime = DateTime.UtcNow;
|
||||
|
||||
public SystemResourcesHealthCheck(ILogger<SystemResourcesHealthCheck> logger)
|
||||
{
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public Task<HealthCheckResult> 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<string, object>
|
||||
{
|
||||
{ "status", "healthy" },
|
||||
{ "duration", $"{duration}ms" },
|
||||
{ "memory", new Dictionary<string, object>
|
||||
{
|
||||
{ "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<string, object>
|
||||
{
|
||||
{ "id", currentProcess.Id },
|
||||
{ "threads", threadCount },
|
||||
{ "handles", currentProcess.HandleCount },
|
||||
{ "uptime", uptimeString },
|
||||
{ "uptime_seconds", (int)uptime.TotalSeconds }
|
||||
}
|
||||
},
|
||||
{ "system", new Dictionary<string, object>
|
||||
{
|
||||
{ "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<string, object>
|
||||
{
|
||||
{ "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";
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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<PageStatusMiddleware> _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<PageStatusMiddleware> 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 = $@"
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>{page.DisplayName} - Pagamento Pendente</title>
|
||||
<meta charset='utf-8'>
|
||||
<meta name='viewport' content='width=device-width, initial-scale=1'>
|
||||
<link href='https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css' rel='stylesheet'>
|
||||
</head>
|
||||
<body class='bg-light'>
|
||||
<div class='container mt-5'>
|
||||
<div class='row justify-content-center'>
|
||||
<div class='col-md-6'>
|
||||
<div class='card border-warning'>
|
||||
<div class='card-header bg-warning text-dark'>
|
||||
<h5 class='mb-0'>
|
||||
<i class='fas fa-exclamation-triangle me-2'></i>
|
||||
Pagamento Pendente
|
||||
</h5>
|
||||
</div>
|
||||
<div class='card-body text-center'>
|
||||
<h4>{page.DisplayName}</h4>
|
||||
<p class='text-muted mb-4'>Esta página está temporariamente indisponível devido a um pagamento pendente.</p>
|
||||
<p class='mb-4'>Para reativar esta página, o proprietário deve regularizar o pagamento.</p>
|
||||
<a href='/Home/Pricing' class='btn btn-primary'>Ver Planos</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>";
|
||||
|
||||
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<PageStatusMiddleware> _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<PageStatusMiddleware> 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 = $@"
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>{page.DisplayName} - Pagamento Pendente</title>
|
||||
<meta charset='utf-8'>
|
||||
<meta name='viewport' content='width=device-width, initial-scale=1'>
|
||||
<link href='https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css' rel='stylesheet'>
|
||||
</head>
|
||||
<body class='bg-light'>
|
||||
<div class='container mt-5'>
|
||||
<div class='row justify-content-center'>
|
||||
<div class='col-md-6'>
|
||||
<div class='card border-warning'>
|
||||
<div class='card-header bg-warning text-dark'>
|
||||
<h5 class='mb-0'>
|
||||
<i class='fas fa-exclamation-triangle me-2'></i>
|
||||
Pagamento Pendente
|
||||
</h5>
|
||||
</div>
|
||||
<div class='card-body text-center'>
|
||||
<h4>{page.DisplayName}</h4>
|
||||
<p class='text-muted mb-4'>Esta página está temporariamente indisponível devido a um pagamento pendente.</p>
|
||||
<p class='mb-4'>Para reativar esta página, o proprietário deve regularizar o pagamento.</p>
|
||||
<a href='/Home/Pricing' class='btn btn-primary'>Ver Planos</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>";
|
||||
|
||||
context.Response.ContentType = "text/html";
|
||||
context.Response.StatusCode = 200; // Keep as 200 for SEO, but show warning
|
||||
await context.Response.WriteAsync(html);
|
||||
}
|
||||
}
|
||||
@ -1,27 +1,27 @@
|
||||
namespace BCards.Web.Models
|
||||
{
|
||||
/// <summary>
|
||||
/// Interface comum para páginas que podem ser exibidas publicamente
|
||||
/// Facilita o envio de dados para views sem duplicação de código
|
||||
/// </summary>
|
||||
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<LinkItem> Links { get; }
|
||||
SeoSettings SeoSettings { get; }
|
||||
string Language { get; }
|
||||
DateTime CreatedAt { get; }
|
||||
|
||||
// Propriedades calculadas comuns
|
||||
string FullUrl { get; }
|
||||
string ProfileImageUrl { get; }
|
||||
}
|
||||
}
|
||||
namespace BCards.Web.Models
|
||||
{
|
||||
/// <summary>
|
||||
/// Interface comum para páginas que podem ser exibidas publicamente
|
||||
/// Facilita o envio de dados para views sem duplicação de código
|
||||
/// </summary>
|
||||
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<LinkItem> Links { get; }
|
||||
SeoSettings SeoSettings { get; }
|
||||
string Language { get; }
|
||||
DateTime CreatedAt { get; }
|
||||
|
||||
// Propriedades calculadas comuns
|
||||
string FullUrl { get; }
|
||||
string ProfileImageUrl { get; }
|
||||
}
|
||||
}
|
||||
|
||||
@ -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; }
|
||||
}
|
||||
@ -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<LinkItem> 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}";
|
||||
|
||||
/// <summary>
|
||||
/// URL da imagem de perfil ou imagem padrão se não houver upload
|
||||
/// </summary>
|
||||
[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<LinkItem> 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}";
|
||||
|
||||
/// <summary>
|
||||
/// URL da imagem de perfil ou imagem padrão se não houver upload
|
||||
/// </summary>
|
||||
[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; }
|
||||
}
|
||||
@ -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<string> 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<string> Issues { get; set; } = new();
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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; }
|
||||
}
|
||||
@ -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; }
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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<LinkItem> 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> 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}";
|
||||
|
||||
/// <summary>
|
||||
/// URL da imagem de perfil ou imagem padrão se não houver upload
|
||||
/// </summary>
|
||||
[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<LinkItem> 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> 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}";
|
||||
|
||||
/// <summary>
|
||||
/// URL da imagem de perfil ou imagem padrão se não houver upload
|
||||
/// </summary>
|
||||
[BsonIgnore]
|
||||
public string ProfileImageUrl => !string.IsNullOrEmpty(ProfileImageId)
|
||||
? $"/api/image/{ProfileImageId}"
|
||||
: "/images/default-avatar.svg";
|
||||
}
|
||||
@ -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;
|
||||
});
|
||||
|
||||
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,18 +1,18 @@
|
||||
using BCards.Web.Models;
|
||||
|
||||
namespace BCards.Web.Repositories;
|
||||
|
||||
public interface ILivePageRepository
|
||||
{
|
||||
Task<LivePage?> GetByCategoryAndSlugAsync(string category, string slug);
|
||||
Task<LivePage?> GetByOriginalPageIdAsync(string originalPageId);
|
||||
Task<LivePage?> GetByIdAsync(string pageId);
|
||||
Task<List<LivePage>> GetAllActiveAsync();
|
||||
Task<LivePage> CreateAsync(LivePage livePage);
|
||||
Task<LivePage> UpdateAsync(LivePage livePage);
|
||||
Task<bool> DeleteAsync(string id);
|
||||
Task<bool> DeleteByOriginalPageIdAsync(string originalPageId);
|
||||
Task<bool> 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<LivePage?> GetByCategoryAndSlugAsync(string category, string slug);
|
||||
Task<LivePage?> GetByOriginalPageIdAsync(string originalPageId);
|
||||
Task<LivePage?> GetByIdAsync(string pageId);
|
||||
Task<List<LivePage>> GetAllActiveAsync();
|
||||
Task<LivePage> CreateAsync(LivePage livePage);
|
||||
Task<LivePage> UpdateAsync(LivePage livePage);
|
||||
Task<bool> DeleteAsync(string id);
|
||||
Task<bool> DeleteByOriginalPageIdAsync(string originalPageId);
|
||||
Task<bool> ExistsByCategoryAndSlugAsync(string category, string slug, string? excludeId = null);
|
||||
Task IncrementViewAsync(string id);
|
||||
Task IncrementLinkClickAsync(string id, int linkIndex);
|
||||
}
|
||||
@ -1,31 +1,31 @@
|
||||
using BCards.Web.Models;
|
||||
using MongoDB.Driver;
|
||||
|
||||
namespace BCards.Web.Repositories;
|
||||
|
||||
public interface IUserPageRepository
|
||||
{
|
||||
Task<UserPage?> GetByIdAsync(string id);
|
||||
Task<UserPage?> GetBySlugAsync(string category, string slug);
|
||||
Task<UserPage?> GetByUserIdAsync(string userId);
|
||||
Task<List<UserPage>> GetByUserIdAllAsync(string userId);
|
||||
Task<List<UserPage>> GetActivePagesAsync();
|
||||
Task<UserPage> CreateAsync(UserPage userPage);
|
||||
Task<UserPage> UpdateAsync(UserPage userPage);
|
||||
Task DeleteAsync(string id);
|
||||
Task<bool> SlugExistsAsync(string category, string slug, string? excludeId = null);
|
||||
Task<List<UserPage>> GetRecentPagesAsync(int limit = 10);
|
||||
Task<List<UserPage>> GetByCategoryAsync(string category, int limit = 20);
|
||||
Task UpdateAnalyticsAsync(string id, PageAnalytics analytics);
|
||||
Task<List<UserPage>> GetManyAsync(
|
||||
FilterDefinition<UserPage> filter,
|
||||
SortDefinition<UserPage>? sort = null,
|
||||
int skip = 0,
|
||||
int take = 20);
|
||||
Task<List<UserPage>> GetPendingModerationAsync(int skip = 0, int take = 20);
|
||||
Task<long> CountAsync(FilterDefinition<UserPage> filter);
|
||||
Task<UpdateResult> UpdateAsync(string id, UpdateDefinition<UserPage> update);
|
||||
Task<UpdateResult> UpdateManyAsync(FilterDefinition<UserPage> filter, UpdateDefinition<UserPage> update);
|
||||
Task<bool> ApprovePageAsync(string pageId);
|
||||
Task<bool> RejectPageAsync(string pageId, string reason, List<string> issues);
|
||||
using BCards.Web.Models;
|
||||
using MongoDB.Driver;
|
||||
|
||||
namespace BCards.Web.Repositories;
|
||||
|
||||
public interface IUserPageRepository
|
||||
{
|
||||
Task<UserPage?> GetByIdAsync(string id);
|
||||
Task<UserPage?> GetBySlugAsync(string category, string slug);
|
||||
Task<UserPage?> GetByUserIdAsync(string userId);
|
||||
Task<List<UserPage>> GetByUserIdAllAsync(string userId);
|
||||
Task<List<UserPage>> GetActivePagesAsync();
|
||||
Task<UserPage> CreateAsync(UserPage userPage);
|
||||
Task<UserPage> UpdateAsync(UserPage userPage);
|
||||
Task DeleteAsync(string id);
|
||||
Task<bool> SlugExistsAsync(string category, string slug, string? excludeId = null);
|
||||
Task<List<UserPage>> GetRecentPagesAsync(int limit = 10);
|
||||
Task<List<UserPage>> GetByCategoryAsync(string category, int limit = 20);
|
||||
Task UpdateAnalyticsAsync(string id, PageAnalytics analytics);
|
||||
Task<List<UserPage>> GetManyAsync(
|
||||
FilterDefinition<UserPage> filter,
|
||||
SortDefinition<UserPage>? sort = null,
|
||||
int skip = 0,
|
||||
int take = 20);
|
||||
Task<List<UserPage>> GetPendingModerationAsync(int skip = 0, int take = 20);
|
||||
Task<long> CountAsync(FilterDefinition<UserPage> filter);
|
||||
Task<UpdateResult> UpdateAsync(string id, UpdateDefinition<UserPage> update);
|
||||
Task<UpdateResult> UpdateManyAsync(FilterDefinition<UserPage> filter, UpdateDefinition<UserPage> update);
|
||||
Task<bool> ApprovePageAsync(string pageId);
|
||||
Task<bool> RejectPageAsync(string pageId, string reason, List<string> issues);
|
||||
}
|
||||
@ -1,127 +1,127 @@
|
||||
using BCards.Web.Models;
|
||||
using MongoDB.Driver;
|
||||
|
||||
namespace BCards.Web.Repositories;
|
||||
|
||||
public class LivePageRepository : ILivePageRepository
|
||||
{
|
||||
private readonly IMongoCollection<LivePage> _collection;
|
||||
|
||||
public LivePageRepository(IMongoDatabase database)
|
||||
{
|
||||
_collection = database.GetCollection<LivePage>("livepages");
|
||||
|
||||
// Criar índices essenciais
|
||||
CreateIndexes();
|
||||
}
|
||||
|
||||
private void CreateIndexes()
|
||||
{
|
||||
try
|
||||
{
|
||||
// Índice único para category + slug
|
||||
var categorySlugIndex = Builders<LivePage>.IndexKeys
|
||||
.Ascending(x => x.Category)
|
||||
.Ascending(x => x.Slug);
|
||||
|
||||
var uniqueOptions = new CreateIndexOptions { Unique = true };
|
||||
_collection.Indexes.CreateOneAsync(new CreateIndexModel<LivePage>(categorySlugIndex, uniqueOptions));
|
||||
|
||||
// Outros índices importantes
|
||||
_collection.Indexes.CreateOneAsync(new CreateIndexModel<LivePage>(
|
||||
Builders<LivePage>.IndexKeys.Ascending(x => x.UserId)));
|
||||
|
||||
_collection.Indexes.CreateOneAsync(new CreateIndexModel<LivePage>(
|
||||
Builders<LivePage>.IndexKeys.Descending(x => x.PublishedAt)));
|
||||
|
||||
_collection.Indexes.CreateOneAsync(new CreateIndexModel<LivePage>(
|
||||
Builders<LivePage>.IndexKeys.Ascending(x => x.OriginalPageId)));
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Ignora erros de criação de índices (já podem existir)
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<LivePage?> GetByIdAsync(string pageId)
|
||||
{
|
||||
return await _collection.Find(x => x.Id == pageId).FirstOrDefaultAsync();
|
||||
}
|
||||
|
||||
public async Task<LivePage?> GetByCategoryAndSlugAsync(string category, string slug)
|
||||
{
|
||||
return await _collection.Find(x => x.Category == category && x.Slug == slug).FirstOrDefaultAsync();
|
||||
}
|
||||
|
||||
public async Task<LivePage?> GetByOriginalPageIdAsync(string originalPageId)
|
||||
{
|
||||
return await _collection.Find(x => x.OriginalPageId == originalPageId).FirstOrDefaultAsync();
|
||||
}
|
||||
|
||||
public async Task<List<LivePage>> GetAllActiveAsync()
|
||||
{
|
||||
return await _collection.Find(x => true)
|
||||
.Sort(Builders<LivePage>.Sort.Descending(x => x.PublishedAt))
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
public async Task<LivePage> CreateAsync(LivePage livePage)
|
||||
{
|
||||
livePage.CreatedAt = DateTime.UtcNow;
|
||||
livePage.LastSyncAt = DateTime.UtcNow;
|
||||
await _collection.InsertOneAsync(livePage);
|
||||
return livePage;
|
||||
}
|
||||
|
||||
public async Task<LivePage> UpdateAsync(LivePage livePage)
|
||||
{
|
||||
livePage.LastSyncAt = DateTime.UtcNow;
|
||||
await _collection.ReplaceOneAsync(x => x.Id == livePage.Id, livePage);
|
||||
return livePage;
|
||||
}
|
||||
|
||||
public async Task<bool> DeleteAsync(string id)
|
||||
{
|
||||
var result = await _collection.DeleteOneAsync(x => x.Id == id);
|
||||
return result.DeletedCount > 0;
|
||||
}
|
||||
|
||||
public async Task<bool> DeleteByOriginalPageIdAsync(string originalPageId)
|
||||
{
|
||||
var result = await _collection.DeleteOneAsync(x => x.OriginalPageId == originalPageId);
|
||||
return result.DeletedCount > 0;
|
||||
}
|
||||
|
||||
public async Task<bool> ExistsByCategoryAndSlugAsync(string category, string slug, string? excludeId = null)
|
||||
{
|
||||
var filter = Builders<LivePage>.Filter.And(
|
||||
Builders<LivePage>.Filter.Eq(x => x.Category, category),
|
||||
Builders<LivePage>.Filter.Eq(x => x.Slug, slug)
|
||||
);
|
||||
|
||||
if (!string.IsNullOrEmpty(excludeId))
|
||||
{
|
||||
filter = Builders<LivePage>.Filter.And(filter,
|
||||
Builders<LivePage>.Filter.Ne(x => x.Id, excludeId));
|
||||
}
|
||||
|
||||
return await _collection.Find(filter).AnyAsync();
|
||||
}
|
||||
|
||||
public async Task IncrementViewAsync(string id)
|
||||
{
|
||||
var update = Builders<LivePage>.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<LivePage>.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<LivePage> _collection;
|
||||
|
||||
public LivePageRepository(IMongoDatabase database)
|
||||
{
|
||||
_collection = database.GetCollection<LivePage>("livepages");
|
||||
|
||||
// Criar índices essenciais
|
||||
CreateIndexes();
|
||||
}
|
||||
|
||||
private void CreateIndexes()
|
||||
{
|
||||
try
|
||||
{
|
||||
// Índice único para category + slug
|
||||
var categorySlugIndex = Builders<LivePage>.IndexKeys
|
||||
.Ascending(x => x.Category)
|
||||
.Ascending(x => x.Slug);
|
||||
|
||||
var uniqueOptions = new CreateIndexOptions { Unique = true };
|
||||
_collection.Indexes.CreateOneAsync(new CreateIndexModel<LivePage>(categorySlugIndex, uniqueOptions));
|
||||
|
||||
// Outros índices importantes
|
||||
_collection.Indexes.CreateOneAsync(new CreateIndexModel<LivePage>(
|
||||
Builders<LivePage>.IndexKeys.Ascending(x => x.UserId)));
|
||||
|
||||
_collection.Indexes.CreateOneAsync(new CreateIndexModel<LivePage>(
|
||||
Builders<LivePage>.IndexKeys.Descending(x => x.PublishedAt)));
|
||||
|
||||
_collection.Indexes.CreateOneAsync(new CreateIndexModel<LivePage>(
|
||||
Builders<LivePage>.IndexKeys.Ascending(x => x.OriginalPageId)));
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Ignora erros de criação de índices (já podem existir)
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<LivePage?> GetByIdAsync(string pageId)
|
||||
{
|
||||
return await _collection.Find(x => x.Id == pageId).FirstOrDefaultAsync();
|
||||
}
|
||||
|
||||
public async Task<LivePage?> GetByCategoryAndSlugAsync(string category, string slug)
|
||||
{
|
||||
return await _collection.Find(x => x.Category == category && x.Slug == slug).FirstOrDefaultAsync();
|
||||
}
|
||||
|
||||
public async Task<LivePage?> GetByOriginalPageIdAsync(string originalPageId)
|
||||
{
|
||||
return await _collection.Find(x => x.OriginalPageId == originalPageId).FirstOrDefaultAsync();
|
||||
}
|
||||
|
||||
public async Task<List<LivePage>> GetAllActiveAsync()
|
||||
{
|
||||
return await _collection.Find(x => true)
|
||||
.Sort(Builders<LivePage>.Sort.Descending(x => x.PublishedAt))
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
public async Task<LivePage> CreateAsync(LivePage livePage)
|
||||
{
|
||||
livePage.CreatedAt = DateTime.UtcNow;
|
||||
livePage.LastSyncAt = DateTime.UtcNow;
|
||||
await _collection.InsertOneAsync(livePage);
|
||||
return livePage;
|
||||
}
|
||||
|
||||
public async Task<LivePage> UpdateAsync(LivePage livePage)
|
||||
{
|
||||
livePage.LastSyncAt = DateTime.UtcNow;
|
||||
await _collection.ReplaceOneAsync(x => x.Id == livePage.Id, livePage);
|
||||
return livePage;
|
||||
}
|
||||
|
||||
public async Task<bool> DeleteAsync(string id)
|
||||
{
|
||||
var result = await _collection.DeleteOneAsync(x => x.Id == id);
|
||||
return result.DeletedCount > 0;
|
||||
}
|
||||
|
||||
public async Task<bool> DeleteByOriginalPageIdAsync(string originalPageId)
|
||||
{
|
||||
var result = await _collection.DeleteOneAsync(x => x.OriginalPageId == originalPageId);
|
||||
return result.DeletedCount > 0;
|
||||
}
|
||||
|
||||
public async Task<bool> ExistsByCategoryAndSlugAsync(string category, string slug, string? excludeId = null)
|
||||
{
|
||||
var filter = Builders<LivePage>.Filter.And(
|
||||
Builders<LivePage>.Filter.Eq(x => x.Category, category),
|
||||
Builders<LivePage>.Filter.Eq(x => x.Slug, slug)
|
||||
);
|
||||
|
||||
if (!string.IsNullOrEmpty(excludeId))
|
||||
{
|
||||
filter = Builders<LivePage>.Filter.And(filter,
|
||||
Builders<LivePage>.Filter.Ne(x => x.Id, excludeId));
|
||||
}
|
||||
|
||||
return await _collection.Find(filter).AnyAsync();
|
||||
}
|
||||
|
||||
public async Task IncrementViewAsync(string id)
|
||||
{
|
||||
var update = Builders<LivePage>.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<LivePage>.Update
|
||||
.Inc(x => x.Analytics.TotalClicks, 1);
|
||||
|
||||
await _collection.UpdateOneAsync(x => x.Id == id, update);
|
||||
}
|
||||
}
|
||||
@ -1,241 +1,241 @@
|
||||
using BCards.Web.Models;
|
||||
using MongoDB.Driver;
|
||||
|
||||
namespace BCards.Web.Repositories;
|
||||
|
||||
public class UserPageRepository : IUserPageRepository
|
||||
{
|
||||
private readonly IMongoCollection<UserPage> _pages;
|
||||
|
||||
public UserPageRepository(IMongoDatabase database)
|
||||
{
|
||||
_pages = database.GetCollection<UserPage>("userpages");
|
||||
|
||||
// Create indexes
|
||||
var slugIndex = Builders<UserPage>.IndexKeys
|
||||
.Ascending(x => x.Category)
|
||||
.Ascending(x => x.Slug);
|
||||
var collation = new Collation("en", strength: CollationStrength.Primary); // Case-insensitive
|
||||
_pages.Indexes.CreateOneAsync(new CreateIndexModel<UserPage>(slugIndex, new CreateIndexOptions {
|
||||
Unique = true,
|
||||
Collation = collation
|
||||
}));
|
||||
|
||||
var userIndex = Builders<UserPage>.IndexKeys.Ascending(x => x.UserId);
|
||||
_pages.Indexes.CreateOneAsync(new CreateIndexModel<UserPage>(userIndex));
|
||||
|
||||
var categoryIndex = Builders<UserPage>.IndexKeys.Ascending(x => x.Category);
|
||||
_pages.Indexes.CreateOneAsync(new CreateIndexModel<UserPage>(categoryIndex));
|
||||
}
|
||||
|
||||
public async Task<UserPage?> 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<UserPage?> GetBySlugAsync(string category, string slug)
|
||||
{
|
||||
// Usar filtro com collation case-insensitive para melhor performance
|
||||
var filter = Builders<UserPage>.Filter.And(
|
||||
Builders<UserPage>.Filter.Eq(x => x.Category, category),
|
||||
Builders<UserPage>.Filter.Eq(x => x.Slug, slug),
|
||||
Builders<UserPage>.Filter.Eq(x => x.IsActive, true)
|
||||
);
|
||||
|
||||
var collation = new Collation("en", strength: CollationStrength.Primary);
|
||||
var findOptions = new FindOptions<UserPage>
|
||||
{
|
||||
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<UserPage?> GetByUserIdAsync(string userId)
|
||||
{
|
||||
return await _pages.Find(x => x.UserId == userId && x.IsActive).FirstOrDefaultAsync();
|
||||
}
|
||||
|
||||
public async Task<List<UserPage>> GetByUserIdAllAsync(string userId)
|
||||
{
|
||||
return await _pages.Find(x => x.UserId == userId && x.IsActive).ToListAsync();
|
||||
}
|
||||
|
||||
public async Task<UserPage> CreateAsync(UserPage userPage)
|
||||
{
|
||||
userPage.CreatedAt = DateTime.UtcNow;
|
||||
userPage.UpdatedAt = DateTime.UtcNow;
|
||||
await _pages.InsertOneAsync(userPage);
|
||||
return userPage;
|
||||
}
|
||||
|
||||
public async Task<UserPage> 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<UserPage>.Update.Set(x => x.IsActive, false).Set(x => x.UpdatedAt, DateTime.UtcNow)
|
||||
);
|
||||
}
|
||||
|
||||
public async Task<bool> SlugExistsAsync(string category, string slug, string? excludeId = null)
|
||||
{
|
||||
var filter = Builders<UserPage>.Filter.And(
|
||||
Builders<UserPage>.Filter.Eq(x => x.Category, category),
|
||||
Builders<UserPage>.Filter.Eq(x => x.Slug, slug)
|
||||
);
|
||||
|
||||
if (!string.IsNullOrEmpty(excludeId))
|
||||
{
|
||||
filter = Builders<UserPage>.Filter.And(filter,
|
||||
Builders<UserPage>.Filter.Ne(x => x.Id, excludeId));
|
||||
}
|
||||
|
||||
return await _pages.Find(filter).AnyAsync();
|
||||
}
|
||||
|
||||
public async Task<List<UserPage>> GetRecentPagesAsync(int limit = 10)
|
||||
{
|
||||
return await _pages.Find(x => x.IsActive && x.PublishedAt != null)
|
||||
.SortByDescending(x => x.PublishedAt)
|
||||
.Limit(limit)
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
public async Task<List<UserPage>> 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<List<UserPage>> 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<UserPage>.Update
|
||||
.Set(x => x.Analytics, analytics)
|
||||
.Set(x => x.UpdatedAt, DateTime.UtcNow)
|
||||
);
|
||||
}
|
||||
|
||||
// Adicione estes m<>todos no UserPageRepository.cs
|
||||
|
||||
public async Task<List<UserPage>> GetManyAsync(
|
||||
FilterDefinition<UserPage> filter,
|
||||
SortDefinition<UserPage>? 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<long> CountAsync(FilterDefinition<UserPage> filter)
|
||||
{
|
||||
return await _pages.CountDocumentsAsync(filter);
|
||||
}
|
||||
|
||||
// M<>todo espec<65>fico para modera<72><61>o (mais simples)
|
||||
public async Task<List<UserPage>> GetPendingModerationAsync(int skip = 0, int take = 20)
|
||||
{
|
||||
var filter = Builders<UserPage>.Filter.Eq(x => x.Status, BCards.Web.ViewModels.PageStatus.PendingModeration);
|
||||
|
||||
var sort = Builders<UserPage>.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<UpdateResult> UpdateAsync(string id, UpdateDefinition<UserPage> update)
|
||||
{
|
||||
var combinedUpdate = Builders<UserPage>.Update
|
||||
.Combine(update, Builders<UserPage>.Update.Set(x => x.UpdatedAt, DateTime.UtcNow));
|
||||
|
||||
return await _pages.UpdateOneAsync(x => x.Id == id, combinedUpdate);
|
||||
}
|
||||
|
||||
public async Task<UpdateResult> UpdateManyAsync(FilterDefinition<UserPage> filter, UpdateDefinition<UserPage> update)
|
||||
{
|
||||
var combinedUpdate = Builders<UserPage>.Update
|
||||
.Combine(update, Builders<UserPage>.Update.Set(x => x.UpdatedAt, DateTime.UtcNow));
|
||||
|
||||
return await _pages.UpdateManyAsync(filter, combinedUpdate);
|
||||
}
|
||||
|
||||
// M<>todos espec<65>ficos para modera<72><61>o (mais f<>ceis de usar)
|
||||
public async Task<bool> ApprovePageAsync(string pageId)
|
||||
{
|
||||
var update = Builders<UserPage>.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<bool> RejectPageAsync(string pageId, string reason, List<string> issues)
|
||||
{
|
||||
var page = await GetByIdAsync(pageId);
|
||||
if (page == null) return false;
|
||||
|
||||
// Adicionar <20> hist<73>ria de modera<72><61>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<UserPage>.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<UserPage> _pages;
|
||||
|
||||
public UserPageRepository(IMongoDatabase database)
|
||||
{
|
||||
_pages = database.GetCollection<UserPage>("userpages");
|
||||
|
||||
// Create indexes
|
||||
var slugIndex = Builders<UserPage>.IndexKeys
|
||||
.Ascending(x => x.Category)
|
||||
.Ascending(x => x.Slug);
|
||||
var collation = new Collation("en", strength: CollationStrength.Primary); // Case-insensitive
|
||||
_pages.Indexes.CreateOneAsync(new CreateIndexModel<UserPage>(slugIndex, new CreateIndexOptions {
|
||||
Unique = true,
|
||||
Collation = collation
|
||||
}));
|
||||
|
||||
var userIndex = Builders<UserPage>.IndexKeys.Ascending(x => x.UserId);
|
||||
_pages.Indexes.CreateOneAsync(new CreateIndexModel<UserPage>(userIndex));
|
||||
|
||||
var categoryIndex = Builders<UserPage>.IndexKeys.Ascending(x => x.Category);
|
||||
_pages.Indexes.CreateOneAsync(new CreateIndexModel<UserPage>(categoryIndex));
|
||||
}
|
||||
|
||||
public async Task<UserPage?> 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<UserPage?> GetBySlugAsync(string category, string slug)
|
||||
{
|
||||
// Usar filtro com collation case-insensitive para melhor performance
|
||||
var filter = Builders<UserPage>.Filter.And(
|
||||
Builders<UserPage>.Filter.Eq(x => x.Category, category),
|
||||
Builders<UserPage>.Filter.Eq(x => x.Slug, slug),
|
||||
Builders<UserPage>.Filter.Eq(x => x.IsActive, true)
|
||||
);
|
||||
|
||||
var collation = new Collation("en", strength: CollationStrength.Primary);
|
||||
var findOptions = new FindOptions<UserPage>
|
||||
{
|
||||
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<UserPage?> GetByUserIdAsync(string userId)
|
||||
{
|
||||
return await _pages.Find(x => x.UserId == userId && x.IsActive).FirstOrDefaultAsync();
|
||||
}
|
||||
|
||||
public async Task<List<UserPage>> GetByUserIdAllAsync(string userId)
|
||||
{
|
||||
return await _pages.Find(x => x.UserId == userId && x.IsActive).ToListAsync();
|
||||
}
|
||||
|
||||
public async Task<UserPage> CreateAsync(UserPage userPage)
|
||||
{
|
||||
userPage.CreatedAt = DateTime.UtcNow;
|
||||
userPage.UpdatedAt = DateTime.UtcNow;
|
||||
await _pages.InsertOneAsync(userPage);
|
||||
return userPage;
|
||||
}
|
||||
|
||||
public async Task<UserPage> 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<UserPage>.Update.Set(x => x.IsActive, false).Set(x => x.UpdatedAt, DateTime.UtcNow)
|
||||
);
|
||||
}
|
||||
|
||||
public async Task<bool> SlugExistsAsync(string category, string slug, string? excludeId = null)
|
||||
{
|
||||
var filter = Builders<UserPage>.Filter.And(
|
||||
Builders<UserPage>.Filter.Eq(x => x.Category, category),
|
||||
Builders<UserPage>.Filter.Eq(x => x.Slug, slug)
|
||||
);
|
||||
|
||||
if (!string.IsNullOrEmpty(excludeId))
|
||||
{
|
||||
filter = Builders<UserPage>.Filter.And(filter,
|
||||
Builders<UserPage>.Filter.Ne(x => x.Id, excludeId));
|
||||
}
|
||||
|
||||
return await _pages.Find(filter).AnyAsync();
|
||||
}
|
||||
|
||||
public async Task<List<UserPage>> GetRecentPagesAsync(int limit = 10)
|
||||
{
|
||||
return await _pages.Find(x => x.IsActive && x.PublishedAt != null)
|
||||
.SortByDescending(x => x.PublishedAt)
|
||||
.Limit(limit)
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
public async Task<List<UserPage>> 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<List<UserPage>> 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<UserPage>.Update
|
||||
.Set(x => x.Analytics, analytics)
|
||||
.Set(x => x.UpdatedAt, DateTime.UtcNow)
|
||||
);
|
||||
}
|
||||
|
||||
// Adicione estes m<>todos no UserPageRepository.cs
|
||||
|
||||
public async Task<List<UserPage>> GetManyAsync(
|
||||
FilterDefinition<UserPage> filter,
|
||||
SortDefinition<UserPage>? 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<long> CountAsync(FilterDefinition<UserPage> filter)
|
||||
{
|
||||
return await _pages.CountDocumentsAsync(filter);
|
||||
}
|
||||
|
||||
// M<>todo espec<65>fico para modera<72><61>o (mais simples)
|
||||
public async Task<List<UserPage>> GetPendingModerationAsync(int skip = 0, int take = 20)
|
||||
{
|
||||
var filter = Builders<UserPage>.Filter.Eq(x => x.Status, BCards.Web.ViewModels.PageStatus.PendingModeration);
|
||||
|
||||
var sort = Builders<UserPage>.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<UpdateResult> UpdateAsync(string id, UpdateDefinition<UserPage> update)
|
||||
{
|
||||
var combinedUpdate = Builders<UserPage>.Update
|
||||
.Combine(update, Builders<UserPage>.Update.Set(x => x.UpdatedAt, DateTime.UtcNow));
|
||||
|
||||
return await _pages.UpdateOneAsync(x => x.Id == id, combinedUpdate);
|
||||
}
|
||||
|
||||
public async Task<UpdateResult> UpdateManyAsync(FilterDefinition<UserPage> filter, UpdateDefinition<UserPage> update)
|
||||
{
|
||||
var combinedUpdate = Builders<UserPage>.Update
|
||||
.Combine(update, Builders<UserPage>.Update.Set(x => x.UpdatedAt, DateTime.UtcNow));
|
||||
|
||||
return await _pages.UpdateManyAsync(filter, combinedUpdate);
|
||||
}
|
||||
|
||||
// M<>todos espec<65>ficos para modera<72><61>o (mais f<>ceis de usar)
|
||||
public async Task<bool> ApprovePageAsync(string pageId)
|
||||
{
|
||||
var update = Builders<UserPage>.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<bool> RejectPageAsync(string pageId, string reason, List<string> issues)
|
||||
{
|
||||
var page = await GetByIdAsync(pageId);
|
||||
if (page == null) return false;
|
||||
|
||||
// Adicionar <20> hist<73>ria de modera<72><61>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<UserPage>.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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@ -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<List<Category>> GetAllCategoriesAsync()
|
||||
{
|
||||
return await _categoryRepository.GetAllActiveAsync();
|
||||
}
|
||||
|
||||
public async Task<Category?> GetCategoryBySlugAsync(string slug)
|
||||
{
|
||||
return await _categoryRepository.GetBySlugAsync(SlugHelper.CreateSlug(slug));
|
||||
}
|
||||
|
||||
public async Task<string> 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<bool> 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<string> { "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<string> { "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<string> { "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<string> { "educação", "ensino", "professor", "curso", "escola" }
|
||||
},
|
||||
new Category
|
||||
{
|
||||
Name = "Comércio",
|
||||
Slug = "comercio",
|
||||
Icon = "🛍️",
|
||||
Description = "Lojas, e-commerce e estabelecimentos comerciais",
|
||||
SeoKeywords = new List<string> { "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<string> { "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<string> { "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<string> { "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<string> { "advogado", "jurídico", "direito", "advocacia", "legal" }
|
||||
},
|
||||
new Category
|
||||
{
|
||||
Name = "Arquitetura",
|
||||
Slug = "arquitetura",
|
||||
Icon = "🏗️",
|
||||
Description = "Arquitetos, engenheiros e profissionais da construção",
|
||||
SeoKeywords = new List<string> { "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<List<Category>> GetAllCategoriesAsync()
|
||||
{
|
||||
return await _categoryRepository.GetAllActiveAsync();
|
||||
}
|
||||
|
||||
public async Task<Category?> GetCategoryBySlugAsync(string slug)
|
||||
{
|
||||
return await _categoryRepository.GetBySlugAsync(SlugHelper.CreateSlug(slug));
|
||||
}
|
||||
|
||||
public async Task<string> 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<bool> 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<string> { "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<string> { "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<string> { "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<string> { "educação", "ensino", "professor", "curso", "escola" }
|
||||
},
|
||||
new Category
|
||||
{
|
||||
Name = "Comércio",
|
||||
Slug = "comercio",
|
||||
Icon = "🛍️",
|
||||
Description = "Lojas, e-commerce e estabelecimentos comerciais",
|
||||
SeoKeywords = new List<string> { "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<string> { "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<string> { "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<string> { "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<string> { "advogado", "jurídico", "direito", "advocacia", "legal" }
|
||||
},
|
||||
new Category
|
||||
{
|
||||
Name = "Arquitetura",
|
||||
Slug = "arquitetura",
|
||||
Icon = "🏗️",
|
||||
Description = "Arquitetos, engenheiros e profissionais da construção",
|
||||
SeoKeywords = new List<string> { "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('-');
|
||||
}
|
||||
}
|
||||
@ -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<EmailService> _logger;
|
||||
|
||||
public EmailService(
|
||||
ISendGridClient sendGridClient,
|
||||
IConfiguration configuration,
|
||||
ILogger<EmailService> 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 = $@"
|
||||
<div style='font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;'>
|
||||
<h2 style='color: #333;'>Nova página para moderação</h2>
|
||||
<div style='background: #f8f9fa; padding: 20px; border-radius: 5px; margin: 20px 0;'>
|
||||
<p><strong>Título:</strong> {pageTitle}</p>
|
||||
<p><strong>Usuário:</strong> {userName}</p>
|
||||
<p><strong>Plano:</strong> {planType}</p>
|
||||
<p><strong>Prioridade:</strong> <span style='color: {GetPriorityColor(planType)};'>{priority}</span></p>
|
||||
<p><strong>ID da Página:</strong> {pageId}</p>
|
||||
</div>
|
||||
<p>
|
||||
<a href='{_configuration["BaseUrl"]}/moderation/review/{pageId}'
|
||||
style='background: #007bff; color: white; padding: 10px 20px; text-decoration: none; border-radius: 5px;'>
|
||||
Moderar Página
|
||||
</a>
|
||||
</p>
|
||||
</div>";
|
||||
|
||||
await SendEmailAsync(moderatorEmail, subject, htmlContent);
|
||||
}
|
||||
|
||||
public async Task<bool> 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)
|
||||
? $"<p><a href='{previewUrl}' style='background: #28a745; color: white; padding: 12px 24px; text-decoration: none; border-radius: 5px; display: inline-block;'>Ver Preview</a></p>"
|
||||
: "";
|
||||
|
||||
var htmlContent = $@"
|
||||
<div style='font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;'>
|
||||
<h2 style='color: #333;'>Olá {userName}!</h2>
|
||||
<p>Sua página <strong>'{pageTitle}'</strong> foi enviada para análise e estará disponível em breve!</p>
|
||||
|
||||
<div style='background: #e3f2fd; padding: 20px; border-radius: 5px; margin: 20px 0;'>
|
||||
<p>🔍 <strong>Tempo estimado:</strong> 3-7 dias úteis</p>
|
||||
<p>👀 <strong>Status:</strong> Em análise</p>
|
||||
</div>
|
||||
|
||||
<p>Nossa equipe verifica se o conteúdo segue nossos termos de uso para manter a qualidade da plataforma.</p>
|
||||
|
||||
{previewButton}
|
||||
|
||||
<hr style='margin: 30px 0;'>
|
||||
<p style='color: #666; font-size: 14px;'>
|
||||
Você receberá outro email assim que sua página for aprovada ou se precisar de ajustes.
|
||||
</p>
|
||||
</div>";
|
||||
|
||||
return (subject, htmlContent);
|
||||
}
|
||||
|
||||
private (string subject, string htmlContent) GetApprovedTemplate(string userName, string pageTitle)
|
||||
{
|
||||
var subject = "✅ Sua página foi aprovada! - bcards.site";
|
||||
var htmlContent = $@"
|
||||
<div style='font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;'>
|
||||
<h2 style='color: #28a745;'>Parabéns {userName}! 🎉</h2>
|
||||
<p>Sua página <strong>'{pageTitle}'</strong> foi aprovada e já está no ar!</p>
|
||||
|
||||
<div style='background: #d4edda; padding: 20px; border-radius: 5px; margin: 20px 0;'>
|
||||
<p>✅ <strong>Status:</strong> Aprovada</p>
|
||||
<p>🌐 <strong>Sua página está online!</strong></p>
|
||||
</div>
|
||||
|
||||
<p>Agora você pode:</p>
|
||||
<ul>
|
||||
<li>Compartilhar sua página nas redes sociais</li>
|
||||
<li>Adicionar o link na sua bio</li>
|
||||
<li>Acompanhar as estatísticas no painel</li>
|
||||
</ul>
|
||||
|
||||
<p>
|
||||
<a href='{_configuration["BaseUrl"]}/admin/dashboard'
|
||||
style='background: #007bff; color: white; padding: 12px 24px; text-decoration: none; border-radius: 5px; display: inline-block;'>
|
||||
Acessar Painel
|
||||
</a>
|
||||
</p>
|
||||
</div>";
|
||||
|
||||
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) ? $"<p><strong>Motivo:</strong> {reason}</p>" : "";
|
||||
|
||||
var htmlContent = $@"
|
||||
<div style='font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;'>
|
||||
<h2 style='color: #dc3545;'>Olá {userName}</h2>
|
||||
<p>Sua página <strong>'{pageTitle}'</strong> não foi aprovada, mas você pode corrigir e reenviar!</p>
|
||||
|
||||
<div style='background: #f8d7da; padding: 20px; border-radius: 5px; margin: 20px 0;'>
|
||||
<p>❌ <strong>Status:</strong> Necessita ajustes</p>
|
||||
{reasonText}
|
||||
</div>
|
||||
|
||||
<p>Para que sua página seja aprovada, certifique-se de que:</p>
|
||||
<ul>
|
||||
<li>Não contém conteúdo proibido ou suspeito</li>
|
||||
<li>Todos os links estão funcionando</li>
|
||||
<li>As informações são precisas</li>
|
||||
<li>Segue nossos termos de uso</li>
|
||||
</ul>
|
||||
|
||||
<p>
|
||||
<a href='{_configuration["BaseUrl"]}/admin/dashboard'
|
||||
style='background: #dc3545; color: white; padding: 12px 24px; text-decoration: none; border-radius: 5px; display: inline-block;'>
|
||||
Editar Página
|
||||
</a>
|
||||
</p>
|
||||
</div>";
|
||||
|
||||
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<EmailService> _logger;
|
||||
|
||||
public EmailService(
|
||||
ISendGridClient sendGridClient,
|
||||
IConfiguration configuration,
|
||||
ILogger<EmailService> 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 = $@"
|
||||
<div style='font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;'>
|
||||
<h2 style='color: #333;'>Nova página para moderação</h2>
|
||||
<div style='background: #f8f9fa; padding: 20px; border-radius: 5px; margin: 20px 0;'>
|
||||
<p><strong>Título:</strong> {pageTitle}</p>
|
||||
<p><strong>Usuário:</strong> {userName}</p>
|
||||
<p><strong>Plano:</strong> {planType}</p>
|
||||
<p><strong>Prioridade:</strong> <span style='color: {GetPriorityColor(planType)};'>{priority}</span></p>
|
||||
<p><strong>ID da Página:</strong> {pageId}</p>
|
||||
</div>
|
||||
<p>
|
||||
<a href='{_configuration["BaseUrl"]}/moderation/review/{pageId}'
|
||||
style='background: #007bff; color: white; padding: 10px 20px; text-decoration: none; border-radius: 5px;'>
|
||||
Moderar Página
|
||||
</a>
|
||||
</p>
|
||||
</div>";
|
||||
|
||||
await SendEmailAsync(moderatorEmail, subject, htmlContent);
|
||||
}
|
||||
|
||||
public async Task<bool> 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)
|
||||
? $"<p><a href='{previewUrl}' style='background: #28a745; color: white; padding: 12px 24px; text-decoration: none; border-radius: 5px; display: inline-block;'>Ver Preview</a></p>"
|
||||
: "";
|
||||
|
||||
var htmlContent = $@"
|
||||
<div style='font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;'>
|
||||
<h2 style='color: #333;'>Olá {userName}!</h2>
|
||||
<p>Sua página <strong>'{pageTitle}'</strong> foi enviada para análise e estará disponível em breve!</p>
|
||||
|
||||
<div style='background: #e3f2fd; padding: 20px; border-radius: 5px; margin: 20px 0;'>
|
||||
<p>🔍 <strong>Tempo estimado:</strong> 3-7 dias úteis</p>
|
||||
<p>👀 <strong>Status:</strong> Em análise</p>
|
||||
</div>
|
||||
|
||||
<p>Nossa equipe verifica se o conteúdo segue nossos termos de uso para manter a qualidade da plataforma.</p>
|
||||
|
||||
{previewButton}
|
||||
|
||||
<hr style='margin: 30px 0;'>
|
||||
<p style='color: #666; font-size: 14px;'>
|
||||
Você receberá outro email assim que sua página for aprovada ou se precisar de ajustes.
|
||||
</p>
|
||||
</div>";
|
||||
|
||||
return (subject, htmlContent);
|
||||
}
|
||||
|
||||
private (string subject, string htmlContent) GetApprovedTemplate(string userName, string pageTitle)
|
||||
{
|
||||
var subject = "✅ Sua página foi aprovada! - bcards.site";
|
||||
var htmlContent = $@"
|
||||
<div style='font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;'>
|
||||
<h2 style='color: #28a745;'>Parabéns {userName}! 🎉</h2>
|
||||
<p>Sua página <strong>'{pageTitle}'</strong> foi aprovada e já está no ar!</p>
|
||||
|
||||
<div style='background: #d4edda; padding: 20px; border-radius: 5px; margin: 20px 0;'>
|
||||
<p>✅ <strong>Status:</strong> Aprovada</p>
|
||||
<p>🌐 <strong>Sua página está online!</strong></p>
|
||||
</div>
|
||||
|
||||
<p>Agora você pode:</p>
|
||||
<ul>
|
||||
<li>Compartilhar sua página nas redes sociais</li>
|
||||
<li>Adicionar o link na sua bio</li>
|
||||
<li>Acompanhar as estatísticas no painel</li>
|
||||
</ul>
|
||||
|
||||
<p>
|
||||
<a href='{_configuration["BaseUrl"]}/admin/dashboard'
|
||||
style='background: #007bff; color: white; padding: 12px 24px; text-decoration: none; border-radius: 5px; display: inline-block;'>
|
||||
Acessar Painel
|
||||
</a>
|
||||
</p>
|
||||
</div>";
|
||||
|
||||
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) ? $"<p><strong>Motivo:</strong> {reason}</p>" : "";
|
||||
|
||||
var htmlContent = $@"
|
||||
<div style='font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;'>
|
||||
<h2 style='color: #dc3545;'>Olá {userName}</h2>
|
||||
<p>Sua página <strong>'{pageTitle}'</strong> não foi aprovada, mas você pode corrigir e reenviar!</p>
|
||||
|
||||
<div style='background: #f8d7da; padding: 20px; border-radius: 5px; margin: 20px 0;'>
|
||||
<p>❌ <strong>Status:</strong> Necessita ajustes</p>
|
||||
{reasonText}
|
||||
</div>
|
||||
|
||||
<p>Para que sua página seja aprovada, certifique-se de que:</p>
|
||||
<ul>
|
||||
<li>Não contém conteúdo proibido ou suspeito</li>
|
||||
<li>Todos os links estão funcionando</li>
|
||||
<li>As informações são precisas</li>
|
||||
<li>Segue nossos termos de uso</li>
|
||||
</ul>
|
||||
|
||||
<p>
|
||||
<a href='{_configuration["BaseUrl"]}/admin/dashboard'
|
||||
style='background: #dc3545; color: white; padding: 12px 24px; text-decoration: none; border-radius: 5px; display: inline-block;'>
|
||||
Editar Página
|
||||
</a>
|
||||
</p>
|
||||
</div>";
|
||||
|
||||
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"
|
||||
};
|
||||
}
|
||||
@ -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<GridFSImageStorage> _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<GridFSImageStorage> logger)
|
||||
{
|
||||
_database = database;
|
||||
_gridFS = new GridFSBucket(database, new GridFSBucketOptions
|
||||
{
|
||||
BucketName = "profile_images"
|
||||
});
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<string> 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<byte[]?> 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<bool> 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<bool> ImageExistsAsync(string imageId)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (string.IsNullOrEmpty(imageId))
|
||||
return false;
|
||||
|
||||
if (!ObjectId.TryParse(imageId, out var objectId))
|
||||
return false;
|
||||
|
||||
var filter = Builders<GridFSFileInfo>.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<byte[]> 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<SixLabors.ImageSharp.PixelFormats.Rgb24>(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<GridFSImageStorage> _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<GridFSImageStorage> logger)
|
||||
{
|
||||
_database = database;
|
||||
_gridFS = new GridFSBucket(database, new GridFSBucketOptions
|
||||
{
|
||||
BucketName = "profile_images"
|
||||
});
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<string> 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<byte[]?> 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<bool> 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<bool> ImageExistsAsync(string imageId)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (string.IsNullOrEmpty(imageId))
|
||||
return false;
|
||||
|
||||
if (!ObjectId.TryParse(imageId, out var objectId))
|
||||
return false;
|
||||
|
||||
var filter = Builders<GridFSFileInfo>.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<byte[]> 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<SixLabors.ImageSharp.PixelFormats.Rgb24>(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}");
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@ -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<bool> 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<bool> SendEmailAsync(string to, string subject, string htmlContent);
|
||||
}
|
||||
@ -1,34 +1,34 @@
|
||||
namespace BCards.Web.Services;
|
||||
|
||||
public interface IImageStorageService
|
||||
{
|
||||
/// <summary>
|
||||
/// Salva uma imagem no storage, com redimensionamento automático para 400x400px
|
||||
/// </summary>
|
||||
/// <param name="imageBytes">Bytes da imagem original</param>
|
||||
/// <param name="fileName">Nome original do arquivo</param>
|
||||
/// <param name="contentType">Tipo de conteúdo da imagem</param>
|
||||
/// <returns>ID único da imagem salva</returns>
|
||||
Task<string> SaveImageAsync(byte[] imageBytes, string fileName, string contentType);
|
||||
|
||||
/// <summary>
|
||||
/// Recupera os bytes de uma imagem pelo ID
|
||||
/// </summary>
|
||||
/// <param name="imageId">ID da imagem</param>
|
||||
/// <returns>Bytes da imagem ou null se não encontrada</returns>
|
||||
Task<byte[]?> GetImageAsync(string imageId);
|
||||
|
||||
/// <summary>
|
||||
/// Remove uma imagem do storage
|
||||
/// </summary>
|
||||
/// <param name="imageId">ID da imagem</param>
|
||||
/// <returns>True se removida com sucesso</returns>
|
||||
Task<bool> DeleteImageAsync(string imageId);
|
||||
|
||||
/// <summary>
|
||||
/// Verifica se uma imagem existe no storage
|
||||
/// </summary>
|
||||
/// <param name="imageId">ID da imagem</param>
|
||||
/// <returns>True se a imagem existe</returns>
|
||||
Task<bool> ImageExistsAsync(string imageId);
|
||||
namespace BCards.Web.Services;
|
||||
|
||||
public interface IImageStorageService
|
||||
{
|
||||
/// <summary>
|
||||
/// Salva uma imagem no storage, com redimensionamento automático para 400x400px
|
||||
/// </summary>
|
||||
/// <param name="imageBytes">Bytes da imagem original</param>
|
||||
/// <param name="fileName">Nome original do arquivo</param>
|
||||
/// <param name="contentType">Tipo de conteúdo da imagem</param>
|
||||
/// <returns>ID único da imagem salva</returns>
|
||||
Task<string> SaveImageAsync(byte[] imageBytes, string fileName, string contentType);
|
||||
|
||||
/// <summary>
|
||||
/// Recupera os bytes de uma imagem pelo ID
|
||||
/// </summary>
|
||||
/// <param name="imageId">ID da imagem</param>
|
||||
/// <returns>Bytes da imagem ou null se não encontrada</returns>
|
||||
Task<byte[]?> GetImageAsync(string imageId);
|
||||
|
||||
/// <summary>
|
||||
/// Remove uma imagem do storage
|
||||
/// </summary>
|
||||
/// <param name="imageId">ID da imagem</param>
|
||||
/// <returns>True se removida com sucesso</returns>
|
||||
Task<bool> DeleteImageAsync(string imageId);
|
||||
|
||||
/// <summary>
|
||||
/// Verifica se uma imagem existe no storage
|
||||
/// </summary>
|
||||
/// <param name="imageId">ID da imagem</param>
|
||||
/// <returns>True se a imagem existe</returns>
|
||||
Task<bool> ImageExistsAsync(string imageId);
|
||||
}
|
||||
@ -1,14 +1,14 @@
|
||||
using BCards.Web.Models;
|
||||
|
||||
namespace BCards.Web.Services;
|
||||
|
||||
public interface ILivePageService
|
||||
{
|
||||
Task<LivePage?> GetByCategoryAndSlugAsync(string category, string slug);
|
||||
Task<List<LivePage>> GetAllActiveAsync();
|
||||
Task<LivePage?> GetLivePageFromUserPageId(string userPageId);
|
||||
Task<LivePage> SyncFromUserPageAsync(string userPageId);
|
||||
Task<bool> 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<LivePage?> GetByCategoryAndSlugAsync(string category, string slug);
|
||||
Task<List<LivePage>> GetAllActiveAsync();
|
||||
Task<LivePage?> GetLivePageFromUserPageId(string userPageId);
|
||||
Task<LivePage> SyncFromUserPageAsync(string userPageId);
|
||||
Task<bool> DeleteByOriginalPageIdAsync(string originalPageId);
|
||||
Task IncrementViewAsync(string livePageId);
|
||||
Task IncrementLinkClickAsync(string livePageId, int linkIndex);
|
||||
}
|
||||
@ -1,11 +1,11 @@
|
||||
using System.Security.Claims;
|
||||
|
||||
namespace BCards.Web.Services
|
||||
{
|
||||
public interface IModerationAuthService
|
||||
{
|
||||
bool IsUserModerator(ClaimsPrincipal user);
|
||||
bool IsEmailModerator(string email);
|
||||
List<string> GetModeratorEmails();
|
||||
}
|
||||
}
|
||||
using System.Security.Claims;
|
||||
|
||||
namespace BCards.Web.Services
|
||||
{
|
||||
public interface IModerationAuthService
|
||||
{
|
||||
bool IsUserModerator(ClaimsPrincipal user);
|
||||
bool IsEmailModerator(string email);
|
||||
List<string> GetModeratorEmails();
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,19 +1,19 @@
|
||||
using BCards.Web.Models;
|
||||
|
||||
namespace BCards.Web.Services;
|
||||
|
||||
public interface IModerationService
|
||||
{
|
||||
Task<string> GeneratePreviewTokenAsync(string pageId);
|
||||
Task<bool> ValidatePreviewTokenAsync(string pageId, string token);
|
||||
Task<List<UserPage>> GetPendingModerationAsync(int skip = 0, int take = 20, string? filter = null);
|
||||
Task<UserPage?> GetPageForModerationAsync(string pageId);
|
||||
Task ApprovePageAsync(string pageId, string moderatorId, string? notes = null);
|
||||
Task RejectPageAsync(string pageId, string moderatorId, string reason, List<string> issues);
|
||||
Task<bool> CanUserCreatePageAsync(string userId);
|
||||
Task<bool> IncrementPreviewViewAsync(string pageId);
|
||||
Task<Dictionary<string, int>> GetModerationStatsAsync();
|
||||
Task<List<UserPage>> GetModerationHistoryAsync(int skip = 0, int take = 20);
|
||||
Task<UserPage?> GetPageByPreviewTokenAsync(string token);
|
||||
Task DeleteForModerationAsync(string pageId);
|
||||
using BCards.Web.Models;
|
||||
|
||||
namespace BCards.Web.Services;
|
||||
|
||||
public interface IModerationService
|
||||
{
|
||||
Task<string> GeneratePreviewTokenAsync(string pageId);
|
||||
Task<bool> ValidatePreviewTokenAsync(string pageId, string token);
|
||||
Task<List<UserPage>> GetPendingModerationAsync(int skip = 0, int take = 20, string? filter = null);
|
||||
Task<UserPage?> GetPageForModerationAsync(string pageId);
|
||||
Task ApprovePageAsync(string pageId, string moderatorId, string? notes = null);
|
||||
Task RejectPageAsync(string pageId, string moderatorId, string reason, List<string> issues);
|
||||
Task<bool> CanUserCreatePageAsync(string userId);
|
||||
Task<bool> IncrementPreviewViewAsync(string pageId);
|
||||
Task<Dictionary<string, int>> GetModerationStatsAsync();
|
||||
Task<List<UserPage>> GetModerationHistoryAsync(int skip = 0, int take = 20);
|
||||
Task<UserPage?> GetPageByPreviewTokenAsync(string token);
|
||||
Task DeleteForModerationAsync(string pageId);
|
||||
}
|
||||
@ -1,20 +1,20 @@
|
||||
namespace BCards.Web.Services;
|
||||
|
||||
public interface IOAuthHealthService
|
||||
{
|
||||
Task<OAuthHealthStatus> CheckOAuthProvidersAsync();
|
||||
Task<bool> IsGoogleAvailableAsync();
|
||||
Task<bool> 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<OAuthHealthStatus> CheckOAuthProvidersAsync();
|
||||
Task<bool> IsGoogleAvailableAsync();
|
||||
Task<bool> 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;
|
||||
}
|
||||
@ -1,10 +1,10 @@
|
||||
using BCards.Web.Models;
|
||||
|
||||
namespace BCards.Web.Services;
|
||||
|
||||
public interface IOpenGraphService
|
||||
{
|
||||
Task<OpenGraphData> ExtractDataAsync(string url, string userId);
|
||||
Task<bool> IsRateLimitedAsync(string userId);
|
||||
Task<OpenGraphCache?> GetCachedDataAsync(string url);
|
||||
using BCards.Web.Models;
|
||||
|
||||
namespace BCards.Web.Services;
|
||||
|
||||
public interface IOpenGraphService
|
||||
{
|
||||
Task<OpenGraphData> ExtractDataAsync(string url, string userId);
|
||||
Task<bool> IsRateLimitedAsync(string userId);
|
||||
Task<OpenGraphCache?> GetCachedDataAsync(string url);
|
||||
}
|
||||
@ -1,27 +1,27 @@
|
||||
using BCards.Web.Models;
|
||||
using Stripe;
|
||||
|
||||
namespace BCards.Web.Services;
|
||||
|
||||
public interface IPaymentService
|
||||
{
|
||||
Task<string> CreateCheckoutSessionAsync(string userId, string planType, string returnUrl, string cancelUrl);
|
||||
Task<Customer> CreateOrGetCustomerAsync(string userId, string email, string name);
|
||||
Task<Stripe.Subscription> HandleWebhookAsync(string requestBody, string signature);
|
||||
Task<List<Price>> GetPricesAsync();
|
||||
Task<bool> CancelSubscriptionAsync(string subscriptionId);
|
||||
Task<Stripe.Subscription> UpdateSubscriptionAsync(string subscriptionId, string newPriceId);
|
||||
Task<PlanLimitations> GetPlanLimitationsAsync(string planType);
|
||||
|
||||
// Novos métodos para gerenciamento de assinatura
|
||||
Task<Stripe.Subscription?> GetSubscriptionDetailsAsync(string userId);
|
||||
Task<List<Invoice>> GetPaymentHistoryAsync(string userId);
|
||||
Task<string> CreatePortalSessionAsync(string customerId, string returnUrl);
|
||||
|
||||
// Métodos para cancelamento com diferentes políticas
|
||||
Task<bool> CancelSubscriptionImmediatelyAsync(string subscriptionId, bool refund = false);
|
||||
Task<bool> CancelSubscriptionAtPeriodEndAsync(string subscriptionId);
|
||||
Task<bool> ReactivateSubscriptionAsync(string subscriptionId);
|
||||
Task<Refund> 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<string> CreateCheckoutSessionAsync(string userId, string planType, string returnUrl, string cancelUrl);
|
||||
Task<Customer> CreateOrGetCustomerAsync(string userId, string email, string name);
|
||||
Task<Stripe.Subscription> HandleWebhookAsync(string requestBody, string signature);
|
||||
Task<List<Price>> GetPricesAsync();
|
||||
Task<bool> CancelSubscriptionAsync(string subscriptionId);
|
||||
Task<Stripe.Subscription> UpdateSubscriptionAsync(string subscriptionId, string newPriceId);
|
||||
Task<PlanLimitations> GetPlanLimitationsAsync(string planType);
|
||||
|
||||
// Novos métodos para gerenciamento de assinatura
|
||||
Task<Stripe.Subscription?> GetSubscriptionDetailsAsync(string userId);
|
||||
Task<List<Invoice>> GetPaymentHistoryAsync(string userId);
|
||||
Task<string> CreatePortalSessionAsync(string customerId, string returnUrl);
|
||||
|
||||
// Métodos para cancelamento com diferentes políticas
|
||||
Task<bool> CancelSubscriptionImmediatelyAsync(string subscriptionId, bool refund = false);
|
||||
Task<bool> CancelSubscriptionAtPeriodEndAsync(string subscriptionId);
|
||||
Task<bool> ReactivateSubscriptionAsync(string subscriptionId);
|
||||
Task<Refund> CreatePartialRefundAsync(string subscriptionId, decimal refundAmount);
|
||||
Task<(bool CanRefundFull, bool CanRefundPartial, decimal RefundAmount)> CalculateRefundAsync(string subscriptionId);
|
||||
}
|
||||
@ -1,57 +1,57 @@
|
||||
using BCards.Web.Models;
|
||||
|
||||
namespace BCards.Web.Services;
|
||||
|
||||
public interface IPlanConfigurationService
|
||||
{
|
||||
/// <summary>
|
||||
/// Mapeia um PriceId do Stripe para o PlanType correspondente
|
||||
/// </summary>
|
||||
PlanType GetPlanTypeFromPriceId(string priceId);
|
||||
|
||||
/// <summary>
|
||||
/// Mapeia um PriceId do Stripe para o nome string do plano
|
||||
/// </summary>
|
||||
string GetPlanNameFromPriceId(string priceId);
|
||||
|
||||
/// <summary>
|
||||
/// Obtém as limitações de um plano baseado no PlanType
|
||||
/// </summary>
|
||||
PlanLimitations GetPlanLimitations(PlanType planType);
|
||||
|
||||
/// <summary>
|
||||
/// Obtém o PriceId de um plano (mensal por padrão)
|
||||
/// </summary>
|
||||
string GetPriceId(PlanType planType, bool yearly = false);
|
||||
|
||||
/// <summary>
|
||||
/// Obtém o preço de um plano
|
||||
/// </summary>
|
||||
decimal GetPlanPrice(PlanType planType, bool yearly = false);
|
||||
|
||||
/// <summary>
|
||||
/// Verifica se um plano é anual baseado no PriceId
|
||||
/// </summary>
|
||||
bool IsYearlyPlan(string priceId);
|
||||
|
||||
/// <summary>
|
||||
/// Obtém todas as configurações de um plano pelo nome da seção
|
||||
/// </summary>
|
||||
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<string> 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
|
||||
{
|
||||
/// <summary>
|
||||
/// Mapeia um PriceId do Stripe para o PlanType correspondente
|
||||
/// </summary>
|
||||
PlanType GetPlanTypeFromPriceId(string priceId);
|
||||
|
||||
/// <summary>
|
||||
/// Mapeia um PriceId do Stripe para o nome string do plano
|
||||
/// </summary>
|
||||
string GetPlanNameFromPriceId(string priceId);
|
||||
|
||||
/// <summary>
|
||||
/// Obtém as limitações de um plano baseado no PlanType
|
||||
/// </summary>
|
||||
PlanLimitations GetPlanLimitations(PlanType planType);
|
||||
|
||||
/// <summary>
|
||||
/// Obtém o PriceId de um plano (mensal por padrão)
|
||||
/// </summary>
|
||||
string GetPriceId(PlanType planType, bool yearly = false);
|
||||
|
||||
/// <summary>
|
||||
/// Obtém o preço de um plano
|
||||
/// </summary>
|
||||
decimal GetPlanPrice(PlanType planType, bool yearly = false);
|
||||
|
||||
/// <summary>
|
||||
/// Verifica se um plano é anual baseado no PriceId
|
||||
/// </summary>
|
||||
bool IsYearlyPlan(string priceId);
|
||||
|
||||
/// <summary>
|
||||
/// Obtém todas as configurações de um plano pelo nome da seção
|
||||
/// </summary>
|
||||
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<string> Features { get; set; } = new();
|
||||
public string Interval { get; set; } = "month";
|
||||
public PlanType BasePlanType { get; set; }
|
||||
}
|
||||
@ -1,14 +1,14 @@
|
||||
using BCards.Web.Models;
|
||||
|
||||
namespace BCards.Web.Services;
|
||||
|
||||
public interface IThemeService
|
||||
{
|
||||
Task<List<PageTheme>> GetAvailableThemesAsync();
|
||||
Task<PageTheme?> GetThemeByIdAsync(string themeId);
|
||||
Task<PageTheme?> GetThemeByNameAsync(string themeName);
|
||||
Task<string> GenerateCustomCssAsync(PageTheme theme);
|
||||
Task<string> GenerateThemeCSSAsync(PageTheme theme, UserPage page);
|
||||
Task InitializeDefaultThemesAsync();
|
||||
PageTheme GetDefaultTheme();
|
||||
using BCards.Web.Models;
|
||||
|
||||
namespace BCards.Web.Services;
|
||||
|
||||
public interface IThemeService
|
||||
{
|
||||
Task<List<PageTheme>> GetAvailableThemesAsync();
|
||||
Task<PageTheme?> GetThemeByIdAsync(string themeId);
|
||||
Task<PageTheme?> GetThemeByNameAsync(string themeName);
|
||||
Task<string> GenerateCustomCssAsync(PageTheme theme);
|
||||
Task<string> GenerateThemeCSSAsync(PageTheme theme, UserPage page);
|
||||
Task InitializeDefaultThemesAsync();
|
||||
PageTheme GetDefaultTheme();
|
||||
}
|
||||
@ -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<LivePageService> _logger;
|
||||
|
||||
public LivePageService(
|
||||
ILivePageRepository livePageRepository,
|
||||
IUserPageRepository userPageRepository,
|
||||
ILogger<LivePageService> logger)
|
||||
{
|
||||
_livePageRepository = livePageRepository;
|
||||
_userPageRepository = userPageRepository;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<LivePage?> GetByCategoryAndSlugAsync(string category, string slug)
|
||||
{
|
||||
return await _livePageRepository.GetByCategoryAndSlugAsync(category, slug);
|
||||
}
|
||||
|
||||
public async Task<List<LivePage>> GetAllActiveAsync()
|
||||
{
|
||||
return await _livePageRepository.GetAllActiveAsync();
|
||||
}
|
||||
|
||||
public async Task<LivePage?> GetLivePageFromUserPageId(string userPageId)
|
||||
{
|
||||
return await _livePageRepository.GetByOriginalPageIdAsync(userPageId);
|
||||
}
|
||||
|
||||
public async Task<LivePage> 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<bool> 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<LivePageService> _logger;
|
||||
|
||||
public LivePageService(
|
||||
ILivePageRepository livePageRepository,
|
||||
IUserPageRepository userPageRepository,
|
||||
ILogger<LivePageService> logger)
|
||||
{
|
||||
_livePageRepository = livePageRepository;
|
||||
_userPageRepository = userPageRepository;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<LivePage?> GetByCategoryAndSlugAsync(string category, string slug)
|
||||
{
|
||||
return await _livePageRepository.GetByCategoryAndSlugAsync(category, slug);
|
||||
}
|
||||
|
||||
public async Task<List<LivePage>> GetAllActiveAsync()
|
||||
{
|
||||
return await _livePageRepository.GetAllActiveAsync();
|
||||
}
|
||||
|
||||
public async Task<LivePage?> GetLivePageFromUserPageId(string userPageId)
|
||||
{
|
||||
return await _livePageRepository.GetByOriginalPageIdAsync(userPageId);
|
||||
}
|
||||
|
||||
public async Task<LivePage> 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<bool> 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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<ModerationSettings> 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<string> 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<ModerationSettings> 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<string> GetModeratorEmails()
|
||||
{
|
||||
return _settings.ModeratorEmails.ToList();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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<ModerationService> _logger;
|
||||
|
||||
public ModerationService(
|
||||
IUserPageRepository userPageRepository,
|
||||
IUserRepository userRepository,
|
||||
ILivePageService livePageService,
|
||||
ILogger<ModerationService> logger)
|
||||
{
|
||||
_userPageRepository = userPageRepository;
|
||||
_userRepository = userRepository;
|
||||
_livePageService = livePageService;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<string> 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<bool> 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<List<UserPage>> GetPendingModerationAsync(int skip = 0, int take = 20, string? filter = null)
|
||||
{
|
||||
var filterBuilder = Builders<UserPage>.Filter;
|
||||
var baseFilter = filterBuilder.Eq(p => p.Status, PageStatus.PendingModeration);
|
||||
|
||||
FilterDefinition<UserPage> 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<UserPage>.Sort
|
||||
.Descending("planLimitations.specialModeration")
|
||||
.Ascending(p => p.CreatedAt);
|
||||
|
||||
var pages = await _userPageRepository.GetManyAsync(finalFilter, sort, skip, take);
|
||||
return pages.ToList();
|
||||
}
|
||||
|
||||
public async Task<UserPage?> 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<UserPage>.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<string> 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<UserPage>.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<bool> 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<UserPage>.Filter.Eq(p => p.UserId, userId);
|
||||
filter &= Builders<UserPage>.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<bool> IncrementPreviewViewAsync(string pageId)
|
||||
{
|
||||
var page = await _userPageRepository.GetByIdAsync(pageId);
|
||||
if (page == null || page.PreviewViewCount >= 50)
|
||||
return false;
|
||||
|
||||
var update = Builders<UserPage>.Update
|
||||
.Inc(p => p.PreviewViewCount, 1);
|
||||
|
||||
await _userPageRepository.UpdateAsync(pageId, update);
|
||||
return true;
|
||||
}
|
||||
|
||||
public async Task<Dictionary<string, int>> GetModerationStatsAsync()
|
||||
{
|
||||
var stats = new Dictionary<string, int>();
|
||||
|
||||
var pendingCount = await _userPageRepository.CountAsync(
|
||||
Builders<UserPage>.Filter.Eq(p => p.Status, PageStatus.PendingModeration));
|
||||
|
||||
var approvedToday = await _userPageRepository.CountAsync(
|
||||
Builders<UserPage>.Filter.And(
|
||||
Builders<UserPage>.Filter.Eq(p => p.Status, PageStatus.Active),
|
||||
Builders<UserPage>.Filter.Gte(p => p.ApprovedAt, DateTime.UtcNow.Date)));
|
||||
|
||||
var rejectedToday = await _userPageRepository.CountAsync(
|
||||
Builders<UserPage>.Filter.And(
|
||||
Builders<UserPage>.Filter.Eq(p => p.Status, PageStatus.Rejected),
|
||||
Builders<UserPage>.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<List<UserPage>> GetModerationHistoryAsync(int skip = 0, int take = 20)
|
||||
{
|
||||
var filter = Builders<UserPage>.Filter.Or(
|
||||
Builders<UserPage>.Filter.Eq(p => p.Status, PageStatus.Active),
|
||||
Builders<UserPage>.Filter.Eq(p => p.Status, PageStatus.Rejected));
|
||||
|
||||
var sort = Builders<UserPage>.Sort.Descending(p => p.UpdatedAt);
|
||||
|
||||
var pages = await _userPageRepository.GetManyAsync(filter, sort, skip, take);
|
||||
return pages.ToList();
|
||||
}
|
||||
|
||||
public async Task<UserPage?> GetPageByPreviewTokenAsync(string token)
|
||||
{
|
||||
var filter = Builders<UserPage>.Filter.And(
|
||||
Builders<UserPage>.Filter.Eq(p => p.PreviewToken, token),
|
||||
Builders<UserPage>.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<ModerationService> _logger;
|
||||
|
||||
public ModerationService(
|
||||
IUserPageRepository userPageRepository,
|
||||
IUserRepository userRepository,
|
||||
ILivePageService livePageService,
|
||||
ILogger<ModerationService> logger)
|
||||
{
|
||||
_userPageRepository = userPageRepository;
|
||||
_userRepository = userRepository;
|
||||
_livePageService = livePageService;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<string> 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<bool> 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<List<UserPage>> GetPendingModerationAsync(int skip = 0, int take = 20, string? filter = null)
|
||||
{
|
||||
var filterBuilder = Builders<UserPage>.Filter;
|
||||
var baseFilter = filterBuilder.Eq(p => p.Status, PageStatus.PendingModeration);
|
||||
|
||||
FilterDefinition<UserPage> 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<UserPage>.Sort
|
||||
.Descending("planLimitations.specialModeration")
|
||||
.Ascending(p => p.CreatedAt);
|
||||
|
||||
var pages = await _userPageRepository.GetManyAsync(finalFilter, sort, skip, take);
|
||||
return pages.ToList();
|
||||
}
|
||||
|
||||
public async Task<UserPage?> 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<UserPage>.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<string> 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<UserPage>.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<bool> 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<UserPage>.Filter.Eq(p => p.UserId, userId);
|
||||
filter &= Builders<UserPage>.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<bool> IncrementPreviewViewAsync(string pageId)
|
||||
{
|
||||
var page = await _userPageRepository.GetByIdAsync(pageId);
|
||||
if (page == null || page.PreviewViewCount >= 50)
|
||||
return false;
|
||||
|
||||
var update = Builders<UserPage>.Update
|
||||
.Inc(p => p.PreviewViewCount, 1);
|
||||
|
||||
await _userPageRepository.UpdateAsync(pageId, update);
|
||||
return true;
|
||||
}
|
||||
|
||||
public async Task<Dictionary<string, int>> GetModerationStatsAsync()
|
||||
{
|
||||
var stats = new Dictionary<string, int>();
|
||||
|
||||
var pendingCount = await _userPageRepository.CountAsync(
|
||||
Builders<UserPage>.Filter.Eq(p => p.Status, PageStatus.PendingModeration));
|
||||
|
||||
var approvedToday = await _userPageRepository.CountAsync(
|
||||
Builders<UserPage>.Filter.And(
|
||||
Builders<UserPage>.Filter.Eq(p => p.Status, PageStatus.Active),
|
||||
Builders<UserPage>.Filter.Gte(p => p.ApprovedAt, DateTime.UtcNow.Date)));
|
||||
|
||||
var rejectedToday = await _userPageRepository.CountAsync(
|
||||
Builders<UserPage>.Filter.And(
|
||||
Builders<UserPage>.Filter.Eq(p => p.Status, PageStatus.Rejected),
|
||||
Builders<UserPage>.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<List<UserPage>> GetModerationHistoryAsync(int skip = 0, int take = 20)
|
||||
{
|
||||
var filter = Builders<UserPage>.Filter.Or(
|
||||
Builders<UserPage>.Filter.Eq(p => p.Status, PageStatus.Active),
|
||||
Builders<UserPage>.Filter.Eq(p => p.Status, PageStatus.Rejected));
|
||||
|
||||
var sort = Builders<UserPage>.Sort.Descending(p => p.UpdatedAt);
|
||||
|
||||
var pages = await _userPageRepository.GetManyAsync(filter, sort, skip, take);
|
||||
return pages.ToList();
|
||||
}
|
||||
|
||||
public async Task<UserPage?> GetPageByPreviewTokenAsync(string token)
|
||||
{
|
||||
var filter = Builders<UserPage>.Filter.And(
|
||||
Builders<UserPage>.Filter.Eq(p => p.PreviewToken, token),
|
||||
Builders<UserPage>.Filter.Gt(p => p.PreviewTokenExpiry, DateTime.UtcNow));
|
||||
|
||||
var pages = await _userPageRepository.GetManyAsync(filter);
|
||||
return pages.FirstOrDefault();
|
||||
}
|
||||
}
|
||||
@ -1,127 +1,127 @@
|
||||
using System.Net;
|
||||
|
||||
namespace BCards.Web.Services;
|
||||
|
||||
public class OAuthHealthService : IOAuthHealthService
|
||||
{
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly ILogger<OAuthHealthService> _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<OAuthHealthService> 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<OAuthHealthStatus> 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<bool> IsGoogleAvailableAsync()
|
||||
{
|
||||
var status = await CheckOAuthProvidersAsync();
|
||||
return status.GoogleAvailable;
|
||||
}
|
||||
|
||||
public async Task<bool> 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<bool> 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<OAuthHealthService> _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<OAuthHealthService> 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<OAuthHealthStatus> 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<bool> IsGoogleAvailableAsync()
|
||||
{
|
||||
var status = await CheckOAuthProvidersAsync();
|
||||
return status.GoogleAvailable;
|
||||
}
|
||||
|
||||
public async Task<bool> 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<bool> 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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<OpenGraphService> _logger;
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly IMongoCollection<OpenGraphCache> _ogCache;
|
||||
|
||||
public OpenGraphService(
|
||||
IMemoryCache cache,
|
||||
ILogger<OpenGraphService> logger,
|
||||
HttpClient httpClient,
|
||||
IMongoDatabase database)
|
||||
{
|
||||
_cache = cache;
|
||||
_logger = logger;
|
||||
_httpClient = httpClient;
|
||||
_ogCache = database.GetCollection<OpenGraphCache>("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<OpenGraphData> 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<bool> IsRateLimitedAsync(string userId)
|
||||
{
|
||||
var rateLimitKey = $"og_rate_{userId}";
|
||||
return Task.FromResult(_cache.TryGetValue(rateLimitKey, out _));
|
||||
}
|
||||
|
||||
public async Task<OpenGraphCache?> GetCachedDataAsync(string url)
|
||||
{
|
||||
var urlHash = GenerateUrlHash(url);
|
||||
return await _ogCache
|
||||
.Find(x => x.UrlHash == urlHash && x.ExpiresAt > DateTime.UtcNow)
|
||||
.FirstOrDefaultAsync();
|
||||
}
|
||||
|
||||
private async Task<OpenGraphData> 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<OpenGraphService> _logger;
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly IMongoCollection<OpenGraphCache> _ogCache;
|
||||
|
||||
public OpenGraphService(
|
||||
IMemoryCache cache,
|
||||
ILogger<OpenGraphService> logger,
|
||||
HttpClient httpClient,
|
||||
IMongoDatabase database)
|
||||
{
|
||||
_cache = cache;
|
||||
_logger = logger;
|
||||
_httpClient = httpClient;
|
||||
_ogCache = database.GetCollection<OpenGraphCache>("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<OpenGraphData> 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<bool> IsRateLimitedAsync(string userId)
|
||||
{
|
||||
var rateLimitKey = $"og_rate_{userId}";
|
||||
return Task.FromResult(_cache.TryGetValue(rateLimitKey, out _));
|
||||
}
|
||||
|
||||
public async Task<OpenGraphCache?> GetCachedDataAsync(string url)
|
||||
{
|
||||
var urlHash = GenerateUrlHash(url);
|
||||
return await _ogCache
|
||||
.Find(x => x.UrlHash == urlHash && x.ExpiresAt > DateTime.UtcNow)
|
||||
.FirstOrDefaultAsync();
|
||||
}
|
||||
|
||||
private async Task<OpenGraphData> 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@ -1,228 +1,228 @@
|
||||
using BCards.Web.Models;
|
||||
|
||||
namespace BCards.Web.Services;
|
||||
|
||||
public class PlanConfigurationService : IPlanConfigurationService
|
||||
{
|
||||
private readonly IConfiguration _configuration;
|
||||
private readonly Dictionary<string, PlanConfiguration> _plans;
|
||||
private readonly Dictionary<string, string> _priceIdToPlanName;
|
||||
private readonly Dictionary<string, PlanType> _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<string, PlanConfiguration> LoadPlansFromConfiguration()
|
||||
{
|
||||
var plans = new Dictionary<string, PlanConfiguration>();
|
||||
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<string, string> BuildPriceIdToPlanNameMap()
|
||||
{
|
||||
var map = new Dictionary<string, string>();
|
||||
|
||||
foreach (var kvp in _plans)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(kvp.Value.PriceId))
|
||||
{
|
||||
map[kvp.Value.PriceId] = kvp.Key;
|
||||
}
|
||||
}
|
||||
|
||||
return map;
|
||||
}
|
||||
|
||||
private Dictionary<string, PlanType> BuildPriceIdToPlanTypeMap()
|
||||
{
|
||||
var map = new Dictionary<string, PlanType>();
|
||||
|
||||
foreach (var kvp in _plans)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(kvp.Value.PriceId))
|
||||
{
|
||||
map[kvp.Value.PriceId] = kvp.Value.BasePlanType;
|
||||
}
|
||||
}
|
||||
|
||||
return map;
|
||||
}
|
||||
|
||||
private T GetConfigValue<T>(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<string, PlanConfiguration> _plans;
|
||||
private readonly Dictionary<string, string> _priceIdToPlanName;
|
||||
private readonly Dictionary<string, PlanType> _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<string, PlanConfiguration> LoadPlansFromConfiguration()
|
||||
{
|
||||
var plans = new Dictionary<string, PlanConfiguration>();
|
||||
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<string, string> BuildPriceIdToPlanNameMap()
|
||||
{
|
||||
var map = new Dictionary<string, string>();
|
||||
|
||||
foreach (var kvp in _plans)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(kvp.Value.PriceId))
|
||||
{
|
||||
map[kvp.Value.PriceId] = kvp.Key;
|
||||
}
|
||||
}
|
||||
|
||||
return map;
|
||||
}
|
||||
|
||||
private Dictionary<string, PlanType> BuildPriceIdToPlanTypeMap()
|
||||
{
|
||||
var map = new Dictionary<string, PlanType>();
|
||||
|
||||
foreach (var kvp in _plans)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(kvp.Value.PriceId))
|
||||
{
|
||||
map[kvp.Value.PriceId] = kvp.Value.BasePlanType;
|
||||
}
|
||||
}
|
||||
|
||||
return map;
|
||||
}
|
||||
|
||||
private T GetConfigValue<T>(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;
|
||||
}
|
||||
}
|
||||
@ -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<string> GenerateKeywords(UserPage userPage, Category category)
|
||||
{
|
||||
var keywords = new List<string>
|
||||
{
|
||||
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<string> GenerateKeywords(UserPage userPage, Category category)
|
||||
{
|
||||
var keywords = new List<string>
|
||||
{
|
||||
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}";
|
||||
}
|
||||
}
|
||||
@ -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<PageTheme> _themes;
|
||||
|
||||
public ThemeService(IMongoDatabase database)
|
||||
{
|
||||
_themes = database.GetCollection<PageTheme>("themes");
|
||||
}
|
||||
|
||||
public async Task<List<PageTheme>> GetAvailableThemesAsync()
|
||||
{
|
||||
return await _themes.Find(x => x.IsActive).ToListAsync();
|
||||
}
|
||||
|
||||
public async Task<PageTheme?> GetThemeByIdAsync(string themeId)
|
||||
{
|
||||
return await _themes.Find(x => x.Id == themeId && x.IsActive).FirstOrDefaultAsync();
|
||||
}
|
||||
|
||||
public async Task<PageTheme?> GetThemeByNameAsync(string themeName)
|
||||
{
|
||||
var theme = await _themes.Find(x => x.Name.ToLower() == themeName.ToLower() && x.IsActive).FirstOrDefaultAsync();
|
||||
return theme ?? GetDefaultTheme();
|
||||
}
|
||||
|
||||
public Task<string> 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<string> 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,<svg xmlns=""http://www.w3.org/2000/svg"" viewBox=""0 0 100 100""><defs><pattern id=""grain"" width=""100"" height=""100"" patternUnits=""userSpaceOnUse""><circle cx=""25"" cy=""25"" r=""1"" fill=""%23059669"" opacity=""0.1""/><circle cx=""75"" cy=""75"" r=""1"" fill=""%23059669"" opacity=""0.1""/></pattern></defs><rect width=""100"" height=""100"" fill=""url(%23grain)""/></svg>');
|
||||
}
|
||||
.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<PageTheme> _themes;
|
||||
|
||||
public ThemeService(IMongoDatabase database)
|
||||
{
|
||||
_themes = database.GetCollection<PageTheme>("themes");
|
||||
}
|
||||
|
||||
public async Task<List<PageTheme>> GetAvailableThemesAsync()
|
||||
{
|
||||
return await _themes.Find(x => x.IsActive).ToListAsync();
|
||||
}
|
||||
|
||||
public async Task<PageTheme?> GetThemeByIdAsync(string themeId)
|
||||
{
|
||||
return await _themes.Find(x => x.Id == themeId && x.IsActive).FirstOrDefaultAsync();
|
||||
}
|
||||
|
||||
public async Task<PageTheme?> GetThemeByNameAsync(string themeName)
|
||||
{
|
||||
var theme = await _themes.Find(x => x.Name.ToLower() == themeName.ToLower() && x.IsActive).FirstOrDefaultAsync();
|
||||
return theme ?? GetDefaultTheme();
|
||||
}
|
||||
|
||||
public Task<string> 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<string> 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,<svg xmlns=""http://www.w3.org/2000/svg"" viewBox=""0 0 100 100""><defs><pattern id=""grain"" width=""100"" height=""100"" patternUnits=""userSpaceOnUse""><circle cx=""25"" cy=""25"" r=""1"" fill=""%23059669"" opacity=""0.1""/><circle cx=""75"" cy=""75"" r=""1"" fill=""%23059669"" opacity=""0.1""/></pattern></defs><rect width=""100"" height=""100"" fill=""url(%23grain)""/></svg>');
|
||||
}
|
||||
.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"
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -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<TrialExpirationService> _logger;
|
||||
private readonly TimeSpan _checkInterval = TimeSpan.FromHours(1); // Check every hour
|
||||
|
||||
public TrialExpirationService(
|
||||
IServiceProvider serviceProvider,
|
||||
ILogger<TrialExpirationService> 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<ISubscriptionRepository>();
|
||||
var userPageRepository = scope.ServiceProvider.GetRequiredService<IUserPageRepository>();
|
||||
var userRepository = scope.ServiceProvider.GetRequiredService<IUserRepository>();
|
||||
|
||||
_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<ISubscriptionRepository>();
|
||||
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<UserPage>.Filter.And(
|
||||
MongoDB.Driver.Builders<UserPage>.Filter.Eq(p => p.Status, PageStatus.Expired),
|
||||
MongoDB.Driver.Builders<UserPage>.Filter.Ne(p => p.DeletedAt, null),
|
||||
MongoDB.Driver.Builders<UserPage>.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<TrialExpirationService> _logger;
|
||||
private readonly TimeSpan _checkInterval = TimeSpan.FromHours(1); // Check every hour
|
||||
|
||||
public TrialExpirationService(
|
||||
IServiceProvider serviceProvider,
|
||||
ILogger<TrialExpirationService> 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<ISubscriptionRepository>();
|
||||
var userPageRepository = scope.ServiceProvider.GetRequiredService<IUserPageRepository>();
|
||||
var userRepository = scope.ServiceProvider.GetRequiredService<IUserRepository>();
|
||||
|
||||
_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<ISubscriptionRepository>();
|
||||
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<UserPage>.Filter.And(
|
||||
MongoDB.Driver.Builders<UserPage>.Filter.Eq(p => p.Status, PageStatus.Expired),
|
||||
MongoDB.Driver.Builders<UserPage>.Filter.Ne(p => p.DeletedAt, null),
|
||||
MongoDB.Driver.Builders<UserPage>.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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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<UserPage?> GetPageAsync(string category, string slug)
|
||||
{
|
||||
return await _userPageRepository.GetBySlugAsync(category, slug);
|
||||
}
|
||||
|
||||
public async Task<UserPage?> GetUserPageAsync(string userId)
|
||||
{
|
||||
return await _userPageRepository.GetByUserIdAsync(userId);
|
||||
}
|
||||
|
||||
public async Task<UserPage?> GetPageByIdAsync(string id)
|
||||
{
|
||||
return await _userPageRepository.GetByIdAsync(id);
|
||||
}
|
||||
|
||||
public async Task<List<UserPage>> GetUserPagesAsync(string userId)
|
||||
{
|
||||
return await _userPageRepository.GetByUserIdAllAsync(userId);
|
||||
}
|
||||
|
||||
public async Task<List<UserPage>> GetActivePagesAsync()
|
||||
{
|
||||
return await _userPageRepository.GetActivePagesAsync();
|
||||
}
|
||||
|
||||
public async Task<UserPage> CreatePageAsync(UserPage userPage)
|
||||
{
|
||||
userPage.Slug = await GenerateSlugAsync(userPage.Category, userPage.DisplayName);
|
||||
return await _userPageRepository.CreateAsync(userPage);
|
||||
}
|
||||
|
||||
public async Task<UserPage> UpdatePageAsync(UserPage userPage)
|
||||
{
|
||||
return await _userPageRepository.UpdateAsync(userPage);
|
||||
}
|
||||
|
||||
public async Task DeletePageAsync(string id)
|
||||
{
|
||||
await _userPageRepository.DeleteAsync(id);
|
||||
}
|
||||
|
||||
public async Task<bool> 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<string> 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<bool> 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<List<UserPage>> GetRecentPagesAsync(int limit = 10)
|
||||
{
|
||||
return await _userPageRepository.GetRecentPagesAsync(limit);
|
||||
}
|
||||
|
||||
public async Task<List<UserPage>> 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<UserPage?> GetPageAsync(string category, string slug)
|
||||
{
|
||||
return await _userPageRepository.GetBySlugAsync(category, slug);
|
||||
}
|
||||
|
||||
public async Task<UserPage?> GetUserPageAsync(string userId)
|
||||
{
|
||||
return await _userPageRepository.GetByUserIdAsync(userId);
|
||||
}
|
||||
|
||||
public async Task<UserPage?> GetPageByIdAsync(string id)
|
||||
{
|
||||
return await _userPageRepository.GetByIdAsync(id);
|
||||
}
|
||||
|
||||
public async Task<List<UserPage>> GetUserPagesAsync(string userId)
|
||||
{
|
||||
return await _userPageRepository.GetByUserIdAllAsync(userId);
|
||||
}
|
||||
|
||||
public async Task<List<UserPage>> GetActivePagesAsync()
|
||||
{
|
||||
return await _userPageRepository.GetActivePagesAsync();
|
||||
}
|
||||
|
||||
public async Task<UserPage> CreatePageAsync(UserPage userPage)
|
||||
{
|
||||
userPage.Slug = await GenerateSlugAsync(userPage.Category, userPage.DisplayName);
|
||||
return await _userPageRepository.CreateAsync(userPage);
|
||||
}
|
||||
|
||||
public async Task<UserPage> UpdatePageAsync(UserPage userPage)
|
||||
{
|
||||
return await _userPageRepository.UpdateAsync(userPage);
|
||||
}
|
||||
|
||||
public async Task DeletePageAsync(string id)
|
||||
{
|
||||
await _userPageRepository.DeleteAsync(id);
|
||||
}
|
||||
|
||||
public async Task<bool> 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<string> 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<bool> 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<List<UserPage>> GetRecentPagesAsync(int limit = 10)
|
||||
{
|
||||
return await _userPageRepository.GetRecentPagesAsync(limit);
|
||||
}
|
||||
|
||||
public async Task<List<UserPage>> 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";
|
||||
}
|
||||
}
|
||||
@ -1 +1 @@
|
||||
stripe listen --forward-to localhost:49178/webhook/stripe
|
||||
stripe listen --forward-to localhost:49178/webhook/stripe
|
||||
|
||||
@ -1,69 +1,69 @@
|
||||
namespace BCards.Web.Utils;
|
||||
|
||||
public static class AllowedDomains
|
||||
{
|
||||
public static readonly HashSet<string> 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<string> 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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<ManageLinkViewModel> 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<ManageLinkViewModel> 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");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,116 +1,116 @@
|
||||
using System.Globalization;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Text;
|
||||
|
||||
namespace BCards.Web.Utils
|
||||
{
|
||||
public static class SlugHelper
|
||||
{
|
||||
/// <summary>
|
||||
/// Remove acentos e caracteres especiais, criando um slug limpo
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Cria um slug limpo e URL-friendly
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Cria uma categoria limpa (sem acentos, minúscula)
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Dicionário de conversões comuns para categorias brasileiras
|
||||
/// </summary>
|
||||
private static readonly Dictionary<string, string> 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" }
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Converte categoria com mapeamento personalizado
|
||||
/// </summary>
|
||||
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
|
||||
{
|
||||
/// <summary>
|
||||
/// Remove acentos e caracteres especiais, criando um slug limpo
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Cria um slug limpo e URL-friendly
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Cria uma categoria limpa (sem acentos, minúscula)
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Dicionário de conversões comuns para categorias brasileiras
|
||||
/// </summary>
|
||||
private static readonly Dictionary<string, string> 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" }
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Converte categoria com mapeamento personalizado
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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<IModerationAuthService>();
|
||||
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<IModerationAuthService>();
|
||||
return moderationAuth.IsUserModerator(user);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
@ -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<CreateLinkViewModel> 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<CreateLinkViewModel> 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;
|
||||
}
|
||||
@ -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<ManageLinkViewModel> Links { get; set; } = new();
|
||||
|
||||
// Profile image fields
|
||||
public string? ProfileImageId { get; set; }
|
||||
public IFormFile? ProfileImageFile { get; set; }
|
||||
|
||||
// Data for dropdowns and selections
|
||||
public List<Category> AvailableCategories { get; set; } = new();
|
||||
public List<PageTheme> 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());
|
||||
|
||||
/// <summary>
|
||||
/// URL da imagem de perfil ou imagem padrão se não houver upload
|
||||
/// </summary>
|
||||
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<UserPageSummary> 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<ManageLinkViewModel> Links { get; set; } = new();
|
||||
|
||||
// Profile image fields
|
||||
public string? ProfileImageId { get; set; }
|
||||
public IFormFile? ProfileImageFile { get; set; }
|
||||
|
||||
// Data for dropdowns and selections
|
||||
public List<Category> AvailableCategories { get; set; } = new();
|
||||
public List<PageTheme> 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());
|
||||
|
||||
/// <summary>
|
||||
/// URL da imagem de perfil ou imagem padrão se não houver upload
|
||||
/// </summary>
|
||||
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<UserPageSummary> 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
|
||||
}
|
||||
@ -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<Invoice> PaymentHistory { get; set; } = new();
|
||||
public List<AvailablePlanViewModel> 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<string> 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<Invoice> PaymentHistory { get; set; } = new();
|
||||
public List<AvailablePlanViewModel> 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<string> 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"
|
||||
};
|
||||
}
|
||||
@ -1,83 +1,83 @@
|
||||
using BCards.Web.Models;
|
||||
|
||||
namespace BCards.Web.ViewModels;
|
||||
|
||||
public class ModerationDashboardViewModel
|
||||
{
|
||||
public List<PendingPageViewModel> PendingPages { get; set; } = new();
|
||||
public Dictionary<string, int> 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<ModerationCriterion> ModerationCriteria { get; set; } = new();
|
||||
}
|
||||
|
||||
public class ModerationHistoryViewModel
|
||||
{
|
||||
public List<ModerationPageViewModel> 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<string> 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<PendingPageViewModel> PendingPages { get; set; } = new();
|
||||
public Dictionary<string, int> 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<ModerationCriterion> ModerationCriteria { get; set; } = new();
|
||||
}
|
||||
|
||||
public class ModerationHistoryViewModel
|
||||
{
|
||||
public List<ModerationPageViewModel> 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<string> 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;
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user