Compare commits
139 Commits
Release/V0
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 7c0d91c68e | |||
| 98709256ea | |||
| a7cbba5c38 | |||
| cf17fd8464 | |||
| 4e97efc160 | |||
| 175effd1be | |||
| 96cc089b60 | |||
| d77af5c614 | |||
| 241ca3560d | |||
| 37cd753a6a | |||
| b6a5329a6b | |||
| 230c6a958d | |||
| 0803a3bcc9 | |||
| 2f8f19d16d | |||
| b382688a8f | |||
| 94c77fc867 | |||
| e59698ee83 | |||
| e1c1f38a34 | |||
| 644dbf0974 | |||
| 32e6ec24e3 | |||
| 8f750ea3ba | |||
| d76435f98a | |||
| c32b4ef034 | |||
| 5d70ba797a | |||
| 5834afc648 | |||
|
|
dc00ea97a9 | ||
|
|
f20f136350 | ||
|
|
34503936dd | ||
|
|
cf9906bafe | ||
|
|
caefa20110 | ||
|
|
ee4db7a910 | ||
|
|
a7e1677949 | ||
|
|
b0d164c8a9 | ||
|
|
4c7c31cd60 | ||
|
|
dc75f4af54 | ||
|
|
3f4fed08d5 | ||
|
|
6c1c6cb543 | ||
|
|
b70fd7c23a | ||
|
|
28777d8437 | ||
| 1d80b48a81 | |||
| 1caac8d7fb | |||
| 3d2ce1f8cf | |||
|
|
e043c853b1 | ||
|
|
7b0bc89f06 | ||
|
|
378bcf54b6 | ||
|
|
2eada5f44c | ||
|
|
4bad39ec85 | ||
|
|
f98dac9178 | ||
|
|
04f406f6bc | ||
|
|
930ce8dab3 | ||
|
|
f28dc8daa8 | ||
|
|
9406997316 | ||
|
|
aea64c0b8e | ||
|
|
55ad73b505 | ||
|
|
ffac8a787b | ||
|
|
0387df1994 | ||
|
|
77a20d3f37 | ||
|
|
ed9fe4902b | ||
|
|
07e4d16428 | ||
|
|
b12ffb0016 | ||
|
|
074c850c67 | ||
|
|
30ca77a12b | ||
|
|
9a850d239f | ||
|
|
61453b65c9 | ||
|
|
ad8cc06fd6 | ||
|
|
25f686eccd | ||
|
|
90a1ee2bfb | ||
|
|
dde67df72f | ||
|
|
fa422ef685 | ||
|
|
c825fa2736 | ||
|
|
56454bec90 | ||
|
|
1978193777 | ||
|
|
ce1c1409de | ||
|
|
32b83923dc | ||
|
|
0f6ae71997 | ||
|
|
79c254905a | ||
|
|
787fa63f68 | ||
|
|
6e70202fce | ||
|
|
004bf284b5 | ||
|
|
5abb4b52c5 | ||
|
|
a434ff56eb | ||
|
|
d700bd35a9 | ||
|
|
3becbe67c3 | ||
|
|
6042eb59e6 | ||
|
|
406c298afb | ||
|
|
572f1ebf2e | ||
|
|
331b3de374 | ||
|
|
7cc8f46a1a | ||
|
|
6598dbdcdd | ||
|
|
ce705c51ec | ||
|
|
e6d46572d1 | ||
|
|
bd90f7b064 | ||
|
|
019893c911 | ||
|
|
0ee5af38cc | ||
|
|
d587992eda | ||
|
|
6f6a02ba3d | ||
|
|
4ab141436f | ||
|
|
f0c93d83a8 | ||
|
|
f97fbc3367 | ||
|
|
8dfcc991f3 | ||
|
|
76357013d7 | ||
|
|
90cc01d7cf | ||
|
|
2d901708b8 | ||
| 46afbb22cd | |||
|
|
def712bffe | ||
|
|
e727ffdedc | ||
| 54fcbc23fa | |||
|
|
054149b26d | ||
| b6432d2701 | |||
|
|
4f200845f5 | ||
| ef1df0b065 | |||
|
|
31ed2b3e15 | ||
|
|
cc6bd6299d | ||
| b58827b724 | |||
| f29a4a8453 | |||
|
|
a49b457a85 | ||
|
|
58057bd033 | ||
|
|
aa2c864689 | ||
| d74e16fbaf | |||
|
|
116134b87a | ||
| a7a5fa7c79 | |||
|
|
5d21374ae2 | ||
| 9b2cfd0e10 | |||
|
|
d03a4c00ab | ||
| 8554b68369 | |||
|
|
5f5e609172 | ||
| 06a078d260 | |||
|
|
ec559e8115 | ||
|
|
38c991758c | ||
|
|
951b44f7eb | ||
|
|
0d9a0988fe | ||
|
|
ef4d189ef1 | ||
|
|
ca98001299 | ||
|
|
d32cc18044 | ||
|
|
c824e9da1c | ||
|
|
9e7ea6ed9a | ||
|
|
5fc7eb5ad3 | ||
|
|
c6129a1c63 | ||
|
|
2449a617ca |
@ -19,8 +19,23 @@
|
|||||||
"Bash(curl:*)",
|
"Bash(curl:*)",
|
||||||
"Bash(docker-compose up:*)",
|
"Bash(docker-compose up:*)",
|
||||||
"Bash(dotnet build:*)",
|
"Bash(dotnet build:*)",
|
||||||
"Bash(chmod:*)"
|
"Bash(chmod:*)",
|
||||||
|
"Bash(mv:*)",
|
||||||
|
"Bash(dotnet nuget locals:*)",
|
||||||
|
"Bash(/mnt/c/vscode/vcart.me.novo/clean-build.sh:*)",
|
||||||
|
"Bash(sed:*)",
|
||||||
|
"Bash(./clean-build.sh:*)",
|
||||||
|
"Bash(git add:*)",
|
||||||
|
"Bash(scp:*)",
|
||||||
|
"Bash(ssh:*)",
|
||||||
|
"Bash(cat:*)",
|
||||||
|
"Bash(dig:*)",
|
||||||
|
"Bash(git commit:*)",
|
||||||
|
"Bash(netstat:*)",
|
||||||
|
"Bash(ss:*)",
|
||||||
|
"Bash(lsof:*)",
|
||||||
|
"Bash(dotnet run:*)"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"enableAllProjectMcpServers": false
|
"enableAllProjectMcpServers": false
|
||||||
}
|
}
|
||||||
|
|||||||
13
.dockerignore
Normal file
13
.dockerignore
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
bin/
|
||||||
|
obj/
|
||||||
|
.git/
|
||||||
|
.gitignore
|
||||||
|
*.md
|
||||||
|
README.md
|
||||||
|
tests/
|
||||||
|
docs/
|
||||||
|
.vs/
|
||||||
|
.vscode/
|
||||||
|
**/.DS_Store
|
||||||
|
**/Thumbs.db
|
||||||
|
|
||||||
614
.gitea/workflows/deploy-bcards.yml
Normal file
614
.gitea/workflows/deploy-bcards.yml
Normal file
@ -0,0 +1,614 @@
|
|||||||
|
name: BCards Deployment Pipeline
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
- 'Release/*'
|
||||||
|
# PRs apenas validam, não fazem deploy
|
||||||
|
pull_request:
|
||||||
|
branches: [ main ]
|
||||||
|
types: [opened, synchronize, reopened]
|
||||||
|
|
||||||
|
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: Test info
|
||||||
|
run: |
|
||||||
|
echo "🧪 Executando testes para ${{ github.ref_name }}"
|
||||||
|
echo "🎯 Trigger: ${{ github.event_name }}"
|
||||||
|
|
||||||
|
# Verificar se deve pular testes
|
||||||
|
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
|
||||||
|
|
||||||
|
- 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: Cache dependencies
|
||||||
|
if: env.TESTS_SKIPPED == 'false'
|
||||||
|
uses: actions/cache@v3
|
||||||
|
with:
|
||||||
|
path: ~/.nuget/packages
|
||||||
|
key: ${{ runner.os }}-nuget-${{ hashFiles('**/*.csproj') }}
|
||||||
|
restore-keys: |
|
||||||
|
${{ runner.os }}-nuget-
|
||||||
|
|
||||||
|
- 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 --collect:"XPlat Code Coverage"
|
||||||
|
|
||||||
|
# Job específico para validação de PRs (sem deploy)
|
||||||
|
pr-validation:
|
||||||
|
name: PR Validation
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: [test]
|
||||||
|
if: github.event_name == 'pull_request'
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: PR Validation Summary
|
||||||
|
run: |
|
||||||
|
echo "✅ Pull Request Validation Summary"
|
||||||
|
echo "🎯 Target Branch: ${{ github.base_ref }}"
|
||||||
|
echo "📂 Source Branch: ${{ github.head_ref }}"
|
||||||
|
echo "🧪 Tests: ${{ needs.test.result }}"
|
||||||
|
echo "👤 Author: ${{ github.event.pull_request.user.login }}"
|
||||||
|
echo "📝 Title: ${{ github.event.pull_request.title }}"
|
||||||
|
echo ""
|
||||||
|
echo "✨ PR está pronto para merge!"
|
||||||
|
|
||||||
|
build-and-push:
|
||||||
|
name: Build and Push Image
|
||||||
|
runs-on: [self-hosted, arm64, bcards]
|
||||||
|
needs: [test]
|
||||||
|
# Só faz build/push em push (não em PR)
|
||||||
|
if: github.event_name == 'push' && (needs.test.result == 'success' || needs.test.result == 'skipped')
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
with:
|
||||||
|
platforms: linux/arm64
|
||||||
|
|
||||||
|
- name: Determine build settings
|
||||||
|
id: settings
|
||||||
|
run: |
|
||||||
|
BRANCH_NAME="${{ github.ref_name }}"
|
||||||
|
|
||||||
|
if [ "$BRANCH_NAME" = "main" ]; then
|
||||||
|
# Main = Produção (ARM64) - usando Dockerfile simples
|
||||||
|
echo "tag=latest" >> $GITHUB_OUTPUT
|
||||||
|
echo "platform=linux/arm64" >> $GITHUB_OUTPUT
|
||||||
|
echo "environment=Production" >> $GITHUB_OUTPUT
|
||||||
|
echo "dockerfile=Dockerfile" >> $GITHUB_OUTPUT
|
||||||
|
echo "deploy_target=production" >> $GITHUB_OUTPUT
|
||||||
|
elif [[ "$BRANCH_NAME" == Release/* ]]; then
|
||||||
|
# Release = Swarm tests (Orange Pi arm64) - usando Dockerfile simples também
|
||||||
|
VERSION_RAW=${BRANCH_NAME#Release/}
|
||||||
|
# Only remove V/v if it's at the start and followed by a number (like v1.0.0)
|
||||||
|
VERSION=$(echo "$VERSION_RAW" | sed 's/^[Vv]\([0-9]\)/\1/')
|
||||||
|
[ -z "$VERSION" ] && VERSION="0.0.1"
|
||||||
|
|
||||||
|
echo "tag=$VERSION" >> $GITHUB_OUTPUT
|
||||||
|
echo "platform=linux/arm64" >> $GITHUB_OUTPUT
|
||||||
|
echo "environment=Testing" >> $GITHUB_OUTPUT
|
||||||
|
echo "dockerfile=Dockerfile" >> $GITHUB_OUTPUT
|
||||||
|
echo "deploy_target=testing" >> $GITHUB_OUTPUT
|
||||||
|
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||||
|
fi
|
||||||
|
|
||||||
|
COMMIT_SHA=${{ github.sha }}
|
||||||
|
SHORT_COMMIT=${COMMIT_SHA:0:7}
|
||||||
|
echo "commit=$SHORT_COMMIT" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
|
echo "📦 Tag: ${{ steps.settings.outputs.tag }}"
|
||||||
|
echo "🏗️ Platform: ${{ steps.settings.outputs.platform }}"
|
||||||
|
echo "🌍 Environment: ${{ steps.settings.outputs.environment }}"
|
||||||
|
echo "🎯 Target: ${{ steps.settings.outputs.deploy_target }}"
|
||||||
|
|
||||||
|
- name: Build and push image
|
||||||
|
run: |
|
||||||
|
echo "🏗️ Building image for ${{ steps.settings.outputs.deploy_target }}..."
|
||||||
|
|
||||||
|
# Debug das variáveis
|
||||||
|
echo "Platform: ${{ steps.settings.outputs.platform }}"
|
||||||
|
echo "Dockerfile: ${{ steps.settings.outputs.dockerfile }}"
|
||||||
|
echo "Tag: ${{ steps.settings.outputs.tag }}"
|
||||||
|
|
||||||
|
# Verificar se o Dockerfile existe
|
||||||
|
if [ ! -f "${{ steps.settings.outputs.dockerfile }}" ]; then
|
||||||
|
echo "❌ Dockerfile não encontrado: ${{ steps.settings.outputs.dockerfile }}"
|
||||||
|
echo "📂 Arquivos na raiz:"
|
||||||
|
ls -la
|
||||||
|
echo "📂 Arquivos em src/BCards.Web/:"
|
||||||
|
ls -la src/BCards.Web/ || echo "Diretório não existe"
|
||||||
|
exit 1
|
||||||
|
else
|
||||||
|
echo "✅ Dockerfile encontrado: ${{ steps.settings.outputs.dockerfile }}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Build para a plataforma correta
|
||||||
|
if [ "${{ steps.settings.outputs.deploy_target }}" = "production" ]; then
|
||||||
|
# Build para produção (main branch) - Usa Configuration=Release (padrão)
|
||||||
|
docker buildx build \
|
||||||
|
--platform ${{ steps.settings.outputs.platform }} \
|
||||||
|
--file ${{ steps.settings.outputs.dockerfile }} \
|
||||||
|
--tag ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.settings.outputs.tag }} \
|
||||||
|
--push \
|
||||||
|
--progress=plain \
|
||||||
|
.
|
||||||
|
else
|
||||||
|
# Build para staging (Release branches) - Usa Configuration=Testing para habilitar código de teste
|
||||||
|
docker buildx build \
|
||||||
|
--platform ${{ steps.settings.outputs.platform }} \
|
||||||
|
--file ${{ steps.settings.outputs.dockerfile }} \
|
||||||
|
--tag ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.settings.outputs.tag }} \
|
||||||
|
--push \
|
||||||
|
--build-arg BUILD_CONFIGURATION=Testing \
|
||||||
|
--build-arg VERSION=${{ steps.settings.outputs.version || 'latest' }} \
|
||||||
|
--build-arg COMMIT=${{ steps.settings.outputs.commit }} \
|
||||||
|
--progress=plain \
|
||||||
|
.
|
||||||
|
fi
|
||||||
|
|
||||||
|
deploy-production:
|
||||||
|
name: Deploy to Production (ARM - OCI)
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: [build-and-push]
|
||||||
|
if: github.ref_name == 'main'
|
||||||
|
environment: production
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Create Production Configuration
|
||||||
|
run: |
|
||||||
|
echo "🔧 Creating appsettings.Production.json with environment variables..."
|
||||||
|
|
||||||
|
# Cria o arquivo de configuração para produção
|
||||||
|
cat > appsettings.Production.json << 'CONFIG_EOF'
|
||||||
|
{
|
||||||
|
"Logging": {
|
||||||
|
"LogLevel": {
|
||||||
|
"Default": "Information",
|
||||||
|
"Microsoft.AspNetCore": "Warning",
|
||||||
|
"Microsoft.EntityFrameworkCore": "Warning",
|
||||||
|
"BCards": "Information",
|
||||||
|
"BCards.Web.Services.GridFSImageStorage": "Debug"
|
||||||
|
},
|
||||||
|
"Console": {
|
||||||
|
"IncludeScopes": false,
|
||||||
|
"LogLevel": {
|
||||||
|
"Default": "Information"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"File": {
|
||||||
|
"Path": "/app/logs/bcards-{Date}.log",
|
||||||
|
"LogLevel": {
|
||||||
|
"Default": "Information"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"AllowedHosts": "*",
|
||||||
|
"Stripe": {
|
||||||
|
"PublishableKey": "${{ vars.STRIPE_PUBLISHABLE_KEY }}",
|
||||||
|
"SecretKey": "${{ secrets.STRIPE_SECRET_KEY }}",
|
||||||
|
"WebhookSecret": "${{ secrets.STRIPE_WEBHOOK_SECRET }}",
|
||||||
|
"Environment": "${{ vars.STRIPE_ENVIRONMENT || 'test' }}"
|
||||||
|
},
|
||||||
|
"Authentication": {
|
||||||
|
"Google": {
|
||||||
|
"ClientId": "${{ vars.GOOGLE_CLIENT_ID }}",
|
||||||
|
"ClientSecret": "${{ secrets.GOOGLE_CLIENT_SECRET }}"
|
||||||
|
},
|
||||||
|
"Microsoft": {
|
||||||
|
"ClientId": "${{ vars.MICROSOFT_CLIENT_ID }}",
|
||||||
|
"ClientSecret": "${{ secrets.MICROSOFT_CLIENT_SECRET }}"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"SendGrid": {
|
||||||
|
"ApiKey": "${{ secrets.SENDGRID_API_KEY }}",
|
||||||
|
"FromEmail": "${{ vars.SENDGRID_FROM_EMAIL || 'ricardo.carneiro@jobmaker.com.br' }}",
|
||||||
|
"FromName": "${{ vars.SENDGRID_FROM_NAME || 'Ricardo Carneiro' }}"
|
||||||
|
},
|
||||||
|
"Plans": {
|
||||||
|
"Basic": {
|
||||||
|
"Name": "Básico",
|
||||||
|
"PriceId": "price_1RycPaBMIadsOxJVKioZZofK",
|
||||||
|
"Price": 5.90,
|
||||||
|
"MaxPages": 3,
|
||||||
|
"MaxLinks": 8,
|
||||||
|
"AllowPremiumThemes": false,
|
||||||
|
"AllowProductLinks": false,
|
||||||
|
"AllowAnalytics": true,
|
||||||
|
"Features": [ "URL Personalizada", "20 temas básicos", "Analytics simples" ],
|
||||||
|
"Interval": "month"
|
||||||
|
},
|
||||||
|
"Professional": {
|
||||||
|
"Name": "Profissional",
|
||||||
|
"PriceId": "price_1RycQmBMIadsOxJVGqjVMaOj",
|
||||||
|
"Price": 12.90,
|
||||||
|
"MaxPages": 5,
|
||||||
|
"MaxLinks": 20,
|
||||||
|
"AllowPremiumThemes": false,
|
||||||
|
"AllowProductLinks": false,
|
||||||
|
"AllowAnalytics": true,
|
||||||
|
"Features": [ "URL Personalizada", "20 temas básicos", "Analytics avançado" ],
|
||||||
|
"Interval": "month"
|
||||||
|
},
|
||||||
|
"Premium": {
|
||||||
|
"Name": "Premium",
|
||||||
|
"PriceId": "price_1RycRUBMIadsOxJVkxGOh3uu",
|
||||||
|
"Price": 19.90,
|
||||||
|
"MaxPages": 15,
|
||||||
|
"MaxLinks": -1,
|
||||||
|
"AllowPremiumThemes": true,
|
||||||
|
"AllowProductLinks": false,
|
||||||
|
"AllowAnalytics": true,
|
||||||
|
"SpecialModeration": false,
|
||||||
|
"Features": [ "URL Personalizada", "40 temas (básicos + premium)", "Suporte prioritário", "Links ilimitados" ],
|
||||||
|
"Interval": "month"
|
||||||
|
},
|
||||||
|
"PremiumAffiliate": {
|
||||||
|
"Name": "Premium+Afiliados",
|
||||||
|
"PriceId": "price_1RycTaBMIadsOxJVeDLseXQq",
|
||||||
|
"Price": 29.90,
|
||||||
|
"MaxPages": 15,
|
||||||
|
"MaxLinks": -1,
|
||||||
|
"AllowPremiumThemes": true,
|
||||||
|
"AllowProductLinks": true,
|
||||||
|
"AllowAnalytics": true,
|
||||||
|
"SpecialModeration": true,
|
||||||
|
"Features": [ "Tudo do Premium", "Links de produto", "Moderação especial", "Até 10 links afiliados" ],
|
||||||
|
"Interval": "month"
|
||||||
|
},
|
||||||
|
"BasicYearly": {
|
||||||
|
"Name": "Básico Anual",
|
||||||
|
"PriceId": "price_1RycWgBMIadsOxJVGdtEeoMS",
|
||||||
|
"Price": 59.00,
|
||||||
|
"MaxPages": 3,
|
||||||
|
"MaxLinks": 8,
|
||||||
|
"AllowPremiumThemes": false,
|
||||||
|
"AllowProductLinks": false,
|
||||||
|
"AllowAnalytics": true,
|
||||||
|
"Features": [ "URL Personalizada", "20 temas básicos", "Analytics simples", "Economize R$ 11,80 (2 meses grátis)" ],
|
||||||
|
"Interval": "year"
|
||||||
|
},
|
||||||
|
"ProfessionalYearly": {
|
||||||
|
"Name": "Profissional Anual",
|
||||||
|
"PriceId": "price_1RycXdBMIadsOxJV5cNX7dHm",
|
||||||
|
"Price": 129.00,
|
||||||
|
"MaxPages": 5,
|
||||||
|
"MaxLinks": 20,
|
||||||
|
"AllowPremiumThemes": false,
|
||||||
|
"AllowProductLinks": false,
|
||||||
|
"AllowAnalytics": true,
|
||||||
|
"Features": [ "URL Personalizada", "20 temas básicos", "Analytics avançado", "Economize R$ 25,80 (2 meses grátis)" ],
|
||||||
|
"Interval": "year"
|
||||||
|
},
|
||||||
|
"PremiumYearly": {
|
||||||
|
"Name": "Premium Anual",
|
||||||
|
"PriceId": "price_1RycYnBMIadsOxJVPdKmzy4m",
|
||||||
|
"Price": 199.00,
|
||||||
|
"MaxPages": 15,
|
||||||
|
"MaxLinks": -1,
|
||||||
|
"AllowPremiumThemes": true,
|
||||||
|
"AllowProductLinks": false,
|
||||||
|
"AllowAnalytics": true,
|
||||||
|
"SpecialModeration": false,
|
||||||
|
"Features": [ "URL Personalizada", "40 temas (básicos + premium)", "Suporte prioritário", "Links ilimitados", "Economize R$ 39,80 (2 meses grátis)" ],
|
||||||
|
"Interval": "year"
|
||||||
|
},
|
||||||
|
"PremiumAffiliateYearly": {
|
||||||
|
"Name": "Premium+Afiliados Anual",
|
||||||
|
"PriceId": "price_1RycaEBMIadsOxJVEhsdB2Y1",
|
||||||
|
"Price": 299.00,
|
||||||
|
"MaxPages": 15,
|
||||||
|
"MaxLinks": -1,
|
||||||
|
"AllowPremiumThemes": true,
|
||||||
|
"AllowProductLinks": true,
|
||||||
|
"AllowAnalytics": true,
|
||||||
|
"SpecialModeration": true,
|
||||||
|
"Features": [ "Tudo do Premium", "Links de produto", "Moderação especial", "Até 10 links afiliados", "Economize R$ 59,80 (2 meses grátis)" ],
|
||||||
|
"Interval": "year"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Moderation": {
|
||||||
|
"PriorityTimeframes": {
|
||||||
|
"Trial": "7.00:00:00",
|
||||||
|
"Basic": "7.00:00:00",
|
||||||
|
"Professional": "3.00:00:00",
|
||||||
|
"Premium": "1.00:00:00"
|
||||||
|
},
|
||||||
|
"MaxAttempts": 3,
|
||||||
|
"ModeratorEmail": "${{ vars.MODERATOR_EMAIL || 'ricardo.carneiro@jobmaker.com.br' }}",
|
||||||
|
"ModeratorEmails": [
|
||||||
|
"${{ vars.MODERATOR_EMAIL_1 || 'rrcgoncalves@gmail.com' }}",
|
||||||
|
"${{ vars.MODERATOR_EMAIL_2 || 'rirocarneiro@gmail.com' }}"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"MongoDb": {
|
||||||
|
"ConnectionString": "mongodb://admin:c4rn31r0@129.146.116.218:27017,141.148.162.114:27017/BCardsDB?replicaSet=rs0&authSource=admin",
|
||||||
|
"DatabaseName": "BCardsDB",
|
||||||
|
"MaxConnectionPoolSize": 100,
|
||||||
|
"ConnectTimeout": "30s",
|
||||||
|
"ServerSelectionTimeout": "30s",
|
||||||
|
"SocketTimeout": "30s"
|
||||||
|
},
|
||||||
|
"BaseUrl": "https://bcards.site",
|
||||||
|
"Environment": {
|
||||||
|
"Name": "Production",
|
||||||
|
"IsStagingEnvironment": false,
|
||||||
|
"AllowTestData": false,
|
||||||
|
"EnableDetailedErrors": false
|
||||||
|
},
|
||||||
|
"Performance": {
|
||||||
|
"EnableCaching": true,
|
||||||
|
"CacheExpirationMinutes": 30,
|
||||||
|
"EnableCompression": true,
|
||||||
|
"EnableResponseCaching": true
|
||||||
|
},
|
||||||
|
"Security": {
|
||||||
|
"EnableHttpsRedirection": true,
|
||||||
|
"EnableHsts": true,
|
||||||
|
"RequireHttpsMetadata": true
|
||||||
|
},
|
||||||
|
"HealthChecks": {
|
||||||
|
"Enabled": true,
|
||||||
|
"Endpoints": {
|
||||||
|
"Health": "/health",
|
||||||
|
"Ready": "/ready",
|
||||||
|
"Live": "/live"
|
||||||
|
},
|
||||||
|
"MongoDb": {
|
||||||
|
"Enabled": true,
|
||||||
|
"Timeout": "10s"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Features": {
|
||||||
|
"EnablePreviewMode": true,
|
||||||
|
"EnableModerationWorkflow": true,
|
||||||
|
"EnableAnalytics": true,
|
||||||
|
"EnableFileUploads": true,
|
||||||
|
"MaxFileUploadSize": "5MB"
|
||||||
|
},
|
||||||
|
"Serilog": {
|
||||||
|
"OpenSearchUrl": "${{ vars.OPENSEARCH_URL || 'http://localhost:9201' }}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
CONFIG_EOF
|
||||||
|
|
||||||
|
echo "✅ Configuration file created!"
|
||||||
|
echo "🔍 File content (sensitive data masked):"
|
||||||
|
cat appsettings.Production.json | sed 's/"[^"]*_[0-9A-Za-z_]*"/"***MASKED***"/g'
|
||||||
|
|
||||||
|
- name: Deploy to Production Swarm
|
||||||
|
run: |
|
||||||
|
echo "🚀 Deploying to production Docker Swarm (ARM64)..."
|
||||||
|
|
||||||
|
# Configura SSH
|
||||||
|
mkdir -p ~/.ssh
|
||||||
|
echo "${{ secrets.SSH_PRIVATE_KEY }}" > ~/.ssh/id_rsa
|
||||||
|
chmod 600 ~/.ssh/id_rsa
|
||||||
|
|
||||||
|
# Adiciona hosts conhecidos
|
||||||
|
ssh-keyscan -H 141.148.162.114 >> ~/.ssh/known_hosts
|
||||||
|
ssh-keyscan -H 129.146.116.218 >> ~/.ssh/known_hosts
|
||||||
|
|
||||||
|
# Testa a chave SSH
|
||||||
|
ssh-add ~/.ssh/id_rsa 2>/dev/null || echo "SSH key loaded"
|
||||||
|
|
||||||
|
# Upload configuration and stack file to swarm manager
|
||||||
|
echo "📤 Uploading files to Swarm manager..."
|
||||||
|
scp -o StrictHostKeyChecking=no appsettings.Production.json ubuntu@141.148.162.114:/tmp/
|
||||||
|
scp -o StrictHostKeyChecking=no deploy/docker-stack.yml ubuntu@141.148.162.114:/tmp/
|
||||||
|
|
||||||
|
# Deploy to Docker Swarm
|
||||||
|
ssh -o StrictHostKeyChecking=no ubuntu@141.148.162.114 << 'EOF'
|
||||||
|
set -e
|
||||||
|
|
||||||
|
echo "🔄 Updating Docker Swarm stack..."
|
||||||
|
|
||||||
|
# Update Docker config with new appsettings
|
||||||
|
echo "📝 Updating bcards-appsettings config..."
|
||||||
|
|
||||||
|
# Remove old config (will fail if in use, that's ok - swarm will use it until update)
|
||||||
|
docker config rm bcards-appsettings 2>/dev/null || echo "Config in use or doesn't exist, will create new one"
|
||||||
|
|
||||||
|
# Create new config with timestamp to force update
|
||||||
|
CONFIG_NAME="bcards-appsettings-$(date +%s)"
|
||||||
|
docker config create ${CONFIG_NAME} /tmp/appsettings.Production.json
|
||||||
|
|
||||||
|
# Update stack file to use new config name
|
||||||
|
sed "s/bcards-appsettings/${CONFIG_NAME}/g" /tmp/docker-stack.yml > /tmp/docker-stack-updated.yml
|
||||||
|
|
||||||
|
echo "🐳 Deploying stack to Swarm (rolling update, zero downtime)..."
|
||||||
|
docker stack deploy -c /tmp/docker-stack-updated.yml bcards --with-registry-auth
|
||||||
|
|
||||||
|
echo "⏳ Waiting for service to update..."
|
||||||
|
sleep 10
|
||||||
|
|
||||||
|
# Show service status
|
||||||
|
docker service ls --filter name=bcards_bcards-app
|
||||||
|
docker service ps bcards_bcards-app --no-trunc --filter "desired-state=running" | head -10
|
||||||
|
|
||||||
|
echo "🧹 Cleaning up standalone containers if they exist..."
|
||||||
|
docker stop bcards-prod 2>/dev/null || echo "No standalone container to stop"
|
||||||
|
docker rm bcards-prod 2>/dev/null || echo "No standalone container to remove"
|
||||||
|
|
||||||
|
# Clean up temp files
|
||||||
|
rm -f /tmp/appsettings.Production.json /tmp/docker-stack.yml /tmp/docker-stack-updated.yml
|
||||||
|
|
||||||
|
echo "✅ Swarm stack updated successfully!"
|
||||||
|
EOF
|
||||||
|
|
||||||
|
- name: Health Check Production
|
||||||
|
run: |
|
||||||
|
echo "🏥 Verificando saúde dos servidores de produção..."
|
||||||
|
sleep 30
|
||||||
|
|
||||||
|
# Verifica Servidor 1
|
||||||
|
echo "Verificando Servidor 1 (ARM)..."
|
||||||
|
ssh -o StrictHostKeyChecking=no ubuntu@141.148.162.114 'curl -f http://localhost:8080/health || echo "⚠️ Servidor 1 pode não estar respondendo"'
|
||||||
|
|
||||||
|
# Verifica Servidor 2
|
||||||
|
echo "Verificando Servidor 2 (ARM)..."
|
||||||
|
ssh -o StrictHostKeyChecking=no ubuntu@129.146.116.218 'curl -f http://localhost:8080/health || echo "⚠️ Servidor 2 pode não estar respondendo"'
|
||||||
|
|
||||||
|
deploy-test:
|
||||||
|
name: Deploy to Release Swarm (ARM)
|
||||||
|
runs-on: [self-hosted, arm64, bcards]
|
||||||
|
needs: [build-and-push]
|
||||||
|
if: startsWith(github.ref_name, 'Release/')
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Extract version
|
||||||
|
id: version
|
||||||
|
run: |
|
||||||
|
BRANCH_NAME="${{ github.ref_name }}"
|
||||||
|
VERSION_RAW=${BRANCH_NAME#Release/}
|
||||||
|
# Only remove V/v if it's at the start and followed by a number (like v1.0.0)
|
||||||
|
VERSION=$(echo "$VERSION_RAW" | sed 's/^[Vv]\([0-9]\)/\1/')
|
||||||
|
[ -z "$VERSION" ] && VERSION="0.0.1"
|
||||||
|
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||||
|
echo "📦 Deploying version: $VERSION"
|
||||||
|
|
||||||
|
- name: Prepare release stack manifest
|
||||||
|
run: |
|
||||||
|
mkdir -p artifacts
|
||||||
|
BCARDS_IMAGE=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.version }}
|
||||||
|
|
||||||
|
# Replace ${BCARDS_IMAGE} with actual image name using sed
|
||||||
|
sed "s|\${BCARDS_IMAGE}|${BCARDS_IMAGE}|g" deploy/docker-stack.release.yml > artifacts/docker-stack.release.yml
|
||||||
|
|
||||||
|
echo "🔧 Generated manifest with image: ${BCARDS_IMAGE}"
|
||||||
|
echo "📄 Manifest content:"
|
||||||
|
head -10 artifacts/docker-stack.release.yml
|
||||||
|
|
||||||
|
- name: Deploy to release swarm
|
||||||
|
run: |
|
||||||
|
echo "🚀 Deploying release stack to Orange Pi swarm..."
|
||||||
|
docker stack deploy -c artifacts/docker-stack.release.yml bcards-release
|
||||||
|
|
||||||
|
- name: Await release service readiness
|
||||||
|
run: |
|
||||||
|
echo "⏳ Aguardando serviço bcards-release estabilizar..."
|
||||||
|
ATTEMPTS=30
|
||||||
|
while [ $ATTEMPTS -gt 0 ]; do
|
||||||
|
REPLICAS=$(docker service ls --filter name=bcards-release_bcards-release --format '{{.Replicas}}')
|
||||||
|
if [ "$REPLICAS" = "1/1" ]; then
|
||||||
|
echo "✅ Serviço com $REPLICAS réplica"
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
echo "Atual: ${REPLICAS:-N/A}; aguardando..."
|
||||||
|
sleep 5
|
||||||
|
ATTEMPTS=$((ATTEMPTS-1))
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ "$REPLICAS" != "1/1" ]; then
|
||||||
|
echo "❌ Serviço não atingiu 1/1 réplica"
|
||||||
|
docker service ps bcards-release_bcards-release
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
docker service ps bcards-release_bcards-release
|
||||||
|
|
||||||
|
cleanup:
|
||||||
|
name: Cleanup Old Resources
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: [deploy-production, deploy-test]
|
||||||
|
if: always() && (needs.deploy-production.result == 'success' || needs.deploy-test.result == 'success')
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Cleanup containers and images
|
||||||
|
run: |
|
||||||
|
echo "🧹 Limpando recursos antigos..."
|
||||||
|
|
||||||
|
if [ "${{ github.ref_name }}" = "main" ]; then
|
||||||
|
mkdir -p ~/.ssh
|
||||||
|
echo "${{ secrets.SSH_PRIVATE_KEY }}" > ~/.ssh/id_rsa
|
||||||
|
chmod 600 ~/.ssh/id_rsa
|
||||||
|
ssh-keyscan -H 141.148.162.114 >> ~/.ssh/known_hosts
|
||||||
|
ssh-keyscan -H 129.146.116.218 >> ~/.ssh/known_hosts
|
||||||
|
ssh-add ~/.ssh/id_rsa 2>/dev/null || echo "SSH key loaded"
|
||||||
|
|
||||||
|
for server in 141.148.162.114 129.146.116.218; do
|
||||||
|
echo "🧹 Limpando servidor $server..."
|
||||||
|
ssh -o StrictHostKeyChecking=no ubuntu@$server << 'EOF'
|
||||||
|
docker container prune -f
|
||||||
|
docker image prune -f
|
||||||
|
docker network prune -f
|
||||||
|
EOF
|
||||||
|
done
|
||||||
|
else
|
||||||
|
echo "ℹ️ Release branch: limpeza remota ignorada (Swarm gerencia recursos)."
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "✅ Limpeza concluída!"
|
||||||
|
|
||||||
|
deployment-summary:
|
||||||
|
name: Deployment Summary
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: [deploy-production, deploy-test]
|
||||||
|
if: always()
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Summary
|
||||||
|
run: |
|
||||||
|
echo "📋 DEPLOYMENT SUMMARY"
|
||||||
|
echo "===================="
|
||||||
|
echo "🎯 Branch: ${{ github.ref_name }}"
|
||||||
|
echo "🔑 Commit: ${{ github.sha }}"
|
||||||
|
echo "🏗️ Registry: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}"
|
||||||
|
|
||||||
|
if [ "${{ github.ref_name }}" = "main" ]; then
|
||||||
|
echo "🌍 Environment: Production (Swarm ARM)"
|
||||||
|
echo "🖥️ Servers: 141.148.162.114, 129.146.116.218"
|
||||||
|
echo "📦 Tag: latest"
|
||||||
|
echo "🔗 Status: ${{ needs.deploy-production.result }}"
|
||||||
|
else
|
||||||
|
echo "🌍 Environment: Release (Swarm ARM)"
|
||||||
|
echo "🖥️ Servers: 141.148.162.114, 129.146.116.218"
|
||||||
|
echo "📦 Branch Tag: ${{ github.ref_name }}"
|
||||||
|
echo "🔗 Status: ${{ needs.deploy-test.result }}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "===================="
|
||||||
|
echo "✅ Pipeline completed!"
|
||||||
@ -1,111 +1,111 @@
|
|||||||
name: PR Validation for Release
|
name: PR Validation for Release
|
||||||
|
|
||||||
on:
|
on:
|
||||||
pull_request:
|
pull_request:
|
||||||
branches:
|
branches:
|
||||||
- 'Release/*'
|
- 'Release/*'
|
||||||
types: [opened, synchronize, reopened, ready_for_review]
|
types: [opened, synchronize, reopened, ready_for_review]
|
||||||
|
|
||||||
env:
|
env:
|
||||||
REGISTRY: registry.redecarneir.us
|
REGISTRY: registry.redecarneir.us
|
||||||
IMAGE_NAME: bcards
|
IMAGE_NAME: bcards
|
||||||
MONGODB_HOST: 192.168.0.100:27017
|
MONGODB_HOST: 192.168.0.100:27017
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
validate-pr:
|
validate-pr:
|
||||||
name: Validate Pull Request
|
name: Validate Pull Request
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
if: github.event.pull_request.draft == false
|
if: github.event.pull_request.draft == false
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: PR Info
|
- name: PR Info
|
||||||
run: |
|
run: |
|
||||||
echo "🔍 Validando PR #${{ github.event.number }}"
|
echo "🔍 Validando PR #${{ github.event.number }}"
|
||||||
echo "📂 Source: ${{ github.head_ref }}"
|
echo "📂 Source: ${{ github.head_ref }}"
|
||||||
echo "🎯 Target: ${{ github.base_ref }}"
|
echo "🎯 Target: ${{ github.base_ref }}"
|
||||||
echo "👤 Author: ${{ github.event.pull_request.user.login }}"
|
echo "👤 Author: ${{ github.event.pull_request.user.login }}"
|
||||||
echo "📝 Title: ${{ github.event.pull_request.title }}"
|
echo "📝 Title: ${{ github.event.pull_request.title }}"
|
||||||
|
|
||||||
- name: Checkout PR code
|
- name: Checkout PR code
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
ref: ${{ github.event.pull_request.head.sha }}
|
ref: ${{ github.event.pull_request.head.sha }}
|
||||||
|
|
||||||
- name: Setup .NET 8
|
- name: Setup .NET 8
|
||||||
uses: actions/setup-dotnet@v4
|
uses: actions/setup-dotnet@v4
|
||||||
with:
|
with:
|
||||||
dotnet-version: '8.0.x'
|
dotnet-version: '8.0.x'
|
||||||
|
|
||||||
- name: Restore dependencies
|
- name: Restore dependencies
|
||||||
run: dotnet restore
|
run: dotnet restore
|
||||||
|
|
||||||
- name: Build solution
|
- name: Build solution
|
||||||
run: dotnet build --no-restore --configuration Release
|
run: dotnet build --no-restore --configuration Release
|
||||||
|
|
||||||
- name: Run tests
|
- name: Run tests
|
||||||
if: ${{ vars.SKIP_TESTS_PR != 'true' }}
|
if: ${{ vars.SKIP_TESTS_PR != 'true' }}
|
||||||
run: |
|
run: |
|
||||||
echo "🧪 Executando testes no PR"
|
echo "🧪 Executando testes no PR"
|
||||||
SKIP_TESTS="${{ github.event.inputs.skip_tests || vars.SKIP_TESTS }}"
|
SKIP_TESTS="${{ github.event.inputs.skip_tests || vars.SKIP_TESTS }}"
|
||||||
|
|
||||||
if [ "$SKIP_TESTS" == "true" ]; then
|
if [ "$SKIP_TESTS" == "true" ]; then
|
||||||
echo "⚠️ Testes PULADOS"
|
echo "⚠️ Testes PULADOS"
|
||||||
echo "TESTS_SKIPPED=true" >> $GITHUB_ENV
|
echo "TESTS_SKIPPED=true" >> $GITHUB_ENV
|
||||||
else
|
else
|
||||||
echo "✅ Executando testes"
|
echo "✅ Executando testes"
|
||||||
dotnet test --no-build --configuration Release --verbosity normal
|
dotnet test --no-build --configuration Release --verbosity normal
|
||||||
echo "TESTS_SKIPPED=false" >> $GITHUB_ENV
|
echo "TESTS_SKIPPED=false" >> $GITHUB_ENV
|
||||||
fi
|
fi
|
||||||
|
|
||||||
- name: Build Docker image (test only)
|
- name: Build Docker image (test only)
|
||||||
run: |
|
run: |
|
||||||
echo "🐳 Testando build da imagem Docker..."
|
echo "🐳 Testando build da imagem Docker..."
|
||||||
|
|
||||||
# Extrair versão da branch de destino
|
# Extrair versão da branch de destino
|
||||||
TARGET_BRANCH="${{ github.base_ref }}"
|
TARGET_BRANCH="${{ github.base_ref }}"
|
||||||
VERSION_RAW=${TARGET_BRANCH#Release/}
|
VERSION_RAW=${TARGET_BRANCH#Release/}
|
||||||
VERSION=$(echo "$VERSION_RAW" | sed 's/^[Vv]//')
|
VERSION=$(echo "$VERSION_RAW" | sed 's/^[Vv]//')
|
||||||
COMMIT_SHA=${{ github.event.pull_request.head.sha }}
|
COMMIT_SHA=${{ github.event.pull_request.head.sha }}
|
||||||
SHORT_COMMIT=${COMMIT_SHA:0:7}
|
SHORT_COMMIT=${COMMIT_SHA:0:7}
|
||||||
|
|
||||||
echo "📦 Version: $VERSION"
|
echo "📦 Version: $VERSION"
|
||||||
echo "🔑 Commit: $SHORT_COMMIT"
|
echo "🔑 Commit: $SHORT_COMMIT"
|
||||||
|
|
||||||
# Build apenas para teste (sem push)
|
# Build apenas para teste (sem push)
|
||||||
docker buildx build \
|
docker buildx build \
|
||||||
--platform linux/amd64 \
|
--platform linux/amd64 \
|
||||||
--file Dockerfile.release \
|
--file Dockerfile.release \
|
||||||
--build-arg VERSION=$VERSION \
|
--build-arg VERSION=$VERSION \
|
||||||
--build-arg COMMIT=$SHORT_COMMIT \
|
--build-arg COMMIT=$SHORT_COMMIT \
|
||||||
--tag $REGISTRY/$IMAGE_NAME:pr-${{ github.event.number }}-$SHORT_COMMIT \
|
--tag $REGISTRY/$IMAGE_NAME:pr-${{ github.event.number }}-$SHORT_COMMIT \
|
||||||
--output type=docker \
|
--output type=docker \
|
||||||
.
|
.
|
||||||
|
|
||||||
- name: Security scan (opcional)
|
- name: Security scan (opcional)
|
||||||
run: |
|
run: |
|
||||||
echo "🔒 Executando verificações de segurança..."
|
echo "🔒 Executando verificações de segurança..."
|
||||||
# Adicione suas verificações de segurança aqui
|
# Adicione suas verificações de segurança aqui
|
||||||
|
|
||||||
- name: PR Status Summary
|
- name: PR Status Summary
|
||||||
run: |
|
run: |
|
||||||
echo "✅ Pull Request Validation Summary"
|
echo "✅ Pull Request Validation Summary"
|
||||||
echo "🎯 Target Branch: ${{ github.base_ref }}"
|
echo "🎯 Target Branch: ${{ github.base_ref }}"
|
||||||
echo "📂 Source Branch: ${{ github.head_ref }}"
|
echo "📂 Source Branch: ${{ github.head_ref }}"
|
||||||
echo "🧪 Tests: ${{ vars.SKIP_TESTS_PR == 'true' && 'SKIPPED' || 'PASSED' }}"
|
echo "🧪 Tests: ${{ vars.SKIP_TESTS_PR == 'true' && 'SKIPPED' || 'PASSED' }}"
|
||||||
echo "🐳 Docker Build: PASSED"
|
echo "🐳 Docker Build: PASSED"
|
||||||
echo "🔒 Security Scan: PASSED"
|
echo "🔒 Security Scan: PASSED"
|
||||||
echo ""
|
echo ""
|
||||||
echo "✨ PR está pronto para merge!"
|
echo "✨ PR está pronto para merge!"
|
||||||
|
|
||||||
# Job que só executa se a validação passou
|
# Job que só executa se a validação passou
|
||||||
ready-for-merge:
|
ready-for-merge:
|
||||||
name: Ready for Merge
|
name: Ready for Merge
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
needs: [validate-pr]
|
needs: [validate-pr]
|
||||||
if: success()
|
if: success()
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Merge readiness
|
- name: Merge readiness
|
||||||
run: |
|
run: |
|
||||||
echo "🎉 Pull Request #${{ github.event.number }} passou em todas as validações!"
|
echo "🎉 Pull Request #${{ github.event.number }} passou em todas as validações!"
|
||||||
echo "✅ Pode ser feito o merge com segurança"
|
echo "✅ Pode ser feito o merge com segurança"
|
||||||
@ -1,120 +1,120 @@
|
|||||||
name: Release Deployment Pipeline
|
name: Release Deployment Pipeline
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches:
|
branches:
|
||||||
- 'Release/*'
|
- 'Release/*'
|
||||||
|
|
||||||
env:
|
env:
|
||||||
REGISTRY: registry.redecarneir.us
|
REGISTRY: registry.redecarneir.us
|
||||||
IMAGE_NAME: bcards
|
IMAGE_NAME: bcards
|
||||||
MONGODB_HOST: 192.168.0.100:27017
|
MONGODB_HOST: 192.168.0.100:27017
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
test:
|
test:
|
||||||
name: Run Tests
|
name: Run Tests
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Check if tests should run
|
- name: Check if tests should run
|
||||||
run: |
|
run: |
|
||||||
# Prioridade: manual input > variável do repo
|
# Prioridade: manual input > variável do repo
|
||||||
SKIP_TESTS="${{ github.event.inputs.skip_tests || vars.SKIP_TESTS }}"
|
SKIP_TESTS="${{ github.event.inputs.skip_tests || vars.SKIP_TESTS }}"
|
||||||
|
|
||||||
if [ "$SKIP_TESTS" == "true" ]; then
|
if [ "$SKIP_TESTS" == "true" ]; then
|
||||||
echo "⚠️ Testes PULADOS"
|
echo "⚠️ Testes PULADOS"
|
||||||
echo "TESTS_SKIPPED=true" >> $GITHUB_ENV
|
echo "TESTS_SKIPPED=true" >> $GITHUB_ENV
|
||||||
else
|
else
|
||||||
echo "✅ Executando testes"
|
echo "✅ Executando testes"
|
||||||
echo "TESTS_SKIPPED=false" >> $GITHUB_ENV
|
echo "TESTS_SKIPPED=false" >> $GITHUB_ENV
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "🎯 Trigger: ${{ github.event_name }}"
|
echo "🎯 Trigger: ${{ github.event_name }}"
|
||||||
echo "📂 Branch: ${{ github.ref_name }}"
|
echo "📂 Branch: ${{ github.ref_name }}"
|
||||||
|
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
if: env.TESTS_SKIPPED == 'false'
|
if: env.TESTS_SKIPPED == 'false'
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Setup .NET 8
|
- name: Setup .NET 8
|
||||||
if: env.TESTS_SKIPPED == 'false'
|
if: env.TESTS_SKIPPED == 'false'
|
||||||
uses: actions/setup-dotnet@v4
|
uses: actions/setup-dotnet@v4
|
||||||
with:
|
with:
|
||||||
dotnet-version: '8.0.x'
|
dotnet-version: '8.0.x'
|
||||||
|
|
||||||
- name: Restore dependencies
|
- name: Restore dependencies
|
||||||
if: env.TESTS_SKIPPED == 'false'
|
if: env.TESTS_SKIPPED == 'false'
|
||||||
run: dotnet restore
|
run: dotnet restore
|
||||||
|
|
||||||
- name: Build solution
|
- name: Build solution
|
||||||
if: env.TESTS_SKIPPED == 'false'
|
if: env.TESTS_SKIPPED == 'false'
|
||||||
run: dotnet build --no-restore --configuration Release
|
run: dotnet build --no-restore --configuration Release
|
||||||
|
|
||||||
- name: Run unit tests
|
- name: Run unit tests
|
||||||
if: env.TESTS_SKIPPED == 'false'
|
if: env.TESTS_SKIPPED == 'false'
|
||||||
run: dotnet test --no-build --configuration Release --verbosity normal
|
run: dotnet test --no-build --configuration Release --verbosity normal
|
||||||
|
|
||||||
build-and-deploy:
|
build-and-deploy:
|
||||||
name: Build and Deploy
|
name: Build and Deploy
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
needs: [test]
|
needs: [test]
|
||||||
if: always() && (needs.test.result == 'success' || needs.test.result == 'failure')
|
if: always() && (needs.test.result == 'success' || needs.test.result == 'failure')
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Deployment info
|
- name: Deployment info
|
||||||
run: |
|
run: |
|
||||||
echo "🚀 Iniciando deployment para ${{ github.ref_name }}"
|
echo "🚀 Iniciando deployment para ${{ github.ref_name }}"
|
||||||
echo "🧪 Tests: ${{ vars.SKIP_TESTS == 'true' && 'SKIPPED' || 'EXECUTED' }}"
|
echo "🧪 Tests: ${{ vars.SKIP_TESTS == 'true' && 'SKIPPED' || 'EXECUTED' }}"
|
||||||
|
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@v3
|
uses: docker/setup-buildx-action@v3
|
||||||
with:
|
with:
|
||||||
platforms: linux/amd64,linux/arm64
|
platforms: linux/amd64,linux/arm64
|
||||||
|
|
||||||
- name: Extract version info
|
- name: Extract version info
|
||||||
id: version
|
id: version
|
||||||
run: |
|
run: |
|
||||||
BRANCH_NAME="${{ github.ref_name }}"
|
BRANCH_NAME="${{ github.ref_name }}"
|
||||||
VERSION_RAW=${BRANCH_NAME#Release/}
|
VERSION_RAW=${BRANCH_NAME#Release/}
|
||||||
VERSION=$(echo "$VERSION_RAW" | sed 's/^[Vv]//')
|
VERSION=$(echo "$VERSION_RAW" | sed 's/^[Vv]//')
|
||||||
|
|
||||||
if [ -z "$VERSION" ]; then
|
if [ -z "$VERSION" ]; then
|
||||||
VERSION="0.0.1"
|
VERSION="0.0.1"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
COMMIT_SHA=${{ github.sha }}
|
COMMIT_SHA=${{ github.sha }}
|
||||||
SHORT_COMMIT=${COMMIT_SHA:0:7}
|
SHORT_COMMIT=${COMMIT_SHA:0:7}
|
||||||
|
|
||||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||||
echo "commit=$SHORT_COMMIT" >> $GITHUB_OUTPUT
|
echo "commit=$SHORT_COMMIT" >> $GITHUB_OUTPUT
|
||||||
echo "tag=$VERSION-$SHORT_COMMIT" >> $GITHUB_OUTPUT
|
echo "tag=$VERSION-$SHORT_COMMIT" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
echo "📦 Version: $VERSION"
|
echo "📦 Version: $VERSION"
|
||||||
echo "🔑 Commit: $SHORT_COMMIT"
|
echo "🔑 Commit: $SHORT_COMMIT"
|
||||||
echo "🏷️ Tag: $VERSION-$SHORT_COMMIT"
|
echo "🏷️ Tag: $VERSION-$SHORT_COMMIT"
|
||||||
|
|
||||||
- name: Build and push multi-arch image
|
- name: Build and push multi-arch image
|
||||||
run: |
|
run: |
|
||||||
echo "🏗️ Building multi-arch image..."
|
echo "🏗️ Building multi-arch image..."
|
||||||
|
|
||||||
docker buildx build \
|
docker buildx build \
|
||||||
--platform linux/amd64,linux/arm64 \
|
--platform linux/amd64,linux/arm64 \
|
||||||
--file Dockerfile.release \
|
--file Dockerfile.release \
|
||||||
--tag $REGISTRY/$IMAGE_NAME:${{ steps.version.outputs.tag }} \
|
--tag $REGISTRY/$IMAGE_NAME:${{ steps.version.outputs.tag }} \
|
||||||
--tag $REGISTRY/$IMAGE_NAME:${{ steps.version.outputs.version }}-latest \
|
--tag $REGISTRY/$IMAGE_NAME:${{ steps.version.outputs.version }}-latest \
|
||||||
--tag $REGISTRY/$IMAGE_NAME:release-latest \
|
--tag $REGISTRY/$IMAGE_NAME:release-latest \
|
||||||
--push \
|
--push \
|
||||||
--build-arg VERSION=${{ steps.version.outputs.version }} \
|
--build-arg VERSION=${{ steps.version.outputs.version }} \
|
||||||
--build-arg COMMIT=${{ steps.version.outputs.commit }} \
|
--build-arg COMMIT=${{ steps.version.outputs.commit }} \
|
||||||
--progress=plain
|
--progress=plain
|
||||||
|
|
||||||
# Resto do deployment...
|
# Resto do deployment...
|
||||||
- name: Deploy notification
|
- name: Deploy notification
|
||||||
run: |
|
run: |
|
||||||
echo "✅ Deployment concluído!"
|
echo "✅ Deployment concluído!"
|
||||||
echo "📦 Image: $REGISTRY/$IMAGE_NAME:${{ steps.version.outputs.tag }}"
|
echo "📦 Image: $REGISTRY/$IMAGE_NAME:${{ steps.version.outputs.tag }}"
|
||||||
echo "🎯 Trigger: ${{ github.event_name }}"
|
echo "🎯 Trigger: ${{ github.event_name }}"
|
||||||
echo "📂 Branch: ${{ github.ref_name }}"
|
echo "📂 Branch: ${{ github.ref_name }}"
|
||||||
36
AGENTS.md
Normal file
36
AGENTS.md
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
# Repository Guidelines
|
||||||
|
|
||||||
|
## Project Structure & Module Organization
|
||||||
|
- `src/BCards.Web` is the main MVC app (Controllers, Services, Repositories, Razor Views, `wwwroot` assets).
|
||||||
|
- `src/BCards.IntegrationTests` spins up the site with fixtures for API-level checks.
|
||||||
|
- `tests/BCards.Tests` hosts xUnit + Moq unit coverage with overrides in `appsettings.Testing.json`.
|
||||||
|
- `tests.e2e` carries Playwright specs and config; utility scripts live under `scripts/`, with `clean-build.sh` mirroring CI cleanup.
|
||||||
|
|
||||||
|
## Build, Test & Development Commands
|
||||||
|
- `dotnet restore && dotnet build BCards.sln` primes dependencies and compiles.
|
||||||
|
- `dotnet run --project src/BCards.Web` launches the site (HTTPS on 5001 by default).
|
||||||
|
- `dotnet test` executes unit + integration suites; add `--collect:"XPlat Code Coverage"` to emit coverlet results.
|
||||||
|
- In `tests.e2e`, run `npm install` once and `npx playwright test` per change; append `--headed` when debugging flows.
|
||||||
|
- `./clean-build.sh` removes stale `bin/obj` output before CI or release builds.
|
||||||
|
|
||||||
|
## Coding Style & Naming Conventions
|
||||||
|
- Use 4-space indents, file-scoped namespaces, PascalCase for types, camelCase for locals, and `_camelCase` for DI fields.
|
||||||
|
- Keep Razor views presentation-only; push logic into Services, ViewModels, or TagHelpers.
|
||||||
|
- Store localization strings in `Resources/`, shared UI in `Views/Shared`, and bundle-ready assets in `wwwroot`.
|
||||||
|
- Run `dotnet format` before pushing; .NET 8 analyzers treat warnings as errors in the pipeline.
|
||||||
|
|
||||||
|
## Testing Guidelines
|
||||||
|
- Mirror namespaces when creating unit files (`FooServiceTests` for `FooService`) and favour FluentAssertions for expressiveness.
|
||||||
|
- Integration scenarios reside in `src/BCards.IntegrationTests/Tests`; use shared fixtures to mock MongoDB/Stripe without polluting global state.
|
||||||
|
- End-to-end cases focus on signup, checkout, and profile rendering; keep snapshots in `tests.e2e/debug_*`.
|
||||||
|
- Target ≥80% coverage across `Services/` and `Repositories/`; call out gaps explicitly in the PR body.
|
||||||
|
|
||||||
|
## Commit & Pull Request Guidelines
|
||||||
|
- Follow the observed `type: resumo` format (`feat: artigos & tutoriais`, `fix: checkout`); keep scopes short, Portuguese when public-facing.
|
||||||
|
- Squash WIP branches before review; one functional change per commit.
|
||||||
|
- Every PR needs a summary, verification list (`dotnet test`, Playwright when touched), related issue link, and UI artifacts when visuals change.
|
||||||
|
- Tag a module expert for review and flip the `Ready for QA` label only after E2E automation passes.
|
||||||
|
|
||||||
|
## Security & Configuration Notes
|
||||||
|
- Keep secrets out of version control; base new configs on `appsettings.Production.example.json` and document required keys.
|
||||||
|
- When callback URLs move, update both the environment files (`Dockerfile`, `docker-compose*.yml`) and external provider dashboards together.
|
||||||
99
BCards.sln
99
BCards.sln
@ -1,46 +1,53 @@
|
|||||||
Microsoft Visual Studio Solution File, Format Version 12.00
|
Microsoft Visual Studio Solution File, Format Version 12.00
|
||||||
# Visual Studio Version 17
|
# Visual Studio Version 17
|
||||||
VisualStudioVersion = 17.0.31903.59
|
VisualStudioVersion = 17.0.31903.59
|
||||||
MinimumVisualStudioVersion = 10.0.40219.1
|
MinimumVisualStudioVersion = 10.0.40219.1
|
||||||
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "BCards.Web", "src\BCards.Web\BCards.Web.csproj", "{2E8F4B5C-9B3A-4F8E-8C7D-1A2B3C4D5E6F}"
|
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "BCards.Web", "src\BCards.Web\BCards.Web.csproj", "{2E8F4B5C-9B3A-4F8E-8C7D-1A2B3C4D5E6F}"
|
||||||
EndProject
|
EndProject
|
||||||
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "BCards.IntegrationTests", "src\BCards.IntegrationTests\BCards.IntegrationTests.csproj", "{8F9E4C7D-2A3B-4E5F-9C8D-1B2A3E4F5C6D}"
|
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "BCards.IntegrationTests", "src\BCards.IntegrationTests\BCards.IntegrationTests.csproj", "{8F9E4C7D-2A3B-4E5F-9C8D-1B2A3E4F5C6D}"
|
||||||
EndProject
|
EndProject
|
||||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Scripts", "Scripts", "{02EA681E-C7D8-13C7-8484-4AC65E1B71E8}"
|
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Scripts", "Scripts", "{02EA681E-C7D8-13C7-8484-4AC65E1B71E8}"
|
||||||
ProjectSection(SolutionItems) = preProject
|
ProjectSection(SolutionItems) = preProject
|
||||||
scripts\deploy-release.sh = scripts\deploy-release.sh
|
scripts\deploy-release.sh = scripts\deploy-release.sh
|
||||||
scripts\init-mongo.js = scripts\init-mongo.js
|
scripts\init-mongo.js = scripts\init-mongo.js
|
||||||
scripts\test-mongodb-connection.sh = scripts\test-mongodb-connection.sh
|
scripts\test-mongodb-connection.sh = scripts\test-mongodb-connection.sh
|
||||||
EndProjectSection
|
EndProjectSection
|
||||||
EndProject
|
EndProject
|
||||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Pipeline", "Pipeline", "{3F3DEEDF-9E0A-434D-8130-1FBAC43FD1F7}"
|
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Pipeline", "Pipeline", "{3F3DEEDF-9E0A-434D-8130-1FBAC43FD1F7}"
|
||||||
ProjectSection(SolutionItems) = preProject
|
ProjectSection(SolutionItems) = preProject
|
||||||
docker-compose.staging.yml = docker-compose.staging.yml
|
.gitea\workflows\deploy-bcards.yml = .gitea\workflows\deploy-bcards.yml
|
||||||
docker-compose.yml = docker-compose.yml
|
EndProjectSection
|
||||||
Dockerfile.release = Dockerfile.release
|
EndProject
|
||||||
.gitea\workflows\pr-validation.yml = .gitea\workflows\pr-validation.yml
|
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{8EC462FD-D22E-90A8-E5CE-7E832BA40C5D}"
|
||||||
.gitea\workflows\release-deploy.yml = .gitea\workflows\release-deploy.yml
|
ProjectSection(SolutionItems) = preProject
|
||||||
EndProjectSection
|
Conexoes.txt = Conexoes.txt
|
||||||
EndProject
|
README.md = README.md
|
||||||
Global
|
EndProjectSection
|
||||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
EndProject
|
||||||
Debug|Any CPU = Debug|Any CPU
|
Global
|
||||||
Release|Any CPU = Release|Any CPU
|
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||||
EndGlobalSection
|
Debug|Any CPU = Debug|Any CPU
|
||||||
GlobalSection(ProjectConfigurationPlatforms) = postSolution
|
Release|Any CPU = Release|Any CPU
|
||||||
{2E8F4B5C-9B3A-4F8E-8C7D-1A2B3C4D5E6F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
Testing|Any CPU = Testing|Any CPU
|
||||||
{2E8F4B5C-9B3A-4F8E-8C7D-1A2B3C4D5E6F}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
EndGlobalSection
|
||||||
{2E8F4B5C-9B3A-4F8E-8C7D-1A2B3C4D5E6F}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
GlobalSection(ProjectConfigurationPlatforms) = postSolution
|
||||||
{2E8F4B5C-9B3A-4F8E-8C7D-1A2B3C4D5E6F}.Release|Any CPU.Build.0 = Release|Any CPU
|
{2E8F4B5C-9B3A-4F8E-8C7D-1A2B3C4D5E6F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
{8F9E4C7D-2A3B-4E5F-9C8D-1B2A3E4F5C6D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
{2E8F4B5C-9B3A-4F8E-8C7D-1A2B3C4D5E6F}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
{8F9E4C7D-2A3B-4E5F-9C8D-1B2A3E4F5C6D}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
{2E8F4B5C-9B3A-4F8E-8C7D-1A2B3C4D5E6F}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
{8F9E4C7D-2A3B-4E5F-9C8D-1B2A3E4F5C6D}.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}.Release|Any CPU.Build.0 = Release|Any CPU
|
{2E8F4B5C-9B3A-4F8E-8C7D-1A2B3C4D5E6F}.Testing|Any CPU.ActiveCfg = Testing|Any CPU
|
||||||
EndGlobalSection
|
{2E8F4B5C-9B3A-4F8E-8C7D-1A2B3C4D5E6F}.Testing|Any CPU.Build.0 = Testing|Any CPU
|
||||||
GlobalSection(SolutionProperties) = preSolution
|
{8F9E4C7D-2A3B-4E5F-9C8D-1B2A3E4F5C6D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
HideSolutionNode = FALSE
|
{8F9E4C7D-2A3B-4E5F-9C8D-1B2A3E4F5C6D}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
EndGlobalSection
|
{8F9E4C7D-2A3B-4E5F-9C8D-1B2A3E4F5C6D}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
GlobalSection(ExtensibilityGlobals) = postSolution
|
{8F9E4C7D-2A3B-4E5F-9C8D-1B2A3E4F5C6D}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
SolutionGuid = {3DA87F09-8B78-450D-9EF8-A0C0E02F0E04}
|
{8F9E4C7D-2A3B-4E5F-9C8D-1B2A3E4F5C6D}.Testing|Any CPU.ActiveCfg = Testing|Any CPU
|
||||||
EndGlobalSection
|
{8F9E4C7D-2A3B-4E5F-9C8D-1B2A3E4F5C6D}.Testing|Any CPU.Build.0 = Testing|Any CPU
|
||||||
EndGlobal
|
EndGlobalSection
|
||||||
|
GlobalSection(SolutionProperties) = preSolution
|
||||||
|
HideSolutionNode = FALSE
|
||||||
|
EndGlobalSection
|
||||||
|
GlobalSection(ExtensibilityGlobals) = postSolution
|
||||||
|
SolutionGuid = {3DA87F09-8B78-450D-9EF8-A0C0E02F0E04}
|
||||||
|
EndGlobalSection
|
||||||
|
EndGlobal
|
||||||
|
|||||||
740
BCardsDB_Dev.categories.json
Normal file
740
BCardsDB_Dev.categories.json
Normal file
@ -0,0 +1,740 @@
|
|||||||
|
[{
|
||||||
|
"_id": {
|
||||||
|
"$oid": "685b17a70138fec28779d354"
|
||||||
|
},
|
||||||
|
"name": "Corretor de Imóveis",
|
||||||
|
"slug": "corretor",
|
||||||
|
"icon": "🏠",
|
||||||
|
"seoKeywords": [
|
||||||
|
"corretor",
|
||||||
|
"imóveis",
|
||||||
|
"casa",
|
||||||
|
"apartamento",
|
||||||
|
"venda",
|
||||||
|
"locação"
|
||||||
|
],
|
||||||
|
"description": "Profissionais especializados em compra, venda e locação de imóveis",
|
||||||
|
"isActive": true,
|
||||||
|
"createdAt": {
|
||||||
|
"$date": "2025-06-24T21:24:55.336Z"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"_id": {
|
||||||
|
"$oid": "685b17c20138fec28779d355"
|
||||||
|
},
|
||||||
|
"name": "Tecnologia",
|
||||||
|
"slug": "tecnologia",
|
||||||
|
"icon": "💻",
|
||||||
|
"seoKeywords": [
|
||||||
|
"desenvolvimento",
|
||||||
|
"software",
|
||||||
|
"programação",
|
||||||
|
"tecnologia",
|
||||||
|
"TI"
|
||||||
|
],
|
||||||
|
"description": "Empresas e profissionais de tecnologia, desenvolvimento e TI",
|
||||||
|
"isActive": true,
|
||||||
|
"createdAt": {
|
||||||
|
"$date": "2025-06-24T21:25:22.709Z"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"_id": {
|
||||||
|
"$oid": "685b17c20138fec28779d356"
|
||||||
|
},
|
||||||
|
"name": "Saúde",
|
||||||
|
"slug": "saude",
|
||||||
|
"icon": "🏥",
|
||||||
|
"seoKeywords": [
|
||||||
|
"médico",
|
||||||
|
"saúde",
|
||||||
|
"clínica",
|
||||||
|
"consulta",
|
||||||
|
"tratamento"
|
||||||
|
],
|
||||||
|
"description": "Profissionais da saúde, clínicas e consultórios médicos",
|
||||||
|
"isActive": true,
|
||||||
|
"createdAt": {
|
||||||
|
"$date": "2025-06-24T21:25:22.713Z"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"_id": {
|
||||||
|
"$oid": "685b17c20138fec28779d357"
|
||||||
|
},
|
||||||
|
"name": "Educação",
|
||||||
|
"slug": "educacao",
|
||||||
|
"icon": "📚",
|
||||||
|
"seoKeywords": [
|
||||||
|
"educação",
|
||||||
|
"ensino",
|
||||||
|
"professor",
|
||||||
|
"curso",
|
||||||
|
"escola"
|
||||||
|
],
|
||||||
|
"description": "Professores, escolas, cursos e instituições de ensino",
|
||||||
|
"isActive": true,
|
||||||
|
"createdAt": {
|
||||||
|
"$date": "2025-06-24T21:25:22.717Z"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"_id": {
|
||||||
|
"$oid": "685b17c20138fec28779d358"
|
||||||
|
},
|
||||||
|
"name": "Comércio",
|
||||||
|
"slug": "comercio",
|
||||||
|
"icon": "🛍️",
|
||||||
|
"seoKeywords": [
|
||||||
|
"loja",
|
||||||
|
"comércio",
|
||||||
|
"venda",
|
||||||
|
"produtos",
|
||||||
|
"e-commerce"
|
||||||
|
],
|
||||||
|
"description": "Lojas, e-commerce e estabelecimentos comerciais",
|
||||||
|
"isActive": true,
|
||||||
|
"createdAt": {
|
||||||
|
"$date": "2025-06-24T21:25:22.720Z"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"_id": {
|
||||||
|
"$oid": "685b17c20138fec28779d359"
|
||||||
|
},
|
||||||
|
"name": "Serviços",
|
||||||
|
"slug": "servicos",
|
||||||
|
"icon": "🔧",
|
||||||
|
"seoKeywords": [
|
||||||
|
"serviços",
|
||||||
|
"prestador",
|
||||||
|
"profissional",
|
||||||
|
"especializado"
|
||||||
|
],
|
||||||
|
"description": "Prestadores de serviços gerais e especializados",
|
||||||
|
"isActive": true,
|
||||||
|
"createdAt": {
|
||||||
|
"$date": "2025-06-24T21:25:22.723Z"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"_id": {
|
||||||
|
"$oid": "685b17c20138fec28779d35a"
|
||||||
|
},
|
||||||
|
"name": "Alimentação",
|
||||||
|
"slug": "alimentacao",
|
||||||
|
"icon": "🍽️",
|
||||||
|
"seoKeywords": [
|
||||||
|
"restaurante",
|
||||||
|
"comida",
|
||||||
|
"delivery",
|
||||||
|
"alimentação",
|
||||||
|
"gastronomia"
|
||||||
|
],
|
||||||
|
"description": "Restaurantes, delivery, food trucks e estabelecimentos alimentícios",
|
||||||
|
"isActive": true,
|
||||||
|
"createdAt": {
|
||||||
|
"$date": "2025-06-24T21:25:22.727Z"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"_id": {
|
||||||
|
"$oid": "685b17c20138fec28779d35b"
|
||||||
|
},
|
||||||
|
"name": "Beleza",
|
||||||
|
"slug": "beleza",
|
||||||
|
"icon": "💄",
|
||||||
|
"seoKeywords": [
|
||||||
|
"beleza",
|
||||||
|
"salão",
|
||||||
|
"estética",
|
||||||
|
"cabeleireiro",
|
||||||
|
"manicure"
|
||||||
|
],
|
||||||
|
"description": "Salões de beleza, barbearias, estética e cuidados pessoais",
|
||||||
|
"isActive": true,
|
||||||
|
"createdAt": {
|
||||||
|
"$date": "2025-06-24T21:25:22.731Z"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"_id": {
|
||||||
|
"$oid": "685b17c20138fec28779d35c"
|
||||||
|
},
|
||||||
|
"name": "Advocacia",
|
||||||
|
"slug": "advocacia",
|
||||||
|
"icon": "⚖️",
|
||||||
|
"seoKeywords": [
|
||||||
|
"advogado",
|
||||||
|
"jurídico",
|
||||||
|
"direito",
|
||||||
|
"advocacia",
|
||||||
|
"legal"
|
||||||
|
],
|
||||||
|
"description": "Advogados, escritórios jurídicos e consultoria legal",
|
||||||
|
"isActive": true,
|
||||||
|
"createdAt": {
|
||||||
|
"$date": "2025-06-24T21:25:22.734Z"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"_id": {
|
||||||
|
"$oid": "685b17c20138fec28779d35d"
|
||||||
|
},
|
||||||
|
"name": "Arquitetura",
|
||||||
|
"slug": "arquitetura",
|
||||||
|
"icon": "🏗️",
|
||||||
|
"seoKeywords": [
|
||||||
|
"arquiteto",
|
||||||
|
"engenheiro",
|
||||||
|
"construção",
|
||||||
|
"projeto",
|
||||||
|
"reforma"
|
||||||
|
],
|
||||||
|
"description": "Arquitetos, engenheiros e profissionais da construção",
|
||||||
|
"isActive": true,
|
||||||
|
"createdAt": {
|
||||||
|
"$date": "2025-06-24T21:25:22.737Z"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"_id": {
|
||||||
|
"$oid": "68c1bd62b1e9f4be8ecb6933"
|
||||||
|
},
|
||||||
|
"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
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"_id": {
|
||||||
|
"$oid": "68c1bd62b1e9f4be8ecb6934"
|
||||||
|
},
|
||||||
|
"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
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"_id": {
|
||||||
|
"$oid": "68c1bd62b1e9f4be8ecb6935"
|
||||||
|
},
|
||||||
|
"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
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"_id": {
|
||||||
|
"$oid": "68c1bd62b1e9f4be8ecb6936"
|
||||||
|
},
|
||||||
|
"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
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"_id": {
|
||||||
|
"$oid": "68c1bd62b1e9f4be8ecb6937"
|
||||||
|
},
|
||||||
|
"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
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"_id": {
|
||||||
|
"$oid": "68c1bd62b1e9f4be8ecb6938"
|
||||||
|
},
|
||||||
|
"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
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"_id": {
|
||||||
|
"$oid": "68c1bd62b1e9f4be8ecb6939"
|
||||||
|
},
|
||||||
|
"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
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"_id": {
|
||||||
|
"$oid": "68c1bd62b1e9f4be8ecb693a"
|
||||||
|
},
|
||||||
|
"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
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"_id": {
|
||||||
|
"$oid": "68c1bd62b1e9f4be8ecb693b"
|
||||||
|
},
|
||||||
|
"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
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"_id": {
|
||||||
|
"$oid": "68c1bd62b1e9f4be8ecb693c"
|
||||||
|
},
|
||||||
|
"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
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"_id": {
|
||||||
|
"$oid": "68c1bd62b1e9f4be8ecb693d"
|
||||||
|
},
|
||||||
|
"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
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"_id": {
|
||||||
|
"$oid": "68c1bd62b1e9f4be8ecb693e"
|
||||||
|
},
|
||||||
|
"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
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"_id": {
|
||||||
|
"$oid": "68c1bd62b1e9f4be8ecb693f"
|
||||||
|
},
|
||||||
|
"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
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"_id": {
|
||||||
|
"$oid": "68c1bd62b1e9f4be8ecb6940"
|
||||||
|
},
|
||||||
|
"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
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"_id": {
|
||||||
|
"$oid": "68c1bd62b1e9f4be8ecb6941"
|
||||||
|
},
|
||||||
|
"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
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"_id": {
|
||||||
|
"$oid": "68c1bd62b1e9f4be8ecb6942"
|
||||||
|
},
|
||||||
|
"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
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"_id": {
|
||||||
|
"$oid": "68c1bd62b1e9f4be8ecb6943"
|
||||||
|
},
|
||||||
|
"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
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"_id": {
|
||||||
|
"$oid": "68c1bd62b1e9f4be8ecb6944"
|
||||||
|
},
|
||||||
|
"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
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"_id": {
|
||||||
|
"$oid": "68c1bd62b1e9f4be8ecb6945"
|
||||||
|
},
|
||||||
|
"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
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"_id": {
|
||||||
|
"$oid": "68c1bd62b1e9f4be8ecb6946"
|
||||||
|
},
|
||||||
|
"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
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"_id": {
|
||||||
|
"$oid": "68c1bd62b1e9f4be8ecb6947"
|
||||||
|
},
|
||||||
|
"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
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"_id": {
|
||||||
|
"$oid": "68c1bd62b1e9f4be8ecb6948"
|
||||||
|
},
|
||||||
|
"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
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"_id": {
|
||||||
|
"$oid": "68c1bd62b1e9f4be8ecb6949"
|
||||||
|
},
|
||||||
|
"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
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"_id": {
|
||||||
|
"$oid": "68c1bd62b1e9f4be8ecb694a"
|
||||||
|
},
|
||||||
|
"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
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"_id": {
|
||||||
|
"$oid": "68c1bd62b1e9f4be8ecb694b"
|
||||||
|
},
|
||||||
|
"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
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"_id": {
|
||||||
|
"$oid": "68c1bd62b1e9f4be8ecb694c"
|
||||||
|
},
|
||||||
|
"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
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"_id": {
|
||||||
|
"$oid": "68c1bd62b1e9f4be8ecb694d"
|
||||||
|
},
|
||||||
|
"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
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"_id": {
|
||||||
|
"$oid": "68c1bd62b1e9f4be8ecb694e"
|
||||||
|
},
|
||||||
|
"name": "Floricultura",
|
||||||
|
"slug": "floricultura",
|
||||||
|
"icon": "🌸",
|
||||||
|
"seoKeywords": [
|
||||||
|
"floricultura",
|
||||||
|
"flores",
|
||||||
|
"buquê",
|
||||||
|
"plantas",
|
||||||
|
"arranjos",
|
||||||
|
"casamento"
|
||||||
|
],
|
||||||
|
"description": "Floriculturas, arranjos florais e comércio de plantas ornamentais",
|
||||||
|
"isActive": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"_id": {
|
||||||
|
"$oid": "68c1bd62b1e9f4be8ecb694f"
|
||||||
|
},
|
||||||
|
"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
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"_id": {
|
||||||
|
"$oid": "68c1bd62b1e9f4be8ecb6950"
|
||||||
|
},
|
||||||
|
"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
|
||||||
|
}]
|
||||||
472
CLAUDE.md
472
CLAUDE.md
@ -1,228 +1,246 @@
|
|||||||
# CLAUDE.md
|
# CLAUDE.md
|
||||||
|
|
||||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||||
|
|
||||||
## Project Overview
|
## 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.
|
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
|
## Development Commands
|
||||||
|
|
||||||
### Build & Run
|
### Build & Run
|
||||||
```bash
|
```bash
|
||||||
# Restore dependencies
|
# Quick clean build (RECOMMENDED after VS 2022 updates)
|
||||||
dotnet restore
|
./clean-build.sh
|
||||||
|
|
||||||
# Build solution
|
# Manual process:
|
||||||
dotnet build
|
dotnet restore
|
||||||
|
dotnet build
|
||||||
# Run development server
|
|
||||||
cd src/BCards.Web
|
# Run development server
|
||||||
dotnet run
|
cd src/BCards.Web
|
||||||
# Access: https://localhost:49178
|
dotnet run
|
||||||
|
# Access: https://localhost:49178
|
||||||
# Run with Docker
|
|
||||||
docker-compose up -d
|
# Run with Docker
|
||||||
# Access: http://localhost:8080
|
docker-compose up -d
|
||||||
```
|
# Access: http://localhost:8080
|
||||||
|
```
|
||||||
### Testing
|
|
||||||
```bash
|
### 🚨 Known Issues After VS 2022 Updates
|
||||||
# Run all tests
|
|
||||||
dotnet test
|
**Problem**: After VS 2022 updates, build cache gets corrupted causing:
|
||||||
|
- OAuth login failures (especially Google in Edge browser)
|
||||||
# Run tests with coverage
|
- Need for constant clean/rebuild cycles
|
||||||
dotnet test --collect:"XPlat Code Coverage"
|
- NuGet package resolution errors
|
||||||
|
|
||||||
# Run specific test
|
**Solution**: Use the automated cleanup script:
|
||||||
dotnet test --filter "TestClassName"
|
```bash
|
||||||
```
|
./clean-build.sh
|
||||||
|
```
|
||||||
## Architecture Overview
|
|
||||||
|
**Google OAuth Edge Issue**: If Google login shows "browser not secure" error:
|
||||||
### Technology Stack
|
1. Clear browser data for localhost:49178 and accounts.google.com
|
||||||
- **Framework**: ASP.NET Core MVC (.NET 8)
|
2. Test in incognito/private mode
|
||||||
- **Database**: MongoDB 7.0 with MongoDB.Driver 2.25.0
|
3. Use Vivaldi or Chrome (Edge has known compatibility issues)
|
||||||
- **Authentication**: OAuth 2.0 (Google + Microsoft)
|
|
||||||
- **Payments**: Stripe.NET 44.7.0
|
### Testing
|
||||||
- **Frontend**: Bootstrap 5.3.2, jQuery 3.7.1, vanilla JavaScript
|
```bash
|
||||||
- **Email**: SendGrid 9.29.3
|
# Run all tests
|
||||||
- **Containerization**: Docker + Docker Compose
|
dotnet test
|
||||||
|
|
||||||
### Core Architecture Patterns
|
# Run tests with coverage
|
||||||
- **MVC Pattern**: Controllers handle HTTP requests, Views render UI, Models represent data
|
dotnet test --collect:"XPlat Code Coverage"
|
||||||
- **Repository Pattern**: Data access abstraction via IUserPageRepository, ICategoryRepository, etc.
|
|
||||||
- **Service Layer**: Business logic encapsulation (IUserPageService, IModerationService, etc.)
|
# Run specific test
|
||||||
- **Dependency Injection**: Built-in ASP.NET Core DI container
|
dotnet test --filter "TestClassName"
|
||||||
- **Domain-Driven Design**: Rich domain models with business logic
|
```
|
||||||
|
|
||||||
### Key Business Logic
|
## Architecture Overview
|
||||||
|
|
||||||
#### Page Status System
|
### Technology Stack
|
||||||
Pages follow this lifecycle with explicit numeric enum values:
|
- **Framework**: ASP.NET Core MVC (.NET 8)
|
||||||
- `Creating = 6`: Development phase, requires preview tokens
|
- **Database**: MongoDB 7.0 with MongoDB.Driver 2.25.0
|
||||||
- `PendingModeration = 4`: Submitted for approval, requires preview tokens
|
- **Authentication**: OAuth 2.0 (Google + Microsoft)
|
||||||
- `Rejected = 5`: Failed moderation, requires preview tokens
|
- **Payments**: Stripe.NET 44.7.0
|
||||||
- `Active = 0`: Live and publicly accessible
|
- **Frontend**: Bootstrap 5.3.2, jQuery 3.7.1, vanilla JavaScript
|
||||||
- `Inactive = 3`: Paused by user
|
- **Email**: SendGrid 9.29.3
|
||||||
- `Expired = 1`: Trial expired, redirects to pricing
|
- **Containerization**: Docker + Docker Compose
|
||||||
- `PendingPayment = 2`: Payment overdue, shows warning
|
|
||||||
|
### Core Architecture Patterns
|
||||||
#### Moderation System
|
- **MVC Pattern**: Controllers handle HTTP requests, Views render UI, Models represent data
|
||||||
- Content approval workflow with attempts tracking
|
- **Repository Pattern**: Data access abstraction via IUserPageRepository, ICategoryRepository, etc.
|
||||||
- Preview tokens with 4-hour expiration for non-Active pages
|
- **Service Layer**: Business logic encapsulation (IUserPageService, IModerationService, etc.)
|
||||||
- Email notifications for status changes
|
- **Dependency Injection**: Built-in ASP.NET Core DI container
|
||||||
- Automatic status transitions based on user actions
|
- **Domain-Driven Design**: Rich domain models with business logic
|
||||||
|
|
||||||
#### Pricing Strategy
|
### Key Business Logic
|
||||||
Three-tier system with psychological pricing (decoy effect):
|
|
||||||
- Basic (R$ 9.90/mês): 5 links, basic themes
|
#### Page Status System
|
||||||
- Professional (R$ 24.90/mês): 15 links, all themes - *DECOY*
|
Pages follow this lifecycle with explicit numeric enum values:
|
||||||
- Premium (R$ 29.90/mês): Unlimited links, custom themes
|
- `Creating = 6`: Development phase, requires preview tokens
|
||||||
|
- `PendingModeration = 4`: Submitted for approval, requires preview tokens
|
||||||
### Project Structure
|
- `Rejected = 5`: Failed moderation, requires preview tokens
|
||||||
```
|
- `Active = 0`: Live and publicly accessible
|
||||||
src/BCards.Web/
|
- `Inactive = 3`: Paused by user
|
||||||
├── Controllers/ # MVC Controllers (9 total)
|
- `Expired = 1`: Trial expired, redirects to pricing
|
||||||
│ ├── AdminController # User dashboard and page management
|
- `PendingPayment = 2`: Payment overdue, shows warning
|
||||||
│ ├── AuthController # OAuth authentication
|
|
||||||
│ ├── HomeController # Public pages and landing
|
#### Moderation System
|
||||||
│ ├── PaymentController # Stripe integration
|
- Content approval workflow with attempts tracking
|
||||||
│ ├── ModerationController # Content approval system
|
- Preview tokens with 4-hour expiration for non-Active pages
|
||||||
│ └── UserPageController # Public page display
|
- Email notifications for status changes
|
||||||
├── Models/ # Domain entities (12 total)
|
- Automatic status transitions based on user actions
|
||||||
│ ├── User # Authentication and subscriptions
|
|
||||||
│ ├── UserPage # Main business card entity
|
#### Pricing Strategy
|
||||||
│ ├── LinkItem # Individual links with analytics
|
Three-tier system with psychological pricing (decoy effect):
|
||||||
│ └── Category # Business categories
|
- Basic (R$ 9.90/mês): 5 links, basic themes
|
||||||
├── Services/ # Business logic (20 services)
|
- Professional (R$ 24.90/mês): 15 links, all themes - *DECOY*
|
||||||
│ ├── IUserPageService # Core page operations
|
- Premium (R$ 29.90/mês): Unlimited links, custom themes
|
||||||
│ ├── IModerationService # Content approval
|
|
||||||
│ ├── IAuthService # Authentication
|
### Project Structure
|
||||||
│ └── IPaymentService # Stripe integration
|
```
|
||||||
├── Repositories/ # Data access (8 repositories)
|
src/BCards.Web/
|
||||||
├── ViewModels/ # View-specific models
|
├── Controllers/ # MVC Controllers (9 total)
|
||||||
├── Middleware/ # Custom middleware (4 pieces)
|
│ ├── AdminController # User dashboard and page management
|
||||||
│ ├── PageStatusMiddleware # Handles page access by status
|
│ ├── AuthController # OAuth authentication
|
||||||
│ ├── ModerationAuthMiddleware # Admin access control
|
│ ├── HomeController # Public pages and landing
|
||||||
│ └── PreviewTokenMiddleware # Preview token validation
|
│ ├── PaymentController # Stripe integration
|
||||||
└── Views/ # Razor templates with Bootstrap 5
|
│ ├── ModerationController # Content approval system
|
||||||
```
|
│ └── UserPageController # Public page display
|
||||||
|
├── Models/ # Domain entities (12 total)
|
||||||
### Database Design (MongoDB)
|
│ ├── User # Authentication and subscriptions
|
||||||
|
│ ├── UserPage # Main business card entity
|
||||||
#### Core Collections
|
│ ├── LinkItem # Individual links with analytics
|
||||||
- `users`: Authentication, subscription status, Stripe customer data
|
│ └── Category # Business categories
|
||||||
- `userpages`: Business cards with status, links, themes, moderation history
|
├── Services/ # Business logic (20 services)
|
||||||
- `categories`: Business categories with SEO metadata
|
│ ├── IUserPageService # Core page operations
|
||||||
- `subscriptions`: Stripe subscription tracking
|
│ ├── IModerationService # Content approval
|
||||||
|
│ ├── IAuthService # Authentication
|
||||||
#### Important Indexes
|
│ └── IPaymentService # Stripe integration
|
||||||
- Compound: `{category: 1, slug: 1}` for page lookups
|
├── Repositories/ # Data access (8 repositories)
|
||||||
- User pages: `{userId: 1, status: 1}` for dashboard filtering
|
├── ViewModels/ # View-specific models
|
||||||
- Active pages: `{status: 1, category: 1}` for public listings
|
├── Middleware/ # Custom middleware (4 pieces)
|
||||||
|
│ ├── PageStatusMiddleware # Handles page access by status
|
||||||
### Key Features Implementation
|
│ ├── ModerationAuthMiddleware # Admin access control
|
||||||
|
│ └── PreviewTokenMiddleware # Preview token validation
|
||||||
#### Preview Token System
|
└── Views/ # Razor templates with Bootstrap 5
|
||||||
Non-Active pages require preview tokens for access:
|
```
|
||||||
```csharp
|
|
||||||
// Generate fresh token (4-hour expiry)
|
### Database Design (MongoDB)
|
||||||
POST /Admin/GeneratePreviewToken/{id}
|
|
||||||
|
#### Core Collections
|
||||||
// Access page with token
|
- `users`: Authentication, subscription status, Stripe customer data
|
||||||
GET /page/{category}/{slug}?preview={token}
|
- `userpages`: Business cards with status, links, themes, moderation history
|
||||||
```
|
- `categories`: Business categories with SEO metadata
|
||||||
|
- `subscriptions`: Stripe subscription tracking
|
||||||
#### OAuth Integration
|
|
||||||
Supports Google and Microsoft OAuth with automatic user creation and session management.
|
#### Important Indexes
|
||||||
|
- Compound: `{category: 1, slug: 1}` for page lookups
|
||||||
#### Stripe Payment Flow
|
- User pages: `{userId: 1, status: 1}` for dashboard filtering
|
||||||
Complete subscription lifecycle:
|
- Active pages: `{status: 1, category: 1}` for public listings
|
||||||
1. Checkout session creation
|
|
||||||
2. Webhook handling for events
|
### Key Features Implementation
|
||||||
3. Subscription status updates
|
|
||||||
4. Plan limitation enforcement
|
#### Preview Token System
|
||||||
|
Non-Active pages require preview tokens for access:
|
||||||
#### Dynamic Theming
|
```csharp
|
||||||
CSS generation system with customizable colors, backgrounds, and layouts based on user's plan limitations.
|
// Generate fresh token (4-hour expiry)
|
||||||
|
POST /Admin/GeneratePreviewToken/{id}
|
||||||
## Configuration
|
|
||||||
|
// Access page with token
|
||||||
### Required Environment Variables
|
GET /page/{category}/{slug}?preview={token}
|
||||||
```json
|
```
|
||||||
{
|
|
||||||
"MongoDb": {
|
#### OAuth Integration
|
||||||
"ConnectionString": "mongodb://localhost:27017",
|
Supports Google and Microsoft OAuth with automatic user creation and session management.
|
||||||
"DatabaseName": "BCardsDB"
|
|
||||||
},
|
#### Stripe Payment Flow
|
||||||
"Stripe": {
|
Complete subscription lifecycle:
|
||||||
"PublishableKey": "pk_test_...",
|
1. Checkout session creation
|
||||||
"SecretKey": "sk_test_...",
|
2. Webhook handling for events
|
||||||
"WebhookSecret": "whsec_..."
|
3. Subscription status updates
|
||||||
},
|
4. Plan limitation enforcement
|
||||||
"Authentication": {
|
|
||||||
"Google": { "ClientId": "...", "ClientSecret": "..." },
|
#### Dynamic Theming
|
||||||
"Microsoft": { "ClientId": "...", "ClientSecret": "..." }
|
CSS generation system with customizable colors, backgrounds, and layouts based on user's plan limitations.
|
||||||
}
|
|
||||||
}
|
## Configuration
|
||||||
```
|
|
||||||
|
### Required Environment Variables
|
||||||
### Development Setup
|
```json
|
||||||
1. Install .NET 8 SDK, MongoDB 4.4+
|
{
|
||||||
2. Configure OAuth credentials (Google Cloud Console, Azure Portal)
|
"MongoDb": {
|
||||||
3. Set up Stripe account with test keys
|
"ConnectionString": "mongodb://localhost:27017",
|
||||||
4. Configure webhook endpoints for `/webhook/stripe`
|
"DatabaseName": "BCardsDB"
|
||||||
|
},
|
||||||
## Important Implementation Notes
|
"Stripe": {
|
||||||
|
"PublishableKey": "pk_test_...",
|
||||||
### Page Status Middleware
|
"SecretKey": "sk_test_...",
|
||||||
`PageStatusMiddleware` intercepts all `/page/{category}/{slug}` requests and enforces access rules based on page status. Non-Active pages require valid preview tokens.
|
"WebhookSecret": "whsec_..."
|
||||||
|
},
|
||||||
### Moderation Workflow
|
"Authentication": {
|
||||||
1. Pages start as `Creating` status
|
"Google": { "ClientId": "...", "ClientSecret": "..." },
|
||||||
2. Users click "Submit for Moderation" → `PendingModeration`
|
"Microsoft": { "ClientId": "...", "ClientSecret": "..." }
|
||||||
3. Moderators approve/reject → `Active` or `Rejected`
|
}
|
||||||
4. Rejected pages can be edited and resubmitted
|
}
|
||||||
|
```
|
||||||
### Preview Token Security
|
|
||||||
- Tokens expire after 4 hours
|
### Development Setup
|
||||||
- Generated on-demand via AJAX calls
|
1. Install .NET 8 SDK, MongoDB 4.4+
|
||||||
- Required for Creating, PendingModeration, and Rejected pages
|
2. Configure OAuth credentials (Google Cloud Console, Azure Portal)
|
||||||
- Validated by middleware before page access
|
3. Set up Stripe account with test keys
|
||||||
|
4. Configure webhook endpoints for `/webhook/stripe`
|
||||||
### Plan Limitations
|
|
||||||
Enforced throughout the application:
|
## Important Implementation Notes
|
||||||
- Link count limits per plan
|
|
||||||
- Theme availability restrictions
|
### Page Status Middleware
|
||||||
- Analytics access control
|
`PageStatusMiddleware` intercepts all `/page/{category}/{slug}` requests and enforces access rules based on page status. Non-Active pages require valid preview tokens.
|
||||||
- Page creation limits
|
|
||||||
|
### Moderation Workflow
|
||||||
## Common Development Patterns
|
1. Pages start as `Creating` status
|
||||||
|
2. Users click "Submit for Moderation" → `PendingModeration`
|
||||||
### Repository Usage
|
3. Moderators approve/reject → `Active` or `Rejected`
|
||||||
```csharp
|
4. Rejected pages can be edited and resubmitted
|
||||||
var page = await _userPageService.GetPageByIdAsync(id);
|
|
||||||
await _userPageService.UpdatePageAsync(page);
|
### Preview Token Security
|
||||||
```
|
- Tokens expire after 4 hours
|
||||||
|
- Generated on-demand via AJAX calls
|
||||||
### Service Layer Pattern
|
- Required for Creating, PendingModeration, and Rejected pages
|
||||||
Business logic resides in services, not controllers:
|
- Validated by middleware before page access
|
||||||
```csharp
|
|
||||||
public class UserPageService : IUserPageService
|
### Plan Limitations
|
||||||
{
|
Enforced throughout the application:
|
||||||
private readonly IUserPageRepository _repository;
|
- Link count limits per plan
|
||||||
// Implementation with business rules
|
- Theme availability restrictions
|
||||||
}
|
- Analytics access control
|
||||||
```
|
- Page creation limits
|
||||||
|
|
||||||
### Status-Based Logic
|
## Common Development Patterns
|
||||||
Always check page status before operations:
|
|
||||||
```csharp
|
### Repository Usage
|
||||||
if (page.Status == PageStatus.Creating || page.Status == PageStatus.Rejected)
|
```csharp
|
||||||
{
|
var page = await _userPageService.GetPageByIdAsync(id);
|
||||||
// Allow editing
|
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.
|
This architecture supports a production-ready SaaS application with complex business rules, payment integration, and content moderation workflows.
|
||||||
4
Conexoes.txt
Normal file
4
Conexoes.txt
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
bcards
|
||||||
|
ssh ubuntu@141.148.162.114
|
||||||
|
convert-it
|
||||||
|
ssh ubuntu@129.146.116.218
|
||||||
394
Content/Artigos/bcards-vs-linktree.pt-BR.md
Normal file
394
Content/Artigos/bcards-vs-linktree.pt-BR.md
Normal file
@ -0,0 +1,394 @@
|
|||||||
|
---
|
||||||
|
title: "BCards vs LinkTree: A Alternativa Brasileira para Profissionais"
|
||||||
|
description: "Descubra por que o BCards é a melhor alternativa brasileira ao LinkTree para profissionais e empresas. Comparação completa de recursos, preços e diferenciais."
|
||||||
|
keywords: "linktree, link tree, alternativa ao linktree, melhor que linktree, bcards, cartão digital, bio links, página de links, linktree brasil, linktree brasileiro"
|
||||||
|
author: "BCards"
|
||||||
|
date: 2025-11-02
|
||||||
|
lastmod: 2025-11-02
|
||||||
|
image: "/images/artigos/bcards-vs-linktree-hero.jpg"
|
||||||
|
---
|
||||||
|
|
||||||
|
# BCards vs LinkTree: A Alternativa Brasileira para Profissionais
|
||||||
|
|
||||||
|
Se você está procurando uma alternativa ao LinkTree, você chegou ao lugar certo. O BCards é uma solução brasileira desenvolvida especificamente para profissionais e empresas que querem uma presença digital profissional sem depender de plataformas internacionais.
|
||||||
|
|
||||||
|
## O Que São Páginas de Bio Links?
|
||||||
|
|
||||||
|
Antes de compararmos, vamos entender o conceito. Ferramentas como LinkTree e BCards são páginas de "bio links" - uma única URL que centraliza todos os seus links importantes:
|
||||||
|
|
||||||
|
- Redes sociais (Instagram, LinkedIn, Facebook)
|
||||||
|
- WhatsApp para contato direto
|
||||||
|
- Site ou portfólio
|
||||||
|
- Loja online ou produtos
|
||||||
|
- Localização física
|
||||||
|
- E muito mais
|
||||||
|
|
||||||
|
A ideia é simples: ao invés de ter apenas um link na bio do Instagram, você tem uma página com todos os seus links organizados.
|
||||||
|
|
||||||
|
## Por Que Escolher uma Alternativa Brasileira ao LinkTree?
|
||||||
|
|
||||||
|
### 🇧🇷 1. Suporte em Português (de Verdade)
|
||||||
|
|
||||||
|
**LinkTree:**
|
||||||
|
- Suporte majoritariamente em inglês
|
||||||
|
- Fuso horário dos EUA
|
||||||
|
- Documentação traduzida automaticamente
|
||||||
|
|
||||||
|
**BCards:**
|
||||||
|
- Suporte 100% em português
|
||||||
|
- Atendimento no horário comercial brasileiro
|
||||||
|
- Documentação escrita para o mercado brasileiro
|
||||||
|
- Entendimento de necessidades locais
|
||||||
|
|
||||||
|
### 💰 2. Preços em Real (Sem Surpresas)
|
||||||
|
|
||||||
|
**LinkTree:**
|
||||||
|
- Preços em dólar (USD)
|
||||||
|
- Sujeito a variação cambial
|
||||||
|
- IOF em pagamentos internacionais (6.38%)
|
||||||
|
- Preço final imprevisível
|
||||||
|
|
||||||
|
**BCards:**
|
||||||
|
- Preços fixos em Real (R$)
|
||||||
|
- Sem variação cambial
|
||||||
|
- Sem taxas de IOF
|
||||||
|
- Pagamento via Pix, cartão brasileiro
|
||||||
|
|
||||||
|
**Exemplo Real:**
|
||||||
|
- LinkTree Pro: $9 USD/mês ≈ R$ 45-50 (variável)
|
||||||
|
- BCards Básico: R$ 12,90/mês (fixo)
|
||||||
|
|
||||||
|
### 🎯 3. URLs Profissionais e Semânticas
|
||||||
|
|
||||||
|
**LinkTree:**
|
||||||
|
```
|
||||||
|
linktr.ee/seunome
|
||||||
|
```
|
||||||
|
- Domínio genérico
|
||||||
|
- Não transmite profissionalismo
|
||||||
|
- Óbvio que é uma ferramenta de terceiros
|
||||||
|
|
||||||
|
**BCards:**
|
||||||
|
```
|
||||||
|
bcards.site/page/tecnologia/seunome
|
||||||
|
bcards.site/page/advocacia/drjoao
|
||||||
|
bcards.site/page/saude/drmaria
|
||||||
|
```
|
||||||
|
- URLs categorizadas por profissão
|
||||||
|
- SEO otimizado com categoria
|
||||||
|
- Aparência mais profissional
|
||||||
|
|
||||||
|
## Comparação Técnica: LinkTree vs BCards
|
||||||
|
|
||||||
|
### Recursos Disponíveis
|
||||||
|
|
||||||
|
| Recurso | LinkTree Free | LinkTree Pro ($9/mês) | BCards Grátis | BCards Básico (R$ 12,90/mês) |
|
||||||
|
|---------|---------------|----------------------|---------------|----------------------------|
|
||||||
|
| **Número de Links** | Ilimitado | Ilimitado | 5 | 15 |
|
||||||
|
| **Temas** | Básicos | Avançados | Básicos | Todos |
|
||||||
|
| **Analytics** | Básico | Detalhado | Básico | Detalhado |
|
||||||
|
| **Remover Logo** | ❌ | ✅ | ✅ (Grátis!) | ✅ |
|
||||||
|
| **URLs Customizáveis** | Limitado | ✅ | ✅ | ✅ |
|
||||||
|
| **Suporte em Português** | ❌ | ❌ | ✅ | ✅ |
|
||||||
|
| **Pagamento em Real** | ❌ | ❌ | ✅ | ✅ |
|
||||||
|
| **Categorização por Profissão** | ❌ | ❌ | ✅ | ✅ |
|
||||||
|
| **Moderação Humana** | ❌ | ❌ | ✅ | ✅ |
|
||||||
|
|
||||||
|
### Diferenciais Técnicos do BCards
|
||||||
|
|
||||||
|
#### 1. Sistema de Categorias Profissionais
|
||||||
|
|
||||||
|
O BCards organiza perfis por categoria profissional, o que oferece vantagens:
|
||||||
|
|
||||||
|
**SEO:**
|
||||||
|
- Google indexa melhor URLs categorizadas
|
||||||
|
- Buscar "advogado + cidade" pode trazer seu BCard
|
||||||
|
- Autoridade topical (especialização por nicho)
|
||||||
|
|
||||||
|
**Profissionalismo:**
|
||||||
|
- URL mostra sua área de atuação
|
||||||
|
- Facilita encontrar profissionais similares
|
||||||
|
- Networking dentro da categoria
|
||||||
|
|
||||||
|
**Exemplos:**
|
||||||
|
```
|
||||||
|
bcards.site/page/advocacia/dr-pedro-silva
|
||||||
|
bcards.site/page/tecnologia/joao-dev
|
||||||
|
bcards.site/page/saude/dra-ana-cardiologia
|
||||||
|
bcards.site/page/beleza/salao-mariana
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 2. Moderação Humana (Sem Conteúdo Inapropriado)
|
||||||
|
|
||||||
|
**LinkTree:**
|
||||||
|
- Moderação automática (quando há)
|
||||||
|
- Permite conteúdo adulto em alguns planos
|
||||||
|
- Risco de associação com perfis inadequados
|
||||||
|
|
||||||
|
**BCards:**
|
||||||
|
- Moderação humana de todos os perfis
|
||||||
|
- Políticas claras de conteúdo profissional
|
||||||
|
- Ambiente seguro para profissionais sérios
|
||||||
|
- Rejeição de conteúdo inapropriado
|
||||||
|
|
||||||
|
**Por que isso importa?**
|
||||||
|
- Sua marca não fica ao lado de conteúdo duvidoso
|
||||||
|
- Transmite profissionalismo
|
||||||
|
- Ideal para advogados, médicos, contadores, etc.
|
||||||
|
|
||||||
|
#### 3. Integração com Mercado Brasileiro
|
||||||
|
|
||||||
|
**LinkTree:**
|
||||||
|
- Links para Venmo, CashApp (EUA)
|
||||||
|
- Integração com plataformas americanas
|
||||||
|
- Checkout Shopify (internacional)
|
||||||
|
|
||||||
|
**BCards:**
|
||||||
|
- Foco em WhatsApp Business (essencial no Brasil)
|
||||||
|
- Integração futura com Pix
|
||||||
|
- Mercado Livre, Mercado Pago, PagSeguro
|
||||||
|
- Google Maps para localização (crucial para negócios locais)
|
||||||
|
|
||||||
|
## Casos de Uso: Quando BCards é Melhor?
|
||||||
|
|
||||||
|
### ✅ Ideal para BCards:
|
||||||
|
|
||||||
|
1. **Advogados e Profissionais Liberais**
|
||||||
|
- URL profissional com categoria
|
||||||
|
- Moderação garante ambiente sério
|
||||||
|
- Preço acessível em Real
|
||||||
|
|
||||||
|
2. **Pequenos Negócios Locais**
|
||||||
|
- Integração com Google Maps
|
||||||
|
- WhatsApp Business como principal contato
|
||||||
|
- Preços em moeda local
|
||||||
|
|
||||||
|
3. **Profissionais de Saúde**
|
||||||
|
- Ambiente moderado e profissional
|
||||||
|
- Links para agendamento local
|
||||||
|
- Conformidade com políticas do CFM
|
||||||
|
|
||||||
|
4. **Prestadores de Serviços**
|
||||||
|
- Categorização por área
|
||||||
|
- Fácil de compartilhar localmente
|
||||||
|
- Suporte em português
|
||||||
|
|
||||||
|
### 🤔 Quando Considerar LinkTree:
|
||||||
|
|
||||||
|
1. **Influenciadores Internacionais**
|
||||||
|
- Audiência global
|
||||||
|
- Receita em dólar
|
||||||
|
- Integrações com plataformas americanas
|
||||||
|
|
||||||
|
2. **E-commerce Internacional**
|
||||||
|
- Vendas para fora do Brasil
|
||||||
|
- Shopify, Amazon global
|
||||||
|
- Múltiplas moedas
|
||||||
|
|
||||||
|
## Recursos Exclusivos do BCards
|
||||||
|
|
||||||
|
### 1. Links de Produtos com Preview Automático
|
||||||
|
|
||||||
|
Adicione links de produtos e o BCards captura automaticamente:
|
||||||
|
- Imagem do produto
|
||||||
|
- Preço
|
||||||
|
- Descrição
|
||||||
|
- Botão direto para compra
|
||||||
|
|
||||||
|
**Suportado:**
|
||||||
|
- Mercado Livre
|
||||||
|
- Shopee
|
||||||
|
- Amazon Brasil
|
||||||
|
- Lojas com Open Graph
|
||||||
|
|
||||||
|
### 2. Sistema de Preview para Moderação
|
||||||
|
|
||||||
|
Antes de seu perfil ficar público:
|
||||||
|
- Revisão humana do conteúdo
|
||||||
|
- Feedback se algo precisa ser ajustado
|
||||||
|
- Link de preview para você testar
|
||||||
|
- Aprovação rápida (geralmente em 24h)
|
||||||
|
|
||||||
|
### 3. Analytics Focado no Mercado Brasileiro
|
||||||
|
|
||||||
|
**Métricas relevantes:**
|
||||||
|
- Cliques por link
|
||||||
|
- Horários de maior acesso (fuso brasileiro)
|
||||||
|
- Origem do tráfego (Instagram, WhatsApp, etc.)
|
||||||
|
- Dispositivos (maioria mobile no Brasil)
|
||||||
|
|
||||||
|
## Preços Comparados (Detalhado)
|
||||||
|
|
||||||
|
### LinkTree
|
||||||
|
|
||||||
|
**Free:**
|
||||||
|
- Links ilimitados
|
||||||
|
- Temas básicos
|
||||||
|
- Analytics limitado
|
||||||
|
- Logo LinkTree presente
|
||||||
|
|
||||||
|
**Pro ($9 USD/mês ≈ R$ 45-50):**
|
||||||
|
- Temas premium
|
||||||
|
- Analytics avançado
|
||||||
|
- Remove logo
|
||||||
|
- Links prioritários
|
||||||
|
- **Problema:** Preço varia com dólar + IOF
|
||||||
|
|
||||||
|
**Premium ($24 USD/mês ≈ R$ 120-135):**
|
||||||
|
- Tudo do Pro
|
||||||
|
- Integrações avançadas
|
||||||
|
- Suporte prioritário
|
||||||
|
|
||||||
|
### BCards
|
||||||
|
|
||||||
|
**Gratuito:**
|
||||||
|
- 5 links
|
||||||
|
- Temas básicos
|
||||||
|
- Analytics básico
|
||||||
|
- **Sem logo BCards** (diferencial!)
|
||||||
|
|
||||||
|
**Básico (R$ 12,90/mês fixo):**
|
||||||
|
- 15 links
|
||||||
|
- Todos os temas
|
||||||
|
- Analytics detalhado
|
||||||
|
- Suporte em português
|
||||||
|
|
||||||
|
**Premium (R$ 29,90/mês fixo):**
|
||||||
|
- Links ilimitados
|
||||||
|
- Temas personalizados
|
||||||
|
- Logo personalizado
|
||||||
|
- Cores customizadas
|
||||||
|
- Suporte prioritário
|
||||||
|
- Upload de PDFs (até 5 arquivos)
|
||||||
|
|
||||||
|
**Economia Real:**
|
||||||
|
- LinkTree Pro anual: ~$108 + IOF = ~R$ 540-600
|
||||||
|
- BCards Básico anual: R$ 118,80
|
||||||
|
- **Economia: ~R$ 420/ano**
|
||||||
|
|
||||||
|
## Migração do LinkTree para BCards
|
||||||
|
|
||||||
|
### É Fácil Migrar?
|
||||||
|
|
||||||
|
**Sim! Processo simples:**
|
||||||
|
|
||||||
|
1. **Crie sua conta BCards** (2 minutos)
|
||||||
|
2. **Copie seus links do LinkTree** (5 minutos)
|
||||||
|
3. **Cole no BCards** (5 minutos)
|
||||||
|
4. **Ajuste tema e cores** (5 minutos)
|
||||||
|
5. **Atualize bio das redes sociais** (2 minutos)
|
||||||
|
|
||||||
|
**Total: ~20 minutos**
|
||||||
|
|
||||||
|
### Posso Testar Antes de Migrar?
|
||||||
|
|
||||||
|
Sim! O plano gratuito do BCards permite:
|
||||||
|
- Criar perfil completo
|
||||||
|
- Testar 5 links principais
|
||||||
|
- Ver como funciona
|
||||||
|
- Decidir se vale a pena
|
||||||
|
|
||||||
|
**Sem risco:**
|
||||||
|
- Não precisa cancelar LinkTree antes
|
||||||
|
- Teste paralelamente
|
||||||
|
- Migre quando se sentir confortável
|
||||||
|
|
||||||
|
## Perguntas Frequentes: BCards vs LinkTree
|
||||||
|
|
||||||
|
### O BCards funciona com Instagram, TikTok, YouTube?
|
||||||
|
|
||||||
|
Sim! Funciona com qualquer rede social que permite um link na bio:
|
||||||
|
- Instagram ✅
|
||||||
|
- TikTok ✅
|
||||||
|
- YouTube ✅
|
||||||
|
- LinkedIn ✅
|
||||||
|
- Facebook ✅
|
||||||
|
- Twitter/X ✅
|
||||||
|
|
||||||
|
### Posso usar domínio próprio?
|
||||||
|
|
||||||
|
**LinkTree:** Apenas em planos premium ($24/mês)
|
||||||
|
**BCards:** Em desenvolvimento (plano Premium futuro)
|
||||||
|
|
||||||
|
Atualmente: `bcards.site/page/categoria/seunome`
|
||||||
|
|
||||||
|
### E se eu já tenho linktr.ee nas minhas mídias impressas?
|
||||||
|
|
||||||
|
Você pode:
|
||||||
|
1. Manter LinkTree redirecionando para BCards temporariamente
|
||||||
|
2. Atualizar gradualmente materiais impressos
|
||||||
|
3. Usar QR Code do BCards (gera automaticamente)
|
||||||
|
|
||||||
|
### O BCards tem app mobile?
|
||||||
|
|
||||||
|
Não é necessário! É 100% web:
|
||||||
|
- Funciona perfeitamente no mobile
|
||||||
|
- Não ocupa espaço no celular
|
||||||
|
- Atualiza instantaneamente
|
||||||
|
- Sem necessidade de instalar nada
|
||||||
|
|
||||||
|
### Posso cancelar a qualquer momento?
|
||||||
|
|
||||||
|
Sim! Sem fidelidade:
|
||||||
|
- Cancele quando quiser
|
||||||
|
- Sem multa
|
||||||
|
- Sem burocracia
|
||||||
|
- Dados não são deletados (voltam ao plano grátis)
|
||||||
|
|
||||||
|
## Conclusão: Vale a Pena Trocar?
|
||||||
|
|
||||||
|
### ✅ Vale a pena trocar do LinkTree para BCards se:
|
||||||
|
|
||||||
|
- Você é profissional ou empresa brasileira
|
||||||
|
- Quer economia real (3-5x mais barato)
|
||||||
|
- Prefere suporte em português
|
||||||
|
- Busca URL mais profissional
|
||||||
|
- Quer ambiente moderado e sério
|
||||||
|
- Precisa de pagamento em Real sem surpresas
|
||||||
|
|
||||||
|
### 🤔 Talvez LinkTree seja melhor se:
|
||||||
|
|
||||||
|
- Sua audiência é majoritariamente internacional
|
||||||
|
- Você vende em dólar
|
||||||
|
- Precisa integrações específicas americanas
|
||||||
|
- Já tem grande investimento na marca "linktr.ee"
|
||||||
|
|
||||||
|
## Comece Agora
|
||||||
|
|
||||||
|
### Teste Gratuitamente
|
||||||
|
|
||||||
|
Não precisa acreditar na nossa palavra. Teste você mesmo:
|
||||||
|
|
||||||
|
1. **Crie conta grátis** (sem cartão de crédito)
|
||||||
|
2. **Configure seus 5 links principais**
|
||||||
|
3. **Escolha um tema profissional**
|
||||||
|
4. **Compartilhe e veja os resultados**
|
||||||
|
|
||||||
|
Se gostar, faça upgrade para ter mais links. Se não gostar, não paga nada.
|
||||||
|
|
||||||
|
**Pronto para dar o próximo passo?** [Criar meu BCard grátis](https://bcards.site)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Comparação Rápida (TL;DR)
|
||||||
|
|
||||||
|
| Aspecto | LinkTree | BCards |
|
||||||
|
|---------|----------|--------|
|
||||||
|
| **Preço (plano básico)** | ~R$ 45-50/mês (varia) | R$ 12,90/mês (fixo) |
|
||||||
|
| **Suporte** | Inglês | Português |
|
||||||
|
| **Pagamento** | Dólar + IOF | Real (Pix/Cartão BR) |
|
||||||
|
| **URL** | linktr.ee/nome | bcards.site/categoria/nome |
|
||||||
|
| **Moderação** | Automática | Humana |
|
||||||
|
| **Foco** | Global | Brasil |
|
||||||
|
| **Remover logo (grátis)** | ❌ | ✅ |
|
||||||
|
|
||||||
|
**Economia anual:** ~R$ 420 escolhendo BCards
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Recursos Adicionais
|
||||||
|
|
||||||
|
- [Tutorial: Como Criar um BCard](/tutoriais/tecnologia/como-criar-um-bcard)
|
||||||
|
- [BCards para Advogados](/tutoriais/advocacia/como-advogados-podem-usar-bcards)
|
||||||
|
- [Transformação Digital](/artigos/transformacao-digital-pequenos-negocios)
|
||||||
|
- [Fale com nosso suporte](/support)
|
||||||
208
Content/Artigos/transformacao-digital-pequenos-negocios.pt-BR.md
Normal file
208
Content/Artigos/transformacao-digital-pequenos-negocios.pt-BR.md
Normal file
@ -0,0 +1,208 @@
|
|||||||
|
---
|
||||||
|
title: "Transformação Digital para Pequenos Negócios: Por Onde Começar?"
|
||||||
|
description: "Descubra como a transformação digital pode revolucionar seu pequeno negócio e quais são os primeiros passos para entrar no mundo digital"
|
||||||
|
keywords: "transformação digital, pequenos negócios, digitalização, empreendedorismo, tecnologia"
|
||||||
|
author: "BCards"
|
||||||
|
date: 2025-11-02
|
||||||
|
lastmod: 2025-11-02
|
||||||
|
image: "/images/artigos/transformacao-digital-hero.jpg"
|
||||||
|
---
|
||||||
|
|
||||||
|
# Transformação Digital para Pequenos Negócios: Por Onde Começar?
|
||||||
|
|
||||||
|
A transformação digital não é mais uma opção, mas uma necessidade para pequenos negócios que querem se manter competitivos. Mas o que exatamente significa "transformação digital" e como um pequeno negócio pode embarcar nessa jornada sem gastar fortunas?
|
||||||
|
|
||||||
|
## O Que É Transformação Digital?
|
||||||
|
|
||||||
|
Transformação digital é o processo de usar tecnologias digitais para criar novos processos de negócio, cultura e experiências do cliente — ou modificar os existentes — para atender às mudanças nas demandas do mercado.
|
||||||
|
|
||||||
|
Para pequenos negócios, isso pode ser tão simples quanto:
|
||||||
|
- Ter uma presença online profissional
|
||||||
|
- Aceitar pagamentos digitais
|
||||||
|
- Usar redes sociais para marketing
|
||||||
|
- Digitalizar processos internos
|
||||||
|
- Oferecer atendimento online
|
||||||
|
|
||||||
|
## Por Que Pequenos Negócios Precisam Se Digitalizar?
|
||||||
|
|
||||||
|
### 1. Alcance Maior
|
||||||
|
|
||||||
|
Com presença digital, você não está mais limitado à sua localização física. Clientes de qualquer lugar podem encontrar e conhecer seu negócio.
|
||||||
|
|
||||||
|
### 2. Custos Menores
|
||||||
|
|
||||||
|
Marketing digital é significativamente mais barato que publicidade tradicional. Uma campanha no Instagram pode custar centenas de vezes menos que um anúncio em jornal.
|
||||||
|
|
||||||
|
### 3. Competitividade
|
||||||
|
|
||||||
|
Seus concorrentes já estão online. Não estar presente digitalmente significa perder clientes para quem está.
|
||||||
|
|
||||||
|
### 4. Conveniência para o Cliente
|
||||||
|
|
||||||
|
Clientes modernos esperam poder:
|
||||||
|
- Encontrar informações online
|
||||||
|
- Fazer pedidos pelo WhatsApp
|
||||||
|
- Pagar com Pix ou cartão
|
||||||
|
- Ver avaliações de outros clientes
|
||||||
|
|
||||||
|
## Os 5 Primeiros Passos da Transformação Digital
|
||||||
|
|
||||||
|
### 1. Crie uma Presença Digital Básica
|
||||||
|
|
||||||
|
Você não precisa de um site complexo. Comece com:
|
||||||
|
|
||||||
|
**Mínimo Necessário:**
|
||||||
|
- Uma página de links profissional (como o BCards)
|
||||||
|
- Perfil no Instagram ou Facebook
|
||||||
|
- WhatsApp Business configurado
|
||||||
|
|
||||||
|
**Por que isso funciona:**
|
||||||
|
- É gratuito ou muito barato
|
||||||
|
- Pode ser feito em 1 dia
|
||||||
|
- Já coloca você no mapa digital
|
||||||
|
|
||||||
|
### 2. Organize Seus Contatos Digitais
|
||||||
|
|
||||||
|
Centralize todos os seus pontos de contato:
|
||||||
|
- WhatsApp para pedidos
|
||||||
|
- Instagram para divulgação
|
||||||
|
- Email para orçamentos
|
||||||
|
- Link para catálogo de produtos
|
||||||
|
|
||||||
|
**Solução simples:** Use um cartão digital (BCard) que concentra todos esses links em um só lugar. Assim, você compartilha um único link e o cliente escolhe como prefere contatar você.
|
||||||
|
|
||||||
|
### 3. Aceite Pagamentos Digitais
|
||||||
|
|
||||||
|
Configure:
|
||||||
|
- Pix (essencial!)
|
||||||
|
- Maquininha de cartão
|
||||||
|
- Link de pagamento (Mercado Pago, PagSeguro)
|
||||||
|
|
||||||
|
**Resultado:** Você não perde vendas porque "o cliente não tem dinheiro trocado".
|
||||||
|
|
||||||
|
### 4. Use as Redes Sociais Estrategicamente
|
||||||
|
|
||||||
|
Não precisa estar em todas. Escolha UMA ou DUAS e faça bem:
|
||||||
|
|
||||||
|
**Para produtos físicos:** Instagram + Facebook
|
||||||
|
**Para serviços profissionais:** LinkedIn + Instagram
|
||||||
|
**Para comércio local:** Facebook + Google Meu Negócio
|
||||||
|
|
||||||
|
**Dica de ouro:** Poste regularmente (mesmo que seja 2-3x por semana). Consistência > Quantidade.
|
||||||
|
|
||||||
|
### 5. Peça Avaliações e Depoimentos
|
||||||
|
|
||||||
|
Clientes satisfeitos são seu melhor marketing. Peça:
|
||||||
|
- Avaliações no Google
|
||||||
|
- Comentários no Instagram
|
||||||
|
- Depoimentos em vídeo (WhatsApp)
|
||||||
|
|
||||||
|
**Como pedir:** "Se você ficou satisfeito com nosso serviço, pode deixar uma avaliação no Google? Isso nos ajuda muito!"
|
||||||
|
|
||||||
|
## Ferramentas Essenciais (e Baratas!)
|
||||||
|
|
||||||
|
### Gratuitas
|
||||||
|
- **Google Meu Negócio** - Apareça no Google Maps
|
||||||
|
- **WhatsApp Business** - Atendimento profissional
|
||||||
|
- **Canva** - Design de posts e materiais
|
||||||
|
- **BCards** - Página de links profissional (plano grátis disponível)
|
||||||
|
|
||||||
|
### Investimento Baixo (menos de R$ 50/mês)
|
||||||
|
- **Instagram Ads** - Impulsione suas publicações
|
||||||
|
- **Hotmart/Eduzz** - Venda produtos digitais
|
||||||
|
- **Mercado Pago** - Links de pagamento
|
||||||
|
- **BCards Premium** - Cartão digital profissional completo
|
||||||
|
|
||||||
|
## Erros Comuns a Evitar
|
||||||
|
|
||||||
|
### ❌ Erro 1: Querer Fazer Tudo de Uma Vez
|
||||||
|
|
||||||
|
Não tente criar site, app, loja virtual e estar em todas as redes sociais simultaneamente. Comece pequeno e expanda gradualmente.
|
||||||
|
|
||||||
|
### ❌ Erro 2: Comprar Ferramentas Caras Demais
|
||||||
|
|
||||||
|
Você não precisa de um site de R$ 10.000 quando está começando. Use ferramentas simples primeiro e evolua conforme cresce.
|
||||||
|
|
||||||
|
### ❌ Erro 3: Não Ter um Ponto Central
|
||||||
|
|
||||||
|
Ter Instagram, Facebook, WhatsApp é ótimo. Mas onde você manda as pessoas? Tenha UM lugar que centraliza tudo (um BCard, por exemplo).
|
||||||
|
|
||||||
|
### ❌ Erro 4: Não Medir Resultados
|
||||||
|
|
||||||
|
Use as ferramentas gratuitas de analytics:
|
||||||
|
- Instagram Insights
|
||||||
|
- Google Analytics
|
||||||
|
- Relatórios do WhatsApp Business
|
||||||
|
|
||||||
|
## Casos de Sucesso Inspiradores
|
||||||
|
|
||||||
|
### Padaria da Dona Maria
|
||||||
|
|
||||||
|
**Antes:** Apenas loja física, dependia de clientes que passavam na frente
|
||||||
|
**Depois:**
|
||||||
|
- BCard com cardápio e WhatsApp
|
||||||
|
- QR Code nas embalagens
|
||||||
|
- Pedidos pelo WhatsApp aumentaram 60%
|
||||||
|
- Clientes de bairros vizinhos começaram a pedir
|
||||||
|
|
||||||
|
### Consultório do Dr. Pedro (Dentista)
|
||||||
|
|
||||||
|
**Antes:** Agendamentos apenas por telefone em horário comercial
|
||||||
|
**Depois:**
|
||||||
|
- Sistema de agendamento online
|
||||||
|
- BCard com links para WhatsApp, Google Maps e Instagram
|
||||||
|
- Reduziu trabalho da secretária em 40%
|
||||||
|
- Mais agendamentos fora do horário comercial
|
||||||
|
|
||||||
|
### Loja de Roupas da Ana
|
||||||
|
|
||||||
|
**Antes:** Vendas apenas presenciais
|
||||||
|
**Depois:**
|
||||||
|
- Catálogo no Instagram
|
||||||
|
- BCard com link para catálogo completo
|
||||||
|
- WhatsApp Business para pedidos
|
||||||
|
- Vendas online representam 35% do faturamento
|
||||||
|
|
||||||
|
## Próximos Passos Para Seu Negócio
|
||||||
|
|
||||||
|
### Semana 1: Organização
|
||||||
|
- Liste todos os seus pontos de contato atuais
|
||||||
|
- Crie um BCard com todos os links
|
||||||
|
- Configure WhatsApp Business
|
||||||
|
|
||||||
|
### Semana 2: Presença Online
|
||||||
|
- Atualize perfis de redes sociais
|
||||||
|
- Adicione o BCard na bio
|
||||||
|
- Tire fotos profissionais dos produtos/serviços
|
||||||
|
|
||||||
|
### Semana 3: Divulgação
|
||||||
|
- Crie QR Code do seu BCard
|
||||||
|
- Coloque em materiais impressos
|
||||||
|
- Peça avaliações de clientes fiéis
|
||||||
|
|
||||||
|
### Semana 4: Otimização
|
||||||
|
- Analise o que funcionou
|
||||||
|
- Ajuste e melhore
|
||||||
|
- Planeje próximos passos
|
||||||
|
|
||||||
|
## Conclusão
|
||||||
|
|
||||||
|
A transformação digital para pequenos negócios não precisa ser cara ou complicada. Comece com o básico:
|
||||||
|
|
||||||
|
1. Presença online simples (BCard + redes sociais)
|
||||||
|
2. Facilite o contato (WhatsApp Business)
|
||||||
|
3. Aceite pagamentos digitais (Pix + cartão)
|
||||||
|
4. Peça avaliações de clientes
|
||||||
|
5. Evolua gradualmente
|
||||||
|
|
||||||
|
O importante é começar. Cada pequeno passo digital que você dá coloca seu negócio à frente dos concorrentes que ainda não se moveram.
|
||||||
|
|
||||||
|
**Pronto para dar o primeiro passo?** [Crie seu BCard profissional grátis](https://bcards.site) e comece sua transformação digital hoje!
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Recursos Adicionais
|
||||||
|
|
||||||
|
- [Tutorial: Como Criar um BCard](/tutoriais/tecnologia/como-criar-um-bcard)
|
||||||
|
- [BCards para Advogados](/tutoriais/advocacia/como-advogados-podem-usar-bcards)
|
||||||
|
- [Fale com nosso suporte](/support)
|
||||||
@ -0,0 +1,197 @@
|
|||||||
|
---
|
||||||
|
title: "BCards para Advogados: Guia Completo"
|
||||||
|
description: "Descubra como advogados podem usar o BCards para fortalecer sua presença digital, atrair mais clientes e organizar todos os seus contatos profissionais"
|
||||||
|
keywords: "advogado, advocacia, cartão digital, marketing jurídico, presença online, bcards"
|
||||||
|
author: "BCards"
|
||||||
|
date: 2025-11-02
|
||||||
|
lastmod: 2025-11-02
|
||||||
|
image: "/images/tutoriais/advocacia/advogados-bcards-hero.jpg"
|
||||||
|
category: "advocacia"
|
||||||
|
---
|
||||||
|
|
||||||
|
# BCards para Advogados: Guia Completo
|
||||||
|
|
||||||
|
Na era digital, ter uma presença online profissional é essencial para advogados que querem se destacar e atrair mais clientes. O BCards é a solução perfeita para centralizar todos os seus contatos e informações profissionais em um único lugar.
|
||||||
|
|
||||||
|
## Por que Advogados Precisam de um BCard?
|
||||||
|
|
||||||
|
### 1. Credibilidade Profissional
|
||||||
|
|
||||||
|
Um cartão digital transmite modernidade e profissionalismo. Seus clientes verão que você está atualizado com as tecnologias atuais.
|
||||||
|
|
||||||
|
### 2. Facilidade de Contato
|
||||||
|
|
||||||
|
Centralize todas as formas de contato em um único link:
|
||||||
|
- WhatsApp para consultas rápidas
|
||||||
|
- Email profissional
|
||||||
|
- Telefone do escritório
|
||||||
|
- Endereço com localização no mapa
|
||||||
|
- Redes sociais profissionais (LinkedIn)
|
||||||
|
|
||||||
|
### 3. Marketing Jurídico Eficiente
|
||||||
|
|
||||||
|
Divulgue seus serviços de forma ética e profissional:
|
||||||
|
- Link para artigos jurídicos que você escreve
|
||||||
|
- Vídeos educativos no YouTube
|
||||||
|
- Depoimentos de clientes satisfeitos
|
||||||
|
- Áreas de atuação
|
||||||
|
|
||||||
|
## Como Configurar seu BCard Profissional
|
||||||
|
|
||||||
|
### Passo 1: Informações Essenciais
|
||||||
|
|
||||||
|
Inclua em seu perfil:
|
||||||
|
|
||||||
|
**Informações Básicas:**
|
||||||
|
- Nome completo e OAB
|
||||||
|
- Foto profissional (de terno/blazer)
|
||||||
|
- Bio concisa e profissional
|
||||||
|
- Áreas de especialização
|
||||||
|
|
||||||
|
**Exemplo de Bio:**
|
||||||
|
> "Dr. João Silva - OAB/SP 123.456
|
||||||
|
> Advogado especialista em Direito do Consumidor
|
||||||
|
> Atuação em todo território nacional
|
||||||
|
> Mais de 15 anos de experiência"
|
||||||
|
|
||||||
|
### Passo 2: Links Estratégicos
|
||||||
|
|
||||||
|
Organize seus links por ordem de importância:
|
||||||
|
|
||||||
|
1. **WhatsApp Business** - Para consultas rápidas
|
||||||
|
2. **Agendar Consulta** - Link para sistema de agendamento
|
||||||
|
3. **Áreas de Atuação** - Página explicando seus serviços
|
||||||
|
4. **Blog Jurídico** - Artigos e conteúdo educativo
|
||||||
|
5. **LinkedIn** - Networking profissional
|
||||||
|
6. **Localização** - Google Maps do seu escritório
|
||||||
|
|
||||||
|
### Passo 3: Conteúdo Educativo
|
||||||
|
|
||||||
|
Advogados podem se destacar compartilhando conhecimento:
|
||||||
|
|
||||||
|
- Artigos sobre direitos do consumidor
|
||||||
|
- Vídeos explicando processos jurídicos
|
||||||
|
- FAQ com perguntas frequentes
|
||||||
|
- Guias práticos para clientes
|
||||||
|
|
||||||
|
## Estratégias de Marketing para Advogados
|
||||||
|
|
||||||
|
### 1. Use no Cartão de Visitas
|
||||||
|
|
||||||
|
Adicione um QR Code do seu BCard no cartão de visitas físico. Assim, as pessoas podem salvar todos os seus contatos instantaneamente.
|
||||||
|
|
||||||
|
### 2. Assinatura de Email
|
||||||
|
|
||||||
|
Inclua o link do seu BCard na assinatura de todos os emails profissionais.
|
||||||
|
|
||||||
|
**Exemplo:**
|
||||||
|
```
|
||||||
|
Dr. João Silva
|
||||||
|
Advogado - OAB/SP 123.456
|
||||||
|
📱 Todos os meus contatos: bcards.site/page/advocacia/joao-silva
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Redes Sociais
|
||||||
|
|
||||||
|
- Instagram: coloque na bio
|
||||||
|
- LinkedIn: adicione na seção "Sobre"
|
||||||
|
- Facebook: fixe uma publicação com o link
|
||||||
|
|
||||||
|
### 4. Materiais Impressos
|
||||||
|
|
||||||
|
Adicione QR Codes em:
|
||||||
|
- Folders informativos
|
||||||
|
- Cartazes do escritório
|
||||||
|
- Apresentações em palestras
|
||||||
|
- Documentos entregues a clientes
|
||||||
|
|
||||||
|
## Links Recomendados para Advogados
|
||||||
|
|
||||||
|
### Essenciais
|
||||||
|
- ✅ WhatsApp Business (atendimento)
|
||||||
|
- ✅ Email profissional
|
||||||
|
- ✅ Telefone do escritório
|
||||||
|
- ✅ Localização (Google Maps)
|
||||||
|
- ✅ LinkedIn
|
||||||
|
|
||||||
|
### Opcionais mas Recomendados
|
||||||
|
- 📝 Blog jurídico
|
||||||
|
- 📺 Canal no YouTube (vídeos educativos)
|
||||||
|
- 📅 Sistema de agendamento online
|
||||||
|
- 📄 Portfólio de casos (quando permitido)
|
||||||
|
- 💬 Depoimentos de clientes
|
||||||
|
- 📖 E-books e materiais gratuitos
|
||||||
|
|
||||||
|
## Ética e Boas Práticas
|
||||||
|
|
||||||
|
### O que Fazer ✅
|
||||||
|
|
||||||
|
- Mantenha uma apresentação sóbria e profissional
|
||||||
|
- Use foto com traje formal
|
||||||
|
- Divulgue conteúdo educativo
|
||||||
|
- Informe número da OAB sempre
|
||||||
|
- Seja transparente sobre áreas de atuação
|
||||||
|
|
||||||
|
### O que Evitar ❌
|
||||||
|
|
||||||
|
- Promessas de resultado garantido
|
||||||
|
- Comparações com outros advogados
|
||||||
|
- Preços divulgados publicamente (consulte a OAB)
|
||||||
|
- Linguagem sensacionalista
|
||||||
|
- Imagens inadequadas
|
||||||
|
|
||||||
|
## Casos de Uso Reais
|
||||||
|
|
||||||
|
### Caso 1: Dra. Maria - Direito de Família
|
||||||
|
|
||||||
|
A Dra. Maria aumentou suas consultas em 40% após criar seu BCard. Ela incluiu:
|
||||||
|
- Link para agendar consulta online
|
||||||
|
- Artigos sobre divórcio e pensão alimentícia
|
||||||
|
- Depoimentos de clientes (com autorização)
|
||||||
|
- WhatsApp exclusivo para novos casos
|
||||||
|
|
||||||
|
### Caso 2: Dr. Pedro - Direito Trabalhista
|
||||||
|
|
||||||
|
O Dr. Pedro usa seu BCard para:
|
||||||
|
- Compartilhar guias sobre direitos trabalhistas
|
||||||
|
- Vídeos curtos explicando rescisão, FGTS, etc.
|
||||||
|
- Formulário para pré-avaliação de casos
|
||||||
|
- Links para suas redes sociais educativas
|
||||||
|
|
||||||
|
## Planos Recomendados
|
||||||
|
|
||||||
|
### Para Advogados Iniciantes
|
||||||
|
|
||||||
|
**Plano Básico (R$ 12,90/mês)**
|
||||||
|
- Até 15 links
|
||||||
|
- Temas profissionais
|
||||||
|
- Analytics para acompanhar acessos
|
||||||
|
|
||||||
|
### Para Escritórios Estabelecidos
|
||||||
|
|
||||||
|
**Plano Premium (R$ 29,90/mês)**
|
||||||
|
- Links ilimitados
|
||||||
|
- Logo do escritório
|
||||||
|
- Cores personalizadas
|
||||||
|
- Suporte prioritário
|
||||||
|
- Upload de PDFs para contratos, petições e materiais exclusivos
|
||||||
|
|
||||||
|
## Conclusão
|
||||||
|
|
||||||
|
O BCards é uma ferramenta essencial para advogados modernos que querem:
|
||||||
|
- Facilitar o contato com clientes
|
||||||
|
- Apresentar-se de forma profissional
|
||||||
|
- Centralizar todas as informações em um só lugar
|
||||||
|
- Aumentar a captação de clientes
|
||||||
|
|
||||||
|
A melhor parte? É rápido de configurar e totalmente compatível com as regras da OAB para marketing jurídico.
|
||||||
|
|
||||||
|
**Pronto para modernizar sua presença digital?** [Crie seu BCard profissional agora!](https://bcards.site)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Recursos Adicionais
|
||||||
|
|
||||||
|
- [Tutorial: Como Criar um BCard](/tutoriais/tecnologia/como-criar-um-bcard)
|
||||||
|
- [Suporte BCards](/support)
|
||||||
|
- [Políticas de Privacidade](/privacidade)
|
||||||
148
Content/Tutoriais/tecnologia/como-criar-um-bcard.pt-BR.md
Normal file
148
Content/Tutoriais/tecnologia/como-criar-um-bcard.pt-BR.md
Normal file
@ -0,0 +1,148 @@
|
|||||||
|
---
|
||||||
|
title: "Como Criar um BCard Profissional em 5 Minutos"
|
||||||
|
description: "Aprenda passo a passo como criar seu cartão digital profissional com BCards e centralize todos os seus links em um único lugar"
|
||||||
|
keywords: "bcards, cartão digital, tutorial, tecnologia, links, página profissional"
|
||||||
|
author: "BCards"
|
||||||
|
date: 2025-11-02
|
||||||
|
lastmod: 2025-11-02
|
||||||
|
image: "/images/tutoriais/tecnologia/criar-bcard-hero.jpg"
|
||||||
|
category: "tecnologia"
|
||||||
|
---
|
||||||
|
|
||||||
|
# Como Criar um BCard Profissional em 5 Minutos
|
||||||
|
|
||||||
|
Criar um cartão digital profissional nunca foi tão fácil! Neste tutorial, vamos mostrar como você pode ter sua página de links profissional em apenas 5 minutos.
|
||||||
|
|
||||||
|
## O que é um BCard?
|
||||||
|
|
||||||
|
O BCard é sua página profissional na internet, onde você centraliza todos os seus links importantes:
|
||||||
|
|
||||||
|
- Redes sociais (Instagram, LinkedIn, Facebook)
|
||||||
|
- WhatsApp para contato
|
||||||
|
- Site ou portfólio
|
||||||
|
- Links de produtos ou serviços
|
||||||
|
- E muito mais!
|
||||||
|
|
||||||
|
## Passo 1: Faça seu Cadastro
|
||||||
|
|
||||||
|
1. Acesse [bcards.site](https://bcards.site)
|
||||||
|
2. Clique em "Criar meu BCard grátis"
|
||||||
|
3. Escolha fazer login com Google ou Microsoft
|
||||||
|
4. Pronto! Sua conta está criada
|
||||||
|
|
||||||
|
> **Dica:** Use o mesmo email que você usa profissionalmente para facilitar o gerenciamento
|
||||||
|
|
||||||
|
## Passo 2: Escolha seu Tema
|
||||||
|
|
||||||
|
Temos diversos temas profissionais disponíveis:
|
||||||
|
|
||||||
|
- **Minimalista**: Clean e elegante
|
||||||
|
- **Moderno**: Cores vibrantes e design atual
|
||||||
|
- **Profissional**: Sóbrio e corporativo
|
||||||
|
- **Criativo**: Para quem quer se destacar
|
||||||
|
|
||||||
|
Escolha o tema que mais combina com sua personalidade ou marca!
|
||||||
|
|
||||||
|
## Passo 3: Adicione seus Links
|
||||||
|
|
||||||
|
Agora é hora de adicionar os links mais importantes:
|
||||||
|
|
||||||
|
1. Clique em "Adicionar Link"
|
||||||
|
2. Escolha o tipo (Instagram, WhatsApp, Site, etc.)
|
||||||
|
3. Cole o link
|
||||||
|
4. Dê um título descritivo
|
||||||
|
5. Salve!
|
||||||
|
|
||||||
|
### Tipos de Links Suportados
|
||||||
|
|
||||||
|
- Redes Sociais (Instagram, Facebook, LinkedIn, TikTok)
|
||||||
|
- Contato (WhatsApp, Email, Telefone)
|
||||||
|
- Sites e Portfólios
|
||||||
|
- Vídeos (YouTube, Vimeo)
|
||||||
|
- Documentos e PDFs
|
||||||
|
- Links personalizados
|
||||||
|
|
||||||
|
## Passo 4: Personalize seu Perfil
|
||||||
|
|
||||||
|
Deixe seu BCard com a sua cara:
|
||||||
|
|
||||||
|
- Adicione uma foto de perfil profissional
|
||||||
|
- Escreva uma bio atrativa
|
||||||
|
- Configure cores personalizadas (plano Premium)
|
||||||
|
- Adicione seu logo (plano Premium)
|
||||||
|
|
||||||
|
## Passo 5: Compartilhe!
|
||||||
|
|
||||||
|
Agora que está pronto, é hora de compartilhar:
|
||||||
|
|
||||||
|
- Seu link será: `bcards.site/page/{categoria}/{seu-nome}`
|
||||||
|
- Adicione na bio do Instagram
|
||||||
|
- Coloque na assinatura de email
|
||||||
|
- Compartilhe em grupos de WhatsApp
|
||||||
|
- Adicione ao cartão de visitas físico com QR Code
|
||||||
|
|
||||||
|
## Dicas Profissionais
|
||||||
|
|
||||||
|
### 1. Mantenha Atualizado
|
||||||
|
|
||||||
|
Revise seus links regularmente e remova os que não são mais relevantes.
|
||||||
|
|
||||||
|
### 2. Use Descrições Claras
|
||||||
|
|
||||||
|
Ao invés de "Clique aqui", use "Veja meu portfólio completo" ou "Agende uma consulta".
|
||||||
|
|
||||||
|
### 3. Priorize os Links Mais Importantes
|
||||||
|
|
||||||
|
Coloque os links mais relevantes no topo. As pessoas geralmente clicam nos primeiros links.
|
||||||
|
|
||||||
|
### 4. Teste Regularmente
|
||||||
|
|
||||||
|
Clique em todos os seus links periodicamente para garantir que estão funcionando.
|
||||||
|
|
||||||
|
## Planos e Recursos
|
||||||
|
|
||||||
|
### Plano Gratuito
|
||||||
|
|
||||||
|
- Até 5 links
|
||||||
|
- Temas básicos
|
||||||
|
- Analytics básico
|
||||||
|
|
||||||
|
### Plano Básico (R$ 12,90/mês)
|
||||||
|
|
||||||
|
- Até 15 links
|
||||||
|
- Todos os temas
|
||||||
|
- Analytics detalhado
|
||||||
|
|
||||||
|
### Plano Premium (R$ 29,90/mês)
|
||||||
|
|
||||||
|
- Links ilimitados
|
||||||
|
- Temas personalizados
|
||||||
|
- Logo personalizado
|
||||||
|
- Suporte prioritário
|
||||||
|
- Upload de PDFs para materiais extras
|
||||||
|
|
||||||
|
## Conclusão
|
||||||
|
|
||||||
|
Criar seu BCard profissional é rápido, fácil e pode transformar a forma como você se apresenta online. Em apenas 5 minutos, você tem uma página profissional que centraliza todos os seus contatos e links importantes.
|
||||||
|
|
||||||
|
**Pronto para começar?** [Crie seu BCard grátis agora!](https://bcards.site)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Perguntas Frequentes
|
||||||
|
|
||||||
|
### Posso mudar meu tema depois?
|
||||||
|
|
||||||
|
Sim! Você pode mudar o tema quantas vezes quiser, a qualquer momento.
|
||||||
|
|
||||||
|
### Como faço para adicionar um QR Code?
|
||||||
|
|
||||||
|
No dashboard, clique em "Baixar QR Code" para fazer download do código QR do seu BCard.
|
||||||
|
|
||||||
|
### Posso ter mais de um BCard?
|
||||||
|
|
||||||
|
No momento, cada conta pode ter apenas um BCard ativo.
|
||||||
|
|
||||||
|
### Precisa de mais ajuda?
|
||||||
|
|
||||||
|
Entre em contato com nosso [suporte](https://bcards.site/support) ou confira outros tutoriais!
|
||||||
41
Dockerfile
Normal file
41
Dockerfile
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
# 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
|
||||||
|
ARG BUILD_CONFIGURATION=Release
|
||||||
|
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 ${BUILD_CONFIGURATION} -o /app/build
|
||||||
|
|
||||||
|
FROM build AS publish
|
||||||
|
ARG BUILD_CONFIGURATION=Release
|
||||||
|
RUN dotnet publish "BCards.Web.csproj" -c ${BUILD_CONFIGURATION} -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"]
|
||||||
178
GITEA-VARIABLES-SETUP.md
Normal file
178
GITEA-VARIABLES-SETUP.md
Normal file
@ -0,0 +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]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Resultado**: Configuração 100% automatizada, segura e versionada! 🎉
|
||||||
24
README.md
24
README.md
@ -5,7 +5,7 @@ Um clone profissional do LinkTree desenvolvido em ASP.NET Core MVC, focado no me
|
|||||||
## 🚀 Características Principais
|
## 🚀 Características Principais
|
||||||
|
|
||||||
### ✨ Funcionalidades
|
### ✨ Funcionalidades
|
||||||
- **URLs Hierárquicas**: Organização por categoria (ex: `vcart.me/corretor/jose-silva`)
|
- **URLs Hierárquicas**: Organização por categoria (ex: `bcards.site/corretor/jose-silva`)
|
||||||
- **Sistema de Pagamentos**: Integração completa com Stripe
|
- **Sistema de Pagamentos**: Integração completa com Stripe
|
||||||
- **3 Planos com Efeito Decoy**: Estratégia psicológica de pricing
|
- **3 Planos com Efeito Decoy**: Estratégia psicológica de pricing
|
||||||
- **Autenticação OAuth**: Google e Microsoft
|
- **Autenticação OAuth**: Google e Microsoft
|
||||||
@ -16,9 +16,9 @@ Um clone profissional do LinkTree desenvolvido em ASP.NET Core MVC, focado no me
|
|||||||
- **Renderização SSR**: SEO-friendly
|
- **Renderização SSR**: SEO-friendly
|
||||||
|
|
||||||
### 🎯 Planos e Pricing (Estratégia Decoy)
|
### 🎯 Planos e Pricing (Estratégia Decoy)
|
||||||
- **Básico** (R$ 9,90/mês): 5 links, temas básicos, analytics simples
|
- **Básico** (R$ 12,90/mês): 5 links, temas básicos, analytics simples
|
||||||
- **Profissional** (R$ 24,90/mês): 15 links, todos os temas, analytics avançado, domínio personalizado *(DECOY)*
|
- **Profissional** (R$ 25,90/mês): 15 links, todos os temas, analytics avançado, domínio personalizado *(DECOY)*
|
||||||
- **Premium** (R$ 29,90/mês): Links ilimitados, temas customizáveis, analytics completo, múltiplos domínios
|
- **Premium** (R$ 29,90/mês): Links ilimitados, temas customizáveis, analytics completo, múltiplos domínios, upload de PDFs
|
||||||
|
|
||||||
## 🛠️ Tecnologias
|
## 🛠️ Tecnologias
|
||||||
|
|
||||||
@ -86,7 +86,7 @@ Edite `appsettings.json` ou `appsettings.Development.json`:
|
|||||||
"ClientSecret": "seu_microsoft_client_secret"
|
"ClientSecret": "seu_microsoft_client_secret"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"BaseUrl": "https://vcart.me"
|
"BaseUrl": "https://bcards.site"
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@ -94,8 +94,8 @@ Edite `appsettings.json` ou `appsettings.Development.json`:
|
|||||||
|
|
||||||
1. Crie uma conta no [Stripe](https://stripe.com)
|
1. Crie uma conta no [Stripe](https://stripe.com)
|
||||||
2. Configure os produtos e preços:
|
2. Configure os produtos e preços:
|
||||||
- Básico: R$ 9,90/mês
|
- Básico: R$ 12,90/mês
|
||||||
- Profissional: R$ 24,90/mês
|
- Profissional: R$ 25,90/mês
|
||||||
- Premium: R$ 29,90/mês
|
- Premium: R$ 29,90/mês
|
||||||
3. Configure webhooks para: `/webhook/stripe`
|
3. Configure webhooks para: `/webhook/stripe`
|
||||||
4. Eventos necessários:
|
4. Eventos necessários:
|
||||||
@ -254,7 +254,7 @@ Sistema de analytics integrado que rastreia:
|
|||||||
"SecretKey": "sk_live_seu_secret_key",
|
"SecretKey": "sk_live_seu_secret_key",
|
||||||
"WebhookSecret": "whsec_seu_webhook_secret_producao"
|
"WebhookSecret": "whsec_seu_webhook_secret_producao"
|
||||||
},
|
},
|
||||||
"BaseUrl": "https://vcart.me"
|
"BaseUrl": "https://bcards.site"
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@ -303,17 +303,17 @@ Este projeto está sob a licença MIT. Veja o arquivo [LICENSE](LICENSE) para de
|
|||||||
## 🆘 Suporte
|
## 🆘 Suporte
|
||||||
|
|
||||||
Para suporte técnico, entre em contato:
|
Para suporte técnico, entre em contato:
|
||||||
- Email: suporte@vcart.me
|
- Email: suporte@bcards.site
|
||||||
- Discord: [Servidor da Comunidade]
|
- Discord: [Servidor da Comunidade]
|
||||||
- Issues: [GitHub Issues](https://github.com/seuusuario/bcards/issues)
|
- Issues: [GitHub Issues](https://github.com/seuusuario/bcards/issues)
|
||||||
|
|
||||||
## 📞 Contato
|
## 📞 Contato
|
||||||
|
|
||||||
- **Website**: https://vcart.me
|
- **Website**: https://bcards.site
|
||||||
- **Email**: contato@vcart.me
|
- **Email**: contato@bcards.site
|
||||||
- **LinkedIn**: [Seu LinkedIn]
|
- **LinkedIn**: [Seu LinkedIn]
|
||||||
- **Twitter**: [@vcartme]
|
- **Twitter**: [@vcartme]
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
**Desenvolvido com ❤️ para profissionais brasileiros e hispânicos**
|
**Desenvolvido com ❤️ para profissionais brasileiros e hispânicos**
|
||||||
|
|||||||
1357
RELATORIO_VIABILIDADE_ARTIGOS.md
Normal file
1357
RELATORIO_VIABILIDADE_ARTIGOS.md
Normal file
File diff suppressed because it is too large
Load Diff
219
STRIPE-SETUP-GUIDE.md
Normal file
219
STRIPE-SETUP-GUIDE.md
Normal file
@ -0,0 +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
|
||||||
|
|
||||||
|
**O site funcionará online em modo teste, permitindo que qualquer pessoa teste com cartões fake!** 🎉
|
||||||
27
appsettings.Production.example.json
Normal file
27
appsettings.Production.example.json
Normal file
@ -0,0 +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"
|
||||||
|
]
|
||||||
|
}
|
||||||
452
categorias.json
Normal file
452
categorias.json
Normal file
@ -0,0 +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
|
||||||
|
}
|
||||||
|
]
|
||||||
34
clean-build.sh
Normal file
34
clean-build.sh
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
echo "🧹 Iniciando limpeza completa do projeto BCards..."
|
||||||
|
|
||||||
|
# 1. Limpar todos os caches NuGet
|
||||||
|
echo "📦 Limpando cache NuGet..."
|
||||||
|
dotnet nuget locals all --clear
|
||||||
|
|
||||||
|
# 2. Remover pastas bin/obj recursivamente
|
||||||
|
echo "🗑️ Removendo pastas bin/obj..."
|
||||||
|
find . -name "bin" -type d -exec rm -rf {} + 2>/dev/null || true
|
||||||
|
find . -name "obj" -type d -exec rm -rf {} + 2>/dev/null || true
|
||||||
|
|
||||||
|
# 3. Limpar solution
|
||||||
|
echo "🧽 Executando dotnet clean..."
|
||||||
|
dotnet clean --verbosity quiet
|
||||||
|
|
||||||
|
# 4. Restaurar packages sem cache
|
||||||
|
echo "📥 Restaurando packages..."
|
||||||
|
dotnet restore --no-cache --force --verbosity quiet
|
||||||
|
|
||||||
|
# 5. Build completo
|
||||||
|
echo "🔨 Executando build..."
|
||||||
|
dotnet build --no-restore --verbosity quiet
|
||||||
|
|
||||||
|
if [ $? -eq 0 ]; then
|
||||||
|
echo "✅ Build concluído com sucesso!"
|
||||||
|
echo "🚀 Pronto para executar: dotnet run"
|
||||||
|
else
|
||||||
|
echo "❌ Build falhou! Verifique os erros acima."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "🎉 Limpeza completa finalizada!"
|
||||||
68
deploy-manual.ps1
Normal file
68
deploy-manual.ps1
Normal file
@ -0,0 +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
|
||||||
|
}
|
||||||
88
deploy/cleanup-standalone.sh
Normal file
88
deploy/cleanup-standalone.sh
Normal file
@ -0,0 +1,88 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Script para limpar containers standalone do BCards
|
||||||
|
# Deve ser executado DEPOIS que o Swarm estiver rodando corretamente
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
echo "🔍 Verificando containers standalone do BCards..."
|
||||||
|
|
||||||
|
# Lista de possíveis nomes de containers standalone
|
||||||
|
STANDALONE_CONTAINERS=(
|
||||||
|
"bcards-prod"
|
||||||
|
"bcards-release"
|
||||||
|
"bcards-app"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Verifica se o Swarm está rodando
|
||||||
|
echo "✅ Verificando status do Docker Swarm..."
|
||||||
|
if ! docker info | grep -q "Swarm: active"; then
|
||||||
|
echo "❌ ERRO: Docker Swarm não está ativo neste servidor!"
|
||||||
|
echo "Este script só deve ser executado em servidores do Swarm."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Verifica se o serviço do Swarm está rodando
|
||||||
|
echo "✅ Verificando serviço bcards_bcards-app no Swarm..."
|
||||||
|
if ! docker service ls | grep -q "bcards_bcards-app"; then
|
||||||
|
echo "❌ ERRO: Serviço bcards_bcards-app não encontrado no Swarm!"
|
||||||
|
echo "Certifique-se de que o deploy do Swarm foi feito antes de executar este script."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Mostra status do serviço Swarm
|
||||||
|
echo ""
|
||||||
|
echo "📊 Status atual do serviço Swarm:"
|
||||||
|
docker service ls --filter name=bcards_bcards-app
|
||||||
|
echo ""
|
||||||
|
docker service ps bcards_bcards-app --filter "desired-state=running" | head -10
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Verifica se há containers standalone rodando
|
||||||
|
FOUND_CONTAINERS=false
|
||||||
|
for container_name in "${STANDALONE_CONTAINERS[@]}"; do
|
||||||
|
if docker ps -a --format '{{.Names}}' | grep -q "^${container_name}$"; then
|
||||||
|
FOUND_CONTAINERS=true
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ "$FOUND_CONTAINERS" = false ]; then
|
||||||
|
echo "✅ Nenhum container standalone encontrado. Sistema está OK!"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Lista containers encontrados
|
||||||
|
echo "⚠️ Containers standalone encontrados:"
|
||||||
|
for container_name in "${STANDALONE_CONTAINERS[@]}"; do
|
||||||
|
if docker ps -a --format '{{.Names}}' | grep -q "^${container_name}$"; then
|
||||||
|
echo " - $container_name"
|
||||||
|
docker ps -a --filter "name=^${container_name}$" --format "table {{.ID}}\t{{.Names}}\t{{.Status}}\t{{.Ports}}"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Pergunta confirmação
|
||||||
|
read -p "🗑️ Deseja remover estes containers standalone? (sim/não): " -r
|
||||||
|
echo
|
||||||
|
if [[ ! $REPLY =~ ^[Ss][Ii][Mm]$ ]]; then
|
||||||
|
echo "❌ Operação cancelada pelo usuário."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Remove os containers
|
||||||
|
echo "🧹 Removendo containers standalone..."
|
||||||
|
for container_name in "${STANDALONE_CONTAINERS[@]}"; do
|
||||||
|
if docker ps -a --format '{{.Names}}' | grep -q "^${container_name}$"; then
|
||||||
|
echo " Parando e removendo: $container_name"
|
||||||
|
docker stop "$container_name" 2>/dev/null || true
|
||||||
|
docker rm "$container_name" 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "✅ Limpeza concluída!"
|
||||||
|
echo ""
|
||||||
|
echo "📊 Status final do serviço Swarm:"
|
||||||
|
docker service ls --filter name=bcards_bcards-app
|
||||||
|
echo ""
|
||||||
|
docker service ps bcards_bcards-app --filter "desired-state=running" | head -10
|
||||||
52
deploy/docker-stack.release.yml
Normal file
52
deploy/docker-stack.release.yml
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
version: '3.8'
|
||||||
|
|
||||||
|
services:
|
||||||
|
bcards-release:
|
||||||
|
image: ${BCARDS_IMAGE}
|
||||||
|
networks:
|
||||||
|
- bcards-net
|
||||||
|
deploy:
|
||||||
|
replicas: 1
|
||||||
|
update_config:
|
||||||
|
parallelism: 1
|
||||||
|
delay: 10s
|
||||||
|
order: start-first
|
||||||
|
monitor: 60s
|
||||||
|
failure_action: rollback
|
||||||
|
rollback_config:
|
||||||
|
parallelism: 0
|
||||||
|
delay: 5s
|
||||||
|
environment:
|
||||||
|
ASPNETCORE_ENVIRONMENT: Release
|
||||||
|
ASPNETCORE_URLS: http://+:8080
|
||||||
|
ASPNETCORE_FORWARDEDHEADERS_ENABLED: "true"
|
||||||
|
# MongoDB local (Core i5)
|
||||||
|
MongoDb__ConnectionString: mongodb://192.168.0.100:27017/BCardsDB
|
||||||
|
MongoDb__DatabaseName: BCardsDB
|
||||||
|
DataProtection__Mongo__ConnectionString: mongodb://192.168.0.100:27017/BCardsDB
|
||||||
|
DataProtection__Mongo__DatabaseName: BCardsDB
|
||||||
|
DataProtection__Mongo__CollectionName: DataProtectionKeys
|
||||||
|
# OpenSearch local (Core i5)
|
||||||
|
Serilog__OpenSearchUrl: http://192.168.0.100:9200
|
||||||
|
Serilog__OpenSearchFallback: http://192.168.0.100:9200
|
||||||
|
# Stripe test keys (same as development)
|
||||||
|
Stripe__PublishableKey: pk_test_51RjUmIBMIadsOxJVP4bWc54pHEOSf5km1hpOkOBSoGVoKxI46N4KSWtevpXCSq68OjFazBuXmPJGBwZ1KDN5MNJy003lj1YmAS
|
||||||
|
Stripe__SecretKey: sk_test_51RjUmIBMIadsOxJVeqsMFxnZ8ePR7d8IbnaF4sAwBVJv9rrfODPEQ2C9fF3beoABpITdfzEk0ZDzGTTQfvKv63xI00PeZoABGO
|
||||||
|
Stripe__WebhookSecret: whsec_8d189c137ff170ab5e62498003512b9d073e2db50c50ed7d8712b7ef11a37543
|
||||||
|
Stripe__Environment: test
|
||||||
|
Logging__LogLevel__Default: Debug
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "curl", "-f", "http://localhost:8080/health"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 3
|
||||||
|
start_period: 40s
|
||||||
|
ports:
|
||||||
|
- published: 28080
|
||||||
|
target: 8080
|
||||||
|
protocol: tcp
|
||||||
|
mode: ingress
|
||||||
|
|
||||||
|
networks:
|
||||||
|
bcards-net:
|
||||||
|
external: true
|
||||||
52
deploy/docker-stack.yml
Normal file
52
deploy/docker-stack.yml
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
version: '3.8'
|
||||||
|
|
||||||
|
configs:
|
||||||
|
bcards-appsettings:
|
||||||
|
external: true
|
||||||
|
|
||||||
|
services:
|
||||||
|
bcards-app:
|
||||||
|
image: registry.redecarneir.us/bcards:latest
|
||||||
|
networks:
|
||||||
|
- bcards-net
|
||||||
|
deploy:
|
||||||
|
replicas: 4
|
||||||
|
placement:
|
||||||
|
max_replicas_per_node: 2
|
||||||
|
update_config:
|
||||||
|
parallelism: 1
|
||||||
|
order: stop-first
|
||||||
|
delay: 10s
|
||||||
|
monitor: 60s
|
||||||
|
failure_action: rollback
|
||||||
|
rollback_config:
|
||||||
|
parallelism: 0
|
||||||
|
delay: 5s
|
||||||
|
configs:
|
||||||
|
- source: bcards-appsettings
|
||||||
|
target: /app/appsettings.Production.json
|
||||||
|
mode: 0444
|
||||||
|
environment:
|
||||||
|
ASPNETCORE_ENVIRONMENT: Production
|
||||||
|
ASPNETCORE_URLS: http://+:8080
|
||||||
|
ASPNETCORE_FORWARDEDHEADERS_ENABLED: "true"
|
||||||
|
MongoDb__ConnectionString: mongodb://admin:c4rn31r0@129.146.116.218:27017,141.148.162.114:27017/BCardsDB?replicaSet=rs0&authSource=admin
|
||||||
|
MongoDb__DatabaseName: BCardsDB
|
||||||
|
Serilog__OpenSearchUrl: http://141.148.162.114:19201
|
||||||
|
Serilog__OpenSearchFallback: http://129.146.116.218:19202
|
||||||
|
Logging__LogLevel__Default: Information
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "curl", "-f", "http://localhost:8080/health"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 3
|
||||||
|
start_period: 40s
|
||||||
|
ports:
|
||||||
|
- published: 8080
|
||||||
|
target: 8080
|
||||||
|
protocol: tcp
|
||||||
|
mode: ingress
|
||||||
|
|
||||||
|
networks:
|
||||||
|
bcards-net:
|
||||||
|
external: true
|
||||||
@ -1,160 +1,160 @@
|
|||||||
version: '3.8'
|
version: '3.8'
|
||||||
|
|
||||||
services:
|
services:
|
||||||
bcards-web:
|
bcards-web:
|
||||||
image: ${REGISTRY:-registry.redecarneir.us}/bcards:${IMAGE_TAG:-release-latest}
|
image: ${REGISTRY:-registry.redecarneir.us}/bcards:${IMAGE_TAG:-release-latest}
|
||||||
container_name: bcards-staging
|
container_name: bcards-staging
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
- "8090:8080"
|
- "8090:8080"
|
||||||
- "8453:8443"
|
- "8453:8443"
|
||||||
environment:
|
environment:
|
||||||
# Core ASP.NET Configuration
|
# Core ASP.NET Configuration
|
||||||
- ASPNETCORE_ENVIRONMENT=Release
|
- ASPNETCORE_ENVIRONMENT=Release
|
||||||
- ASPNETCORE_URLS=http://+:8080
|
- ASPNETCORE_URLS=http://+:8080
|
||||||
- ASPNETCORE_FORWARDEDHEADERS_ENABLED=true
|
- ASPNETCORE_FORWARDEDHEADERS_ENABLED=true
|
||||||
|
|
||||||
# MongoDB Configuration
|
# MongoDB Configuration
|
||||||
- MongoDb__ConnectionString=${MONGODB_CONNECTION_STRING:-mongodb://192.168.0.100:27017/BCardsDB}
|
- MongoDb__ConnectionString=${MONGODB_CONNECTION_STRING:-mongodb://192.168.0.100:27017/BCardsDB}
|
||||||
- MongoDb__DatabaseName=BCardsDB
|
- MongoDb__DatabaseName=BCardsDB
|
||||||
|
|
||||||
# Application Settings
|
# Application Settings
|
||||||
- AppSettings__Environment=Staging
|
- AppSettings__Environment=Staging
|
||||||
- AppSettings__Version=${IMAGE_TAG:-unknown}
|
- AppSettings__Version=${IMAGE_TAG:-unknown}
|
||||||
- AppSettings__AllowedHosts=*
|
- AppSettings__AllowedHosts=*
|
||||||
|
|
||||||
# Logging Configuration
|
# Logging Configuration
|
||||||
- Logging__LogLevel__Default=Information
|
- Logging__LogLevel__Default=Information
|
||||||
- Logging__LogLevel__Microsoft.AspNetCore=Warning
|
- Logging__LogLevel__Microsoft.AspNetCore=Warning
|
||||||
- Logging__LogLevel__BCards=Debug
|
- Logging__LogLevel__BCards=Debug
|
||||||
|
|
||||||
# Performance Optimizations
|
# Performance Optimizations
|
||||||
- DOTNET_RUNNING_IN_CONTAINER=true
|
- DOTNET_RUNNING_IN_CONTAINER=true
|
||||||
- DOTNET_EnableDiagnostics=0
|
- DOTNET_EnableDiagnostics=0
|
||||||
- DOTNET_USE_POLLING_FILE_WATCHER=true
|
- DOTNET_USE_POLLING_FILE_WATCHER=true
|
||||||
- DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=false
|
- DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=false
|
||||||
- DOTNET_TieredPGO=1
|
- DOTNET_TieredPGO=1
|
||||||
- DOTNET_TC_QuickJitForLoops=1
|
- DOTNET_TC_QuickJitForLoops=1
|
||||||
|
|
||||||
# Security Headers
|
# Security Headers
|
||||||
- ASPNETCORE_HTTPS_PORT=8443
|
- ASPNETCORE_HTTPS_PORT=8443
|
||||||
- ASPNETCORE_Kestrel__Certificates__Default__Path=/app/certs/cert.pfx
|
- ASPNETCORE_Kestrel__Certificates__Default__Path=/app/certs/cert.pfx
|
||||||
- ASPNETCORE_Kestrel__Certificates__Default__Password=${CERT_PASSWORD:-}
|
- ASPNETCORE_Kestrel__Certificates__Default__Password=${CERT_PASSWORD:-}
|
||||||
|
|
||||||
# Redis Configuration (if needed)
|
# Redis Configuration (if needed)
|
||||||
- Redis__ConnectionString=localhost:6379
|
- Redis__ConnectionString=localhost:6379
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
# Application logs
|
# Application logs
|
||||||
- ./logs:/app/logs:rw
|
- ./logs:/app/logs:rw
|
||||||
|
|
||||||
# File uploads (if needed)
|
# File uploads (if needed)
|
||||||
- ./uploads:/app/uploads:rw
|
- ./uploads:/app/uploads:rw
|
||||||
|
|
||||||
# SSL certificates (if using HTTPS)
|
# SSL certificates (if using HTTPS)
|
||||||
# - ./certs:/app/certs:ro
|
# - ./certs:/app/certs:ro
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
- bcards-staging-network
|
- bcards-staging-network
|
||||||
|
|
||||||
# Health check configuration
|
# Health check configuration
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "curl", "-f", "http://localhost:8080/health"]
|
test: ["CMD", "curl", "-f", "http://localhost:8080/health"]
|
||||||
interval: 30s
|
interval: 30s
|
||||||
timeout: 10s
|
timeout: 10s
|
||||||
retries: 3
|
retries: 3
|
||||||
start_period: 60s
|
start_period: 60s
|
||||||
|
|
||||||
# Resource limits for staging environment
|
# Resource limits for staging environment
|
||||||
deploy:
|
deploy:
|
||||||
resources:
|
resources:
|
||||||
limits:
|
limits:
|
||||||
memory: 1G
|
memory: 1G
|
||||||
cpus: '1.0'
|
cpus: '1.0'
|
||||||
reservations:
|
reservations:
|
||||||
memory: 512M
|
memory: 512M
|
||||||
cpus: '0.5'
|
cpus: '0.5'
|
||||||
|
|
||||||
# Logging configuration
|
# Logging configuration
|
||||||
logging:
|
logging:
|
||||||
driver: "json-file"
|
driver: "json-file"
|
||||||
options:
|
options:
|
||||||
max-size: "100m"
|
max-size: "100m"
|
||||||
max-file: "5"
|
max-file: "5"
|
||||||
|
|
||||||
# Platform specification (will use the appropriate arch from multi-arch image)
|
# Platform specification (will use the appropriate arch from multi-arch image)
|
||||||
# platform: linux/amd64 # Uncomment if forcing specific architecture
|
# platform: linux/amd64 # Uncomment if forcing specific architecture
|
||||||
|
|
||||||
# Security options
|
# Security options
|
||||||
security_opt:
|
security_opt:
|
||||||
- no-new-privileges:true
|
- no-new-privileges:true
|
||||||
read_only: false # Set to true for extra security, but may need volume mounts for temp files
|
read_only: false # Set to true for extra security, but may need volume mounts for temp files
|
||||||
|
|
||||||
# Process limits
|
# Process limits
|
||||||
ulimits:
|
ulimits:
|
||||||
nproc: 65535
|
nproc: 65535
|
||||||
nofile:
|
nofile:
|
||||||
soft: 65535
|
soft: 65535
|
||||||
hard: 65535
|
hard: 65535
|
||||||
|
|
||||||
# Optional: Redis for caching (if application uses it)
|
# Optional: Redis for caching (if application uses it)
|
||||||
redis:
|
redis:
|
||||||
image: redis:7-alpine
|
image: redis:7-alpine
|
||||||
container_name: bcards-redis-staging
|
container_name: bcards-redis-staging
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
- "6379:6379"
|
- "6379:6379"
|
||||||
command: redis-server --appendonly yes --maxmemory 256mb --maxmemory-policy allkeys-lru
|
command: redis-server --appendonly yes --maxmemory 256mb --maxmemory-policy allkeys-lru
|
||||||
volumes:
|
volumes:
|
||||||
- redis_staging_data:/data
|
- redis_staging_data:/data
|
||||||
networks:
|
networks:
|
||||||
- bcards-staging-network
|
- bcards-staging-network
|
||||||
deploy:
|
deploy:
|
||||||
resources:
|
resources:
|
||||||
limits:
|
limits:
|
||||||
memory: 256M
|
memory: 256M
|
||||||
cpus: '0.5'
|
cpus: '0.5'
|
||||||
logging:
|
logging:
|
||||||
driver: "json-file"
|
driver: "json-file"
|
||||||
options:
|
options:
|
||||||
max-size: "50m"
|
max-size: "50m"
|
||||||
max-file: "3"
|
max-file: "3"
|
||||||
|
|
||||||
# Optional: Nginx reverse proxy for additional features
|
# Optional: Nginx reverse proxy for additional features
|
||||||
nginx:
|
nginx:
|
||||||
image: nginx:alpine
|
image: nginx:alpine
|
||||||
container_name: bcards-nginx-staging
|
container_name: bcards-nginx-staging
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
- "8091:80"
|
- "8091:80"
|
||||||
- "8454:443"
|
- "8454:443"
|
||||||
volumes:
|
volumes:
|
||||||
- ./nginx/staging.conf:/etc/nginx/conf.d/default.conf:ro
|
- ./nginx/staging.conf:/etc/nginx/conf.d/default.conf:ro
|
||||||
- ./nginx/ssl:/etc/ssl/certs:ro
|
- ./nginx/ssl:/etc/ssl/certs:ro
|
||||||
- ./logs/nginx:/var/log/nginx:rw
|
- ./logs/nginx:/var/log/nginx:rw
|
||||||
depends_on:
|
depends_on:
|
||||||
- bcards-web
|
- bcards-web
|
||||||
networks:
|
networks:
|
||||||
- bcards-staging-network
|
- bcards-staging-network
|
||||||
deploy:
|
deploy:
|
||||||
resources:
|
resources:
|
||||||
limits:
|
limits:
|
||||||
memory: 128M
|
memory: 128M
|
||||||
cpus: '0.25'
|
cpus: '0.25'
|
||||||
|
|
||||||
# Named volumes for persistent data
|
# Named volumes for persistent data
|
||||||
volumes:
|
volumes:
|
||||||
redis_staging_data:
|
redis_staging_data:
|
||||||
driver: local
|
driver: local
|
||||||
driver_opts:
|
driver_opts:
|
||||||
type: none
|
type: none
|
||||||
o: bind
|
o: bind
|
||||||
device: ./data/redis
|
device: ./data/redis
|
||||||
|
|
||||||
# Network for staging environment
|
# Network for staging environment
|
||||||
networks:
|
networks:
|
||||||
bcards-staging-network:
|
bcards-staging-network:
|
||||||
driver: bridge
|
driver: bridge
|
||||||
ipam:
|
ipam:
|
||||||
config:
|
config:
|
||||||
- subnet: 172.20.0.0/16
|
- subnet: 172.20.0.0/16
|
||||||
@ -1,369 +1,369 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
|
|
||||||
# Deploy script for Release environment with multi-architecture support
|
# Deploy script for Release environment with multi-architecture support
|
||||||
# Usage: ./deploy-release.sh <IMAGE_TAG>
|
# Usage: ./deploy-release.sh <IMAGE_TAG>
|
||||||
|
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
# Configuration
|
# Configuration
|
||||||
readonly SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
readonly SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
readonly PROJECT_ROOT="$(dirname "$SCRIPT_DIR")"
|
readonly PROJECT_ROOT="$(dirname "$SCRIPT_DIR")"
|
||||||
readonly DEPLOY_DIR="/opt/bcards-staging"
|
readonly DEPLOY_DIR="/opt/bcards-staging"
|
||||||
readonly DOCKER_COMPOSE_FILE="docker-compose.staging.yml"
|
readonly DOCKER_COMPOSE_FILE="docker-compose.staging.yml"
|
||||||
readonly CONTAINER_NAME="bcards-staging"
|
readonly CONTAINER_NAME="bcards-staging"
|
||||||
readonly HEALTH_CHECK_URL="http://localhost:8090/health"
|
readonly HEALTH_CHECK_URL="http://localhost:8090/health"
|
||||||
readonly MAX_HEALTH_CHECK_ATTEMPTS=10
|
readonly MAX_HEALTH_CHECK_ATTEMPTS=10
|
||||||
readonly HEALTH_CHECK_INTERVAL=10
|
readonly HEALTH_CHECK_INTERVAL=10
|
||||||
readonly ROLLBACK_TIMEOUT=300
|
readonly ROLLBACK_TIMEOUT=300
|
||||||
|
|
||||||
# Colors for output
|
# Colors for output
|
||||||
readonly RED='\033[0;31m'
|
readonly RED='\033[0;31m'
|
||||||
readonly GREEN='\033[0;32m'
|
readonly GREEN='\033[0;32m'
|
||||||
readonly YELLOW='\033[1;33m'
|
readonly YELLOW='\033[1;33m'
|
||||||
readonly BLUE='\033[0;34m'
|
readonly BLUE='\033[0;34m'
|
||||||
readonly NC='\033[0m' # No Color
|
readonly NC='\033[0m' # No Color
|
||||||
|
|
||||||
# Logging functions
|
# Logging functions
|
||||||
log_info() {
|
log_info() {
|
||||||
echo -e "${BLUE}[INFO]${NC} $1"
|
echo -e "${BLUE}[INFO]${NC} $1"
|
||||||
}
|
}
|
||||||
|
|
||||||
log_success() {
|
log_success() {
|
||||||
echo -e "${GREEN}[SUCCESS]${NC} $1"
|
echo -e "${GREEN}[SUCCESS]${NC} $1"
|
||||||
}
|
}
|
||||||
|
|
||||||
log_warning() {
|
log_warning() {
|
||||||
echo -e "${YELLOW}[WARNING]${NC} $1"
|
echo -e "${YELLOW}[WARNING]${NC} $1"
|
||||||
}
|
}
|
||||||
|
|
||||||
log_error() {
|
log_error() {
|
||||||
echo -e "${RED}[ERROR]${NC} $1"
|
echo -e "${RED}[ERROR]${NC} $1"
|
||||||
}
|
}
|
||||||
|
|
||||||
# Cleanup function
|
# Cleanup function
|
||||||
cleanup() {
|
cleanup() {
|
||||||
local exit_code=$?
|
local exit_code=$?
|
||||||
if [ $exit_code -ne 0 ]; then
|
if [ $exit_code -ne 0 ]; then
|
||||||
log_error "Deployment failed with exit code $exit_code"
|
log_error "Deployment failed with exit code $exit_code"
|
||||||
rollback_deployment
|
rollback_deployment
|
||||||
fi
|
fi
|
||||||
exit $exit_code
|
exit $exit_code
|
||||||
}
|
}
|
||||||
|
|
||||||
# Set trap for cleanup on exit
|
# Set trap for cleanup on exit
|
||||||
trap cleanup EXIT
|
trap cleanup EXIT
|
||||||
|
|
||||||
# Validate input parameters
|
# Validate input parameters
|
||||||
validate_input() {
|
validate_input() {
|
||||||
if [ $# -ne 1 ]; then
|
if [ $# -ne 1 ]; then
|
||||||
log_error "Usage: $0 <IMAGE_TAG>"
|
log_error "Usage: $0 <IMAGE_TAG>"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
local image_tag="$1"
|
local image_tag="$1"
|
||||||
if [[ ! "$image_tag" =~ ^[a-zA-Z0-9._-]+$ ]]; then
|
if [[ ! "$image_tag" =~ ^[a-zA-Z0-9._-]+$ ]]; then
|
||||||
log_error "Invalid image tag format: $image_tag"
|
log_error "Invalid image tag format: $image_tag"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
# Check prerequisites
|
# Check prerequisites
|
||||||
check_prerequisites() {
|
check_prerequisites() {
|
||||||
log_info "Checking prerequisites..."
|
log_info "Checking prerequisites..."
|
||||||
|
|
||||||
# Check if Docker is running
|
# Check if Docker is running
|
||||||
if ! docker info >/dev/null 2>&1; then
|
if ! docker info >/dev/null 2>&1; then
|
||||||
log_error "Docker is not running or not accessible"
|
log_error "Docker is not running or not accessible"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Check if docker-compose is available
|
# Check if docker-compose is available
|
||||||
if ! command -v docker-compose >/dev/null 2>&1; then
|
if ! command -v docker-compose >/dev/null 2>&1; then
|
||||||
log_error "docker-compose is not installed"
|
log_error "docker-compose is not installed"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Check if deployment directory exists
|
# Check if deployment directory exists
|
||||||
if [ ! -d "$DEPLOY_DIR" ]; then
|
if [ ! -d "$DEPLOY_DIR" ]; then
|
||||||
log_info "Creating deployment directory: $DEPLOY_DIR"
|
log_info "Creating deployment directory: $DEPLOY_DIR"
|
||||||
mkdir -p "$DEPLOY_DIR"
|
mkdir -p "$DEPLOY_DIR"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
log_success "Prerequisites check passed"
|
log_success "Prerequisites check passed"
|
||||||
}
|
}
|
||||||
|
|
||||||
# Backup current deployment
|
# Backup current deployment
|
||||||
backup_current_deployment() {
|
backup_current_deployment() {
|
||||||
log_info "Backing up current deployment..."
|
log_info "Backing up current deployment..."
|
||||||
|
|
||||||
local timestamp=$(date +%Y%m%d_%H%M%S)
|
local timestamp=$(date +%Y%m%d_%H%M%S)
|
||||||
local backup_dir="$DEPLOY_DIR/backups/$timestamp"
|
local backup_dir="$DEPLOY_DIR/backups/$timestamp"
|
||||||
|
|
||||||
mkdir -p "$backup_dir"
|
mkdir -p "$backup_dir"
|
||||||
|
|
||||||
# Backup environment file if exists
|
# Backup environment file if exists
|
||||||
if [ -f "$DEPLOY_DIR/.env" ]; then
|
if [ -f "$DEPLOY_DIR/.env" ]; then
|
||||||
cp "$DEPLOY_DIR/.env" "$backup_dir/.env.backup"
|
cp "$DEPLOY_DIR/.env" "$backup_dir/.env.backup"
|
||||||
cp "$DEPLOY_DIR/.env" "$DEPLOY_DIR/.env.backup"
|
cp "$DEPLOY_DIR/.env" "$DEPLOY_DIR/.env.backup"
|
||||||
log_info "Environment file backed up"
|
log_info "Environment file backed up"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Backup docker-compose file if exists
|
# Backup docker-compose file if exists
|
||||||
if [ -f "$DEPLOY_DIR/$DOCKER_COMPOSE_FILE" ]; then
|
if [ -f "$DEPLOY_DIR/$DOCKER_COMPOSE_FILE" ]; then
|
||||||
cp "$DEPLOY_DIR/$DOCKER_COMPOSE_FILE" "$backup_dir/${DOCKER_COMPOSE_FILE}.backup"
|
cp "$DEPLOY_DIR/$DOCKER_COMPOSE_FILE" "$backup_dir/${DOCKER_COMPOSE_FILE}.backup"
|
||||||
log_info "Docker compose file backed up"
|
log_info "Docker compose file backed up"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Get current container image for potential rollback
|
# Get current container image for potential rollback
|
||||||
if docker ps --format "table {{.Names}}\t{{.Image}}" | grep -q "$CONTAINER_NAME"; then
|
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 "")
|
local current_image=$(docker inspect --format='{{.Config.Image}}' "$CONTAINER_NAME" 2>/dev/null || echo "")
|
||||||
if [ -n "$current_image" ]; then
|
if [ -n "$current_image" ]; then
|
||||||
echo "$current_image" > "$DEPLOY_DIR/.previous_image"
|
echo "$current_image" > "$DEPLOY_DIR/.previous_image"
|
||||||
log_info "Current image backed up: $current_image"
|
log_info "Current image backed up: $current_image"
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
log_success "Backup completed: $backup_dir"
|
log_success "Backup completed: $backup_dir"
|
||||||
}
|
}
|
||||||
|
|
||||||
# Test MongoDB connectivity
|
# Test MongoDB connectivity
|
||||||
test_mongodb_connection() {
|
test_mongodb_connection() {
|
||||||
log_info "Testing MongoDB connectivity..."
|
log_info "Testing MongoDB connectivity..."
|
||||||
|
|
||||||
local mongodb_host="192.168.0.100"
|
local mongodb_host="192.168.0.100"
|
||||||
local mongodb_port="27017"
|
local mongodb_port="27017"
|
||||||
|
|
||||||
# Test basic connectivity
|
# Test basic connectivity
|
||||||
if timeout 10 bash -c "</dev/tcp/$mongodb_host/$mongodb_port" 2>/dev/null; then
|
if timeout 10 bash -c "</dev/tcp/$mongodb_host/$mongodb_port" 2>/dev/null; then
|
||||||
log_success "MongoDB connection test passed"
|
log_success "MongoDB connection test passed"
|
||||||
else
|
else
|
||||||
log_error "Cannot connect to MongoDB at $mongodb_host:$mongodb_port"
|
log_error "Cannot connect to MongoDB at $mongodb_host:$mongodb_port"
|
||||||
return 1
|
return 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Run detailed MongoDB test script if available
|
# Run detailed MongoDB test script if available
|
||||||
if [ -f "$SCRIPT_DIR/test-mongodb-connection.sh" ]; then
|
if [ -f "$SCRIPT_DIR/test-mongodb-connection.sh" ]; then
|
||||||
log_info "Running detailed MongoDB connection tests..."
|
log_info "Running detailed MongoDB connection tests..."
|
||||||
bash "$SCRIPT_DIR/test-mongodb-connection.sh"
|
bash "$SCRIPT_DIR/test-mongodb-connection.sh"
|
||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
# Pull new Docker image
|
# Pull new Docker image
|
||||||
pull_docker_image() {
|
pull_docker_image() {
|
||||||
local image_tag="$1"
|
local image_tag="$1"
|
||||||
local full_image="registry.redecarneir.us/bcards:$image_tag"
|
local full_image="registry.redecarneir.us/bcards:$image_tag"
|
||||||
|
|
||||||
log_info "Pulling Docker image: $full_image"
|
log_info "Pulling Docker image: $full_image"
|
||||||
|
|
||||||
# Pull the multi-arch image
|
# Pull the multi-arch image
|
||||||
if docker pull "$full_image"; then
|
if docker pull "$full_image"; then
|
||||||
log_success "Image pulled successfully"
|
log_success "Image pulled successfully"
|
||||||
else
|
else
|
||||||
log_error "Failed to pull image: $full_image"
|
log_error "Failed to pull image: $full_image"
|
||||||
return 1
|
return 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Verify image architecture
|
# Verify image architecture
|
||||||
local image_arch=$(docker inspect --format='{{.Architecture}}' "$full_image" 2>/dev/null || echo "unknown")
|
local image_arch=$(docker inspect --format='{{.Architecture}}' "$full_image" 2>/dev/null || echo "unknown")
|
||||||
local system_arch=$(uname -m)
|
local system_arch=$(uname -m)
|
||||||
|
|
||||||
log_info "Image architecture: $image_arch"
|
log_info "Image architecture: $image_arch"
|
||||||
log_info "System architecture: $system_arch"
|
log_info "System architecture: $system_arch"
|
||||||
|
|
||||||
# Convert system arch format to Docker format for comparison
|
# Convert system arch format to Docker format for comparison
|
||||||
case "$system_arch" in
|
case "$system_arch" in
|
||||||
x86_64) system_arch="amd64" ;;
|
x86_64) system_arch="amd64" ;;
|
||||||
aarch64) system_arch="arm64" ;;
|
aarch64) system_arch="arm64" ;;
|
||||||
esac
|
esac
|
||||||
|
|
||||||
if [ "$image_arch" = "$system_arch" ] || [ "$image_arch" = "unknown" ]; then
|
if [ "$image_arch" = "$system_arch" ] || [ "$image_arch" = "unknown" ]; then
|
||||||
log_success "Image architecture is compatible"
|
log_success "Image architecture is compatible"
|
||||||
else
|
else
|
||||||
log_warning "Image architecture ($image_arch) may not match system ($system_arch), but multi-arch support should handle this"
|
log_warning "Image architecture ($image_arch) may not match system ($system_arch), but multi-arch support should handle this"
|
||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
# Deploy new version
|
# Deploy new version
|
||||||
deploy_new_version() {
|
deploy_new_version() {
|
||||||
local image_tag="$1"
|
local image_tag="$1"
|
||||||
|
|
||||||
log_info "Deploying new version with tag: $image_tag"
|
log_info "Deploying new version with tag: $image_tag"
|
||||||
|
|
||||||
# Copy docker-compose file to deployment directory
|
# Copy docker-compose file to deployment directory
|
||||||
cp "$PROJECT_ROOT/$DOCKER_COMPOSE_FILE" "$DEPLOY_DIR/"
|
cp "$PROJECT_ROOT/$DOCKER_COMPOSE_FILE" "$DEPLOY_DIR/"
|
||||||
|
|
||||||
# Create/update environment file
|
# Create/update environment file
|
||||||
cat > "$DEPLOY_DIR/.env" << EOF
|
cat > "$DEPLOY_DIR/.env" << EOF
|
||||||
IMAGE_TAG=$image_tag
|
IMAGE_TAG=$image_tag
|
||||||
REGISTRY=registry.redecarneir.us
|
REGISTRY=registry.redecarneir.us
|
||||||
MONGODB_CONNECTION_STRING=mongodb://192.168.0.100:27017/BCardsDB
|
MONGODB_CONNECTION_STRING=mongodb://admin:c4rn31r0@129.146.116.218:27017,141.148.162.114:27017/BCardsDB?replicaSet=rs0&authSource=admin
|
||||||
ASPNETCORE_ENVIRONMENT=Release
|
ASPNETCORE_ENVIRONMENT=Release
|
||||||
CERT_PASSWORD=
|
CERT_PASSWORD=
|
||||||
EOF
|
EOF
|
||||||
|
|
||||||
# Stop existing containers
|
# Stop existing containers
|
||||||
cd "$DEPLOY_DIR"
|
cd "$DEPLOY_DIR"
|
||||||
if docker-compose -f "$DOCKER_COMPOSE_FILE" ps -q | grep -q .; then
|
if docker-compose -f "$DOCKER_COMPOSE_FILE" ps -q | grep -q .; then
|
||||||
log_info "Stopping existing containers..."
|
log_info "Stopping existing containers..."
|
||||||
docker-compose -f "$DOCKER_COMPOSE_FILE" down --remove-orphans
|
docker-compose -f "$DOCKER_COMPOSE_FILE" down --remove-orphans
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Start new containers
|
# Start new containers
|
||||||
log_info "Starting new containers..."
|
log_info "Starting new containers..."
|
||||||
docker-compose -f "$DOCKER_COMPOSE_FILE" up -d
|
docker-compose -f "$DOCKER_COMPOSE_FILE" up -d
|
||||||
|
|
||||||
# Wait for containers to start
|
# Wait for containers to start
|
||||||
sleep 15
|
sleep 15
|
||||||
|
|
||||||
log_success "New version deployed"
|
log_success "New version deployed"
|
||||||
}
|
}
|
||||||
|
|
||||||
# Health check
|
# Health check
|
||||||
perform_health_check() {
|
perform_health_check() {
|
||||||
log_info "Performing health check..."
|
log_info "Performing health check..."
|
||||||
|
|
||||||
local attempt=1
|
local attempt=1
|
||||||
while [ $attempt -le $MAX_HEALTH_CHECK_ATTEMPTS ]; do
|
while [ $attempt -le $MAX_HEALTH_CHECK_ATTEMPTS ]; do
|
||||||
log_info "Health check attempt $attempt/$MAX_HEALTH_CHECK_ATTEMPTS"
|
log_info "Health check attempt $attempt/$MAX_HEALTH_CHECK_ATTEMPTS"
|
||||||
|
|
||||||
# Check if container is running
|
# Check if container is running
|
||||||
if ! docker ps --format "table {{.Names}}" | grep -q "$CONTAINER_NAME"; then
|
if ! docker ps --format "table {{.Names}}" | grep -q "$CONTAINER_NAME"; then
|
||||||
log_warning "Container $CONTAINER_NAME is not running"
|
log_warning "Container $CONTAINER_NAME is not running"
|
||||||
else
|
else
|
||||||
# Check application health endpoint
|
# Check application health endpoint
|
||||||
if curl -f -s "$HEALTH_CHECK_URL" >/dev/null 2>&1; then
|
if curl -f -s "$HEALTH_CHECK_URL" >/dev/null 2>&1; then
|
||||||
log_success "Health check passed"
|
log_success "Health check passed"
|
||||||
return 0
|
return 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Check if the application is responding on port 80
|
# Check if the application is responding on port 80
|
||||||
if curl -f -s "http://localhost:8090/" >/dev/null 2>&1; then
|
if curl -f -s "http://localhost:8090/" >/dev/null 2>&1; then
|
||||||
log_success "Application is responding (health endpoint may not be configured)"
|
log_success "Application is responding (health endpoint may not be configured)"
|
||||||
return 0
|
return 0
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [ $attempt -eq $MAX_HEALTH_CHECK_ATTEMPTS ]; then
|
if [ $attempt -eq $MAX_HEALTH_CHECK_ATTEMPTS ]; then
|
||||||
log_error "Health check failed after $MAX_HEALTH_CHECK_ATTEMPTS attempts"
|
log_error "Health check failed after $MAX_HEALTH_CHECK_ATTEMPTS attempts"
|
||||||
return 1
|
return 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
log_info "Waiting $HEALTH_CHECK_INTERVAL seconds before next attempt..."
|
log_info "Waiting $HEALTH_CHECK_INTERVAL seconds before next attempt..."
|
||||||
sleep $HEALTH_CHECK_INTERVAL
|
sleep $HEALTH_CHECK_INTERVAL
|
||||||
((attempt++))
|
((attempt++))
|
||||||
done
|
done
|
||||||
}
|
}
|
||||||
|
|
||||||
# Rollback deployment
|
# Rollback deployment
|
||||||
rollback_deployment() {
|
rollback_deployment() {
|
||||||
log_warning "Initiating rollback..."
|
log_warning "Initiating rollback..."
|
||||||
|
|
||||||
cd "$DEPLOY_DIR"
|
cd "$DEPLOY_DIR"
|
||||||
|
|
||||||
# Stop current containers
|
# Stop current containers
|
||||||
if [ -f "$DOCKER_COMPOSE_FILE" ]; then
|
if [ -f "$DOCKER_COMPOSE_FILE" ]; then
|
||||||
docker-compose -f "$DOCKER_COMPOSE_FILE" down --remove-orphans
|
docker-compose -f "$DOCKER_COMPOSE_FILE" down --remove-orphans
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Restore previous environment if backup exists
|
# Restore previous environment if backup exists
|
||||||
if [ -f ".env.backup" ]; then
|
if [ -f ".env.backup" ]; then
|
||||||
mv ".env.backup" ".env"
|
mv ".env.backup" ".env"
|
||||||
log_info "Previous environment restored"
|
log_info "Previous environment restored"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Try to start previous version if image is available
|
# Try to start previous version if image is available
|
||||||
if [ -f ".previous_image" ]; then
|
if [ -f ".previous_image" ]; then
|
||||||
local previous_image=$(cat ".previous_image")
|
local previous_image=$(cat ".previous_image")
|
||||||
log_info "Attempting to restore previous image: $previous_image"
|
log_info "Attempting to restore previous image: $previous_image"
|
||||||
|
|
||||||
# Update .env with previous image tag
|
# Update .env with previous image tag
|
||||||
local previous_tag=$(echo "$previous_image" | cut -d':' -f2)
|
local previous_tag=$(echo "$previous_image" | cut -d':' -f2)
|
||||||
sed -i "s/IMAGE_TAG=.*/IMAGE_TAG=$previous_tag/" .env 2>/dev/null || true
|
sed -i "s/IMAGE_TAG=.*/IMAGE_TAG=$previous_tag/" .env 2>/dev/null || true
|
||||||
|
|
||||||
# Try to start previous version
|
# Try to start previous version
|
||||||
if docker-compose -f "$DOCKER_COMPOSE_FILE" up -d; then
|
if docker-compose -f "$DOCKER_COMPOSE_FILE" up -d; then
|
||||||
log_success "Rollback completed successfully"
|
log_success "Rollback completed successfully"
|
||||||
else
|
else
|
||||||
log_error "Rollback failed - manual intervention required"
|
log_error "Rollback failed - manual intervention required"
|
||||||
fi
|
fi
|
||||||
else
|
else
|
||||||
log_warning "No previous version found for rollback"
|
log_warning "No previous version found for rollback"
|
||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
# Cleanup old images and containers
|
# Cleanup old images and containers
|
||||||
cleanup_old_resources() {
|
cleanup_old_resources() {
|
||||||
log_info "Cleaning up old Docker resources..."
|
log_info "Cleaning up old Docker resources..."
|
||||||
|
|
||||||
# Remove dangling images
|
# Remove dangling images
|
||||||
if docker images -f "dangling=true" -q | head -1 | grep -q .; then
|
if docker images -f "dangling=true" -q | head -1 | grep -q .; then
|
||||||
docker rmi $(docker images -f "dangling=true" -q) || true
|
docker rmi $(docker images -f "dangling=true" -q) || true
|
||||||
log_info "Dangling images removed"
|
log_info "Dangling images removed"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Remove old backups (keep last 5)
|
# Remove old backups (keep last 5)
|
||||||
if [ -d "$DEPLOY_DIR/backups" ]; then
|
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
|
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"
|
log_info "Old backups cleaned up"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
log_success "Cleanup completed"
|
log_success "Cleanup completed"
|
||||||
}
|
}
|
||||||
|
|
||||||
# Display deployment summary
|
# Display deployment summary
|
||||||
display_summary() {
|
display_summary() {
|
||||||
local image_tag="$1"
|
local image_tag="$1"
|
||||||
|
|
||||||
log_success "Deployment Summary:"
|
log_success "Deployment Summary:"
|
||||||
echo "=================================="
|
echo "=================================="
|
||||||
echo "🚀 Image Tag: $image_tag"
|
echo "🚀 Image Tag: $image_tag"
|
||||||
echo "🌐 Environment: Release (Staging)"
|
echo "🌐 Environment: Release (Staging)"
|
||||||
echo "🔗 Application URL: http://localhost:8090"
|
echo "🔗 Application URL: http://localhost:8090"
|
||||||
echo "🔗 Health Check: $HEALTH_CHECK_URL"
|
echo "🔗 Health Check: $HEALTH_CHECK_URL"
|
||||||
echo "🗄️ MongoDB: 192.168.0.100:27017"
|
echo "🗄️ MongoDB: 192.168.0.100:27017"
|
||||||
echo "📁 Deploy Directory: $DEPLOY_DIR"
|
echo "📁 Deploy Directory: $DEPLOY_DIR"
|
||||||
echo "🐳 Container: $CONTAINER_NAME"
|
echo "🐳 Container: $CONTAINER_NAME"
|
||||||
|
|
||||||
# Show container status
|
# Show container status
|
||||||
echo ""
|
echo ""
|
||||||
echo "Container Status:"
|
echo "Container Status:"
|
||||||
docker ps --format "table {{.Names}}\t{{.Status}}\t{{.Ports}}" | grep -E "(NAMES|$CONTAINER_NAME)" || true
|
docker ps --format "table {{.Names}}\t{{.Status}}\t{{.Ports}}" | grep -E "(NAMES|$CONTAINER_NAME)" || true
|
||||||
|
|
||||||
# Show image info
|
# Show image info
|
||||||
echo ""
|
echo ""
|
||||||
echo "Image Information:"
|
echo "Image Information:"
|
||||||
docker images --format "table {{.Repository}}\t{{.Tag}}\t{{.CreatedAt}}\t{{.Size}}" | grep -E "(REPOSITORY|bcards)" | head -5 || true
|
docker images --format "table {{.Repository}}\t{{.Tag}}\t{{.CreatedAt}}\t{{.Size}}" | grep -E "(REPOSITORY|bcards)" | head -5 || true
|
||||||
|
|
||||||
echo "=================================="
|
echo "=================================="
|
||||||
}
|
}
|
||||||
|
|
||||||
# Main deployment function
|
# Main deployment function
|
||||||
main() {
|
main() {
|
||||||
local image_tag="$1"
|
local image_tag="$1"
|
||||||
|
|
||||||
log_info "Starting deployment process for BCards Release environment"
|
log_info "Starting deployment process for BCards Release environment"
|
||||||
log_info "Target image tag: $image_tag"
|
log_info "Target image tag: $image_tag"
|
||||||
log_info "Target architecture: $(uname -m)"
|
log_info "Target architecture: $(uname -m)"
|
||||||
log_info "Deploy directory: $DEPLOY_DIR"
|
log_info "Deploy directory: $DEPLOY_DIR"
|
||||||
|
|
||||||
# Execute deployment steps
|
# Execute deployment steps
|
||||||
validate_input "$@"
|
validate_input "$@"
|
||||||
check_prerequisites
|
check_prerequisites
|
||||||
test_mongodb_connection
|
test_mongodb_connection
|
||||||
backup_current_deployment
|
backup_current_deployment
|
||||||
pull_docker_image "$image_tag"
|
pull_docker_image "$image_tag"
|
||||||
deploy_new_version "$image_tag"
|
deploy_new_version "$image_tag"
|
||||||
|
|
||||||
# Perform health check (rollback handled by trap if this fails)
|
# Perform health check (rollback handled by trap if this fails)
|
||||||
if perform_health_check; then
|
if perform_health_check; then
|
||||||
cleanup_old_resources
|
cleanup_old_resources
|
||||||
display_summary "$image_tag"
|
display_summary "$image_tag"
|
||||||
log_success "Deployment completed successfully!"
|
log_success "Deployment completed successfully!"
|
||||||
else
|
else
|
||||||
log_error "Health check failed - rollback will be triggered"
|
log_error "Health check failed - rollback will be triggered"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
# Run main function with all arguments
|
# Run main function with all arguments
|
||||||
main "$@"
|
main "$@"
|
||||||
|
|||||||
84
scripts/swarm_deploy.sh
Normal file
84
scripts/swarm_deploy.sh
Normal file
@ -0,0 +1,84 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
if [[ $# -lt 4 ]]; then
|
||||||
|
echo "Usage: $0 <stack-name> <service-name> <stack-file> <health-url> [expected-replicas]" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
STACK_NAME="$1"
|
||||||
|
SERVICE_NAME="$2"
|
||||||
|
STACK_FILE="$3"
|
||||||
|
HEALTH_URL="$4"
|
||||||
|
EXPECTED_REPLICAS="${5:-4}"
|
||||||
|
|
||||||
|
SERVICE_FQDN="${STACK_NAME}_${SERVICE_NAME}"
|
||||||
|
LOG_PREFIX="[swarm-deploy]"
|
||||||
|
|
||||||
|
log() {
|
||||||
|
printf '%s %s %s\n' "$(date --iso-8601=seconds)" "$LOG_PREFIX" "$*"
|
||||||
|
}
|
||||||
|
|
||||||
|
retry() {
|
||||||
|
local attempts=$1; shift
|
||||||
|
local delay=$1; shift
|
||||||
|
local n=1
|
||||||
|
while true; do
|
||||||
|
if "$@"; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
if (( n == attempts )); then
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
((n++))
|
||||||
|
sleep "$delay"
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
log "Deploying stack '${STACK_NAME}' using ${STACK_FILE}"
|
||||||
|
docker stack deploy --compose-file "$STACK_FILE" "$STACK_NAME"
|
||||||
|
|
||||||
|
log "Waiting for service ${SERVICE_FQDN} to reach ${EXPECTED_REPLICAS} replicas"
|
||||||
|
retries=24
|
||||||
|
while (( retries > 0 )); do
|
||||||
|
replicas_raw=$(docker service ls --filter "name=${SERVICE_FQDN}" --format '{{.Replicas}}' || true)
|
||||||
|
if [[ -z "$replicas_raw" ]]; then
|
||||||
|
log "Service ${SERVICE_FQDN} not found yet; retrying"
|
||||||
|
sleep 5
|
||||||
|
((retries--))
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
replicas_clean=${replicas_raw%% (*}
|
||||||
|
running=${replicas_clean%%/*}
|
||||||
|
desired=${replicas_clean##*/}
|
||||||
|
if [[ "$running" == "$desired" && "$running" == "$EXPECTED_REPLICAS" ]]; then
|
||||||
|
log "Service reached desired replica count: ${running}/${desired}"
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
log "Current replicas ${running}/${desired}; waiting..."
|
||||||
|
sleep 5
|
||||||
|
((retries--))
|
||||||
|
done
|
||||||
|
|
||||||
|
if (( retries == 0 )); then
|
||||||
|
log "Timed out waiting for replicas"
|
||||||
|
docker service ps "$SERVICE_FQDN"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
log "Checking task states"
|
||||||
|
if ! docker service ps "$SERVICE_FQDN" --no-trunc --filter 'desired-state=Running' --format '{{.CurrentState}}' | grep -q '^Running '; then
|
||||||
|
log "Some tasks are not running"
|
||||||
|
docker service ps "$SERVICE_FQDN"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
log "Running health check against ${HEALTH_URL}"
|
||||||
|
if ! retry 3 5 curl -fsS "$HEALTH_URL"; then
|
||||||
|
log "Health check failed; rolling back service"
|
||||||
|
docker service update --rollback "$SERVICE_FQDN" || true
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
log "Health check succeeded"
|
||||||
|
log "Deployment finished successfully"
|
||||||
@ -1,495 +1,495 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
|
|
||||||
# MongoDB Connection Test Script for Release Environment
|
# MongoDB Connection Test Script for Release Environment
|
||||||
# Tests connectivity, database operations, and index validation
|
# Tests connectivity, database operations, and index validation
|
||||||
|
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
# Configuration
|
# Configuration
|
||||||
readonly MONGODB_HOST="${MONGODB_HOST:-192.168.0.100}"
|
readonly MONGODB_HOST="${MONGODB_HOST:-192.168.0.100}"
|
||||||
readonly MONGODB_PORT="${MONGODB_PORT:-27017}"
|
readonly MONGODB_PORT="${MONGODB_PORT:-27017}"
|
||||||
readonly DATABASE_NAME="${DATABASE_NAME:-BCardsDB}"
|
readonly DATABASE_NAME="${DATABASE_NAME:-BCardsDB}"
|
||||||
readonly CONNECTION_STRING="mongodb://${MONGODB_HOST}:${MONGODB_PORT}/${DATABASE_NAME}"
|
readonly CONNECTION_STRING="mongodb://${MONGODB_HOST}:${MONGODB_PORT}/${DATABASE_NAME}"
|
||||||
readonly TIMEOUT=30
|
readonly TIMEOUT=30
|
||||||
|
|
||||||
# Colors for output
|
# Colors for output
|
||||||
readonly RED='\033[0;31m'
|
readonly RED='\033[0;31m'
|
||||||
readonly GREEN='\033[0;32m'
|
readonly GREEN='\033[0;32m'
|
||||||
readonly YELLOW='\033[1;33m'
|
readonly YELLOW='\033[1;33m'
|
||||||
readonly BLUE='\033[0;34m'
|
readonly BLUE='\033[0;34m'
|
||||||
readonly NC='\033[0m' # No Color
|
readonly NC='\033[0m' # No Color
|
||||||
|
|
||||||
# Logging functions
|
# Logging functions
|
||||||
log_info() {
|
log_info() {
|
||||||
echo -e "${BLUE}[INFO]${NC} $1"
|
echo -e "${BLUE}[INFO]${NC} $1"
|
||||||
}
|
}
|
||||||
|
|
||||||
log_success() {
|
log_success() {
|
||||||
echo -e "${GREEN}[SUCCESS]${NC} $1"
|
echo -e "${GREEN}[SUCCESS]${NC} $1"
|
||||||
}
|
}
|
||||||
|
|
||||||
log_warning() {
|
log_warning() {
|
||||||
echo -e "${YELLOW}[WARNING]${NC} $1"
|
echo -e "${YELLOW}[WARNING]${NC} $1"
|
||||||
}
|
}
|
||||||
|
|
||||||
log_error() {
|
log_error() {
|
||||||
echo -e "${RED}[ERROR]${NC} $1"
|
echo -e "${RED}[ERROR]${NC} $1"
|
||||||
}
|
}
|
||||||
|
|
||||||
# Test basic TCP connectivity
|
# Test basic TCP connectivity
|
||||||
test_tcp_connection() {
|
test_tcp_connection() {
|
||||||
log_info "Testing TCP connection to $MONGODB_HOST:$MONGODB_PORT..."
|
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
|
if timeout $TIMEOUT bash -c "</dev/tcp/$MONGODB_HOST/$MONGODB_PORT" 2>/dev/null; then
|
||||||
log_success "TCP connection successful"
|
log_success "TCP connection successful"
|
||||||
return 0
|
return 0
|
||||||
else
|
else
|
||||||
log_error "TCP connection failed"
|
log_error "TCP connection failed"
|
||||||
return 1
|
return 1
|
||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
# Test MongoDB connectivity using mongosh if available
|
# Test MongoDB connectivity using mongosh if available
|
||||||
test_mongodb_with_mongosh() {
|
test_mongodb_with_mongosh() {
|
||||||
if ! command -v mongosh >/dev/null 2>&1; then
|
if ! command -v mongosh >/dev/null 2>&1; then
|
||||||
log_warning "mongosh not available, skipping MongoDB shell tests"
|
log_warning "mongosh not available, skipping MongoDB shell tests"
|
||||||
return 1
|
return 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
log_info "Testing MongoDB connection with mongosh..."
|
log_info "Testing MongoDB connection with mongosh..."
|
||||||
|
|
||||||
# Test basic connection
|
# Test basic connection
|
||||||
local test_output=$(timeout $TIMEOUT mongosh "$CONNECTION_STRING" --quiet --eval "db.runCommand({ping: 1})" 2>/dev/null || echo "FAILED")
|
local test_output=$(timeout $TIMEOUT mongosh "$CONNECTION_STRING" --quiet --eval "db.runCommand({ping: 1})" 2>/dev/null || echo "FAILED")
|
||||||
|
|
||||||
if [[ "$test_output" == *"ok"* ]]; then
|
if [[ "$test_output" == *"ok"* ]]; then
|
||||||
log_success "MongoDB ping successful"
|
log_success "MongoDB ping successful"
|
||||||
else
|
else
|
||||||
log_error "MongoDB ping failed"
|
log_error "MongoDB ping failed"
|
||||||
return 1
|
return 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Test database access
|
# Test database access
|
||||||
log_info "Testing database operations..."
|
log_info "Testing database operations..."
|
||||||
|
|
||||||
local db_test=$(timeout $TIMEOUT mongosh "$CONNECTION_STRING" --quiet --eval "
|
local db_test=$(timeout $TIMEOUT mongosh "$CONNECTION_STRING" --quiet --eval "
|
||||||
try {
|
try {
|
||||||
// Test basic database operations
|
// Test basic database operations
|
||||||
db.connection_test.insertOne({test: true, timestamp: new Date()});
|
db.connection_test.insertOne({test: true, timestamp: new Date()});
|
||||||
var result = db.connection_test.findOne({test: true});
|
var result = db.connection_test.findOne({test: true});
|
||||||
db.connection_test.deleteOne({test: true});
|
db.connection_test.deleteOne({test: true});
|
||||||
print('DATABASE_ACCESS_OK');
|
print('DATABASE_ACCESS_OK');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('DATABASE_ACCESS_FAILED: ' + e.message);
|
print('DATABASE_ACCESS_FAILED: ' + e.message);
|
||||||
}
|
}
|
||||||
" 2>/dev/null || echo "DATABASE_ACCESS_FAILED")
|
" 2>/dev/null || echo "DATABASE_ACCESS_FAILED")
|
||||||
|
|
||||||
if [[ "$db_test" == *"DATABASE_ACCESS_OK"* ]]; then
|
if [[ "$db_test" == *"DATABASE_ACCESS_OK"* ]]; then
|
||||||
log_success "Database operations test passed"
|
log_success "Database operations test passed"
|
||||||
else
|
else
|
||||||
log_error "Database operations test failed: $db_test"
|
log_error "Database operations test failed: $db_test"
|
||||||
return 1
|
return 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
# Test MongoDB connectivity using Python if available
|
# Test MongoDB connectivity using Python if available
|
||||||
test_mongodb_with_python() {
|
test_mongodb_with_python() {
|
||||||
if ! command -v python3 >/dev/null 2>&1; then
|
if ! command -v python3 >/dev/null 2>&1; then
|
||||||
log_warning "Python3 not available, skipping Python MongoDB tests"
|
log_warning "Python3 not available, skipping Python MongoDB tests"
|
||||||
return 1
|
return 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
log_info "Testing MongoDB connection with Python..."
|
log_info "Testing MongoDB connection with Python..."
|
||||||
|
|
||||||
python3 << EOF
|
python3 << EOF
|
||||||
import sys
|
import sys
|
||||||
try:
|
try:
|
||||||
import pymongo
|
import pymongo
|
||||||
from pymongo import MongoClient
|
from pymongo import MongoClient
|
||||||
import socket
|
import socket
|
||||||
|
|
||||||
# Test connection
|
# Test connection
|
||||||
client = MongoClient("$CONNECTION_STRING", serverSelectionTimeoutMS=$((TIMEOUT * 1000)))
|
client = MongoClient("$CONNECTION_STRING", serverSelectionTimeoutMS=$((TIMEOUT * 1000)))
|
||||||
|
|
||||||
# Test ping
|
# Test ping
|
||||||
client.admin.command('ping')
|
client.admin.command('ping')
|
||||||
print("MongoDB ping successful (Python)")
|
print("MongoDB ping successful (Python)")
|
||||||
|
|
||||||
# Test database access
|
# Test database access
|
||||||
db = client["$DATABASE_NAME"]
|
db = client["$DATABASE_NAME"]
|
||||||
|
|
||||||
# Insert test document
|
# Insert test document
|
||||||
test_collection = db.connection_test
|
test_collection = db.connection_test
|
||||||
result = test_collection.insert_one({"test": True, "source": "python"})
|
result = test_collection.insert_one({"test": True, "source": "python"})
|
||||||
|
|
||||||
# Read test document
|
# Read test document
|
||||||
doc = test_collection.find_one({"_id": result.inserted_id})
|
doc = test_collection.find_one({"_id": result.inserted_id})
|
||||||
if doc:
|
if doc:
|
||||||
print("Database read/write test passed (Python)")
|
print("Database read/write test passed (Python)")
|
||||||
|
|
||||||
# Cleanup
|
# Cleanup
|
||||||
test_collection.delete_one({"_id": result.inserted_id})
|
test_collection.delete_one({"_id": result.inserted_id})
|
||||||
|
|
||||||
client.close()
|
client.close()
|
||||||
print("PYTHON_TEST_SUCCESS")
|
print("PYTHON_TEST_SUCCESS")
|
||||||
|
|
||||||
except ImportError:
|
except ImportError:
|
||||||
print("PyMongo not installed, skipping Python tests")
|
print("PyMongo not installed, skipping Python tests")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Python MongoDB test failed: {e}")
|
print(f"Python MongoDB test failed: {e}")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
EOF
|
EOF
|
||||||
|
|
||||||
local python_result=$?
|
local python_result=$?
|
||||||
if [ $python_result -eq 0 ]; then
|
if [ $python_result -eq 0 ]; then
|
||||||
log_success "Python MongoDB test passed"
|
log_success "Python MongoDB test passed"
|
||||||
return 0
|
return 0
|
||||||
else
|
else
|
||||||
log_error "Python MongoDB test failed"
|
log_error "Python MongoDB test failed"
|
||||||
return 1
|
return 1
|
||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
# Test using Docker MongoDB client
|
# Test using Docker MongoDB client
|
||||||
test_mongodb_with_docker() {
|
test_mongodb_with_docker() {
|
||||||
if ! command -v docker >/dev/null 2>&1; then
|
if ! command -v docker >/dev/null 2>&1; then
|
||||||
log_warning "Docker not available, skipping Docker MongoDB tests"
|
log_warning "Docker not available, skipping Docker MongoDB tests"
|
||||||
return 1
|
return 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
log_info "Testing MongoDB connection using Docker MongoDB client..."
|
log_info "Testing MongoDB connection using Docker MongoDB client..."
|
||||||
|
|
||||||
# Use official MongoDB image to test connection
|
# Use official MongoDB image to test connection
|
||||||
local docker_test=$(timeout $TIMEOUT docker run --rm mongo:7.0 mongosh "$CONNECTION_STRING" --quiet --eval "
|
local docker_test=$(timeout $TIMEOUT docker run --rm mongo:7.0 mongosh "$CONNECTION_STRING" --quiet --eval "
|
||||||
try {
|
try {
|
||||||
db.runCommand({ping: 1});
|
db.runCommand({ping: 1});
|
||||||
db.connection_test.insertOne({test: true, source: 'docker', timestamp: new Date()});
|
db.connection_test.insertOne({test: true, source: 'docker', timestamp: new Date()});
|
||||||
var doc = db.connection_test.findOne({source: 'docker'});
|
var doc = db.connection_test.findOne({source: 'docker'});
|
||||||
db.connection_test.deleteOne({source: 'docker'});
|
db.connection_test.deleteOne({source: 'docker'});
|
||||||
print('DOCKER_TEST_SUCCESS');
|
print('DOCKER_TEST_SUCCESS');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('DOCKER_TEST_FAILED: ' + e.message);
|
print('DOCKER_TEST_FAILED: ' + e.message);
|
||||||
}
|
}
|
||||||
" 2>/dev/null || echo "DOCKER_TEST_FAILED")
|
" 2>/dev/null || echo "DOCKER_TEST_FAILED")
|
||||||
|
|
||||||
if [[ "$docker_test" == *"DOCKER_TEST_SUCCESS"* ]]; then
|
if [[ "$docker_test" == *"DOCKER_TEST_SUCCESS"* ]]; then
|
||||||
log_success "Docker MongoDB test passed"
|
log_success "Docker MongoDB test passed"
|
||||||
return 0
|
return 0
|
||||||
else
|
else
|
||||||
log_error "Docker MongoDB test failed: $docker_test"
|
log_error "Docker MongoDB test failed: $docker_test"
|
||||||
return 1
|
return 1
|
||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
# Test MongoDB from application container
|
# Test MongoDB from application container
|
||||||
test_from_application_container() {
|
test_from_application_container() {
|
||||||
local container_name="bcards-staging"
|
local container_name="bcards-staging"
|
||||||
|
|
||||||
if ! docker ps --format "{{.Names}}" | grep -q "^${container_name}$"; then
|
if ! docker ps --format "{{.Names}}" | grep -q "^${container_name}$"; then
|
||||||
log_warning "Application container '$container_name' not running, skipping application test"
|
log_warning "Application container '$container_name' not running, skipping application test"
|
||||||
return 1
|
return 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
log_info "Testing MongoDB connection from application container..."
|
log_info "Testing MongoDB connection from application container..."
|
||||||
|
|
||||||
# Test connection from the application container
|
# Test connection from the application container
|
||||||
local app_test=$(docker exec "$container_name" timeout 10 bash -c "
|
local app_test=$(docker exec "$container_name" timeout 10 bash -c "
|
||||||
# Test TCP connection
|
# Test TCP connection
|
||||||
if timeout 5 bash -c '</dev/tcp/$MONGODB_HOST/$MONGODB_PORT' 2>/dev/null; then
|
if timeout 5 bash -c '</dev/tcp/$MONGODB_HOST/$MONGODB_PORT' 2>/dev/null; then
|
||||||
echo 'APP_TCP_OK'
|
echo 'APP_TCP_OK'
|
||||||
else
|
else
|
||||||
echo 'APP_TCP_FAILED'
|
echo 'APP_TCP_FAILED'
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Test HTTP health endpoint if available
|
# Test HTTP health endpoint if available
|
||||||
if curl -f -s http://localhost:8080/health >/dev/null 2>&1; then
|
if curl -f -s http://localhost:8080/health >/dev/null 2>&1; then
|
||||||
echo 'APP_HEALTH_OK'
|
echo 'APP_HEALTH_OK'
|
||||||
else
|
else
|
||||||
echo 'APP_HEALTH_FAILED'
|
echo 'APP_HEALTH_FAILED'
|
||||||
fi
|
fi
|
||||||
" 2>/dev/null || echo "APP_TEST_FAILED")
|
" 2>/dev/null || echo "APP_TEST_FAILED")
|
||||||
|
|
||||||
if [[ "$app_test" == *"APP_TCP_OK"* ]]; then
|
if [[ "$app_test" == *"APP_TCP_OK"* ]]; then
|
||||||
log_success "Application container can connect to MongoDB"
|
log_success "Application container can connect to MongoDB"
|
||||||
|
|
||||||
if [[ "$app_test" == *"APP_HEALTH_OK"* ]]; then
|
if [[ "$app_test" == *"APP_HEALTH_OK"* ]]; then
|
||||||
log_success "Application health check passed"
|
log_success "Application health check passed"
|
||||||
else
|
else
|
||||||
log_warning "Application health check failed - app may still be starting"
|
log_warning "Application health check failed - app may still be starting"
|
||||||
fi
|
fi
|
||||||
return 0
|
return 0
|
||||||
else
|
else
|
||||||
log_error "Application container cannot connect to MongoDB"
|
log_error "Application container cannot connect to MongoDB"
|
||||||
return 1
|
return 1
|
||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
# Check MongoDB server status and version
|
# Check MongoDB server status and version
|
||||||
check_mongodb_status() {
|
check_mongodb_status() {
|
||||||
log_info "Checking MongoDB server status..."
|
log_info "Checking MongoDB server status..."
|
||||||
|
|
||||||
# Try multiple methods to check status
|
# Try multiple methods to check status
|
||||||
local status_checked=false
|
local status_checked=false
|
||||||
|
|
||||||
# Method 1: Using mongosh
|
# Method 1: Using mongosh
|
||||||
if command -v mongosh >/dev/null 2>&1; then
|
if command -v mongosh >/dev/null 2>&1; then
|
||||||
local server_status=$(timeout $TIMEOUT mongosh "$CONNECTION_STRING" --quiet --eval "
|
local server_status=$(timeout $TIMEOUT mongosh "$CONNECTION_STRING" --quiet --eval "
|
||||||
try {
|
try {
|
||||||
var status = db.runCommand({serverStatus: 1});
|
var status = db.runCommand({serverStatus: 1});
|
||||||
print('MongoDB Version: ' + status.version);
|
print('MongoDB Version: ' + status.version);
|
||||||
print('Uptime: ' + status.uptime + ' seconds');
|
print('Uptime: ' + status.uptime + ' seconds');
|
||||||
print('Connections: ' + status.connections.current + '/' + status.connections.available);
|
print('Connections: ' + status.connections.current + '/' + status.connections.available);
|
||||||
print('STATUS_CHECK_OK');
|
print('STATUS_CHECK_OK');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('STATUS_CHECK_FAILED: ' + e.message);
|
print('STATUS_CHECK_FAILED: ' + e.message);
|
||||||
}
|
}
|
||||||
" 2>/dev/null || echo "STATUS_CHECK_FAILED")
|
" 2>/dev/null || echo "STATUS_CHECK_FAILED")
|
||||||
|
|
||||||
if [[ "$server_status" == *"STATUS_CHECK_OK"* ]]; then
|
if [[ "$server_status" == *"STATUS_CHECK_OK"* ]]; then
|
||||||
echo "$server_status" | grep -v "STATUS_CHECK_OK"
|
echo "$server_status" | grep -v "STATUS_CHECK_OK"
|
||||||
log_success "MongoDB server status check passed"
|
log_success "MongoDB server status check passed"
|
||||||
status_checked=true
|
status_checked=true
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Method 2: Using Docker if mongosh failed
|
# Method 2: Using Docker if mongosh failed
|
||||||
if [ "$status_checked" = false ] && command -v docker >/dev/null 2>&1; then
|
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 "
|
local docker_status=$(timeout $TIMEOUT docker run --rm mongo:7.0 mongosh "$CONNECTION_STRING" --quiet --eval "
|
||||||
try {
|
try {
|
||||||
var status = db.runCommand({serverStatus: 1});
|
var status = db.runCommand({serverStatus: 1});
|
||||||
print('MongoDB Version: ' + status.version);
|
print('MongoDB Version: ' + status.version);
|
||||||
print('STATUS_CHECK_OK');
|
print('STATUS_CHECK_OK');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('STATUS_CHECK_FAILED: ' + e.message);
|
print('STATUS_CHECK_FAILED: ' + e.message);
|
||||||
}
|
}
|
||||||
" 2>/dev/null || echo "STATUS_CHECK_FAILED")
|
" 2>/dev/null || echo "STATUS_CHECK_FAILED")
|
||||||
|
|
||||||
if [[ "$docker_status" == *"STATUS_CHECK_OK"* ]]; then
|
if [[ "$docker_status" == *"STATUS_CHECK_OK"* ]]; then
|
||||||
echo "$docker_status" | grep -v "STATUS_CHECK_OK"
|
echo "$docker_status" | grep -v "STATUS_CHECK_OK"
|
||||||
log_success "MongoDB server status check passed (via Docker)"
|
log_success "MongoDB server status check passed (via Docker)"
|
||||||
status_checked=true
|
status_checked=true
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [ "$status_checked" = false ]; then
|
if [ "$status_checked" = false ]; then
|
||||||
log_warning "Could not retrieve MongoDB server status"
|
log_warning "Could not retrieve MongoDB server status"
|
||||||
return 1
|
return 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
# Test BCards specific collections and indexes
|
# Test BCards specific collections and indexes
|
||||||
test_bcards_collections() {
|
test_bcards_collections() {
|
||||||
if ! command -v mongosh >/dev/null 2>&1 && ! command -v docker >/dev/null 2>&1; then
|
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"
|
log_warning "Cannot test BCards collections - no MongoDB client available"
|
||||||
return 1
|
return 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
log_info "Testing BCards specific collections and indexes..."
|
log_info "Testing BCards specific collections and indexes..."
|
||||||
|
|
||||||
local mongo_cmd="mongosh"
|
local mongo_cmd="mongosh"
|
||||||
local docker_prefix=""
|
local docker_prefix=""
|
||||||
|
|
||||||
if ! command -v mongosh >/dev/null 2>&1; then
|
if ! command -v mongosh >/dev/null 2>&1; then
|
||||||
mongo_cmd="docker run --rm mongo:7.0 mongosh"
|
mongo_cmd="docker run --rm mongo:7.0 mongosh"
|
||||||
docker_prefix="timeout $TIMEOUT "
|
docker_prefix="timeout $TIMEOUT "
|
||||||
fi
|
fi
|
||||||
|
|
||||||
local collections_test=$(${docker_prefix}${mongo_cmd} "$CONNECTION_STRING" --quiet --eval "
|
local collections_test=$(${docker_prefix}${mongo_cmd} "$CONNECTION_STRING" --quiet --eval "
|
||||||
try {
|
try {
|
||||||
// Check required collections
|
// Check required collections
|
||||||
var collections = db.listCollectionNames();
|
var collections = db.listCollectionNames();
|
||||||
var requiredCollections = ['users', 'userpages', 'categories'];
|
var requiredCollections = ['users', 'userpages', 'categories'];
|
||||||
var missingCollections = [];
|
var missingCollections = [];
|
||||||
|
|
||||||
requiredCollections.forEach(function(collection) {
|
requiredCollections.forEach(function(collection) {
|
||||||
if (collections.indexOf(collection) === -1) {
|
if (collections.indexOf(collection) === -1) {
|
||||||
missingCollections.push(collection);
|
missingCollections.push(collection);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
if (missingCollections.length > 0) {
|
if (missingCollections.length > 0) {
|
||||||
print('Missing collections: ' + missingCollections.join(', '));
|
print('Missing collections: ' + missingCollections.join(', '));
|
||||||
} else {
|
} else {
|
||||||
print('All required collections exist');
|
print('All required collections exist');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check indexes on userpages collection
|
// Check indexes on userpages collection
|
||||||
if (collections.indexOf('userpages') !== -1) {
|
if (collections.indexOf('userpages') !== -1) {
|
||||||
var indexes = db.userpages.getIndexes();
|
var indexes = db.userpages.getIndexes();
|
||||||
print('UserPages collection has ' + indexes.length + ' indexes');
|
print('UserPages collection has ' + indexes.length + ' indexes');
|
||||||
|
|
||||||
// Check for important compound index
|
// Check for important compound index
|
||||||
var hasCompoundIndex = indexes.some(function(index) {
|
var hasCompoundIndex = indexes.some(function(index) {
|
||||||
return index.key && index.key.category && index.key.slug;
|
return index.key && index.key.category && index.key.slug;
|
||||||
});
|
});
|
||||||
|
|
||||||
if (hasCompoundIndex) {
|
if (hasCompoundIndex) {
|
||||||
print('Required compound index (category, slug) exists');
|
print('Required compound index (category, slug) exists');
|
||||||
} else {
|
} else {
|
||||||
print('WARNING: Compound index (category, slug) is missing');
|
print('WARNING: Compound index (category, slug) is missing');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
print('COLLECTIONS_TEST_OK');
|
print('COLLECTIONS_TEST_OK');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('COLLECTIONS_TEST_FAILED: ' + e.message);
|
print('COLLECTIONS_TEST_FAILED: ' + e.message);
|
||||||
}
|
}
|
||||||
" 2>/dev/null || echo "COLLECTIONS_TEST_FAILED")
|
" 2>/dev/null || echo "COLLECTIONS_TEST_FAILED")
|
||||||
|
|
||||||
if [[ "$collections_test" == *"COLLECTIONS_TEST_OK"* ]]; then
|
if [[ "$collections_test" == *"COLLECTIONS_TEST_OK"* ]]; then
|
||||||
echo "$collections_test" | grep -v "COLLECTIONS_TEST_OK"
|
echo "$collections_test" | grep -v "COLLECTIONS_TEST_OK"
|
||||||
log_success "BCards collections test passed"
|
log_success "BCards collections test passed"
|
||||||
return 0
|
return 0
|
||||||
else
|
else
|
||||||
log_warning "BCards collections test had issues: $collections_test"
|
log_warning "BCards collections test had issues: $collections_test"
|
||||||
return 1
|
return 1
|
||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
# Performance test
|
# Performance test
|
||||||
test_mongodb_performance() {
|
test_mongodb_performance() {
|
||||||
log_info "Running basic performance test..."
|
log_info "Running basic performance test..."
|
||||||
|
|
||||||
if ! command -v mongosh >/dev/null 2>&1; then
|
if ! command -v mongosh >/dev/null 2>&1; then
|
||||||
log_warning "mongosh not available, skipping performance test"
|
log_warning "mongosh not available, skipping performance test"
|
||||||
return 1
|
return 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
local perf_test=$(timeout $TIMEOUT mongosh "$CONNECTION_STRING" --quiet --eval "
|
local perf_test=$(timeout $TIMEOUT mongosh "$CONNECTION_STRING" --quiet --eval "
|
||||||
try {
|
try {
|
||||||
var start = new Date();
|
var start = new Date();
|
||||||
|
|
||||||
// Insert test documents
|
// Insert test documents
|
||||||
var docs = [];
|
var docs = [];
|
||||||
for (var i = 0; i < 100; i++) {
|
for (var i = 0; i < 100; i++) {
|
||||||
docs.push({test: true, index: i, timestamp: new Date()});
|
docs.push({test: true, index: i, timestamp: new Date()});
|
||||||
}
|
}
|
||||||
db.performance_test.insertMany(docs);
|
db.performance_test.insertMany(docs);
|
||||||
|
|
||||||
// Read test
|
// Read test
|
||||||
var count = db.performance_test.countDocuments({test: true});
|
var count = db.performance_test.countDocuments({test: true});
|
||||||
|
|
||||||
// Update test
|
// Update test
|
||||||
db.performance_test.updateMany({test: true}, {\$set: {updated: true}});
|
db.performance_test.updateMany({test: true}, {\$set: {updated: true}});
|
||||||
|
|
||||||
// Delete test
|
// Delete test
|
||||||
db.performance_test.deleteMany({test: true});
|
db.performance_test.deleteMany({test: true});
|
||||||
|
|
||||||
var end = new Date();
|
var end = new Date();
|
||||||
var duration = end - start;
|
var duration = end - start;
|
||||||
|
|
||||||
print('Performance test completed in ' + duration + 'ms');
|
print('Performance test completed in ' + duration + 'ms');
|
||||||
print('Operations: 100 inserts, 1 count, 100 updates, 100 deletes');
|
print('Operations: 100 inserts, 1 count, 100 updates, 100 deletes');
|
||||||
|
|
||||||
if (duration < 5000) {
|
if (duration < 5000) {
|
||||||
print('PERFORMANCE_TEST_OK');
|
print('PERFORMANCE_TEST_OK');
|
||||||
} else {
|
} else {
|
||||||
print('PERFORMANCE_TEST_SLOW');
|
print('PERFORMANCE_TEST_SLOW');
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('PERFORMANCE_TEST_FAILED: ' + e.message);
|
print('PERFORMANCE_TEST_FAILED: ' + e.message);
|
||||||
}
|
}
|
||||||
" 2>/dev/null || echo "PERFORMANCE_TEST_FAILED")
|
" 2>/dev/null || echo "PERFORMANCE_TEST_FAILED")
|
||||||
|
|
||||||
if [[ "$perf_test" == *"PERFORMANCE_TEST_OK"* ]]; then
|
if [[ "$perf_test" == *"PERFORMANCE_TEST_OK"* ]]; then
|
||||||
echo "$perf_test" | grep -v "PERFORMANCE_TEST_OK"
|
echo "$perf_test" | grep -v "PERFORMANCE_TEST_OK"
|
||||||
log_success "Performance test passed"
|
log_success "Performance test passed"
|
||||||
return 0
|
return 0
|
||||||
elif [[ "$perf_test" == *"PERFORMANCE_TEST_SLOW"* ]]; then
|
elif [[ "$perf_test" == *"PERFORMANCE_TEST_SLOW"* ]]; then
|
||||||
echo "$perf_test" | grep -v "PERFORMANCE_TEST_SLOW"
|
echo "$perf_test" | grep -v "PERFORMANCE_TEST_SLOW"
|
||||||
log_warning "Performance test completed but was slow"
|
log_warning "Performance test completed but was slow"
|
||||||
return 0
|
return 0
|
||||||
else
|
else
|
||||||
log_error "Performance test failed: $perf_test"
|
log_error "Performance test failed: $perf_test"
|
||||||
return 1
|
return 1
|
||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
# Display connection summary
|
# Display connection summary
|
||||||
display_summary() {
|
display_summary() {
|
||||||
echo ""
|
echo ""
|
||||||
log_info "MongoDB Connection Test Summary"
|
log_info "MongoDB Connection Test Summary"
|
||||||
echo "=================================================="
|
echo "=================================================="
|
||||||
echo "🏠 Host: $MONGODB_HOST:$MONGODB_PORT"
|
echo "🏠 Host: $MONGODB_HOST:$MONGODB_PORT"
|
||||||
echo "🗄️ Database: $DATABASE_NAME"
|
echo "🗄️ Database: $DATABASE_NAME"
|
||||||
echo "🔗 Connection String: $CONNECTION_STRING"
|
echo "🔗 Connection String: $CONNECTION_STRING"
|
||||||
echo "⏱️ Timeout: ${TIMEOUT}s"
|
echo "⏱️ Timeout: ${TIMEOUT}s"
|
||||||
echo "📊 Tests completed: $(date)"
|
echo "📊 Tests completed: $(date)"
|
||||||
echo "=================================================="
|
echo "=================================================="
|
||||||
}
|
}
|
||||||
|
|
||||||
# Main test function
|
# Main test function
|
||||||
main() {
|
main() {
|
||||||
log_info "Starting MongoDB connection tests for Release environment"
|
log_info "Starting MongoDB connection tests for Release environment"
|
||||||
|
|
||||||
local test_results=()
|
local test_results=()
|
||||||
local overall_success=true
|
local overall_success=true
|
||||||
|
|
||||||
# Run all tests
|
# Run all tests
|
||||||
if test_tcp_connection; then
|
if test_tcp_connection; then
|
||||||
test_results+=("✅ TCP Connection")
|
test_results+=("✅ TCP Connection")
|
||||||
else
|
else
|
||||||
test_results+=("❌ TCP Connection")
|
test_results+=("❌ TCP Connection")
|
||||||
overall_success=false
|
overall_success=false
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if test_mongodb_with_mongosh; then
|
if test_mongodb_with_mongosh; then
|
||||||
test_results+=("✅ MongoDB Shell")
|
test_results+=("✅ MongoDB Shell")
|
||||||
elif test_mongodb_with_docker; then
|
elif test_mongodb_with_docker; then
|
||||||
test_results+=("✅ MongoDB Docker")
|
test_results+=("✅ MongoDB Docker")
|
||||||
elif test_mongodb_with_python; then
|
elif test_mongodb_with_python; then
|
||||||
test_results+=("✅ MongoDB Python")
|
test_results+=("✅ MongoDB Python")
|
||||||
else
|
else
|
||||||
test_results+=("❌ MongoDB Client")
|
test_results+=("❌ MongoDB Client")
|
||||||
overall_success=false
|
overall_success=false
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if test_from_application_container; then
|
if test_from_application_container; then
|
||||||
test_results+=("✅ Application Container")
|
test_results+=("✅ Application Container")
|
||||||
else
|
else
|
||||||
test_results+=("⚠️ Application Container")
|
test_results+=("⚠️ Application Container")
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if check_mongodb_status; then
|
if check_mongodb_status; then
|
||||||
test_results+=("✅ Server Status")
|
test_results+=("✅ Server Status")
|
||||||
else
|
else
|
||||||
test_results+=("⚠️ Server Status")
|
test_results+=("⚠️ Server Status")
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if test_bcards_collections; then
|
if test_bcards_collections; then
|
||||||
test_results+=("✅ BCards Collections")
|
test_results+=("✅ BCards Collections")
|
||||||
else
|
else
|
||||||
test_results+=("⚠️ BCards Collections")
|
test_results+=("⚠️ BCards Collections")
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if test_mongodb_performance; then
|
if test_mongodb_performance; then
|
||||||
test_results+=("✅ Performance Test")
|
test_results+=("✅ Performance Test")
|
||||||
else
|
else
|
||||||
test_results+=("⚠️ Performance Test")
|
test_results+=("⚠️ Performance Test")
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Display results
|
# Display results
|
||||||
display_summary
|
display_summary
|
||||||
echo ""
|
echo ""
|
||||||
log_info "Test Results:"
|
log_info "Test Results:"
|
||||||
for result in "${test_results[@]}"; do
|
for result in "${test_results[@]}"; do
|
||||||
echo " $result"
|
echo " $result"
|
||||||
done
|
done
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
if [ "$overall_success" = true ]; then
|
if [ "$overall_success" = true ]; then
|
||||||
log_success "All critical MongoDB tests passed!"
|
log_success "All critical MongoDB tests passed!"
|
||||||
exit 0
|
exit 0
|
||||||
else
|
else
|
||||||
log_error "Some critical MongoDB tests failed!"
|
log_error "Some critical MongoDB tests failed!"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
# Run main function
|
# Run main function
|
||||||
main "$@"
|
main "$@"
|
||||||
@ -1,45 +1,46 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk">
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<TargetFramework>net8.0</TargetFramework>
|
<TargetFramework>net8.0</TargetFramework>
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
<IsPackable>false</IsPackable>
|
<IsPackable>false</IsPackable>
|
||||||
<IsTestProject>true</IsTestProject>
|
<IsTestProject>true</IsTestProject>
|
||||||
</PropertyGroup>
|
<Configurations>Debug;Release;Testing</Configurations>
|
||||||
|
</PropertyGroup>
|
||||||
<ItemGroup>
|
|
||||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.9.0" />
|
<ItemGroup>
|
||||||
<PackageReference Include="xunit" Version="2.6.6" />
|
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.9.0" />
|
||||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.5.6">
|
<PackageReference Include="xunit" Version="2.6.6" />
|
||||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
<PackageReference Include="xunit.runner.visualstudio" Version="2.5.6">
|
||||||
<PrivateAssets>all</PrivateAssets>
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
</PackageReference>
|
<PrivateAssets>all</PrivateAssets>
|
||||||
<PackageReference Include="coverlet.collector" Version="6.0.1">
|
</PackageReference>
|
||||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
<PackageReference Include="coverlet.collector" Version="6.0.1">
|
||||||
<PrivateAssets>all</PrivateAssets>
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
</PackageReference>
|
<PrivateAssets>all</PrivateAssets>
|
||||||
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="8.0.0" />
|
</PackageReference>
|
||||||
<PackageReference Include="PuppeteerSharp" Version="13.0.2" />
|
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="8.0.0" />
|
||||||
<PackageReference Include="MongoDB.Driver" Version="2.25.0" />
|
<PackageReference Include="PuppeteerSharp" Version="13.0.2" />
|
||||||
<PackageReference Include="Testcontainers.MongoDb" Version="3.6.0" />
|
<PackageReference Include="MongoDB.Driver" Version="3.4.2" />
|
||||||
<PackageReference Include="Stripe.net" Version="44.7.0" />
|
<PackageReference Include="Testcontainers.MongoDb" Version="3.6.0" />
|
||||||
<PackageReference Include="FluentAssertions" Version="6.12.0" />
|
<PackageReference Include="Stripe.net" Version="48.4.0" />
|
||||||
<PackageReference Include="Microsoft.Extensions.Configuration" Version="8.0.0" />
|
<PackageReference Include="FluentAssertions" Version="6.12.0" />
|
||||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="8.0.0" />
|
<PackageReference Include="Microsoft.Extensions.Configuration" Version="8.0.0" />
|
||||||
<PackageReference Include="Microsoft.Extensions.Logging" Version="8.0.0" />
|
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="8.0.0" />
|
||||||
<PackageReference Include="Microsoft.Extensions.Http" Version="8.0.0" />
|
<PackageReference Include="Microsoft.Extensions.Logging" Version="8.0.0" />
|
||||||
<PackageReference Include="Moq" Version="4.20.70" />
|
<PackageReference Include="Microsoft.Extensions.Http" Version="8.0.0" />
|
||||||
</ItemGroup>
|
<PackageReference Include="Moq" Version="4.20.70" />
|
||||||
|
</ItemGroup>
|
||||||
<ItemGroup>
|
|
||||||
<ProjectReference Include="..\BCards.Web\BCards.Web.csproj" />
|
<ItemGroup>
|
||||||
</ItemGroup>
|
<ProjectReference Include="..\BCards.Web\BCards.Web.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
<ItemGroup>
|
|
||||||
<None Update="appsettings.Testing.json">
|
<ItemGroup>
|
||||||
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
|
<None Update="appsettings.Testing.json">
|
||||||
</None>
|
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
|
||||||
</ItemGroup>
|
</None>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
@ -1,133 +1,133 @@
|
|||||||
using Microsoft.AspNetCore.Hosting;
|
using Microsoft.AspNetCore.Hosting;
|
||||||
using Microsoft.AspNetCore.Mvc.Testing;
|
using Microsoft.AspNetCore.Mvc.Testing;
|
||||||
using Microsoft.Extensions.Configuration;
|
using Microsoft.Extensions.Configuration;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using MongoDB.Driver;
|
using MongoDB.Driver;
|
||||||
using BCards.Web.Configuration;
|
using BCards.Web.Configuration;
|
||||||
using BCards.Web.Services;
|
using BCards.Web.Services;
|
||||||
using Testcontainers.MongoDb;
|
using Testcontainers.MongoDb;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
|
|
||||||
namespace BCards.IntegrationTests.Fixtures;
|
namespace BCards.IntegrationTests.Fixtures;
|
||||||
|
|
||||||
public class BCardsWebApplicationFactory : WebApplicationFactory<Program>, IAsyncLifetime
|
public class BCardsWebApplicationFactory : WebApplicationFactory<Program>, IAsyncLifetime
|
||||||
{
|
{
|
||||||
private readonly MongoDbContainer _mongoContainer = new MongoDbBuilder()
|
private readonly MongoDbContainer _mongoContainer = new MongoDbBuilder()
|
||||||
.WithImage("mongo:7.0")
|
.WithImage("mongo:7.0")
|
||||||
.WithEnvironment("MONGO_INITDB_DATABASE", "BCardsDB_Test")
|
.WithEnvironment("MONGO_INITDB_DATABASE", "BCardsDB_Test")
|
||||||
.Build();
|
.Build();
|
||||||
|
|
||||||
public IMongoDatabase TestDatabase { get; private set; } = null!;
|
public IMongoDatabase TestDatabase { get; private set; } = null!;
|
||||||
public string TestDatabaseName => $"BCardsDB_Test_{Guid.NewGuid():N}";
|
public string TestDatabaseName => $"BCardsDB_Test_{Guid.NewGuid():N}";
|
||||||
|
|
||||||
protected override void ConfigureWebHost(IWebHostBuilder builder)
|
protected override void ConfigureWebHost(IWebHostBuilder builder)
|
||||||
{
|
{
|
||||||
builder.ConfigureAppConfiguration((context, config) =>
|
builder.ConfigureAppConfiguration((context, config) =>
|
||||||
{
|
{
|
||||||
// Remove existing configuration and add test configuration
|
// Remove existing configuration and add test configuration
|
||||||
config.Sources.Clear();
|
config.Sources.Clear();
|
||||||
config.AddJsonFile("appsettings.Testing.json", optional: false, reloadOnChange: false);
|
config.AddJsonFile("appsettings.Testing.json", optional: false, reloadOnChange: false);
|
||||||
config.AddInMemoryCollection(new Dictionary<string, string?>
|
config.AddInMemoryCollection(new Dictionary<string, string?>
|
||||||
{
|
{
|
||||||
["MongoDb:ConnectionString"] = _mongoContainer.GetConnectionString(),
|
["MongoDb:ConnectionString"] = _mongoContainer.GetConnectionString(),
|
||||||
["MongoDb:DatabaseName"] = TestDatabaseName,
|
["MongoDb:DatabaseName"] = TestDatabaseName,
|
||||||
["ASPNETCORE_ENVIRONMENT"] = "Testing"
|
["ASPNETCORE_ENVIRONMENT"] = "Testing"
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
builder.ConfigureServices(services =>
|
builder.ConfigureServices(services =>
|
||||||
{
|
{
|
||||||
// Remove existing MongoDB services
|
// Remove existing MongoDB services
|
||||||
services.RemoveAll(typeof(IMongoClient));
|
services.RemoveAll(typeof(IMongoClient));
|
||||||
services.RemoveAll(typeof(IMongoDatabase));
|
services.RemoveAll(typeof(IMongoDatabase));
|
||||||
|
|
||||||
// Add test MongoDB services
|
// Add test MongoDB services
|
||||||
services.AddSingleton<IMongoClient>(serviceProvider =>
|
services.AddSingleton<IMongoClient>(serviceProvider =>
|
||||||
{
|
{
|
||||||
return new MongoClient(_mongoContainer.GetConnectionString());
|
return new MongoClient(_mongoContainer.GetConnectionString());
|
||||||
});
|
});
|
||||||
|
|
||||||
services.AddScoped(serviceProvider =>
|
services.AddScoped(serviceProvider =>
|
||||||
{
|
{
|
||||||
var client = serviceProvider.GetRequiredService<IMongoClient>();
|
var client = serviceProvider.GetRequiredService<IMongoClient>();
|
||||||
TestDatabase = client.GetDatabase(TestDatabaseName);
|
TestDatabase = client.GetDatabase(TestDatabaseName);
|
||||||
return TestDatabase;
|
return TestDatabase;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Override Stripe settings for testing
|
// Override Stripe settings for testing
|
||||||
services.Configure<StripeSettings>(options =>
|
services.Configure<StripeSettings>(options =>
|
||||||
{
|
{
|
||||||
options.PublishableKey = "pk_test_51234567890abcdef";
|
options.PublishableKey = "pk_test_51234567890abcdef";
|
||||||
options.SecretKey = "sk_test_51234567890abcdef";
|
options.SecretKey = "sk_test_51234567890abcdef";
|
||||||
options.WebhookSecret = "whsec_test_1234567890abcdef";
|
options.WebhookSecret = "whsec_test_1234567890abcdef";
|
||||||
});
|
});
|
||||||
|
|
||||||
// Mock external services that we don't want to test
|
// Mock external services that we don't want to test
|
||||||
services.RemoveAll(typeof(IEmailService));
|
services.RemoveAll(typeof(IEmailService));
|
||||||
services.AddScoped<IEmailService, MockEmailService>();
|
services.AddScoped<IEmailService, MockEmailService>();
|
||||||
});
|
});
|
||||||
|
|
||||||
builder.UseEnvironment("Testing");
|
builder.UseEnvironment("Testing");
|
||||||
|
|
||||||
// Reduce logging noise during tests
|
// Reduce logging noise during tests
|
||||||
builder.ConfigureLogging(logging =>
|
builder.ConfigureLogging(logging =>
|
||||||
{
|
{
|
||||||
logging.ClearProviders();
|
logging.ClearProviders();
|
||||||
logging.AddConsole();
|
logging.AddConsole();
|
||||||
logging.SetMinimumLevel(LogLevel.Warning);
|
logging.SetMinimumLevel(LogLevel.Warning);
|
||||||
logging.AddFilter("BCards", LogLevel.Information);
|
logging.AddFilter("BCards", LogLevel.Information);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task InitializeAsync()
|
public async Task InitializeAsync()
|
||||||
{
|
{
|
||||||
await _mongoContainer.StartAsync();
|
await _mongoContainer.StartAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
public new async Task DisposeAsync()
|
public new async Task DisposeAsync()
|
||||||
{
|
{
|
||||||
await _mongoContainer.DisposeAsync();
|
await _mongoContainer.DisposeAsync();
|
||||||
await base.DisposeAsync();
|
await base.DisposeAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task CleanDatabaseAsync()
|
public async Task CleanDatabaseAsync()
|
||||||
{
|
{
|
||||||
if (TestDatabase != null)
|
if (TestDatabase != null)
|
||||||
{
|
{
|
||||||
var collections = new[] { "users", "userpages", "categories", "livepages", "subscriptions" };
|
var collections = new[] { "users", "userpages", "categories", "livepages", "subscriptions" };
|
||||||
|
|
||||||
foreach (var collectionName in collections)
|
foreach (var collectionName in collections)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await TestDatabase.DropCollectionAsync(collectionName);
|
await TestDatabase.DropCollectionAsync(collectionName);
|
||||||
}
|
}
|
||||||
catch (Exception)
|
catch (Exception)
|
||||||
{
|
{
|
||||||
// Ignore errors if collection doesn't exist
|
// Ignore errors if collection doesn't exist
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mock email service to avoid external dependencies in tests
|
// Mock email service to avoid external dependencies in tests
|
||||||
public class MockEmailService : IEmailService
|
public class MockEmailService : IEmailService
|
||||||
{
|
{
|
||||||
public Task SendModerationStatusAsync(string userEmail, string userName, string pageTitle, string status, string? reason = null, string? previewUrl = null)
|
public Task SendModerationStatusAsync(string userEmail, string userName, string pageTitle, string status, string? reason = null, string? previewUrl = null)
|
||||||
{
|
{
|
||||||
return Task.CompletedTask;
|
return Task.CompletedTask;
|
||||||
}
|
}
|
||||||
|
|
||||||
public Task SendModeratorNotificationAsync(string pageId, string pageTitle, string planType, string userName)
|
public Task SendModeratorNotificationAsync(string pageId, string pageTitle, string planType, string userName)
|
||||||
{
|
{
|
||||||
return Task.CompletedTask;
|
return Task.CompletedTask;
|
||||||
}
|
}
|
||||||
|
|
||||||
public Task<bool> SendEmailAsync(string to, string subject, string htmlContent)
|
public Task<bool> SendEmailAsync(string to, string subject, string htmlContent)
|
||||||
{
|
{
|
||||||
return Task.FromResult(true);
|
return Task.FromResult(true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -1,182 +1,182 @@
|
|||||||
using MongoDB.Driver;
|
using MongoDB.Driver;
|
||||||
using BCards.Web.Models;
|
using BCards.Web.Models;
|
||||||
using BCards.Web.Repositories;
|
using BCards.Web.Repositories;
|
||||||
using BCards.Web.ViewModels;
|
using BCards.Web.ViewModels;
|
||||||
|
|
||||||
namespace BCards.IntegrationTests.Fixtures;
|
namespace BCards.IntegrationTests.Fixtures;
|
||||||
|
|
||||||
public class MongoDbTestFixture
|
public class MongoDbTestFixture
|
||||||
{
|
{
|
||||||
public IMongoDatabase Database { get; }
|
public IMongoDatabase Database { get; }
|
||||||
public IUserRepository UserRepository { get; }
|
public IUserRepository UserRepository { get; }
|
||||||
public IUserPageRepository UserPageRepository { get; }
|
public IUserPageRepository UserPageRepository { get; }
|
||||||
public ICategoryRepository CategoryRepository { get; }
|
public ICategoryRepository CategoryRepository { get; }
|
||||||
|
|
||||||
public MongoDbTestFixture(IMongoDatabase database)
|
public MongoDbTestFixture(IMongoDatabase database)
|
||||||
{
|
{
|
||||||
Database = database;
|
Database = database;
|
||||||
UserRepository = new UserRepository(database);
|
UserRepository = new UserRepository(database);
|
||||||
UserPageRepository = new UserPageRepository(database);
|
UserPageRepository = new UserPageRepository(database);
|
||||||
CategoryRepository = new CategoryRepository(database);
|
CategoryRepository = new CategoryRepository(database);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task InitializeTestDataAsync()
|
public async Task InitializeTestDataAsync()
|
||||||
{
|
{
|
||||||
// Initialize test categories
|
// Initialize test categories
|
||||||
var categories = new List<Category>
|
var categories = new List<Category>
|
||||||
{
|
{
|
||||||
new() { Id = "tecnologia", Name = "Tecnologia", Description = "Empresas e profissionais de tecnologia" },
|
new() { Id = "tecnologia", Name = "Tecnologia", Description = "Empresas e profissionais de tecnologia" },
|
||||||
new() { Id = "negocios", Name = "Negócios", Description = "Empresas e empreendedores" },
|
new() { Id = "negocios", Name = "Negócios", Description = "Empresas e empreendedores" },
|
||||||
new() { Id = "pessoal", Name = "Pessoal", Description = "Páginas pessoais e freelancers" },
|
new() { Id = "pessoal", Name = "Pessoal", Description = "Páginas pessoais e freelancers" },
|
||||||
new() { Id = "saude", Name = "Saúde", Description = "Profissionais da área da saúde" }
|
new() { Id = "saude", Name = "Saúde", Description = "Profissionais da área da saúde" }
|
||||||
};
|
};
|
||||||
|
|
||||||
var existingCategories = await CategoryRepository.GetAllActiveAsync();
|
var existingCategories = await CategoryRepository.GetAllActiveAsync();
|
||||||
if (!existingCategories.Any())
|
if (!existingCategories.Any())
|
||||||
{
|
{
|
||||||
foreach (var category in categories)
|
foreach (var category in categories)
|
||||||
{
|
{
|
||||||
await CategoryRepository.CreateAsync(category);
|
await CategoryRepository.CreateAsync(category);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<User> CreateTestUserAsync(PlanType planType = PlanType.Basic, string? email = null, string? name = null)
|
public async Task<User> CreateTestUserAsync(PlanType planType = PlanType.Basic, string? email = null, string? name = null)
|
||||||
{
|
{
|
||||||
var user = new User
|
var user = new User
|
||||||
{
|
{
|
||||||
Id = Guid.NewGuid().ToString(),
|
Id = Guid.NewGuid().ToString(),
|
||||||
Email = email ?? $"test-{Guid.NewGuid():N}@example.com",
|
Email = email ?? $"test-{Guid.NewGuid():N}@example.com",
|
||||||
Name = name ?? "Test User",
|
Name = name ?? "Test User",
|
||||||
CurrentPlan = planType.ToString(),
|
CurrentPlan = planType.ToString(),
|
||||||
CreatedAt = DateTime.UtcNow,
|
CreatedAt = DateTime.UtcNow,
|
||||||
UpdatedAt = DateTime.UtcNow,
|
UpdatedAt = DateTime.UtcNow,
|
||||||
IsActive = true
|
IsActive = true
|
||||||
};
|
};
|
||||||
|
|
||||||
await UserRepository.CreateAsync(user);
|
await UserRepository.CreateAsync(user);
|
||||||
return user;
|
return user;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<UserPage> CreateTestUserPageAsync(
|
public async Task<UserPage> CreateTestUserPageAsync(
|
||||||
string userId,
|
string userId,
|
||||||
PageStatus status = PageStatus.Creating,
|
PageStatus status = PageStatus.Creating,
|
||||||
string category = "tecnologia",
|
string category = "tecnologia",
|
||||||
int normalLinkCount = 3,
|
int normalLinkCount = 3,
|
||||||
int productLinkCount = 1,
|
int productLinkCount = 1,
|
||||||
string? slug = null)
|
string? slug = null)
|
||||||
{
|
{
|
||||||
var pageSlug = slug ?? $"test-page-{Guid.NewGuid():N}";
|
var pageSlug = slug ?? $"test-page-{Guid.NewGuid():N}";
|
||||||
|
|
||||||
var userPage = new UserPage
|
var userPage = new UserPage
|
||||||
{
|
{
|
||||||
Id = Guid.NewGuid().ToString(),
|
Id = Guid.NewGuid().ToString(),
|
||||||
UserId = userId,
|
UserId = userId,
|
||||||
DisplayName = "Test Page",
|
DisplayName = "Test Page",
|
||||||
Category = category,
|
Category = category,
|
||||||
Slug = pageSlug,
|
Slug = pageSlug,
|
||||||
Bio = "Test page for integration testing",
|
Bio = "Test page for integration testing",
|
||||||
Status = status,
|
Status = status,
|
||||||
BusinessType = "individual",
|
BusinessType = "individual",
|
||||||
Theme = new PageTheme { Name = "minimalist" },
|
Theme = new PageTheme { Name = "minimalist" },
|
||||||
Links = new List<LinkItem>(),
|
Links = new List<LinkItem>(),
|
||||||
CreatedAt = DateTime.UtcNow,
|
CreatedAt = DateTime.UtcNow,
|
||||||
UpdatedAt = DateTime.UtcNow,
|
UpdatedAt = DateTime.UtcNow,
|
||||||
ModerationAttempts = 0,
|
ModerationAttempts = 0,
|
||||||
ModerationHistory = new List<ModerationHistory>()
|
ModerationHistory = new List<ModerationHistory>()
|
||||||
};
|
};
|
||||||
|
|
||||||
// Generate preview token for non-Active pages
|
// Generate preview token for non-Active pages
|
||||||
if (status != PageStatus.Active)
|
if (status != PageStatus.Active)
|
||||||
{
|
{
|
||||||
userPage.PreviewToken = Guid.NewGuid().ToString("N")[..16];
|
userPage.PreviewToken = Guid.NewGuid().ToString("N")[..16];
|
||||||
userPage.PreviewTokenExpiry = DateTime.UtcNow.AddHours(4);
|
userPage.PreviewTokenExpiry = DateTime.UtcNow.AddHours(4);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add normal links
|
// Add normal links
|
||||||
for (int i = 0; i < normalLinkCount; i++)
|
for (int i = 0; i < normalLinkCount; i++)
|
||||||
{
|
{
|
||||||
userPage.Links.Add(new LinkItem
|
userPage.Links.Add(new LinkItem
|
||||||
{
|
{
|
||||||
Title = $"Test Link {i + 1}",
|
Title = $"Test Link {i + 1}",
|
||||||
Url = $"https://example.com/link{i + 1}",
|
Url = $"https://example.com/link{i + 1}",
|
||||||
Description = $"Description for test link {i + 1}",
|
Description = $"Description for test link {i + 1}",
|
||||||
Icon = "fas fa-link",
|
Icon = "fas fa-link",
|
||||||
IsActive = true,
|
IsActive = true,
|
||||||
Order = i,
|
Order = i,
|
||||||
Type = LinkType.Normal
|
Type = LinkType.Normal
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add product links
|
// Add product links
|
||||||
for (int i = 0; i < productLinkCount; i++)
|
for (int i = 0; i < productLinkCount; i++)
|
||||||
{
|
{
|
||||||
userPage.Links.Add(new LinkItem
|
userPage.Links.Add(new LinkItem
|
||||||
{
|
{
|
||||||
Title = $"Test Product {i + 1}",
|
Title = $"Test Product {i + 1}",
|
||||||
Url = $"https://example.com/product{i + 1}",
|
Url = $"https://example.com/product{i + 1}",
|
||||||
Description = $"Description for test product {i + 1}",
|
Description = $"Description for test product {i + 1}",
|
||||||
Icon = "fas fa-shopping-cart",
|
Icon = "fas fa-shopping-cart",
|
||||||
IsActive = true,
|
IsActive = true,
|
||||||
Order = normalLinkCount + i,
|
Order = normalLinkCount + i,
|
||||||
Type = LinkType.Product,
|
Type = LinkType.Product,
|
||||||
ProductTitle = $"Amazing Product {i + 1}",
|
ProductTitle = $"Amazing Product {i + 1}",
|
||||||
ProductPrice = "R$ 99,90",
|
ProductPrice = "R$ 99,90",
|
||||||
ProductDescription = $"This is an amazing product for testing purposes {i + 1}",
|
ProductDescription = $"This is an amazing product for testing purposes {i + 1}",
|
||||||
ProductImage = $"https://example.com/images/product{i + 1}.jpg"
|
ProductImage = $"https://example.com/images/product{i + 1}.jpg"
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
await UserPageRepository.CreateAsync(userPage);
|
await UserPageRepository.CreateAsync(userPage);
|
||||||
return userPage;
|
return userPage;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<User> CreateTestUserWithPageAsync(
|
public async Task<User> CreateTestUserWithPageAsync(
|
||||||
PlanType planType = PlanType.Basic,
|
PlanType planType = PlanType.Basic,
|
||||||
PageStatus pageStatus = PageStatus.Creating,
|
PageStatus pageStatus = PageStatus.Creating,
|
||||||
int normalLinks = 3,
|
int normalLinks = 3,
|
||||||
int productLinks = 1)
|
int productLinks = 1)
|
||||||
{
|
{
|
||||||
var user = await CreateTestUserAsync(planType);
|
var user = await CreateTestUserAsync(planType);
|
||||||
var page = await CreateTestUserPageAsync(user.Id, pageStatus, "tecnologia", normalLinks, productLinks);
|
var page = await CreateTestUserPageAsync(user.Id, pageStatus, "tecnologia", normalLinks, productLinks);
|
||||||
|
|
||||||
return user;
|
return user;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task CleanAllDataAsync()
|
public async Task CleanAllDataAsync()
|
||||||
{
|
{
|
||||||
var collections = new[] { "users", "userpages", "categories", "livepages", "subscriptions" };
|
var collections = new[] { "users", "userpages", "categories", "livepages", "subscriptions" };
|
||||||
|
|
||||||
foreach (var collectionName in collections)
|
foreach (var collectionName in collections)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await Database.DropCollectionAsync(collectionName);
|
await Database.DropCollectionAsync(collectionName);
|
||||||
}
|
}
|
||||||
catch (Exception)
|
catch (Exception)
|
||||||
{
|
{
|
||||||
// Ignore errors if collection doesn't exist
|
// Ignore errors if collection doesn't exist
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await InitializeTestDataAsync();
|
await InitializeTestDataAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<List<UserPage>> GetUserPagesAsync(string userId)
|
public async Task<List<UserPage>> GetUserPagesAsync(string userId)
|
||||||
{
|
{
|
||||||
var filter = Builders<UserPage>.Filter.Eq(p => p.UserId, userId);
|
var filter = Builders<UserPage>.Filter.Eq(p => p.UserId, userId);
|
||||||
var pages = await UserPageRepository.GetManyAsync(filter);
|
var pages = await UserPageRepository.GetManyAsync(filter);
|
||||||
return pages.ToList();
|
return pages.ToList();
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<UserPage?> GetUserPageAsync(string category, string slug)
|
public async Task<UserPage?> GetUserPageAsync(string category, string slug)
|
||||||
{
|
{
|
||||||
var filter = Builders<UserPage>.Filter.And(
|
var filter = Builders<UserPage>.Filter.And(
|
||||||
Builders<UserPage>.Filter.Eq(p => p.Category, category),
|
Builders<UserPage>.Filter.Eq(p => p.Category, category),
|
||||||
Builders<UserPage>.Filter.Eq(p => p.Slug, slug)
|
Builders<UserPage>.Filter.Eq(p => p.Slug, slug)
|
||||||
);
|
);
|
||||||
|
|
||||||
var pages = await UserPageRepository.GetManyAsync(filter);
|
var pages = await UserPageRepository.GetManyAsync(filter);
|
||||||
return pages.FirstOrDefault();
|
return pages.FirstOrDefault();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -1,92 +1,92 @@
|
|||||||
using System.Security.Claims;
|
using System.Security.Claims;
|
||||||
using System.Text.Encodings.Web;
|
using System.Text.Encodings.Web;
|
||||||
using Microsoft.AspNetCore.Authentication;
|
using Microsoft.AspNetCore.Authentication;
|
||||||
using Microsoft.AspNetCore.Authentication.Cookies;
|
using Microsoft.AspNetCore.Authentication.Cookies;
|
||||||
using Microsoft.AspNetCore.Mvc.Testing;
|
using Microsoft.AspNetCore.Mvc.Testing;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
using BCards.Web.Models;
|
using BCards.Web.Models;
|
||||||
|
|
||||||
namespace BCards.IntegrationTests.Helpers;
|
namespace BCards.IntegrationTests.Helpers;
|
||||||
|
|
||||||
public static class AuthenticationHelper
|
public static class AuthenticationHelper
|
||||||
{
|
{
|
||||||
public static async Task<HttpClient> CreateAuthenticatedClientAsync(
|
public static Task<HttpClient> CreateAuthenticatedClientAsync(
|
||||||
WebApplicationFactory<Program> factory,
|
WebApplicationFactory<Program> factory,
|
||||||
User testUser)
|
User testUser)
|
||||||
{
|
{
|
||||||
var client = factory.WithWebHostBuilder(builder =>
|
var client = factory.WithWebHostBuilder(builder =>
|
||||||
{
|
{
|
||||||
builder.ConfigureServices(services =>
|
builder.ConfigureServices(services =>
|
||||||
{
|
{
|
||||||
services.AddAuthentication("Test")
|
services.AddAuthentication("Test")
|
||||||
.AddScheme<AuthenticationSchemeOptions, TestAuthenticationHandler>(
|
.AddScheme<AuthenticationSchemeOptions, TestAuthenticationHandler>(
|
||||||
"Test", options => { });
|
"Test", options => { });
|
||||||
});
|
});
|
||||||
}).CreateClient(new WebApplicationFactoryClientOptions
|
}).CreateClient(new WebApplicationFactoryClientOptions
|
||||||
{
|
{
|
||||||
AllowAutoRedirect = false
|
AllowAutoRedirect = false
|
||||||
});
|
});
|
||||||
|
|
||||||
// Set the test user in headers for the TestAuthenticationHandler
|
// Set the test user in headers for the TestAuthenticationHandler
|
||||||
client.DefaultRequestHeaders.Add("TestUserId", testUser.Id);
|
client.DefaultRequestHeaders.Add("TestUserId", testUser.Id);
|
||||||
client.DefaultRequestHeaders.Add("TestUserEmail", testUser.Email);
|
client.DefaultRequestHeaders.Add("TestUserEmail", testUser.Email);
|
||||||
client.DefaultRequestHeaders.Add("TestUserName", testUser.Name);
|
client.DefaultRequestHeaders.Add("TestUserName", testUser.Name);
|
||||||
|
|
||||||
return client;
|
return Task.FromResult(client);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static ClaimsPrincipal CreateTestClaimsPrincipal(User user)
|
public static ClaimsPrincipal CreateTestClaimsPrincipal(User user)
|
||||||
{
|
{
|
||||||
var claims = new List<Claim>
|
var claims = new List<Claim>
|
||||||
{
|
{
|
||||||
new(ClaimTypes.NameIdentifier, user.Id),
|
new(ClaimTypes.NameIdentifier, user.Id),
|
||||||
new(ClaimTypes.Email, user.Email),
|
new(ClaimTypes.Email, user.Email),
|
||||||
new(ClaimTypes.Name, user.Name),
|
new(ClaimTypes.Name, user.Name),
|
||||||
new("sub", user.Id),
|
new("sub", user.Id),
|
||||||
new("email", user.Email),
|
new("email", user.Email),
|
||||||
new("name", user.Name)
|
new("name", user.Name)
|
||||||
};
|
};
|
||||||
|
|
||||||
var identity = new ClaimsIdentity(claims, "Test");
|
var identity = new ClaimsIdentity(claims, "Test");
|
||||||
return new ClaimsPrincipal(identity);
|
return new ClaimsPrincipal(identity);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public class TestAuthenticationHandler : AuthenticationHandler<AuthenticationSchemeOptions>
|
public class TestAuthenticationHandler : AuthenticationHandler<AuthenticationSchemeOptions>
|
||||||
{
|
{
|
||||||
public TestAuthenticationHandler(IOptionsMonitor<AuthenticationSchemeOptions> options,
|
public TestAuthenticationHandler(IOptionsMonitor<AuthenticationSchemeOptions> options,
|
||||||
ILoggerFactory logger, UrlEncoder encoder)
|
ILoggerFactory logger, UrlEncoder encoder)
|
||||||
: base(options, logger, encoder)
|
: base(options, logger, encoder)
|
||||||
{
|
{
|
||||||
}
|
}
|
||||||
|
|
||||||
protected override Task<AuthenticateResult> HandleAuthenticateAsync()
|
protected override Task<AuthenticateResult> HandleAuthenticateAsync()
|
||||||
{
|
{
|
||||||
var userId = Context.Request.Headers["TestUserId"].FirstOrDefault();
|
var userId = Context.Request.Headers["TestUserId"].FirstOrDefault();
|
||||||
var userEmail = Context.Request.Headers["TestUserEmail"].FirstOrDefault();
|
var userEmail = Context.Request.Headers["TestUserEmail"].FirstOrDefault();
|
||||||
var userName = Context.Request.Headers["TestUserName"].FirstOrDefault();
|
var userName = Context.Request.Headers["TestUserName"].FirstOrDefault();
|
||||||
|
|
||||||
if (string.IsNullOrEmpty(userId) || string.IsNullOrEmpty(userEmail))
|
if (string.IsNullOrEmpty(userId) || string.IsNullOrEmpty(userEmail))
|
||||||
{
|
{
|
||||||
return Task.FromResult(AuthenticateResult.Fail("No test user provided"));
|
return Task.FromResult(AuthenticateResult.Fail("No test user provided"));
|
||||||
}
|
}
|
||||||
|
|
||||||
var claims = new List<Claim>
|
var claims = new List<Claim>
|
||||||
{
|
{
|
||||||
new(ClaimTypes.NameIdentifier, userId),
|
new(ClaimTypes.NameIdentifier, userId),
|
||||||
new(ClaimTypes.Email, userEmail),
|
new(ClaimTypes.Email, userEmail),
|
||||||
new(ClaimTypes.Name, userName ?? "Test User"),
|
new(ClaimTypes.Name, userName ?? "Test User"),
|
||||||
new("sub", userId),
|
new("sub", userId),
|
||||||
new("email", userEmail),
|
new("email", userEmail),
|
||||||
new("name", userName ?? "Test User")
|
new("name", userName ?? "Test User")
|
||||||
};
|
};
|
||||||
|
|
||||||
var identity = new ClaimsIdentity(claims, "Test");
|
var identity = new ClaimsIdentity(claims, "Test");
|
||||||
var principal = new ClaimsPrincipal(identity);
|
var principal = new ClaimsPrincipal(identity);
|
||||||
var ticket = new AuthenticationTicket(principal, "Test");
|
var ticket = new AuthenticationTicket(principal, "Test");
|
||||||
|
|
||||||
return Task.FromResult(AuthenticateResult.Success(ticket));
|
return Task.FromResult(AuthenticateResult.Success(ticket));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -1,195 +1,195 @@
|
|||||||
using PuppeteerSharp;
|
using PuppeteerSharp;
|
||||||
using Microsoft.AspNetCore.Mvc.Testing;
|
using Microsoft.AspNetCore.Mvc.Testing;
|
||||||
|
|
||||||
namespace BCards.IntegrationTests.Helpers;
|
namespace BCards.IntegrationTests.Helpers;
|
||||||
|
|
||||||
public class PuppeteerTestHelper : IAsyncDisposable
|
public class PuppeteerTestHelper : IAsyncDisposable
|
||||||
{
|
{
|
||||||
private IBrowser? _browser;
|
private IBrowser? _browser;
|
||||||
private IPage? _page;
|
private IPage? _page;
|
||||||
private readonly string _baseUrl;
|
private readonly string _baseUrl;
|
||||||
|
|
||||||
public PuppeteerTestHelper(WebApplicationFactory<Program> factory)
|
public PuppeteerTestHelper(WebApplicationFactory<Program> factory)
|
||||||
{
|
{
|
||||||
_baseUrl = factory.Server.BaseAddress?.ToString() ?? "https://localhost:49178";
|
_baseUrl = factory.Server.BaseAddress?.ToString() ?? "https://localhost:49178";
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task InitializeAsync()
|
public async Task InitializeAsync()
|
||||||
{
|
{
|
||||||
// Download Chrome if not available
|
// Download Chrome if not available
|
||||||
await new BrowserFetcher().DownloadAsync();
|
await new BrowserFetcher().DownloadAsync();
|
||||||
|
|
||||||
_browser = await Puppeteer.LaunchAsync(new LaunchOptions
|
_browser = await Puppeteer.LaunchAsync(new LaunchOptions
|
||||||
{
|
{
|
||||||
Headless = true, // Set to false for debugging
|
Headless = true, // Set to false for debugging
|
||||||
Args = new[]
|
Args = new[]
|
||||||
{
|
{
|
||||||
"--no-sandbox",
|
"--no-sandbox",
|
||||||
"--disable-setuid-sandbox",
|
"--disable-setuid-sandbox",
|
||||||
"--disable-dev-shm-usage",
|
"--disable-dev-shm-usage",
|
||||||
"--disable-web-security",
|
"--disable-web-security",
|
||||||
"--allow-running-insecure-content",
|
"--allow-running-insecure-content",
|
||||||
"--ignore-certificate-errors"
|
"--ignore-certificate-errors"
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
_page = await _browser.NewPageAsync();
|
_page = await _browser.NewPageAsync();
|
||||||
|
|
||||||
// Set viewport for consistent testing
|
// Set viewport for consistent testing
|
||||||
await _page.SetViewportAsync(new ViewPortOptions
|
await _page.SetViewportAsync(new ViewPortOptions
|
||||||
{
|
{
|
||||||
Width = 1920,
|
Width = 1920,
|
||||||
Height = 1080
|
Height = 1080
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public IPage Page => _page ?? throw new InvalidOperationException("PuppeteerTestHelper not initialized. Call InitializeAsync first.");
|
public IPage Page => _page ?? throw new InvalidOperationException("PuppeteerTestHelper not initialized. Call InitializeAsync first.");
|
||||||
|
|
||||||
public async Task NavigateToAsync(string relativeUrl)
|
public async Task NavigateToAsync(string relativeUrl)
|
||||||
{
|
{
|
||||||
var fullUrl = new Uri(new Uri(_baseUrl), relativeUrl).ToString();
|
var fullUrl = new Uri(new Uri(_baseUrl), relativeUrl).ToString();
|
||||||
await Page.GoToAsync(fullUrl, new NavigationOptions
|
await Page.GoToAsync(fullUrl, new NavigationOptions
|
||||||
{
|
{
|
||||||
WaitUntil = new[] { WaitUntilNavigation.Networkidle0 }
|
WaitUntil = new[] { WaitUntilNavigation.Networkidle0 }
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<string> GetPageContentAsync()
|
public async Task<string> GetPageContentAsync()
|
||||||
{
|
{
|
||||||
return await Page.GetContentAsync();
|
return await Page.GetContentAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<string> GetPageTitleAsync()
|
public async Task<string> GetPageTitleAsync()
|
||||||
{
|
{
|
||||||
return await Page.GetTitleAsync();
|
return await Page.GetTitleAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<bool> ElementExistsAsync(string selector)
|
public async Task<bool> ElementExistsAsync(string selector)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await Page.WaitForSelectorAsync(selector, new WaitForSelectorOptions
|
await Page.WaitForSelectorAsync(selector, new WaitForSelectorOptions
|
||||||
{
|
{
|
||||||
Timeout = 5000
|
Timeout = 5000
|
||||||
});
|
});
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
catch (WaitTaskTimeoutException)
|
catch (WaitTaskTimeoutException)
|
||||||
{
|
{
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task ClickAsync(string selector)
|
public async Task ClickAsync(string selector)
|
||||||
{
|
{
|
||||||
await Page.WaitForSelectorAsync(selector);
|
await Page.WaitForSelectorAsync(selector);
|
||||||
await Page.ClickAsync(selector);
|
await Page.ClickAsync(selector);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task TypeAsync(string selector, string text)
|
public async Task TypeAsync(string selector, string text)
|
||||||
{
|
{
|
||||||
await Page.WaitForSelectorAsync(selector);
|
await Page.WaitForSelectorAsync(selector);
|
||||||
await Page.TypeAsync(selector, text);
|
await Page.TypeAsync(selector, text);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task FillFormAsync(Dictionary<string, string> formData)
|
public async Task FillFormAsync(Dictionary<string, string> formData)
|
||||||
{
|
{
|
||||||
foreach (var kvp in formData)
|
foreach (var kvp in formData)
|
||||||
{
|
{
|
||||||
await Page.WaitForSelectorAsync(kvp.Key);
|
await Page.WaitForSelectorAsync(kvp.Key);
|
||||||
await Page.EvaluateExpressionAsync($"document.querySelector('{kvp.Key}').value = ''");
|
await Page.EvaluateExpressionAsync($"document.querySelector('{kvp.Key}').value = ''");
|
||||||
await Page.TypeAsync(kvp.Key, kvp.Value);
|
await Page.TypeAsync(kvp.Key, kvp.Value);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task SubmitFormAsync(string formSelector)
|
public async Task SubmitFormAsync(string formSelector)
|
||||||
{
|
{
|
||||||
await Page.ClickAsync($"{formSelector} button[type='submit'], {formSelector} input[type='submit']");
|
await Page.ClickAsync($"{formSelector} button[type='submit'], {formSelector} input[type='submit']");
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task WaitForNavigationAsync()
|
public async Task WaitForNavigationAsync()
|
||||||
{
|
{
|
||||||
await Page.WaitForNavigationAsync(new NavigationOptions
|
await Page.WaitForNavigationAsync(new NavigationOptions
|
||||||
{
|
{
|
||||||
WaitUntil = new[] { WaitUntilNavigation.Networkidle0 }
|
WaitUntil = new[] { WaitUntilNavigation.Networkidle0 }
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task WaitForElementAsync(string selector, int timeoutMs = 10000)
|
public async Task WaitForElementAsync(string selector, int timeoutMs = 10000)
|
||||||
{
|
{
|
||||||
await Page.WaitForSelectorAsync(selector, new WaitForSelectorOptions
|
await Page.WaitForSelectorAsync(selector, new WaitForSelectorOptions
|
||||||
{
|
{
|
||||||
Timeout = timeoutMs
|
Timeout = timeoutMs
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<string> GetElementTextAsync(string selector)
|
public async Task<string> GetElementTextAsync(string selector)
|
||||||
{
|
{
|
||||||
await Page.WaitForSelectorAsync(selector);
|
await Page.WaitForSelectorAsync(selector);
|
||||||
var element = await Page.QuerySelectorAsync(selector);
|
var element = await Page.QuerySelectorAsync(selector);
|
||||||
var text = await Page.EvaluateFunctionAsync<string>("el => el.textContent", element);
|
var text = await Page.EvaluateFunctionAsync<string>("el => el.textContent", element);
|
||||||
return text?.Trim() ?? string.Empty;
|
return text?.Trim() ?? string.Empty;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<string> GetElementValueAsync(string selector)
|
public async Task<string> GetElementValueAsync(string selector)
|
||||||
{
|
{
|
||||||
await Page.WaitForSelectorAsync(selector);
|
await Page.WaitForSelectorAsync(selector);
|
||||||
var element = await Page.QuerySelectorAsync(selector);
|
var element = await Page.QuerySelectorAsync(selector);
|
||||||
var value = await Page.EvaluateFunctionAsync<string>("el => el.value", element);
|
var value = await Page.EvaluateFunctionAsync<string>("el => el.value", element);
|
||||||
return value ?? string.Empty;
|
return value ?? string.Empty;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<bool> IsElementVisibleAsync(string selector)
|
public async Task<bool> IsElementVisibleAsync(string selector)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await Page.WaitForSelectorAsync(selector, new WaitForSelectorOptions
|
await Page.WaitForSelectorAsync(selector, new WaitForSelectorOptions
|
||||||
{
|
{
|
||||||
Visible = true,
|
Visible = true,
|
||||||
Timeout = 2000
|
Timeout = 2000
|
||||||
});
|
});
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
catch (WaitTaskTimeoutException)
|
catch (WaitTaskTimeoutException)
|
||||||
{
|
{
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task TakeScreenshotAsync(string fileName)
|
public async Task TakeScreenshotAsync(string fileName)
|
||||||
{
|
{
|
||||||
await Page.ScreenshotAsync(fileName);
|
await Page.ScreenshotAsync(fileName);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<string> GetCurrentUrlAsync()
|
public Task<string> GetCurrentUrlAsync()
|
||||||
{
|
{
|
||||||
return Page.Url;
|
return Task.FromResult(Page.Url);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<List<string>> GetAllElementTextsAsync(string selector)
|
public async Task<List<string>> GetAllElementTextsAsync(string selector)
|
||||||
{
|
{
|
||||||
var elements = await Page.QuerySelectorAllAsync(selector);
|
var elements = await Page.QuerySelectorAllAsync(selector);
|
||||||
var texts = new List<string>();
|
var texts = new List<string>();
|
||||||
|
|
||||||
foreach (var element in elements)
|
foreach (var element in elements)
|
||||||
{
|
{
|
||||||
var text = await Page.EvaluateFunctionAsync<string>("el => el.textContent", element);
|
var text = await Page.EvaluateFunctionAsync<string>("el => el.textContent", element);
|
||||||
texts.Add(text?.Trim() ?? string.Empty);
|
texts.Add(text?.Trim() ?? string.Empty);
|
||||||
}
|
}
|
||||||
|
|
||||||
return texts;
|
return texts;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async ValueTask DisposeAsync()
|
public async ValueTask DisposeAsync()
|
||||||
{
|
{
|
||||||
if (_page != null)
|
if (_page != null)
|
||||||
{
|
{
|
||||||
await _page.CloseAsync();
|
await _page.CloseAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (_browser != null)
|
if (_browser != null)
|
||||||
{
|
{
|
||||||
await _browser.CloseAsync();
|
await _browser.CloseAsync();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -1,157 +1,157 @@
|
|||||||
# BCards Integration Tests
|
# 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.
|
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
|
## Estrutura dos Testes
|
||||||
|
|
||||||
### Fixtures
|
### Fixtures
|
||||||
- **BCardsWebApplicationFactory**: Factory personalizada que configura ambiente de teste com MongoDB container
|
- **BCardsWebApplicationFactory**: Factory personalizada que configura ambiente de teste com MongoDB container
|
||||||
- **MongoDbTestFixture**: Helper para criar e gerenciar dados de teste no MongoDB
|
- **MongoDbTestFixture**: Helper para criar e gerenciar dados de teste no MongoDB
|
||||||
- **StripeTestFixture**: Mock para integração Stripe (futuro)
|
- **StripeTestFixture**: Mock para integração Stripe (futuro)
|
||||||
|
|
||||||
### Helpers
|
### Helpers
|
||||||
- **AuthenticationHelper**: Mock para autenticação OAuth (Google/Microsoft)
|
- **AuthenticationHelper**: Mock para autenticação OAuth (Google/Microsoft)
|
||||||
- **PuppeteerTestHelper**: Automação de browser para testes E2E
|
- **PuppeteerTestHelper**: Automação de browser para testes E2E
|
||||||
- **TestDataBuilder**: Builders para criar objetos de teste (futuro)
|
- **TestDataBuilder**: Builders para criar objetos de teste (futuro)
|
||||||
|
|
||||||
### Tests
|
### Tests
|
||||||
- **PageCreationTests**: Validação de criação de páginas e limites por plano
|
- **PageCreationTests**: Validação de criação de páginas e limites por plano
|
||||||
- **PreviewTokenTests**: Sistema de preview tokens para páginas não-ativas
|
- **PreviewTokenTests**: Sistema de preview tokens para páginas não-ativas
|
||||||
- **ModerationWorkflowTests**: Workflow completo de moderação
|
- **ModerationWorkflowTests**: Workflow completo de moderação
|
||||||
- **PlanLimitationTests**: Validação de limitações por plano (futuro)
|
- **PlanLimitationTests**: Validação de limitações por plano (futuro)
|
||||||
- **StripeIntegrationTests**: Testes de upgrade via Stripe (futuro)
|
- **StripeIntegrationTests**: Testes de upgrade via Stripe (futuro)
|
||||||
|
|
||||||
## Cenários Testados
|
## Cenários Testados
|
||||||
|
|
||||||
### Sistema de Páginas
|
### Sistema de Páginas
|
||||||
1. **Criação de páginas** respeitando limites dos planos (Trial: 1, Basic: 3, etc.)
|
1. **Criação de páginas** respeitando limites dos planos (Trial: 1, Basic: 3, etc.)
|
||||||
2. **Status de páginas**: Creating → PendingModeration → Active/Rejected
|
2. **Status de páginas**: Creating → PendingModeration → Active/Rejected
|
||||||
3. **Preview tokens**: Acesso a páginas em desenvolvimento (4h de validade)
|
3. **Preview tokens**: Acesso a páginas em desenvolvimento (4h de validade)
|
||||||
4. **Validação de limites**: Links normais vs produto por plano
|
4. **Validação de limites**: Links normais vs produto por plano
|
||||||
|
|
||||||
### Workflow de Moderação
|
### Workflow de Moderação
|
||||||
1. **Submissão para moderação**: Creating → PendingModeration
|
1. **Submissão para moderação**: Creating → PendingModeration
|
||||||
2. **Aprovação**: PendingModeration → Active (page vira pública)
|
2. **Aprovação**: PendingModeration → Active (page vira pública)
|
||||||
3. **Rejeição**: PendingModeration → Inactive/Rejected
|
3. **Rejeição**: PendingModeration → Inactive/Rejected
|
||||||
4. **Preview system**: Acesso via token para pages não-Active
|
4. **Preview system**: Acesso via token para pages não-Active
|
||||||
|
|
||||||
### Plan Limitations (Basic vs Professional)
|
### Plan Limitations (Basic vs Professional)
|
||||||
- **Basic**: 5 links máximo
|
- **Basic**: 5 links máximo
|
||||||
- **Professional**: 15 links máximo
|
- **Professional**: 15 links máximo
|
||||||
- **Trial**: 1 página, 3 links + 1 produto
|
- **Trial**: 1 página, 3 links + 1 produto
|
||||||
|
|
||||||
## Tecnologias Utilizadas
|
## Tecnologias Utilizadas
|
||||||
|
|
||||||
- **xUnit**: Framework de testes
|
- **xUnit**: Framework de testes
|
||||||
- **FluentAssertions**: Assertions expressivas
|
- **FluentAssertions**: Assertions expressivas
|
||||||
- **WebApplicationFactory**: Testes integrados ASP.NET Core
|
- **WebApplicationFactory**: Testes integrados ASP.NET Core
|
||||||
- **Testcontainers**: MongoDB container para isolamento
|
- **Testcontainers**: MongoDB container para isolamento
|
||||||
- **PuppeteerSharp**: Automação de browser (Chrome)
|
- **PuppeteerSharp**: Automação de browser (Chrome)
|
||||||
- **MongoDB.Driver**: Acesso direto ao banco para validações
|
- **MongoDB.Driver**: Acesso direto ao banco para validações
|
||||||
|
|
||||||
## Configuração
|
## Configuração
|
||||||
|
|
||||||
### Pré-requisitos
|
### Pré-requisitos
|
||||||
- .NET 8 SDK
|
- .NET 8 SDK
|
||||||
- Docker (para MongoDB container)
|
- Docker (para MongoDB container)
|
||||||
- Chrome/Chromium (baixado automaticamente pelo PuppeteerSharp)
|
- Chrome/Chromium (baixado automaticamente pelo PuppeteerSharp)
|
||||||
|
|
||||||
### Executar Testes
|
### Executar Testes
|
||||||
```bash
|
```bash
|
||||||
# Todos os testes
|
# Todos os testes
|
||||||
dotnet test src/BCards.IntegrationTests/
|
dotnet test src/BCards.IntegrationTests/
|
||||||
|
|
||||||
# Testes específicos
|
# Testes específicos
|
||||||
dotnet test src/BCards.IntegrationTests/ --filter "PageCreationTests"
|
dotnet test src/BCards.IntegrationTests/ --filter "PageCreationTests"
|
||||||
dotnet test src/BCards.IntegrationTests/ --filter "PreviewTokenTests"
|
dotnet test src/BCards.IntegrationTests/ --filter "PreviewTokenTests"
|
||||||
```
|
```
|
||||||
|
|
||||||
### Configuração Manual (MongoDB local)
|
### Configuração Manual (MongoDB local)
|
||||||
Se preferir usar MongoDB local em vez do container:
|
Se preferir usar MongoDB local em vez do container:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
// appsettings.Testing.json
|
// appsettings.Testing.json
|
||||||
{
|
{
|
||||||
"MongoDb": {
|
"MongoDb": {
|
||||||
"ConnectionString": "mongodb://localhost:27017",
|
"ConnectionString": "mongodb://localhost:27017",
|
||||||
"DatabaseName": "BCardsDB_Test"
|
"DatabaseName": "BCardsDB_Test"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
## Estrutura de Dados de Teste
|
## Estrutura de Dados de Teste
|
||||||
|
|
||||||
### User
|
### User
|
||||||
- **Trial**: 1 página máx, links limitados
|
- **Trial**: 1 página máx, links limitados
|
||||||
- **Basic**: 3 páginas, 5 links por página
|
- **Basic**: 3 páginas, 5 links por página
|
||||||
- **Professional**: 5 páginas, 15 links por página
|
- **Professional**: 5 páginas, 15 links por página
|
||||||
|
|
||||||
### UserPage
|
### UserPage
|
||||||
- **Status**: Creating, PendingModeration, Active, Rejected
|
- **Status**: Creating, PendingModeration, Active, Rejected
|
||||||
- **Preview Tokens**: 4h de validade para access não-Active
|
- **Preview Tokens**: 4h de validade para access não-Active
|
||||||
- **Links**: Normal vs Product (limites diferentes por plano)
|
- **Links**: Normal vs Product (limites diferentes por plano)
|
||||||
|
|
||||||
### Categories
|
### Categories
|
||||||
- **tecnologia**: Empresas de tech
|
- **tecnologia**: Empresas de tech
|
||||||
- **negocios**: Empresas e empreendedores
|
- **negocios**: Empresas e empreendedores
|
||||||
- **pessoal**: Freelancers e páginas pessoais
|
- **pessoal**: Freelancers e páginas pessoais
|
||||||
- **saude**: Profissionais da área da saúde
|
- **saude**: Profissionais da área da saúde
|
||||||
|
|
||||||
## Padrões de Teste
|
## Padrões de Teste
|
||||||
|
|
||||||
### Arrange-Act-Assert
|
### Arrange-Act-Assert
|
||||||
Todos os testes seguem o padrão AAA:
|
Todos os testes seguem o padrão AAA:
|
||||||
```csharp
|
```csharp
|
||||||
// Arrange
|
// Arrange
|
||||||
var user = await _dbFixture.CreateTestUserAsync(PlanType.Basic);
|
var user = await _dbFixture.CreateTestUserAsync(PlanType.Basic);
|
||||||
var page = await _dbFixture.CreateTestUserPageAsync(user.Id, PageStatus.Creating);
|
var page = await _dbFixture.CreateTestUserPageAsync(user.Id, PageStatus.Creating);
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
var response = await client.PostAsync($"/Admin/SubmitForModeration/{page.Id}", null);
|
var response = await client.PostAsync($"/Admin/SubmitForModeration/{page.Id}", null);
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
response.IsSuccessStatusCode.Should().BeTrue();
|
response.IsSuccessStatusCode.Should().BeTrue();
|
||||||
```
|
```
|
||||||
|
|
||||||
### Cleanup Automático
|
### Cleanup Automático
|
||||||
- Cada teste usa database isolada (GUID no nome)
|
- Cada teste usa database isolada (GUID no nome)
|
||||||
- Container MongoDB é destruído após os testes
|
- Container MongoDB é destruído após os testes
|
||||||
- Sem interferência entre testes
|
- Sem interferência entre testes
|
||||||
|
|
||||||
### Mocks
|
### Mocks
|
||||||
- **EmailService**: Mockado para evitar envios reais
|
- **EmailService**: Mockado para evitar envios reais
|
||||||
- **StripeService**: Mockado para evitar cobrança real
|
- **StripeService**: Mockado para evitar cobrança real
|
||||||
- **OAuth**: Mockado para evitar dependência externa
|
- **OAuth**: Mockado para evitar dependência externa
|
||||||
|
|
||||||
## Debug e Troubleshooting
|
## Debug e Troubleshooting
|
||||||
|
|
||||||
### PuppeteerSharp
|
### PuppeteerSharp
|
||||||
Para debug visual dos testes de browser:
|
Para debug visual dos testes de browser:
|
||||||
```csharp
|
```csharp
|
||||||
_browser = await Puppeteer.LaunchAsync(new LaunchOptions
|
_browser = await Puppeteer.LaunchAsync(new LaunchOptions
|
||||||
{
|
{
|
||||||
Headless = false, // Mostra o browser
|
Headless = false, // Mostra o browser
|
||||||
SlowMo = 100 // Delay entre ações
|
SlowMo = 100 // Delay entre ações
|
||||||
});
|
});
|
||||||
```
|
```
|
||||||
|
|
||||||
### MongoDB
|
### MongoDB
|
||||||
Para inspecionar dados durante testes, conecte no container:
|
Para inspecionar dados durante testes, conecte no container:
|
||||||
```bash
|
```bash
|
||||||
docker exec -it <container-id> mongosh BCardsDB_Test
|
docker exec -it <container-id> mongosh BCardsDB_Test
|
||||||
```
|
```
|
||||||
|
|
||||||
### Logs
|
### Logs
|
||||||
Logs são configurados para mostrar apenas warnings/errors durante testes.
|
Logs são configurados para mostrar apenas warnings/errors durante testes.
|
||||||
Para debug detalhado, altere em `BCardsWebApplicationFactory`:
|
Para debug detalhado, altere em `BCardsWebApplicationFactory`:
|
||||||
```csharp
|
```csharp
|
||||||
logging.SetMinimumLevel(LogLevel.Information);
|
logging.SetMinimumLevel(LogLevel.Information);
|
||||||
```
|
```
|
||||||
|
|
||||||
## Próximos Passos
|
## Próximos Passos
|
||||||
|
|
||||||
1. **PlanLimitationTests**: Validar todas as limitações por plano
|
1. **PlanLimitationTests**: Validar todas as limitações por plano
|
||||||
2. **StripeIntegrationTests**: Testar upgrades via webhook
|
2. **StripeIntegrationTests**: Testar upgrades via webhook
|
||||||
3. **PerformanceTests**: Testar carga no sistema de moderação
|
3. **PerformanceTests**: Testar carga no sistema de moderação
|
||||||
4. **E2E Tests**: Testes completos com PuppeteerSharp
|
4. **E2E Tests**: Testes completos com PuppeteerSharp
|
||||||
5. **TrialExpirationTests**: Validar exclusão automática após 7 dias
|
5. **TrialExpirationTests**: Validar exclusão automática após 7 dias
|
||||||
@ -1,204 +1,204 @@
|
|||||||
using Xunit;
|
using Xunit;
|
||||||
using FluentAssertions;
|
using FluentAssertions;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
using MongoDB.Driver;
|
using MongoDB.Driver;
|
||||||
using BCards.Web.Models;
|
using BCards.Web.Models;
|
||||||
using BCards.Web.ViewModels;
|
using BCards.Web.ViewModels;
|
||||||
using BCards.Web.Services;
|
using BCards.Web.Services;
|
||||||
using BCards.IntegrationTests.Fixtures;
|
using BCards.IntegrationTests.Fixtures;
|
||||||
using BCards.IntegrationTests.Helpers;
|
using BCards.IntegrationTests.Helpers;
|
||||||
using System.Net;
|
using System.Net;
|
||||||
|
|
||||||
namespace BCards.IntegrationTests.Tests;
|
namespace BCards.IntegrationTests.Tests;
|
||||||
|
|
||||||
public class ModerationWorkflowTests : IClassFixture<BCardsWebApplicationFactory>, IAsyncLifetime
|
public class ModerationWorkflowTests : IClassFixture<BCardsWebApplicationFactory>, IAsyncLifetime
|
||||||
{
|
{
|
||||||
private readonly BCardsWebApplicationFactory _factory;
|
private readonly BCardsWebApplicationFactory _factory;
|
||||||
private readonly HttpClient _client;
|
private readonly HttpClient _client;
|
||||||
private MongoDbTestFixture _dbFixture = null!;
|
private MongoDbTestFixture _dbFixture = null!;
|
||||||
|
|
||||||
public ModerationWorkflowTests(BCardsWebApplicationFactory factory)
|
public ModerationWorkflowTests(BCardsWebApplicationFactory factory)
|
||||||
{
|
{
|
||||||
_factory = factory;
|
_factory = factory;
|
||||||
_client = _factory.CreateClient();
|
_client = _factory.CreateClient();
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task InitializeAsync()
|
public async Task InitializeAsync()
|
||||||
{
|
{
|
||||||
using var scope = _factory.Services.CreateScope();
|
using var scope = _factory.Services.CreateScope();
|
||||||
var database = scope.ServiceProvider.GetRequiredService<IMongoDatabase>();
|
var database = scope.ServiceProvider.GetRequiredService<IMongoDatabase>();
|
||||||
_dbFixture = new MongoDbTestFixture(database);
|
_dbFixture = new MongoDbTestFixture(database);
|
||||||
|
|
||||||
await _factory.CleanDatabaseAsync();
|
await _factory.CleanDatabaseAsync();
|
||||||
await _dbFixture.InitializeTestDataAsync();
|
await _dbFixture.InitializeTestDataAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
public Task DisposeAsync() => Task.CompletedTask;
|
public Task DisposeAsync() => Task.CompletedTask;
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task SubmitPageForModeration_ShouldChangeStatusToPendingModeration()
|
public async Task SubmitPageForModeration_ShouldChangeStatusToPendingModeration()
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
var user = await _dbFixture.CreateTestUserAsync(PlanType.Basic);
|
var user = await _dbFixture.CreateTestUserAsync(PlanType.Basic);
|
||||||
var page = await _dbFixture.CreateTestUserPageAsync(user.Id, PageStatus.Creating, "tecnologia", 3, 1);
|
var page = await _dbFixture.CreateTestUserPageAsync(user.Id, PageStatus.Creating, "tecnologia", 3, 1);
|
||||||
var authenticatedClient = await AuthenticationHelper.CreateAuthenticatedClientAsync(_factory, user);
|
var authenticatedClient = await AuthenticationHelper.CreateAuthenticatedClientAsync(_factory, user);
|
||||||
|
|
||||||
// Act - Submit page for moderation
|
// Act - Submit page for moderation
|
||||||
var response = await authenticatedClient.PostAsync($"/Admin/SubmitForModeration/{page.Id}", null);
|
var response = await authenticatedClient.PostAsync($"/Admin/SubmitForModeration/{page.Id}", null);
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
response.IsSuccessStatusCode.Should().BeTrue();
|
response.IsSuccessStatusCode.Should().BeTrue();
|
||||||
|
|
||||||
// Verify page status changed in database
|
// Verify page status changed in database
|
||||||
var updatedPages = await _dbFixture.GetUserPagesAsync(user.Id);
|
var updatedPages = await _dbFixture.GetUserPagesAsync(user.Id);
|
||||||
var updatedPage = updatedPages.First();
|
var updatedPage = updatedPages.First();
|
||||||
|
|
||||||
updatedPage.Status.Should().Be(PageStatus.PendingModeration);
|
updatedPage.Status.Should().Be(PageStatus.PendingModeration);
|
||||||
updatedPage.ModerationAttempts.Should().BeGreaterThan(0);
|
updatedPage.ModerationAttempts.Should().BeGreaterThan(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task SubmitPageForModeration_WithoutActiveLinks_ShouldFail()
|
public async Task SubmitPageForModeration_WithoutActiveLinks_ShouldFail()
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
var user = await _dbFixture.CreateTestUserAsync(PlanType.Basic);
|
var user = await _dbFixture.CreateTestUserAsync(PlanType.Basic);
|
||||||
var page = await _dbFixture.CreateTestUserPageAsync(user.Id, PageStatus.Creating, "tecnologia", 0, 0); // No links
|
var page = await _dbFixture.CreateTestUserPageAsync(user.Id, PageStatus.Creating, "tecnologia", 0, 0); // No links
|
||||||
var authenticatedClient = await AuthenticationHelper.CreateAuthenticatedClientAsync(_factory, user);
|
var authenticatedClient = await AuthenticationHelper.CreateAuthenticatedClientAsync(_factory, user);
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
var response = await authenticatedClient.PostAsync($"/Admin/SubmitForModeration/{page.Id}", null);
|
var response = await authenticatedClient.PostAsync($"/Admin/SubmitForModeration/{page.Id}", null);
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
response.IsSuccessStatusCode.Should().BeFalse();
|
response.IsSuccessStatusCode.Should().BeFalse();
|
||||||
|
|
||||||
// Verify page status didn't change
|
// Verify page status didn't change
|
||||||
var updatedPages = await _dbFixture.GetUserPagesAsync(user.Id);
|
var updatedPages = await _dbFixture.GetUserPagesAsync(user.Id);
|
||||||
var updatedPage = updatedPages.First();
|
var updatedPage = updatedPages.First();
|
||||||
|
|
||||||
updatedPage.Status.Should().Be(PageStatus.Creating);
|
updatedPage.Status.Should().Be(PageStatus.Creating);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task ApprovePage_ShouldChangeStatusToActive()
|
public async Task ApprovePage_ShouldChangeStatusToActive()
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
using var scope = _factory.Services.CreateScope();
|
using var scope = _factory.Services.CreateScope();
|
||||||
var moderationService = scope.ServiceProvider.GetRequiredService<IModerationService>();
|
var moderationService = scope.ServiceProvider.GetRequiredService<IModerationService>();
|
||||||
|
|
||||||
var user = await _dbFixture.CreateTestUserAsync(PlanType.Basic);
|
var user = await _dbFixture.CreateTestUserAsync(PlanType.Basic);
|
||||||
var page = await _dbFixture.CreateTestUserPageAsync(user.Id, PageStatus.PendingModeration, "tecnologia", 3, 1);
|
var page = await _dbFixture.CreateTestUserPageAsync(user.Id, PageStatus.PendingModeration, "tecnologia", 3, 1);
|
||||||
|
|
||||||
// Act - Approve the page
|
// Act - Approve the page
|
||||||
await moderationService.ApprovePageAsync(page.Id, "test-moderator-id", "Page looks good");
|
await moderationService.ApprovePageAsync(page.Id, "test-moderator-id", "Page looks good");
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
var updatedPages = await _dbFixture.GetUserPagesAsync(user.Id);
|
var updatedPages = await _dbFixture.GetUserPagesAsync(user.Id);
|
||||||
var updatedPage = updatedPages.First();
|
var updatedPage = updatedPages.First();
|
||||||
|
|
||||||
updatedPage.Status.Should().Be(PageStatus.Active);
|
updatedPage.Status.Should().Be(PageStatus.Active);
|
||||||
updatedPage.ApprovedAt.Should().BeCloseTo(DateTime.UtcNow, TimeSpan.FromMinutes(1));
|
updatedPage.ApprovedAt.Should().BeCloseTo(DateTime.UtcNow, TimeSpan.FromMinutes(1));
|
||||||
updatedPage.ModerationHistory.Should().HaveCount(1);
|
updatedPage.ModerationHistory.Should().HaveCount(1);
|
||||||
updatedPage.ModerationHistory.First().Status.Should().Be("approved");
|
updatedPage.ModerationHistory.First().Status.Should().Be("approved");
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task RejectPage_ShouldChangeStatusToRejected()
|
public async Task RejectPage_ShouldChangeStatusToRejected()
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
using var scope = _factory.Services.CreateScope();
|
using var scope = _factory.Services.CreateScope();
|
||||||
var moderationService = scope.ServiceProvider.GetRequiredService<IModerationService>();
|
var moderationService = scope.ServiceProvider.GetRequiredService<IModerationService>();
|
||||||
|
|
||||||
var user = await _dbFixture.CreateTestUserAsync(PlanType.Basic);
|
var user = await _dbFixture.CreateTestUserAsync(PlanType.Basic);
|
||||||
var page = await _dbFixture.CreateTestUserPageAsync(user.Id, PageStatus.PendingModeration, "tecnologia", 3, 1);
|
var page = await _dbFixture.CreateTestUserPageAsync(user.Id, PageStatus.PendingModeration, "tecnologia", 3, 1);
|
||||||
|
|
||||||
// Act - Reject the page
|
// Act - Reject the page
|
||||||
await moderationService.RejectPageAsync(page.Id, "test-moderator-id", "Inappropriate content", new List<string> { "spam", "offensive" });
|
await moderationService.RejectPageAsync(page.Id, "test-moderator-id", "Inappropriate content", new List<string> { "spam", "offensive" });
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
var updatedPages = await _dbFixture.GetUserPagesAsync(user.Id);
|
var updatedPages = await _dbFixture.GetUserPagesAsync(user.Id);
|
||||||
var updatedPage = updatedPages.First();
|
var updatedPage = updatedPages.First();
|
||||||
|
|
||||||
updatedPage.Status.Should().Be(PageStatus.Inactive); // First rejection goes to Inactive
|
updatedPage.Status.Should().Be(PageStatus.Inactive); // First rejection goes to Inactive
|
||||||
updatedPage.ModerationHistory.Should().HaveCount(1);
|
updatedPage.ModerationHistory.Should().HaveCount(1);
|
||||||
|
|
||||||
var rejectionHistory = updatedPage.ModerationHistory.First();
|
var rejectionHistory = updatedPage.ModerationHistory.First();
|
||||||
rejectionHistory.Status.Should().Be("rejected");
|
rejectionHistory.Status.Should().Be("rejected");
|
||||||
rejectionHistory.Reason.Should().Be("Inappropriate content");
|
rejectionHistory.Reason.Should().Be("Inappropriate content");
|
||||||
rejectionHistory.Issues.Should().Contain("spam");
|
rejectionHistory.Issues.Should().Contain("spam");
|
||||||
rejectionHistory.Issues.Should().Contain("offensive");
|
rejectionHistory.Issues.Should().Contain("offensive");
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task AccessApprovedPage_WithoutPreviewToken_ShouldSucceed()
|
public async Task AccessApprovedPage_WithoutPreviewToken_ShouldSucceed()
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
using var scope = _factory.Services.CreateScope();
|
using var scope = _factory.Services.CreateScope();
|
||||||
var moderationService = scope.ServiceProvider.GetRequiredService<IModerationService>();
|
var moderationService = scope.ServiceProvider.GetRequiredService<IModerationService>();
|
||||||
|
|
||||||
var user = await _dbFixture.CreateTestUserAsync(PlanType.Basic);
|
var user = await _dbFixture.CreateTestUserAsync(PlanType.Basic);
|
||||||
var page = await _dbFixture.CreateTestUserPageAsync(user.Id, PageStatus.PendingModeration, "tecnologia", 3, 1);
|
var page = await _dbFixture.CreateTestUserPageAsync(user.Id, PageStatus.PendingModeration, "tecnologia", 3, 1);
|
||||||
|
|
||||||
// Approve the page
|
// Approve the page
|
||||||
await moderationService.ApprovePageAsync(page.Id, "test-moderator-id", "Approved");
|
await moderationService.ApprovePageAsync(page.Id, "test-moderator-id", "Approved");
|
||||||
|
|
||||||
// Act - Access the page without preview token
|
// Act - Access the page without preview token
|
||||||
var response = await _client.GetAsync($"/page/{page.Category}/{page.Slug}");
|
var response = await _client.GetAsync($"/page/{page.Category}/{page.Slug}");
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
response.StatusCode.Should().Be(HttpStatusCode.OK);
|
response.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||||
|
|
||||||
var content = await response.Content.ReadAsStringAsync();
|
var content = await response.Content.ReadAsStringAsync();
|
||||||
content.Should().Contain(page.DisplayName);
|
content.Should().Contain(page.DisplayName);
|
||||||
content.Should().NotContain("MODO PREVIEW"); // Should not show preview banner
|
content.Should().NotContain("MODO PREVIEW"); // Should not show preview banner
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task GetPendingModerationPages_ShouldReturnCorrectPages()
|
public async Task GetPendingModerationPages_ShouldReturnCorrectPages()
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
using var scope = _factory.Services.CreateScope();
|
using var scope = _factory.Services.CreateScope();
|
||||||
var moderationService = scope.ServiceProvider.GetRequiredService<IModerationService>();
|
var moderationService = scope.ServiceProvider.GetRequiredService<IModerationService>();
|
||||||
|
|
||||||
var user1 = await _dbFixture.CreateTestUserAsync(PlanType.Basic, "user1@example.com");
|
var user1 = await _dbFixture.CreateTestUserAsync(PlanType.Basic, "user1@example.com");
|
||||||
var user2 = await _dbFixture.CreateTestUserAsync(PlanType.Basic, "user2@example.com");
|
var user2 = await _dbFixture.CreateTestUserAsync(PlanType.Basic, "user2@example.com");
|
||||||
|
|
||||||
// Create pages in different statuses
|
// Create pages in different statuses
|
||||||
await _dbFixture.CreateTestUserPageAsync(user1.Id, PageStatus.PendingModeration, "tecnologia", 3, 1);
|
await _dbFixture.CreateTestUserPageAsync(user1.Id, PageStatus.PendingModeration, "tecnologia", 3, 1);
|
||||||
await _dbFixture.CreateTestUserPageAsync(user2.Id, PageStatus.PendingModeration, "negocios", 4, 2);
|
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(user1.Id, PageStatus.Creating, "pessoal", 2, 0); // Should not appear
|
||||||
await _dbFixture.CreateTestUserPageAsync(user2.Id, PageStatus.Active, "saude", 5, 1); // Should not appear
|
await _dbFixture.CreateTestUserPageAsync(user2.Id, PageStatus.Active, "saude", 5, 1); // Should not appear
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
var pendingPages = await moderationService.GetPendingModerationAsync();
|
var pendingPages = await moderationService.GetPendingModerationAsync();
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
pendingPages.Should().HaveCount(2);
|
pendingPages.Should().HaveCount(2);
|
||||||
pendingPages.Should().OnlyContain(p => p.Status == PageStatus.PendingModeration);
|
pendingPages.Should().OnlyContain(p => p.Status == PageStatus.PendingModeration);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task ModerationStats_ShouldReturnCorrectCounts()
|
public async Task ModerationStats_ShouldReturnCorrectCounts()
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
using var scope = _factory.Services.CreateScope();
|
using var scope = _factory.Services.CreateScope();
|
||||||
var moderationService = scope.ServiceProvider.GetRequiredService<IModerationService>();
|
var moderationService = scope.ServiceProvider.GetRequiredService<IModerationService>();
|
||||||
|
|
||||||
var user = await _dbFixture.CreateTestUserAsync(PlanType.Basic);
|
var user = await _dbFixture.CreateTestUserAsync(PlanType.Basic);
|
||||||
|
|
||||||
// Create pages with different statuses
|
// Create pages with different statuses
|
||||||
var pendingPage1 = await _dbFixture.CreateTestUserPageAsync(user.Id, PageStatus.PendingModeration, "tecnologia", 3, 1);
|
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 pendingPage2 = await _dbFixture.CreateTestUserPageAsync(user.Id, PageStatus.PendingModeration, "negocios", 3, 1);
|
||||||
var activePage = await _dbFixture.CreateTestUserPageAsync(user.Id, PageStatus.PendingModeration, "pessoal", 3, 1);
|
var activePage = await _dbFixture.CreateTestUserPageAsync(user.Id, PageStatus.PendingModeration, "pessoal", 3, 1);
|
||||||
|
|
||||||
// Approve one page today
|
// Approve one page today
|
||||||
await moderationService.ApprovePageAsync(activePage.Id, "moderator", "Good");
|
await moderationService.ApprovePageAsync(activePage.Id, "moderator", "Good");
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
var stats = await moderationService.GetModerationStatsAsync();
|
var stats = await moderationService.GetModerationStatsAsync();
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
stats["pending"].Should().Be(2);
|
stats["pending"].Should().Be(2);
|
||||||
stats["approvedToday"].Should().Be(1);
|
stats["approvedToday"].Should().Be(1);
|
||||||
stats["rejectedToday"].Should().Be(0);
|
stats["rejectedToday"].Should().Be(0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -1,238 +1,238 @@
|
|||||||
using Xunit;
|
using Xunit;
|
||||||
using FluentAssertions;
|
using FluentAssertions;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
using MongoDB.Driver;
|
using MongoDB.Driver;
|
||||||
using BCards.Web.Models;
|
using BCards.Web.Models;
|
||||||
using BCards.Web.ViewModels;
|
using BCards.Web.ViewModels;
|
||||||
using BCards.IntegrationTests.Fixtures;
|
using BCards.IntegrationTests.Fixtures;
|
||||||
using BCards.IntegrationTests.Helpers;
|
using BCards.IntegrationTests.Helpers;
|
||||||
using System.Net.Http.Json;
|
using System.Net.Http.Json;
|
||||||
|
|
||||||
namespace BCards.IntegrationTests.Tests;
|
namespace BCards.IntegrationTests.Tests;
|
||||||
|
|
||||||
public class PageCreationTests : IClassFixture<BCardsWebApplicationFactory>, IAsyncLifetime
|
public class PageCreationTests : IClassFixture<BCardsWebApplicationFactory>, IAsyncLifetime
|
||||||
{
|
{
|
||||||
private readonly BCardsWebApplicationFactory _factory;
|
private readonly BCardsWebApplicationFactory _factory;
|
||||||
private readonly HttpClient _client;
|
private readonly HttpClient _client;
|
||||||
private MongoDbTestFixture _dbFixture = null!;
|
private MongoDbTestFixture _dbFixture = null!;
|
||||||
|
|
||||||
public PageCreationTests(BCardsWebApplicationFactory factory)
|
public PageCreationTests(BCardsWebApplicationFactory factory)
|
||||||
{
|
{
|
||||||
_factory = factory;
|
_factory = factory;
|
||||||
_client = _factory.CreateClient();
|
_client = _factory.CreateClient();
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task InitializeAsync()
|
public async Task InitializeAsync()
|
||||||
{
|
{
|
||||||
using var scope = _factory.Services.CreateScope();
|
using var scope = _factory.Services.CreateScope();
|
||||||
var database = scope.ServiceProvider.GetRequiredService<IMongoDatabase>();
|
var database = scope.ServiceProvider.GetRequiredService<IMongoDatabase>();
|
||||||
_dbFixture = new MongoDbTestFixture(database);
|
_dbFixture = new MongoDbTestFixture(database);
|
||||||
|
|
||||||
await _factory.CleanDatabaseAsync();
|
await _factory.CleanDatabaseAsync();
|
||||||
await _dbFixture.InitializeTestDataAsync();
|
await _dbFixture.InitializeTestDataAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
public Task DisposeAsync() => Task.CompletedTask;
|
public Task DisposeAsync() => Task.CompletedTask;
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task CreatePage_WithBasicPlan_ShouldAllowUpTo5Links()
|
public async Task CreatePage_WithBasicPlan_ShouldAllowUpTo5Links()
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
var user = await _dbFixture.CreateTestUserAsync(PlanType.Basic);
|
var user = await _dbFixture.CreateTestUserAsync(PlanType.Basic);
|
||||||
var authenticatedClient = await AuthenticationHelper.CreateAuthenticatedClientAsync(_factory, user);
|
var authenticatedClient = await AuthenticationHelper.CreateAuthenticatedClientAsync(_factory, user);
|
||||||
|
|
||||||
// Act - Create a page with 5 links (should succeed)
|
// Act - Create a page with 5 links (should succeed)
|
||||||
var pageData = new
|
var pageData = new
|
||||||
{
|
{
|
||||||
DisplayName = "Test Business Page",
|
DisplayName = "Test Business Page",
|
||||||
Category = "tecnologia",
|
Category = "tecnologia",
|
||||||
BusinessType = "company",
|
BusinessType = "company",
|
||||||
Bio = "A test business page",
|
Bio = "A test business page",
|
||||||
Slug = "test-business",
|
Slug = "test-business",
|
||||||
SelectedTheme = "minimalist",
|
SelectedTheme = "minimalist",
|
||||||
Links = new[]
|
Links = new[]
|
||||||
{
|
{
|
||||||
new { Title = "Website", Url = "https://example.com", Description = "Main website", Icon = "fas fa-globe" },
|
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 = "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 = "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 = "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" }
|
new { Title = "Instagram", Url = "https://instagram.com/example", Description = "Instagram profile", Icon = "fab fa-instagram" }
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
var createResponse = await authenticatedClient.PostAsJsonAsync("/Admin/ManagePage", pageData);
|
var createResponse = await authenticatedClient.PostAsJsonAsync("/Admin/ManagePage", pageData);
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
createResponse.IsSuccessStatusCode.Should().BeTrue("Basic plan should allow 5 links");
|
createResponse.IsSuccessStatusCode.Should().BeTrue("Basic plan should allow 5 links");
|
||||||
|
|
||||||
// Verify page was created in database
|
// Verify page was created in database
|
||||||
var createdPages = await _dbFixture.GetUserPagesAsync(user.Id);
|
var createdPages = await _dbFixture.GetUserPagesAsync(user.Id);
|
||||||
createdPages.Should().HaveCount(1);
|
createdPages.Should().HaveCount(1);
|
||||||
|
|
||||||
var createdPage = createdPages.First();
|
var createdPage = createdPages.First();
|
||||||
createdPage.DisplayName.Should().Be("Test Business Page");
|
createdPage.DisplayName.Should().Be("Test Business Page");
|
||||||
createdPage.Category.Should().Be("tecnologia");
|
createdPage.Category.Should().Be("tecnologia");
|
||||||
createdPage.Status.Should().Be(PageStatus.Creating);
|
createdPage.Status.Should().Be(PageStatus.Creating);
|
||||||
createdPage.Links.Should().HaveCount(5);
|
createdPage.Links.Should().HaveCount(5);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task CreatePage_WithBasicPlanExceedingLimits_ShouldFail()
|
public async Task CreatePage_WithBasicPlanExceedingLimits_ShouldFail()
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
var user = await _dbFixture.CreateTestUserAsync(PlanType.Basic);
|
var user = await _dbFixture.CreateTestUserAsync(PlanType.Basic);
|
||||||
var authenticatedClient = await AuthenticationHelper.CreateAuthenticatedClientAsync(_factory, user);
|
var authenticatedClient = await AuthenticationHelper.CreateAuthenticatedClientAsync(_factory, user);
|
||||||
|
|
||||||
// Act - Try to create a page with 6 links (should fail for Basic plan)
|
// Act - Try to create a page with 6 links (should fail for Basic plan)
|
||||||
var pageData = new
|
var pageData = new
|
||||||
{
|
{
|
||||||
DisplayName = "Test Page Exceeding Limits",
|
DisplayName = "Test Page Exceeding Limits",
|
||||||
Category = "tecnologia",
|
Category = "tecnologia",
|
||||||
BusinessType = "individual",
|
BusinessType = "individual",
|
||||||
Bio = "A test page with too many links",
|
Bio = "A test page with too many links",
|
||||||
Slug = "test-exceeding",
|
Slug = "test-exceeding",
|
||||||
SelectedTheme = "minimalist",
|
SelectedTheme = "minimalist",
|
||||||
Links = new[]
|
Links = new[]
|
||||||
{
|
{
|
||||||
new { Title = "Link 1", Url = "https://example1.com", Description = "Link 1", Icon = "fas fa-link" },
|
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 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 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 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 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
|
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);
|
var createResponse = await authenticatedClient.PostAsJsonAsync("/Admin/ManagePage", pageData);
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
createResponse.IsSuccessStatusCode.Should().BeFalse("Basic plan should not allow more than 5 links");
|
createResponse.IsSuccessStatusCode.Should().BeFalse("Basic plan should not allow more than 5 links");
|
||||||
|
|
||||||
// Verify no page was created
|
// Verify no page was created
|
||||||
var createdPages = await _dbFixture.GetUserPagesAsync(user.Id);
|
var createdPages = await _dbFixture.GetUserPagesAsync(user.Id);
|
||||||
createdPages.Should().BeEmpty();
|
createdPages.Should().BeEmpty();
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task CreatePage_ShouldStartInCreatingStatus()
|
public async Task CreatePage_ShouldStartInCreatingStatus()
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
var user = await _dbFixture.CreateTestUserAsync(PlanType.Basic);
|
var user = await _dbFixture.CreateTestUserAsync(PlanType.Basic);
|
||||||
var authenticatedClient = await AuthenticationHelper.CreateAuthenticatedClientAsync(_factory, user);
|
var authenticatedClient = await AuthenticationHelper.CreateAuthenticatedClientAsync(_factory, user);
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
var pageData = new
|
var pageData = new
|
||||||
{
|
{
|
||||||
DisplayName = "New Page",
|
DisplayName = "New Page",
|
||||||
Category = "pessoal",
|
Category = "pessoal",
|
||||||
BusinessType = "individual",
|
BusinessType = "individual",
|
||||||
Bio = "Test page bio",
|
Bio = "Test page bio",
|
||||||
Slug = "new-page",
|
Slug = "new-page",
|
||||||
SelectedTheme = "minimalist",
|
SelectedTheme = "minimalist",
|
||||||
Links = new[]
|
Links = new[]
|
||||||
{
|
{
|
||||||
new { Title = "Portfolio", Url = "https://myportfolio.com", Description = "My work", Icon = "fas fa-briefcase" }
|
new { Title = "Portfolio", Url = "https://myportfolio.com", Description = "My work", Icon = "fas fa-briefcase" }
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
var createResponse = await authenticatedClient.PostAsJsonAsync("/Admin/ManagePage", pageData);
|
var createResponse = await authenticatedClient.PostAsJsonAsync("/Admin/ManagePage", pageData);
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
createResponse.IsSuccessStatusCode.Should().BeTrue();
|
createResponse.IsSuccessStatusCode.Should().BeTrue();
|
||||||
|
|
||||||
var createdPages = await _dbFixture.GetUserPagesAsync(user.Id);
|
var createdPages = await _dbFixture.GetUserPagesAsync(user.Id);
|
||||||
var page = createdPages.First();
|
var page = createdPages.First();
|
||||||
|
|
||||||
page.Status.Should().Be(PageStatus.Creating);
|
page.Status.Should().Be(PageStatus.Creating);
|
||||||
page.PreviewToken.Should().NotBeNullOrEmpty("Creating pages should have preview tokens");
|
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");
|
page.PreviewTokenExpiry.Should().BeAfter(DateTime.UtcNow.AddHours(3), "Preview token should be valid for ~4 hours");
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task CreatePage_WithTrialPlan_ShouldAllowOnePageOnly()
|
public async Task CreatePage_WithTrialPlan_ShouldAllowOnePageOnly()
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
var user = await _dbFixture.CreateTestUserAsync(PlanType.Trial);
|
var user = await _dbFixture.CreateTestUserAsync(PlanType.Trial);
|
||||||
var authenticatedClient = await AuthenticationHelper.CreateAuthenticatedClientAsync(_factory, user);
|
var authenticatedClient = await AuthenticationHelper.CreateAuthenticatedClientAsync(_factory, user);
|
||||||
|
|
||||||
// Act - Create first page (should succeed)
|
// Act - Create first page (should succeed)
|
||||||
var firstPageData = new
|
var firstPageData = new
|
||||||
{
|
{
|
||||||
DisplayName = "First Trial Page",
|
DisplayName = "First Trial Page",
|
||||||
Category = "pessoal",
|
Category = "pessoal",
|
||||||
BusinessType = "individual",
|
BusinessType = "individual",
|
||||||
Bio = "First page in trial",
|
Bio = "First page in trial",
|
||||||
Slug = "first-trial",
|
Slug = "first-trial",
|
||||||
SelectedTheme = "minimalist",
|
SelectedTheme = "minimalist",
|
||||||
Links = new[]
|
Links = new[]
|
||||||
{
|
{
|
||||||
new { Title = "Website", Url = "https://example.com", Description = "My website", Icon = "fas fa-globe" }
|
new { Title = "Website", Url = "https://example.com", Description = "My website", Icon = "fas fa-globe" }
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
var firstResponse = await authenticatedClient.PostAsJsonAsync("/Admin/ManagePage", firstPageData);
|
var firstResponse = await authenticatedClient.PostAsJsonAsync("/Admin/ManagePage", firstPageData);
|
||||||
firstResponse.IsSuccessStatusCode.Should().BeTrue("Trial should allow first page");
|
firstResponse.IsSuccessStatusCode.Should().BeTrue("Trial should allow first page");
|
||||||
|
|
||||||
// Act - Try to create second page (should fail)
|
// Act - Try to create second page (should fail)
|
||||||
var secondPageData = new
|
var secondPageData = new
|
||||||
{
|
{
|
||||||
DisplayName = "Second Trial Page",
|
DisplayName = "Second Trial Page",
|
||||||
Category = "tecnologia",
|
Category = "tecnologia",
|
||||||
BusinessType = "individual",
|
BusinessType = "individual",
|
||||||
Bio = "Second page in trial - should fail",
|
Bio = "Second page in trial - should fail",
|
||||||
Slug = "second-trial",
|
Slug = "second-trial",
|
||||||
SelectedTheme = "minimalist",
|
SelectedTheme = "minimalist",
|
||||||
Links = new[]
|
Links = new[]
|
||||||
{
|
{
|
||||||
new { Title = "LinkedIn", Url = "https://linkedin.com/in/test", Description = "LinkedIn", Icon = "fab fa-linkedin" }
|
new { Title = "LinkedIn", Url = "https://linkedin.com/in/test", Description = "LinkedIn", Icon = "fab fa-linkedin" }
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
var secondResponse = await authenticatedClient.PostAsJsonAsync("/Admin/ManagePage", secondPageData);
|
var secondResponse = await authenticatedClient.PostAsJsonAsync("/Admin/ManagePage", secondPageData);
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
secondResponse.IsSuccessStatusCode.Should().BeFalse("Trial should not allow second page");
|
secondResponse.IsSuccessStatusCode.Should().BeFalse("Trial should not allow second page");
|
||||||
|
|
||||||
var createdPages = await _dbFixture.GetUserPagesAsync(user.Id);
|
var createdPages = await _dbFixture.GetUserPagesAsync(user.Id);
|
||||||
createdPages.Should().HaveCount(1, "Trial should only have one page");
|
createdPages.Should().HaveCount(1, "Trial should only have one page");
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task CreatePage_ShouldGenerateUniqueSlug()
|
public async Task CreatePage_ShouldGenerateUniqueSlug()
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
var user = await _dbFixture.CreateTestUserAsync(PlanType.Professional);
|
var user = await _dbFixture.CreateTestUserAsync(PlanType.Professional);
|
||||||
var authenticatedClient = await AuthenticationHelper.CreateAuthenticatedClientAsync(_factory, user);
|
var authenticatedClient = await AuthenticationHelper.CreateAuthenticatedClientAsync(_factory, user);
|
||||||
|
|
||||||
// Create first page with specific slug
|
// Create first page with specific slug
|
||||||
await _dbFixture.CreateTestUserPageAsync(user.Id, PageStatus.Active, "tecnologia", 3, 1, "test-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)
|
// Act - Try to create another page with same name (should get different slug)
|
||||||
var pageData = new
|
var pageData = new
|
||||||
{
|
{
|
||||||
DisplayName = "Test Page", // Same display name, should generate different slug
|
DisplayName = "Test Page", // Same display name, should generate different slug
|
||||||
Category = "tecnologia",
|
Category = "tecnologia",
|
||||||
BusinessType = "individual",
|
BusinessType = "individual",
|
||||||
Bio = "Another test page",
|
Bio = "Another test page",
|
||||||
Slug = "test-slug", // Try to use same slug
|
Slug = "test-slug", // Try to use same slug
|
||||||
SelectedTheme = "minimalist",
|
SelectedTheme = "minimalist",
|
||||||
Links = new[]
|
Links = new[]
|
||||||
{
|
{
|
||||||
new { Title = "Website", Url = "https://example.com", Description = "Website", Icon = "fas fa-globe" }
|
new { Title = "Website", Url = "https://example.com", Description = "Website", Icon = "fas fa-globe" }
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
var createResponse = await authenticatedClient.PostAsJsonAsync("/Admin/ManagePage", pageData);
|
var createResponse = await authenticatedClient.PostAsJsonAsync("/Admin/ManagePage", pageData);
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
createResponse.IsSuccessStatusCode.Should().BeTrue();
|
createResponse.IsSuccessStatusCode.Should().BeTrue();
|
||||||
|
|
||||||
var userPages = await _dbFixture.GetUserPagesAsync(user.Id);
|
var userPages = await _dbFixture.GetUserPagesAsync(user.Id);
|
||||||
userPages.Should().HaveCount(2);
|
userPages.Should().HaveCount(2);
|
||||||
|
|
||||||
var slugs = userPages.Select(p => p.Slug).ToList();
|
var slugs = userPages.Select(p => p.Slug).ToList();
|
||||||
slugs.Should().OnlyHaveUniqueItems("All pages should have unique slugs");
|
slugs.Should().OnlyHaveUniqueItems("All pages should have unique slugs");
|
||||||
slugs.Should().Contain("test-slug");
|
slugs.Should().Contain("test-slug");
|
||||||
slugs.Should().Contain(slug => slug.StartsWith("test-slug-") || slug == "test-page");
|
slugs.Should().Contain(slug => slug.StartsWith("test-slug-") || slug == "test-page");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -1,240 +1,240 @@
|
|||||||
using Xunit;
|
using Xunit;
|
||||||
using FluentAssertions;
|
using FluentAssertions;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
using MongoDB.Driver;
|
using MongoDB.Driver;
|
||||||
using BCards.Web.Models;
|
using BCards.Web.Models;
|
||||||
using BCards.Web.ViewModels;
|
using BCards.Web.ViewModels;
|
||||||
using BCards.IntegrationTests.Fixtures;
|
using BCards.IntegrationTests.Fixtures;
|
||||||
using BCards.IntegrationTests.Helpers;
|
using BCards.IntegrationTests.Helpers;
|
||||||
using System.Net;
|
using System.Net;
|
||||||
|
|
||||||
namespace BCards.IntegrationTests.Tests;
|
namespace BCards.IntegrationTests.Tests;
|
||||||
|
|
||||||
public class PreviewTokenTests : IClassFixture<BCardsWebApplicationFactory>, IAsyncLifetime
|
public class PreviewTokenTests : IClassFixture<BCardsWebApplicationFactory>, IAsyncLifetime
|
||||||
{
|
{
|
||||||
private readonly BCardsWebApplicationFactory _factory;
|
private readonly BCardsWebApplicationFactory _factory;
|
||||||
private readonly HttpClient _client;
|
private readonly HttpClient _client;
|
||||||
private MongoDbTestFixture _dbFixture = null!;
|
private MongoDbTestFixture _dbFixture = null!;
|
||||||
|
|
||||||
public PreviewTokenTests(BCardsWebApplicationFactory factory)
|
public PreviewTokenTests(BCardsWebApplicationFactory factory)
|
||||||
{
|
{
|
||||||
_factory = factory;
|
_factory = factory;
|
||||||
_client = _factory.CreateClient();
|
_client = _factory.CreateClient();
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task InitializeAsync()
|
public async Task InitializeAsync()
|
||||||
{
|
{
|
||||||
using var scope = _factory.Services.CreateScope();
|
using var scope = _factory.Services.CreateScope();
|
||||||
var database = scope.ServiceProvider.GetRequiredService<IMongoDatabase>();
|
var database = scope.ServiceProvider.GetRequiredService<IMongoDatabase>();
|
||||||
_dbFixture = new MongoDbTestFixture(database);
|
_dbFixture = new MongoDbTestFixture(database);
|
||||||
|
|
||||||
await _factory.CleanDatabaseAsync();
|
await _factory.CleanDatabaseAsync();
|
||||||
await _dbFixture.InitializeTestDataAsync();
|
await _dbFixture.InitializeTestDataAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
public Task DisposeAsync() => Task.CompletedTask;
|
public Task DisposeAsync() => Task.CompletedTask;
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task AccessPageInCreatingStatus_WithValidPreviewToken_ShouldSucceed()
|
public async Task AccessPageInCreatingStatus_WithValidPreviewToken_ShouldSucceed()
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
var user = await _dbFixture.CreateTestUserAsync(PlanType.Basic);
|
var user = await _dbFixture.CreateTestUserAsync(PlanType.Basic);
|
||||||
var page = await _dbFixture.CreateTestUserPageAsync(user.Id, PageStatus.Creating, "tecnologia", 3, 1);
|
var page = await _dbFixture.CreateTestUserPageAsync(user.Id, PageStatus.Creating, "tecnologia", 3, 1);
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
var response = await _client.GetAsync($"/page/{page.Category}/{page.Slug}?preview={page.PreviewToken}");
|
var response = await _client.GetAsync($"/page/{page.Category}/{page.Slug}?preview={page.PreviewToken}");
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
response.StatusCode.Should().Be(HttpStatusCode.OK);
|
response.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||||
|
|
||||||
var content = await response.Content.ReadAsStringAsync();
|
var content = await response.Content.ReadAsStringAsync();
|
||||||
content.Should().Contain(page.DisplayName);
|
content.Should().Contain(page.DisplayName);
|
||||||
content.Should().Contain("MODO PREVIEW"); // Preview banner should be shown
|
content.Should().Contain("MODO PREVIEW"); // Preview banner should be shown
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task AccessPageInCreatingStatus_WithoutPreviewToken_ShouldReturn404()
|
public async Task AccessPageInCreatingStatus_WithoutPreviewToken_ShouldReturn404()
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
var user = await _dbFixture.CreateTestUserAsync(PlanType.Basic);
|
var user = await _dbFixture.CreateTestUserAsync(PlanType.Basic);
|
||||||
var page = await _dbFixture.CreateTestUserPageAsync(user.Id, PageStatus.Creating, "tecnologia", 3, 1);
|
var page = await _dbFixture.CreateTestUserPageAsync(user.Id, PageStatus.Creating, "tecnologia", 3, 1);
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
var response = await _client.GetAsync($"/page/{page.Category}/{page.Slug}");
|
var response = await _client.GetAsync($"/page/{page.Category}/{page.Slug}");
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
response.StatusCode.Should().Be(HttpStatusCode.NotFound);
|
response.StatusCode.Should().Be(HttpStatusCode.NotFound);
|
||||||
|
|
||||||
var content = await response.Content.ReadAsStringAsync();
|
var content = await response.Content.ReadAsStringAsync();
|
||||||
content.Should().Contain("Página em desenvolvimento. Acesso restrito.");
|
content.Should().Contain("Página em desenvolvimento. Acesso restrito.");
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task AccessPageInCreatingStatus_WithInvalidPreviewToken_ShouldReturn404()
|
public async Task AccessPageInCreatingStatus_WithInvalidPreviewToken_ShouldReturn404()
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
var user = await _dbFixture.CreateTestUserAsync(PlanType.Basic);
|
var user = await _dbFixture.CreateTestUserAsync(PlanType.Basic);
|
||||||
var page = await _dbFixture.CreateTestUserPageAsync(user.Id, PageStatus.Creating, "tecnologia", 3, 1);
|
var page = await _dbFixture.CreateTestUserPageAsync(user.Id, PageStatus.Creating, "tecnologia", 3, 1);
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
var response = await _client.GetAsync($"/page/{page.Category}/{page.Slug}?preview=invalid-token");
|
var response = await _client.GetAsync($"/page/{page.Category}/{page.Slug}?preview=invalid-token");
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
response.StatusCode.Should().Be(HttpStatusCode.NotFound);
|
response.StatusCode.Should().Be(HttpStatusCode.NotFound);
|
||||||
|
|
||||||
var content = await response.Content.ReadAsStringAsync();
|
var content = await response.Content.ReadAsStringAsync();
|
||||||
content.Should().Contain("Página em desenvolvimento. Acesso restrito.");
|
content.Should().Contain("Página em desenvolvimento. Acesso restrito.");
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task AccessPageInCreatingStatus_WithExpiredPreviewToken_ShouldReturn404()
|
public async Task AccessPageInCreatingStatus_WithExpiredPreviewToken_ShouldReturn404()
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
var user = await _dbFixture.CreateTestUserAsync(PlanType.Basic);
|
var user = await _dbFixture.CreateTestUserAsync(PlanType.Basic);
|
||||||
var page = await _dbFixture.CreateTestUserPageAsync(user.Id, PageStatus.Creating, "tecnologia", 3, 1);
|
var page = await _dbFixture.CreateTestUserPageAsync(user.Id, PageStatus.Creating, "tecnologia", 3, 1);
|
||||||
|
|
||||||
// Simulate expired token
|
// Simulate expired token
|
||||||
page.PreviewTokenExpiry = DateTime.UtcNow.AddHours(-1); // Expired 1 hour ago
|
page.PreviewTokenExpiry = DateTime.UtcNow.AddHours(-1); // Expired 1 hour ago
|
||||||
await _dbFixture.UserPageRepository.UpdateAsync(page);
|
await _dbFixture.UserPageRepository.UpdateAsync(page);
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
var response = await _client.GetAsync($"/page/{page.Category}/{page.Slug}?preview={page.PreviewToken}");
|
var response = await _client.GetAsync($"/page/{page.Category}/{page.Slug}?preview={page.PreviewToken}");
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
response.StatusCode.Should().Be(HttpStatusCode.NotFound);
|
response.StatusCode.Should().Be(HttpStatusCode.NotFound);
|
||||||
|
|
||||||
var content = await response.Content.ReadAsStringAsync();
|
var content = await response.Content.ReadAsStringAsync();
|
||||||
content.Should().Contain("Página em desenvolvimento. Acesso restrito.");
|
content.Should().Contain("Página em desenvolvimento. Acesso restrito.");
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task GeneratePreviewToken_ForCreatingPage_ShouldReturnNewToken()
|
public async Task GeneratePreviewToken_ForCreatingPage_ShouldReturnNewToken()
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
var user = await _dbFixture.CreateTestUserAsync(PlanType.Basic);
|
var user = await _dbFixture.CreateTestUserAsync(PlanType.Basic);
|
||||||
var page = await _dbFixture.CreateTestUserPageAsync(user.Id, PageStatus.Creating, "tecnologia", 3, 1);
|
var page = await _dbFixture.CreateTestUserPageAsync(user.Id, PageStatus.Creating, "tecnologia", 3, 1);
|
||||||
var authenticatedClient = await AuthenticationHelper.CreateAuthenticatedClientAsync(_factory, user);
|
var authenticatedClient = await AuthenticationHelper.CreateAuthenticatedClientAsync(_factory, user);
|
||||||
|
|
||||||
var oldToken = page.PreviewToken;
|
var oldToken = page.PreviewToken;
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
var response = await authenticatedClient.PostAsync($"/Admin/GeneratePreviewToken/{page.Id}", null);
|
var response = await authenticatedClient.PostAsync($"/Admin/GeneratePreviewToken/{page.Id}", null);
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
response.IsSuccessStatusCode.Should().BeTrue();
|
response.IsSuccessStatusCode.Should().BeTrue();
|
||||||
|
|
||||||
var jsonResponse = await response.Content.ReadAsStringAsync();
|
var jsonResponse = await response.Content.ReadAsStringAsync();
|
||||||
jsonResponse.Should().Contain("success");
|
jsonResponse.Should().Contain("success");
|
||||||
jsonResponse.Should().Contain("previewToken");
|
jsonResponse.Should().Contain("previewToken");
|
||||||
|
|
||||||
// Verify new token is different and works
|
// Verify new token is different and works
|
||||||
var updatedPages = await _dbFixture.GetUserPagesAsync(user.Id);
|
var updatedPages = await _dbFixture.GetUserPagesAsync(user.Id);
|
||||||
var updatedPage = updatedPages.First();
|
var updatedPage = updatedPages.First();
|
||||||
|
|
||||||
updatedPage.PreviewToken.Should().NotBe(oldToken);
|
updatedPage.PreviewToken.Should().NotBe(oldToken);
|
||||||
updatedPage.PreviewTokenExpiry.Should().BeAfter(DateTime.UtcNow.AddHours(3));
|
updatedPage.PreviewTokenExpiry.Should().BeAfter(DateTime.UtcNow.AddHours(3));
|
||||||
|
|
||||||
// Test new token works
|
// Test new token works
|
||||||
var pageResponse = await _client.GetAsync($"/page/{page.Category}/{page.Slug}?preview={updatedPage.PreviewToken}");
|
var pageResponse = await _client.GetAsync($"/page/{page.Category}/{page.Slug}?preview={updatedPage.PreviewToken}");
|
||||||
pageResponse.StatusCode.Should().Be(HttpStatusCode.OK);
|
pageResponse.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task GeneratePreviewToken_ForActivePageByNonOwner_ShouldFail()
|
public async Task GeneratePreviewToken_ForActivePageByNonOwner_ShouldFail()
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
var pageOwner = await _dbFixture.CreateTestUserAsync(PlanType.Basic, "owner@example.com");
|
var pageOwner = await _dbFixture.CreateTestUserAsync(PlanType.Basic, "owner@example.com");
|
||||||
var otherUser = await _dbFixture.CreateTestUserAsync(PlanType.Basic, "other@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 page = await _dbFixture.CreateTestUserPageAsync(pageOwner.Id, PageStatus.Active, "tecnologia", 3, 1);
|
||||||
|
|
||||||
var otherUserClient = await AuthenticationHelper.CreateAuthenticatedClientAsync(_factory, otherUser);
|
var otherUserClient = await AuthenticationHelper.CreateAuthenticatedClientAsync(_factory, otherUser);
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
var response = await otherUserClient.PostAsync($"/Admin/GeneratePreviewToken/{page.Id}", null);
|
var response = await otherUserClient.PostAsync($"/Admin/GeneratePreviewToken/{page.Id}", null);
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
response.IsSuccessStatusCode.Should().BeFalse();
|
response.IsSuccessStatusCode.Should().BeFalse();
|
||||||
}
|
}
|
||||||
|
|
||||||
[Theory]
|
[Theory]
|
||||||
[InlineData(PageStatus.Creating)]
|
[InlineData(PageStatus.Creating)]
|
||||||
[InlineData(PageStatus.PendingModeration)]
|
[InlineData(PageStatus.PendingModeration)]
|
||||||
[InlineData(PageStatus.Rejected)]
|
[InlineData(PageStatus.Rejected)]
|
||||||
public async Task AccessPage_WithPreviewToken_ShouldWorkForNonActiveStatuses(PageStatus status)
|
public async Task AccessPage_WithPreviewToken_ShouldWorkForNonActiveStatuses(PageStatus status)
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
var user = await _dbFixture.CreateTestUserAsync(PlanType.Basic);
|
var user = await _dbFixture.CreateTestUserAsync(PlanType.Basic);
|
||||||
var page = await _dbFixture.CreateTestUserPageAsync(user.Id, status, "tecnologia", 3, 1);
|
var page = await _dbFixture.CreateTestUserPageAsync(user.Id, status, "tecnologia", 3, 1);
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
var response = await _client.GetAsync($"/page/{page.Category}/{page.Slug}?preview={page.PreviewToken}");
|
var response = await _client.GetAsync($"/page/{page.Category}/{page.Slug}?preview={page.PreviewToken}");
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
response.StatusCode.Should().Be(HttpStatusCode.OK, $"Preview token should work for {status} status");
|
response.StatusCode.Should().Be(HttpStatusCode.OK, $"Preview token should work for {status} status");
|
||||||
|
|
||||||
var content = await response.Content.ReadAsStringAsync();
|
var content = await response.Content.ReadAsStringAsync();
|
||||||
content.Should().Contain(page.DisplayName);
|
content.Should().Contain(page.DisplayName);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Theory]
|
[Theory]
|
||||||
[InlineData(PageStatus.Creating)]
|
[InlineData(PageStatus.Creating)]
|
||||||
[InlineData(PageStatus.PendingModeration)]
|
[InlineData(PageStatus.PendingModeration)]
|
||||||
[InlineData(PageStatus.Rejected)]
|
[InlineData(PageStatus.Rejected)]
|
||||||
public async Task AccessPage_WithoutPreviewToken_ShouldFailForNonActiveStatuses(PageStatus status)
|
public async Task AccessPage_WithoutPreviewToken_ShouldFailForNonActiveStatuses(PageStatus status)
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
var user = await _dbFixture.CreateTestUserAsync(PlanType.Basic);
|
var user = await _dbFixture.CreateTestUserAsync(PlanType.Basic);
|
||||||
var page = await _dbFixture.CreateTestUserPageAsync(user.Id, status, "tecnologia", 3, 1);
|
var page = await _dbFixture.CreateTestUserPageAsync(user.Id, status, "tecnologia", 3, 1);
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
var response = await _client.GetAsync($"/page/{page.Category}/{page.Slug}");
|
var response = await _client.GetAsync($"/page/{page.Category}/{page.Slug}");
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
response.StatusCode.Should().Be(HttpStatusCode.NotFound, $"Access without preview token should fail for {status} status");
|
response.StatusCode.Should().Be(HttpStatusCode.NotFound, $"Access without preview token should fail for {status} status");
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task AccessActivePage_WithoutPreviewToken_ShouldSucceed()
|
public async Task AccessActivePage_WithoutPreviewToken_ShouldSucceed()
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
var user = await _dbFixture.CreateTestUserAsync(PlanType.Basic);
|
var user = await _dbFixture.CreateTestUserAsync(PlanType.Basic);
|
||||||
var page = await _dbFixture.CreateTestUserPageAsync(user.Id, PageStatus.Active, "tecnologia", 3, 1);
|
var page = await _dbFixture.CreateTestUserPageAsync(user.Id, PageStatus.Active, "tecnologia", 3, 1);
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
var response = await _client.GetAsync($"/page/{page.Category}/{page.Slug}");
|
var response = await _client.GetAsync($"/page/{page.Category}/{page.Slug}");
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
response.StatusCode.Should().Be(HttpStatusCode.OK);
|
response.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||||
|
|
||||||
var content = await response.Content.ReadAsStringAsync();
|
var content = await response.Content.ReadAsStringAsync();
|
||||||
content.Should().Contain(page.DisplayName);
|
content.Should().Contain(page.DisplayName);
|
||||||
content.Should().NotContain("MODO PREVIEW"); // No preview banner for active pages
|
content.Should().NotContain("MODO PREVIEW"); // No preview banner for active pages
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task RefreshPreviewToken_ShouldExtendExpiry()
|
public async Task RefreshPreviewToken_ShouldExtendExpiry()
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
var user = await _dbFixture.CreateTestUserAsync(PlanType.Basic);
|
var user = await _dbFixture.CreateTestUserAsync(PlanType.Basic);
|
||||||
var page = await _dbFixture.CreateTestUserPageAsync(user.Id, PageStatus.Creating, "tecnologia", 3, 1);
|
var page = await _dbFixture.CreateTestUserPageAsync(user.Id, PageStatus.Creating, "tecnologia", 3, 1);
|
||||||
var authenticatedClient = await AuthenticationHelper.CreateAuthenticatedClientAsync(_factory, user);
|
var authenticatedClient = await AuthenticationHelper.CreateAuthenticatedClientAsync(_factory, user);
|
||||||
|
|
||||||
// Make token close to expiry
|
// Make token close to expiry
|
||||||
page.PreviewTokenExpiry = DateTime.UtcNow.AddMinutes(30);
|
page.PreviewTokenExpiry = DateTime.UtcNow.AddMinutes(30);
|
||||||
await _dbFixture.UserPageRepository.UpdateAsync(page);
|
await _dbFixture.UserPageRepository.UpdateAsync(page);
|
||||||
|
|
||||||
var oldExpiry = page.PreviewTokenExpiry;
|
var oldExpiry = page.PreviewTokenExpiry;
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
var response = await authenticatedClient.PostAsync($"/Admin/RefreshPreviewToken/{page.Id}", null);
|
var response = await authenticatedClient.PostAsync($"/Admin/RefreshPreviewToken/{page.Id}", null);
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
response.IsSuccessStatusCode.Should().BeTrue();
|
response.IsSuccessStatusCode.Should().BeTrue();
|
||||||
|
|
||||||
var updatedPages = await _dbFixture.GetUserPagesAsync(user.Id);
|
var updatedPages = await _dbFixture.GetUserPagesAsync(user.Id);
|
||||||
var updatedPage = updatedPages.First();
|
var updatedPage = updatedPages.First();
|
||||||
|
|
||||||
updatedPage.PreviewTokenExpiry.Should().BeAfter(oldExpiry.Value.AddHours(3));
|
updatedPage.PreviewTokenExpiry.Should().BeAfter(oldExpiry.Value.AddHours(3));
|
||||||
updatedPage.PreviewToken.Should().NotBeNullOrEmpty();
|
updatedPage.PreviewToken.Should().NotBeNullOrEmpty();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -1,43 +1,43 @@
|
|||||||
{
|
{
|
||||||
"ConnectionStrings": {
|
"ConnectionStrings": {
|
||||||
"DefaultConnection": "mongodb://localhost:27017/BCardsDB_Test"
|
"DefaultConnection": "mongodb://localhost:27017/BCardsDB_Test"
|
||||||
},
|
},
|
||||||
"MongoDb": {
|
"MongoDb": {
|
||||||
"ConnectionString": "mongodb://localhost:27017",
|
"ConnectionString": "mongodb://localhost:27017",
|
||||||
"DatabaseName": "BCardsDB_Test"
|
"DatabaseName": "BCardsDB_Test"
|
||||||
},
|
},
|
||||||
"Stripe": {
|
"Stripe": {
|
||||||
"PublishableKey": "pk_test_51234567890abcdef",
|
"PublishableKey": "pk_test_51234567890abcdef",
|
||||||
"SecretKey": "sk_test_51234567890abcdef",
|
"SecretKey": "sk_test_51234567890abcdef",
|
||||||
"WebhookSecret": "whsec_test_1234567890abcdef"
|
"WebhookSecret": "whsec_test_1234567890abcdef"
|
||||||
},
|
},
|
||||||
"Authentication": {
|
"Authentication": {
|
||||||
"Google": {
|
"Google": {
|
||||||
"ClientId": "test-google-client-id.apps.googleusercontent.com",
|
"ClientId": "test-google-client-id.apps.googleusercontent.com",
|
||||||
"ClientSecret": "GOCSPX-test-google-client-secret"
|
"ClientSecret": "GOCSPX-test-google-client-secret"
|
||||||
},
|
},
|
||||||
"Microsoft": {
|
"Microsoft": {
|
||||||
"ClientId": "test-microsoft-client-id",
|
"ClientId": "test-microsoft-client-id",
|
||||||
"ClientSecret": "test-microsoft-client-secret"
|
"ClientSecret": "test-microsoft-client-secret"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"SendGrid": {
|
"SendGrid": {
|
||||||
"ApiKey": "SG.test-sendgrid-api-key"
|
"ApiKey": "SG.test-sendgrid-api-key"
|
||||||
},
|
},
|
||||||
"Moderation": {
|
"Moderation": {
|
||||||
"RequireApproval": true,
|
"RequireApproval": true,
|
||||||
"AuthKey": "test-moderation-auth-key",
|
"AuthKey": "test-moderation-auth-key",
|
||||||
"MaxPendingPages": 100,
|
"MaxPendingPages": 100,
|
||||||
"MaxRejectionsBeforeBan": 3
|
"MaxRejectionsBeforeBan": 3
|
||||||
},
|
},
|
||||||
"Logging": {
|
"Logging": {
|
||||||
"LogLevel": {
|
"LogLevel": {
|
||||||
"Default": "Warning",
|
"Default": "Warning",
|
||||||
"Microsoft.AspNetCore": "Warning",
|
"Microsoft.AspNetCore": "Warning",
|
||||||
"BCards": "Information",
|
"BCards": "Information",
|
||||||
"Microsoft.EntityFrameworkCore": "Warning"
|
"Microsoft.EntityFrameworkCore": "Warning"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"AllowedHosts": "*",
|
"AllowedHosts": "*",
|
||||||
"ASPNETCORE_ENVIRONMENT": "Testing"
|
"ASPNETCORE_ENVIRONMENT": "Testing"
|
||||||
}
|
}
|
||||||
@ -0,0 +1,67 @@
|
|||||||
|
using BCards.Web.Areas.Tutoriais.Models.ViewModels;
|
||||||
|
using BCards.Web.Areas.Tutoriais.Services;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
|
namespace BCards.Web.Areas.Artigos.Controllers;
|
||||||
|
|
||||||
|
[Area("Artigos")]
|
||||||
|
public class ArtigosController : Controller
|
||||||
|
{
|
||||||
|
private readonly IMarkdownService _markdownService;
|
||||||
|
private readonly ILogger<ArtigosController> _logger;
|
||||||
|
|
||||||
|
public ArtigosController(
|
||||||
|
IMarkdownService markdownService,
|
||||||
|
ILogger<ArtigosController> logger)
|
||||||
|
{
|
||||||
|
_markdownService = markdownService;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /artigos
|
||||||
|
public async Task<IActionResult> Index()
|
||||||
|
{
|
||||||
|
var artigos = await _markdownService
|
||||||
|
.GetAllArticlesAsync("Artigos", "pt-BR");
|
||||||
|
|
||||||
|
return View(artigos);
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /artigos/{slug}
|
||||||
|
public async Task<IActionResult> Article(string slug)
|
||||||
|
{
|
||||||
|
// Sanitização
|
||||||
|
slug = slug.Replace("..", "").Replace("/", "").Replace("\\", "");
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var article = await _markdownService.GetArticleAsync(
|
||||||
|
$"Artigos/{slug}",
|
||||||
|
"pt-BR"
|
||||||
|
);
|
||||||
|
|
||||||
|
if (article == null)
|
||||||
|
{
|
||||||
|
_logger.LogWarning("Artigo não encontrado: {Slug}", slug);
|
||||||
|
return NotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Buscar outros artigos para "Leia também"
|
||||||
|
article.RelatedArticles = await _markdownService
|
||||||
|
.GetAllArticlesAsync("Artigos", "pt-BR");
|
||||||
|
|
||||||
|
article.RelatedArticles = article.RelatedArticles
|
||||||
|
.Where(a => a.Slug != slug)
|
||||||
|
.OrderByDescending(a => a.Date)
|
||||||
|
.Take(3)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
return View(article);
|
||||||
|
}
|
||||||
|
catch (FileNotFoundException)
|
||||||
|
{
|
||||||
|
_logger.LogWarning("Arquivo markdown não encontrado: {Slug}", slug);
|
||||||
|
return NotFound();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
253
src/BCards.Web/Areas/Artigos/Views/Artigos/Article.cshtml
Normal file
253
src/BCards.Web/Areas/Artigos/Views/Artigos/Article.cshtml
Normal file
@ -0,0 +1,253 @@
|
|||||||
|
@model BCards.Web.Areas.Tutoriais.Models.ViewModels.ArticleViewModel
|
||||||
|
@{
|
||||||
|
ViewData["Title"] = Model.Metadata.Title;
|
||||||
|
}
|
||||||
|
|
||||||
|
@section Head {
|
||||||
|
<!-- Meta Tags SEO -->
|
||||||
|
<meta name="description" content="@Model.Metadata.Description">
|
||||||
|
<meta name="keywords" content="@Model.Metadata.Keywords">
|
||||||
|
<meta name="author" content="@Model.Metadata.Author">
|
||||||
|
<meta name="robots" content="index, follow">
|
||||||
|
<link rel="canonical" href="@Url.Action("Article", "Artigos", new { area = "Artigos", slug = Model.Slug }, Context.Request.Scheme)">
|
||||||
|
|
||||||
|
<!-- Open Graph -->
|
||||||
|
<meta property="og:type" content="article">
|
||||||
|
<meta property="og:title" content="@Model.Metadata.Title">
|
||||||
|
<meta property="og:description" content="@Model.Metadata.Description">
|
||||||
|
<meta property="og:image" content="@Model.Metadata.Image">
|
||||||
|
<meta property="og:url" content="@Url.Action("Article", "Artigos", new { area = "Artigos", slug = Model.Slug }, Context.Request.Scheme)">
|
||||||
|
<meta property="article:published_time" content="@Model.Metadata.Date.ToString("yyyy-MM-ddTHH:mm:ssZ")">
|
||||||
|
<meta property="article:modified_time" content="@Model.Metadata.LastMod.ToString("yyyy-MM-ddTHH:mm:ssZ")">
|
||||||
|
<meta property="article:author" content="@Model.Metadata.Author">
|
||||||
|
|
||||||
|
<!-- Twitter Card -->
|
||||||
|
<meta name="twitter:card" content="summary_large_image">
|
||||||
|
<meta name="twitter:title" content="@Model.Metadata.Title">
|
||||||
|
<meta name="twitter:description" content="@Model.Metadata.Description">
|
||||||
|
<meta name="twitter:image" content="@Model.Metadata.Image">
|
||||||
|
|
||||||
|
<!-- Schema.org JSON-LD -->
|
||||||
|
<script type="application/ld+json">
|
||||||
|
{
|
||||||
|
"@@context": "https://schema.org",
|
||||||
|
"@@type": "Article",
|
||||||
|
"headline": "@Model.Metadata.Title",
|
||||||
|
"description": "@Model.Metadata.Description",
|
||||||
|
"image": "@Model.Metadata.Image",
|
||||||
|
"datePublished": "@Model.Metadata.Date.ToString("yyyy-MM-dd")",
|
||||||
|
"dateModified": "@Model.Metadata.LastMod.ToString("yyyy-MM-dd")",
|
||||||
|
"author": {
|
||||||
|
"@@type": "Person",
|
||||||
|
"name": "@Model.Metadata.Author"
|
||||||
|
},
|
||||||
|
"publisher": {
|
||||||
|
"@@type": "Organization",
|
||||||
|
"name": "BCards",
|
||||||
|
"logo": {
|
||||||
|
"@@type": "ImageObject",
|
||||||
|
"url": "https://bcards.site/logo.png"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<!-- BreadcrumbList Schema -->
|
||||||
|
<script type="application/ld+json">
|
||||||
|
{
|
||||||
|
"@@context": "https://schema.org",
|
||||||
|
"@@type": "BreadcrumbList",
|
||||||
|
"itemListElement": [
|
||||||
|
{
|
||||||
|
"@@type": "ListItem",
|
||||||
|
"position": 1,
|
||||||
|
"name": "Início",
|
||||||
|
"item": "https://bcards.site"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"@@type": "ListItem",
|
||||||
|
"position": 2,
|
||||||
|
"name": "Artigos",
|
||||||
|
"item": "https://bcards.site/artigos"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"@@type": "ListItem",
|
||||||
|
"position": 3,
|
||||||
|
"name": "@Model.Metadata.Title"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="container py-5">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-lg-8">
|
||||||
|
<!-- Breadcrumb -->
|
||||||
|
<nav aria-label="breadcrumb" class="mb-4">
|
||||||
|
<ol class="breadcrumb">
|
||||||
|
<li class="breadcrumb-item"><a href="/">Início</a></li>
|
||||||
|
<li class="breadcrumb-item"><a href="@Url.Action("Index", "Artigos", new { area = "Artigos" })">Artigos</a></li>
|
||||||
|
<li class="breadcrumb-item active" aria-current="page">@Model.Metadata.Title</li>
|
||||||
|
</ol>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<!-- Article Header -->
|
||||||
|
<article>
|
||||||
|
<header class="mb-4">
|
||||||
|
<div class="d-flex align-items-center mb-2">
|
||||||
|
<span class="badge bg-success me-2">✨ Artigo</span>
|
||||||
|
<span class="text-muted small"><i class="far fa-clock me-1"></i> @Model.Metadata.ReadingTimeMinutes min de leitura</span>
|
||||||
|
</div>
|
||||||
|
<h1 class="display-5 mb-3">@Model.Metadata.Title</h1>
|
||||||
|
<p class="lead text-muted">@Model.Metadata.Description</p>
|
||||||
|
<div class="d-flex align-items-center text-muted small mb-3">
|
||||||
|
<span class="me-3"><i class="fas fa-user me-1"></i> @Model.Metadata.Author</span>
|
||||||
|
<span class="me-3"><i class="fas fa-calendar me-1"></i> @Model.Metadata.Date.ToString("dd/MM/yyyy")</span>
|
||||||
|
<span><i class="fas fa-sync me-1"></i> Atualizado em @Model.Metadata.LastMod.ToString("dd/MM/yyyy")</span>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
@if (!string.IsNullOrEmpty(Model.Metadata.Image))
|
||||||
|
{
|
||||||
|
<img src="@Model.Metadata.Image" class="img-fluid rounded mb-4" alt="@Model.Metadata.Title">
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- Article Content -->
|
||||||
|
<div class="article-content">
|
||||||
|
@Html.Raw(Model.HtmlContent)
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<!-- CTA -->
|
||||||
|
<div class="alert alert-primary mt-5" role="alert">
|
||||||
|
<h4 class="alert-heading"><i class="fas fa-rocket me-2"></i> Pronto para transformar seu negócio?</h4>
|
||||||
|
<p class="mb-3">Crie seu cartão digital profissional e comece a atrair mais clientes hoje mesmo!</p>
|
||||||
|
<a href="/" class="btn btn-primary">Criar meu BCard grátis</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Sidebar -->
|
||||||
|
<div class="col-lg-4">
|
||||||
|
<div class="sticky-top" style="top: 20px;">
|
||||||
|
<!-- Related Articles -->
|
||||||
|
@if (Model.RelatedArticles.Any())
|
||||||
|
{
|
||||||
|
<div class="card border-0 shadow-sm mb-4">
|
||||||
|
<div class="card-body">
|
||||||
|
<h5 class="card-title mb-3"><i class="fas fa-newspaper me-2"></i> Leia Também</h5>
|
||||||
|
<div class="list-group list-group-flush">
|
||||||
|
@foreach (var related in Model.RelatedArticles)
|
||||||
|
{
|
||||||
|
<a href="@Url.Action("Article", "Artigos", new { area = "Artigos", slug = related.Slug })" class="list-group-item list-group-item-action border-0 px-0">
|
||||||
|
<div class="d-flex w-100 justify-content-between">
|
||||||
|
<h6 class="mb-1">@related.Title</h6>
|
||||||
|
</div>
|
||||||
|
<small class="text-muted"><i class="far fa-clock me-1"></i> @related.ReadingTimeMinutes min</small>
|
||||||
|
</a>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- Tutoriais CTA -->
|
||||||
|
<div class="card border-0 shadow-sm bg-light mb-4">
|
||||||
|
<div class="card-body text-center">
|
||||||
|
<i class="fas fa-book-open fa-3x text-primary mb-3"></i>
|
||||||
|
<h5 class="card-title">Quer aprender mais?</h5>
|
||||||
|
<p class="card-text small text-muted">Acesse nossos tutoriais práticos</p>
|
||||||
|
<a href="@Url.Action("Index", "Tutoriais", new { area = "Tutoriais" })" class="btn btn-sm btn-primary">Ver Tutoriais</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Help Card -->
|
||||||
|
<div class="card border-0 shadow-sm bg-light">
|
||||||
|
<div class="card-body text-center">
|
||||||
|
<i class="fas fa-question-circle fa-3x text-primary mb-3"></i>
|
||||||
|
<h5 class="card-title">Precisa de ajuda?</h5>
|
||||||
|
<p class="card-text small text-muted">Entre em contato com nosso suporte</p>
|
||||||
|
<a href="/Support" class="btn btn-sm btn-outline-primary">Falar com suporte</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@section Styles {
|
||||||
|
<style>
|
||||||
|
.article-content {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
line-height: 1.8;
|
||||||
|
}
|
||||||
|
.article-content h1,
|
||||||
|
.article-content h2,
|
||||||
|
.article-content h3,
|
||||||
|
.article-content h4,
|
||||||
|
.article-content h5,
|
||||||
|
.article-content h6 {
|
||||||
|
margin-top: 2rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.article-content h2 {
|
||||||
|
font-size: 1.75rem;
|
||||||
|
padding-bottom: 0.5rem;
|
||||||
|
border-bottom: 2px solid #dee2e6;
|
||||||
|
}
|
||||||
|
.article-content h3 {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
}
|
||||||
|
.article-content p {
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
.article-content img {
|
||||||
|
max-width: 100%;
|
||||||
|
height: auto;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
margin: 1.5rem 0;
|
||||||
|
}
|
||||||
|
.article-content ul,
|
||||||
|
.article-content ol {
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
padding-left: 2rem;
|
||||||
|
}
|
||||||
|
.article-content li {
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
.article-content code {
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
padding: 0.2rem 0.4rem;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
font-size: 0.9em;
|
||||||
|
}
|
||||||
|
.article-content pre {
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
.article-content blockquote {
|
||||||
|
border-left: 4px solid #198754;
|
||||||
|
padding-left: 1rem;
|
||||||
|
margin-left: 0;
|
||||||
|
font-style: italic;
|
||||||
|
color: #6c757d;
|
||||||
|
}
|
||||||
|
.article-content table {
|
||||||
|
width: 100%;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
border-collapse: collapse;
|
||||||
|
}
|
||||||
|
.article-content table th,
|
||||||
|
.article-content table td {
|
||||||
|
padding: 0.75rem;
|
||||||
|
border: 1px solid #dee2e6;
|
||||||
|
}
|
||||||
|
.article-content table th {
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
}
|
||||||
77
src/BCards.Web/Areas/Artigos/Views/Artigos/Index.cshtml
Normal file
77
src/BCards.Web/Areas/Artigos/Views/Artigos/Index.cshtml
Normal file
@ -0,0 +1,77 @@
|
|||||||
|
@model List<BCards.Web.Areas.Tutoriais.Models.ArticleMetadata>
|
||||||
|
@{
|
||||||
|
ViewData["Title"] = "Artigos BCards - Inspiração e Conhecimento";
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="container py-5">
|
||||||
|
<div class="row mb-5">
|
||||||
|
<div class="col-lg-8 mx-auto text-center">
|
||||||
|
<h1 class="display-4 mb-3">✨ Artigos BCards</h1>
|
||||||
|
<p class="lead text-muted">Insights, tendências e inspiração para transformar sua presença digital</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (Model.Any())
|
||||||
|
{
|
||||||
|
<div class="row g-4">
|
||||||
|
@foreach (var artigo in Model)
|
||||||
|
{
|
||||||
|
<div class="col-md-6 col-lg-4">
|
||||||
|
<div class="card h-100 border-0 shadow-sm hover-shadow">
|
||||||
|
@if (!string.IsNullOrEmpty(artigo.Image))
|
||||||
|
{
|
||||||
|
<img src="@artigo.Image" class="card-img-top" alt="@artigo.Title" style="height: 200px; object-fit: cover;">
|
||||||
|
}
|
||||||
|
<div class="card-body">
|
||||||
|
<h5 class="card-title">@artigo.Title</h5>
|
||||||
|
<p class="card-text text-muted small">@artigo.Description</p>
|
||||||
|
<div class="d-flex justify-content-between align-items-center text-muted small">
|
||||||
|
<span><i class="far fa-clock me-1"></i> @artigo.ReadingTimeMinutes min</span>
|
||||||
|
<span>@artigo.Date.ToString("dd/MM/yyyy")</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-footer bg-transparent border-0">
|
||||||
|
<a href="@Url.Action("Article", "Artigos", new { area = "Artigos", slug = artigo.Slug })" class="btn btn-sm btn-primary w-100">
|
||||||
|
Ler artigo <i class="fas fa-arrow-right ms-1"></i>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<div class="text-center py-5">
|
||||||
|
<i class="fas fa-newspaper fa-4x text-muted mb-3"></i>
|
||||||
|
<h3 class="text-muted">Nenhum artigo disponível ainda</h3>
|
||||||
|
<p class="text-muted">Em breve teremos conteúdo inspirador para você!</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- CTA -->
|
||||||
|
<div class="row mt-5">
|
||||||
|
<div class="col-lg-8 mx-auto">
|
||||||
|
<div class="card bg-primary text-white border-0 shadow">
|
||||||
|
<div class="card-body text-center p-5">
|
||||||
|
<h3 class="mb-3">Quer ver tutoriais práticos?</h3>
|
||||||
|
<p class="mb-4">Acesse nossa seção de tutoriais e aprenda passo a passo como usar o BCards</p>
|
||||||
|
<a href="@Url.Action("Index", "Tutoriais", new { area = "Tutoriais" })" class="btn btn-light btn-lg">
|
||||||
|
<i class="fas fa-book-open me-2"></i> Ver Tutoriais
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@section Styles {
|
||||||
|
<style>
|
||||||
|
.hover-shadow {
|
||||||
|
transition: box-shadow 0.3s ease-in-out;
|
||||||
|
}
|
||||||
|
.hover-shadow:hover {
|
||||||
|
box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15) !important;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
}
|
||||||
3
src/BCards.Web/Areas/Artigos/Views/_ViewStart.cshtml
Normal file
3
src/BCards.Web/Areas/Artigos/Views/_ViewStart.cshtml
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
@{
|
||||||
|
Layout = "_Layout";
|
||||||
|
}
|
||||||
@ -0,0 +1,86 @@
|
|||||||
|
using BCards.Web.Areas.Support.Models;
|
||||||
|
using BCards.Web.Areas.Support.Services;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using System.Security.Claims;
|
||||||
|
|
||||||
|
namespace BCards.Web.Areas.Support.Controllers;
|
||||||
|
|
||||||
|
[Area("Support")]
|
||||||
|
[Route("api/ratings")]
|
||||||
|
[ApiController]
|
||||||
|
public class RatingsController : ControllerBase
|
||||||
|
{
|
||||||
|
private readonly IRatingService _ratingService;
|
||||||
|
private readonly ILogger<RatingsController> _logger;
|
||||||
|
|
||||||
|
public RatingsController(IRatingService ratingService, ILogger<RatingsController> logger)
|
||||||
|
{
|
||||||
|
_ratingService = ratingService;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost]
|
||||||
|
public async Task<IActionResult> SubmitRating([FromBody] RatingSubmissionDto dto)
|
||||||
|
{
|
||||||
|
if (!ModelState.IsValid)
|
||||||
|
{
|
||||||
|
_logger.LogWarning("Rating inválido submetido: {Errors}", ModelState.Values.SelectMany(v => v.Errors).Select(e => e.ErrorMessage));
|
||||||
|
return BadRequest(ModelState);
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var userId = User?.FindFirst(ClaimTypes.NameIdentifier)?.Value;
|
||||||
|
var success = await _ratingService.SubmitRatingAsync(dto, userId, HttpContext);
|
||||||
|
|
||||||
|
if (success)
|
||||||
|
{
|
||||||
|
_logger.LogInformation("Rating de {Stars} estrelas submetido com sucesso", dto.RatingValue);
|
||||||
|
return Ok(new { message = "Avaliação enviada com sucesso! Obrigado pelo feedback." });
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.LogError("Falha ao submeter rating");
|
||||||
|
return StatusCode(503, new { message = "Erro ao processar sua avaliação. Tente novamente mais tarde." });
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Erro ao processar rating");
|
||||||
|
return StatusCode(500, new { message = "Erro interno ao processar sua avaliação." });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("average")]
|
||||||
|
public async Task<IActionResult> GetAverageRating()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var average = await _ratingService.GetAverageRatingAsync();
|
||||||
|
var total = await _ratingService.GetTotalCountAsync();
|
||||||
|
|
||||||
|
return Ok(new { average = Math.Round(average, 2), total });
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Erro ao buscar média de ratings");
|
||||||
|
return StatusCode(500, new { message = "Erro ao buscar avaliações" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("recent")]
|
||||||
|
public async Task<IActionResult> GetRecentRatings([FromQuery] int limit = 10)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (limit < 1 || limit > 50)
|
||||||
|
limit = 10;
|
||||||
|
|
||||||
|
var ratings = await _ratingService.GetRecentRatingsAsync(limit);
|
||||||
|
return Ok(ratings);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Erro ao buscar ratings recentes");
|
||||||
|
return StatusCode(500, new { message = "Erro ao buscar avaliações recentes" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,46 @@
|
|||||||
|
using BCards.Web.Areas.Support.Services;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using System.Security.Claims;
|
||||||
|
|
||||||
|
namespace BCards.Web.Areas.Support.Controllers;
|
||||||
|
|
||||||
|
[Area("Support")]
|
||||||
|
[Authorize]
|
||||||
|
public class SupportController : Controller
|
||||||
|
{
|
||||||
|
private readonly ISupportService _supportService;
|
||||||
|
private readonly ILogger<SupportController> _logger;
|
||||||
|
|
||||||
|
public SupportController(ISupportService supportService, ILogger<SupportController> logger)
|
||||||
|
{
|
||||||
|
_supportService = supportService;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet]
|
||||||
|
public async Task<IActionResult> ContactForm()
|
||||||
|
{
|
||||||
|
var userId = User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
|
||||||
|
var options = await _supportService.GetAvailableOptionsAsync(userId);
|
||||||
|
|
||||||
|
if (!options.CanUseContactForm)
|
||||||
|
{
|
||||||
|
_logger.LogWarning("Usuário {UserId} tentou acessar formulário sem permissão", userId);
|
||||||
|
TempData["Error"] = "Seu plano atual não tem acesso ao formulário de contato. Faça upgrade para o plano Básico ou superior.";
|
||||||
|
return RedirectToAction("Index", "Home", new { area = "" });
|
||||||
|
}
|
||||||
|
|
||||||
|
return View(options);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet]
|
||||||
|
[Route("Support/Index")]
|
||||||
|
public async Task<IActionResult> Index()
|
||||||
|
{
|
||||||
|
var userId = User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
|
||||||
|
var options = await _supportService.GetAvailableOptionsAsync(userId);
|
||||||
|
|
||||||
|
return View(options);
|
||||||
|
}
|
||||||
|
}
|
||||||
24
src/BCards.Web/Areas/Support/Models/Rating.cs
Normal file
24
src/BCards.Web/Areas/Support/Models/Rating.cs
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
using MongoDB.Bson;
|
||||||
|
using MongoDB.Bson.Serialization.Attributes;
|
||||||
|
|
||||||
|
namespace BCards.Web.Areas.Support.Models;
|
||||||
|
|
||||||
|
public class Rating
|
||||||
|
{
|
||||||
|
[BsonId]
|
||||||
|
[BsonRepresentation(BsonType.ObjectId)]
|
||||||
|
public string Id { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
public int RatingValue { get; set; } // 1-5 stars
|
||||||
|
public string? Name { get; set; }
|
||||||
|
public string? Email { get; set; }
|
||||||
|
public string? Comment { get; set; }
|
||||||
|
|
||||||
|
public string? UserId { get; set; } // null para anônimos
|
||||||
|
public string? IpAddress { get; set; }
|
||||||
|
public string? UserAgent { get; set; }
|
||||||
|
public string? Culture { get; set; }
|
||||||
|
public string? Url { get; set; }
|
||||||
|
|
||||||
|
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||||
|
}
|
||||||
19
src/BCards.Web/Areas/Support/Models/RatingSubmissionDto.cs
Normal file
19
src/BCards.Web/Areas/Support/Models/RatingSubmissionDto.cs
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
|
||||||
|
namespace BCards.Web.Areas.Support.Models;
|
||||||
|
|
||||||
|
public class RatingSubmissionDto
|
||||||
|
{
|
||||||
|
[Required]
|
||||||
|
[Range(1, 5, ErrorMessage = "A avaliação deve ser entre 1 e 5 estrelas")]
|
||||||
|
public int RatingValue { get; set; }
|
||||||
|
|
||||||
|
[StringLength(100, ErrorMessage = "O nome deve ter no máximo 100 caracteres")]
|
||||||
|
public string? Name { get; set; }
|
||||||
|
|
||||||
|
[EmailAddress(ErrorMessage = "Email inválido")]
|
||||||
|
public string? Email { get; set; }
|
||||||
|
|
||||||
|
[StringLength(500, ErrorMessage = "O comentário deve ter no máximo 500 caracteres")]
|
||||||
|
public string? Comment { get; set; }
|
||||||
|
}
|
||||||
11
src/BCards.Web/Areas/Support/Models/SupportOptions.cs
Normal file
11
src/BCards.Web/Areas/Support/Models/SupportOptions.cs
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
namespace BCards.Web.Areas.Support.Models;
|
||||||
|
|
||||||
|
public class SupportOptions
|
||||||
|
{
|
||||||
|
public bool CanRate { get; set; }
|
||||||
|
public bool CanUseContactForm { get; set; }
|
||||||
|
public bool CanAccessTelegram { get; set; }
|
||||||
|
public string? TelegramUrl { get; set; }
|
||||||
|
public string? FormspreeUrl { get; set; }
|
||||||
|
public string UserPlan { get; set; } = "Trial";
|
||||||
|
}
|
||||||
@ -0,0 +1,12 @@
|
|||||||
|
using BCards.Web.Areas.Support.Models;
|
||||||
|
|
||||||
|
namespace BCards.Web.Areas.Support.Repositories;
|
||||||
|
|
||||||
|
public interface IRatingRepository
|
||||||
|
{
|
||||||
|
Task<Rating> CreateAsync(Rating rating);
|
||||||
|
Task<List<Rating>> GetRecentAsync(int limit = 10);
|
||||||
|
Task<double> GetAverageRatingAsync();
|
||||||
|
Task<int> GetTotalCountAsync();
|
||||||
|
Task<List<Rating>> GetByUserIdAsync(string userId);
|
||||||
|
}
|
||||||
123
src/BCards.Web/Areas/Support/Repositories/RatingRepository.cs
Normal file
123
src/BCards.Web/Areas/Support/Repositories/RatingRepository.cs
Normal file
@ -0,0 +1,123 @@
|
|||||||
|
using BCards.Web.Areas.Support.Models;
|
||||||
|
using MongoDB.Driver;
|
||||||
|
|
||||||
|
namespace BCards.Web.Areas.Support.Repositories;
|
||||||
|
|
||||||
|
public class RatingRepository : IRatingRepository
|
||||||
|
{
|
||||||
|
private readonly IMongoCollection<Rating> _ratings;
|
||||||
|
private readonly ILogger<RatingRepository> _logger;
|
||||||
|
|
||||||
|
public RatingRepository(IMongoDatabase database, ILogger<RatingRepository> logger)
|
||||||
|
{
|
||||||
|
_ratings = database.GetCollection<Rating>("ratings");
|
||||||
|
_logger = logger;
|
||||||
|
|
||||||
|
// Criar índices
|
||||||
|
CreateIndexes();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void CreateIndexes()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var indexKeysDefinition = Builders<Rating>.IndexKeys.Descending(r => r.CreatedAt);
|
||||||
|
var indexModel = new CreateIndexModel<Rating>(indexKeysDefinition);
|
||||||
|
_ratings.Indexes.CreateOne(indexModel);
|
||||||
|
|
||||||
|
var userIdIndexKeys = Builders<Rating>.IndexKeys.Ascending(r => r.UserId);
|
||||||
|
var userIdIndexModel = new CreateIndexModel<Rating>(userIdIndexKeys);
|
||||||
|
_ratings.Indexes.CreateOne(userIdIndexModel);
|
||||||
|
|
||||||
|
var ratingValueIndexKeys = Builders<Rating>.IndexKeys.Ascending(r => r.RatingValue);
|
||||||
|
var ratingValueIndexModel = new CreateIndexModel<Rating>(ratingValueIndexKeys);
|
||||||
|
_ratings.Indexes.CreateOne(ratingValueIndexModel);
|
||||||
|
|
||||||
|
var cultureIndexKeys = Builders<Rating>.IndexKeys.Ascending(r => r.Culture);
|
||||||
|
var cultureIndexModel = new CreateIndexModel<Rating>(cultureIndexKeys);
|
||||||
|
_ratings.Indexes.CreateOne(cultureIndexModel);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex, "Não foi possível criar índices para a collection ratings");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Rating> CreateAsync(Rating rating)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await _ratings.InsertOneAsync(rating);
|
||||||
|
_logger.LogInformation("Rating criado com sucesso: {RatingId}", rating.Id);
|
||||||
|
return rating;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Erro ao criar rating");
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<List<Rating>> GetRecentAsync(int limit = 10)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return await _ratings
|
||||||
|
.Find(_ => true)
|
||||||
|
.SortByDescending(r => r.CreatedAt)
|
||||||
|
.Limit(limit)
|
||||||
|
.ToListAsync();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Erro ao buscar ratings recentes");
|
||||||
|
return new List<Rating>();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<double> GetAverageRatingAsync()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var ratings = await _ratings.Find(_ => true).ToListAsync();
|
||||||
|
if (ratings.Count == 0)
|
||||||
|
return 0;
|
||||||
|
|
||||||
|
return ratings.Average(r => r.RatingValue);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Erro ao calcular média de ratings");
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<int> GetTotalCountAsync()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return (int)await _ratings.CountDocumentsAsync(_ => true);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Erro ao contar ratings");
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<List<Rating>> GetByUserIdAsync(string userId)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return await _ratings
|
||||||
|
.Find(r => r.UserId == userId)
|
||||||
|
.SortByDescending(r => r.CreatedAt)
|
||||||
|
.ToListAsync();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Erro ao buscar ratings do usuário {UserId}", userId);
|
||||||
|
return new List<Rating>();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
11
src/BCards.Web/Areas/Support/Services/IRatingService.cs
Normal file
11
src/BCards.Web/Areas/Support/Services/IRatingService.cs
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
using BCards.Web.Areas.Support.Models;
|
||||||
|
|
||||||
|
namespace BCards.Web.Areas.Support.Services;
|
||||||
|
|
||||||
|
public interface IRatingService
|
||||||
|
{
|
||||||
|
Task<bool> SubmitRatingAsync(RatingSubmissionDto dto, string? userId, HttpContext httpContext);
|
||||||
|
Task<double> GetAverageRatingAsync();
|
||||||
|
Task<int> GetTotalCountAsync();
|
||||||
|
Task<List<Rating>> GetRecentRatingsAsync(int limit = 10);
|
||||||
|
}
|
||||||
8
src/BCards.Web/Areas/Support/Services/ISupportService.cs
Normal file
8
src/BCards.Web/Areas/Support/Services/ISupportService.cs
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
using BCards.Web.Areas.Support.Models;
|
||||||
|
|
||||||
|
namespace BCards.Web.Areas.Support.Services;
|
||||||
|
|
||||||
|
public interface ISupportService
|
||||||
|
{
|
||||||
|
Task<SupportOptions> GetAvailableOptionsAsync(string? userId);
|
||||||
|
}
|
||||||
61
src/BCards.Web/Areas/Support/Services/RatingService.cs
Normal file
61
src/BCards.Web/Areas/Support/Services/RatingService.cs
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
using BCards.Web.Areas.Support.Models;
|
||||||
|
using BCards.Web.Areas.Support.Repositories;
|
||||||
|
using System.Globalization;
|
||||||
|
|
||||||
|
namespace BCards.Web.Areas.Support.Services;
|
||||||
|
|
||||||
|
public class RatingService : IRatingService
|
||||||
|
{
|
||||||
|
private readonly IRatingRepository _repository;
|
||||||
|
private readonly ILogger<RatingService> _logger;
|
||||||
|
|
||||||
|
public RatingService(IRatingRepository repository, ILogger<RatingService> logger)
|
||||||
|
{
|
||||||
|
_repository = repository;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<bool> SubmitRatingAsync(RatingSubmissionDto dto, string? userId, HttpContext httpContext)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var rating = new Rating
|
||||||
|
{
|
||||||
|
RatingValue = dto.RatingValue,
|
||||||
|
Name = dto.Name,
|
||||||
|
Email = dto.Email,
|
||||||
|
Comment = dto.Comment,
|
||||||
|
UserId = userId,
|
||||||
|
IpAddress = httpContext.Connection.RemoteIpAddress?.ToString(),
|
||||||
|
UserAgent = httpContext.Request.Headers["User-Agent"].ToString(),
|
||||||
|
Culture = CultureInfo.CurrentCulture.Name,
|
||||||
|
Url = httpContext.Request.Headers["Referer"].ToString(),
|
||||||
|
CreatedAt = DateTime.UtcNow
|
||||||
|
};
|
||||||
|
|
||||||
|
await _repository.CreateAsync(rating);
|
||||||
|
_logger.LogInformation("Rating submetido com sucesso por usuário {UserId}", userId ?? "anônimo");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Erro ao submeter rating");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<double> GetAverageRatingAsync()
|
||||||
|
{
|
||||||
|
return await _repository.GetAverageRatingAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<int> GetTotalCountAsync()
|
||||||
|
{
|
||||||
|
return await _repository.GetTotalCountAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<List<Rating>> GetRecentRatingsAsync(int limit = 10)
|
||||||
|
{
|
||||||
|
return await _repository.GetRecentAsync(limit);
|
||||||
|
}
|
||||||
|
}
|
||||||
101
src/BCards.Web/Areas/Support/Services/SupportService.cs
Normal file
101
src/BCards.Web/Areas/Support/Services/SupportService.cs
Normal file
@ -0,0 +1,101 @@
|
|||||||
|
using BCards.Web.Areas.Support.Models;
|
||||||
|
using BCards.Web.Configuration;
|
||||||
|
using BCards.Web.Models;
|
||||||
|
using BCards.Web.Repositories;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
|
||||||
|
namespace BCards.Web.Areas.Support.Services;
|
||||||
|
|
||||||
|
public class SupportService : ISupportService
|
||||||
|
{
|
||||||
|
private readonly IUserRepository _userRepository;
|
||||||
|
private readonly IOptions<SupportSettings> _settings;
|
||||||
|
private readonly ILogger<SupportService> _logger;
|
||||||
|
|
||||||
|
public SupportService(
|
||||||
|
IUserRepository userRepository,
|
||||||
|
IOptions<SupportSettings> settings,
|
||||||
|
ILogger<SupportService> logger)
|
||||||
|
{
|
||||||
|
_userRepository = userRepository;
|
||||||
|
_settings = settings;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<SupportOptions> GetAvailableOptionsAsync(string? userId)
|
||||||
|
{
|
||||||
|
var options = new SupportOptions
|
||||||
|
{
|
||||||
|
CanRate = _settings.Value.EnableRatingForAllUsers,
|
||||||
|
CanUseContactForm = false,
|
||||||
|
CanAccessTelegram = false,
|
||||||
|
TelegramUrl = _settings.Value.TelegramUrl,
|
||||||
|
FormspreeUrl = _settings.Value.FormspreeUrl,
|
||||||
|
UserPlan = "Trial"
|
||||||
|
};
|
||||||
|
|
||||||
|
// Usuário não autenticado ou trial
|
||||||
|
if (string.IsNullOrEmpty(userId))
|
||||||
|
{
|
||||||
|
_logger.LogDebug("Usuário não autenticado - apenas rating disponível");
|
||||||
|
return options;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var user = await _userRepository.GetByIdAsync(userId);
|
||||||
|
if (user == null)
|
||||||
|
{
|
||||||
|
_logger.LogWarning("Usuário {UserId} não encontrado", userId);
|
||||||
|
return options;
|
||||||
|
}
|
||||||
|
|
||||||
|
var planName = user.CurrentPlan?.ToLower() ?? "trial";
|
||||||
|
options.UserPlan = planName;
|
||||||
|
|
||||||
|
_logger.LogDebug("Verificando opções de suporte para usuário {UserId} com plano {Plan}", userId, planName);
|
||||||
|
|
||||||
|
// Trial: apenas rating
|
||||||
|
if (planName == "trial")
|
||||||
|
{
|
||||||
|
_logger.LogDebug("Plano Trial - apenas rating disponível");
|
||||||
|
return options;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Básico: rating + formulário
|
||||||
|
if (planName == "basic" || planName == "básico")
|
||||||
|
{
|
||||||
|
options.CanUseContactForm = true;
|
||||||
|
options.UserPlan = "Básico";
|
||||||
|
_logger.LogDebug("Plano Básico - rating + formulário disponíveis");
|
||||||
|
return options;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Profissional: rating + formulário (sem telegram)
|
||||||
|
if (planName == "professional" || planName == "profissional")
|
||||||
|
{
|
||||||
|
options.CanUseContactForm = true;
|
||||||
|
options.UserPlan = "Profissional";
|
||||||
|
_logger.LogDebug("Plano Profissional - rating + formulário disponíveis");
|
||||||
|
return options;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Premium e PremiumAffiliate: tudo
|
||||||
|
if (planName == "premium" || planName == "premiumaffiliate" || planName == "premium+afiliados")
|
||||||
|
{
|
||||||
|
options.CanUseContactForm = true;
|
||||||
|
options.CanAccessTelegram = true;
|
||||||
|
options.UserPlan = planName.Contains("affiliate") || planName.Contains("afiliados") ? "Premium+Afiliados" : "Premium";
|
||||||
|
_logger.LogDebug("Plano {Plan} - todas as opções disponíveis", planName);
|
||||||
|
return options;
|
||||||
|
}
|
||||||
|
|
||||||
|
return options;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Erro ao verificar opções de suporte para usuário {UserId}", userId);
|
||||||
|
return options;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,36 @@
|
|||||||
|
using BCards.Web.Areas.Support.Services;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using System.Security.Claims;
|
||||||
|
|
||||||
|
namespace BCards.Web.Areas.Support.ViewComponents;
|
||||||
|
|
||||||
|
public class SupportFabViewComponent : ViewComponent
|
||||||
|
{
|
||||||
|
private readonly ISupportService _supportService;
|
||||||
|
private readonly ILogger<SupportFabViewComponent> _logger;
|
||||||
|
|
||||||
|
public SupportFabViewComponent(ISupportService supportService, ILogger<SupportFabViewComponent> logger)
|
||||||
|
{
|
||||||
|
_supportService = supportService;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IViewComponentResult> InvokeAsync()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var userId = UserClaimsPrincipal?.FindFirst(ClaimTypes.NameIdentifier)?.Value;
|
||||||
|
var options = await _supportService.GetAvailableOptionsAsync(userId);
|
||||||
|
|
||||||
|
_logger.LogDebug("SupportFab invocado para usuário {UserId} - Opções: Rating={CanRate}, Form={CanUseContactForm}, Telegram={CanAccessTelegram}",
|
||||||
|
userId ?? "anônimo", options.CanRate, options.CanUseContactForm, options.CanAccessTelegram);
|
||||||
|
|
||||||
|
return View(options);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Erro ao carregar SupportFab ViewComponent");
|
||||||
|
return Content(string.Empty);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,134 @@
|
|||||||
|
@model BCards.Web.Areas.Support.Models.SupportOptions
|
||||||
|
|
||||||
|
<!-- Support FAB (Floating Action Button) -->
|
||||||
|
<div class="support-fab-container">
|
||||||
|
<button class="support-fab-trigger" id="supportFabTrigger" aria-label="Precisa de Ajuda?">
|
||||||
|
<i class="fas fa-question-circle"></i>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="support-fab-menu" id="supportFabMenu" style="display: none;">
|
||||||
|
<div class="support-fab-header">
|
||||||
|
<h6>Precisa de Ajuda?</h6>
|
||||||
|
<p class="small text-muted mb-0">Plano: @Model.UserPlan</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (Model.CanAccessTelegram)
|
||||||
|
{
|
||||||
|
<a href="@Model.TelegramUrl" target="_blank" rel="noopener noreferrer" class="support-fab-option support-fab-telegram">
|
||||||
|
<div class="support-fab-option-icon">
|
||||||
|
<i class="fab fa-telegram"></i>
|
||||||
|
</div>
|
||||||
|
<div class="support-fab-option-content">
|
||||||
|
<strong>Falar no Telegram</strong>
|
||||||
|
<small>Suporte prioritário</small>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (Model.CanUseContactForm)
|
||||||
|
{
|
||||||
|
<a href="@Url.Action("ContactForm", "Support", new { area = "Support" })" class="support-fab-option support-fab-form">
|
||||||
|
<div class="support-fab-option-icon">
|
||||||
|
<i class="fas fa-envelope"></i>
|
||||||
|
</div>
|
||||||
|
<div class="support-fab-option-content">
|
||||||
|
<strong>Enviar Mensagem</strong>
|
||||||
|
<small>Formulário de contato</small>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (Model.CanRate)
|
||||||
|
{
|
||||||
|
<button type="button" class="support-fab-option support-fab-rating" data-bs-toggle="modal" data-bs-target="#ratingModal">
|
||||||
|
<div class="support-fab-option-icon">
|
||||||
|
<i class="fas fa-star"></i>
|
||||||
|
</div>
|
||||||
|
<div class="support-fab-option-content">
|
||||||
|
<strong>Avaliar Serviço</strong>
|
||||||
|
<small>Conte sua experiência</small>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (!Model.CanUseContactForm && !Model.CanAccessTelegram)
|
||||||
|
{
|
||||||
|
<div class="support-fab-upgrade">
|
||||||
|
<p class="small mb-2">
|
||||||
|
<i class="fas fa-lock text-warning"></i>
|
||||||
|
Faça upgrade para acessar mais opções de suporte!
|
||||||
|
</p>
|
||||||
|
<a href="@Url.Action("Index", "Payment", new { area = "" })" class="btn btn-sm btn-primary">Ver Planos</a>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Rating Modal -->
|
||||||
|
<div class="modal fade" id="ratingModal" tabindex="-1" aria-labelledby="ratingModalLabel" aria-hidden="true">
|
||||||
|
<div class="modal-dialog modal-dialog-centered">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title" id="ratingModalLabel">Avalie nossa plataforma</h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Fechar"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<form id="ratingForm">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Sua avaliação: <span class="text-danger">*</span></label>
|
||||||
|
<div class="star-rating" id="starRating">
|
||||||
|
<i class="far fa-star" data-rating="1"></i>
|
||||||
|
<i class="far fa-star" data-rating="2"></i>
|
||||||
|
<i class="far fa-star" data-rating="3"></i>
|
||||||
|
<i class="far fa-star" data-rating="4"></i>
|
||||||
|
<i class="far fa-star" data-rating="5"></i>
|
||||||
|
</div>
|
||||||
|
<input type="hidden" id="ratingValue" name="ratingValue" required>
|
||||||
|
<div class="invalid-feedback d-block" id="ratingError" style="display: none !important;">
|
||||||
|
Por favor, selecione uma avaliação
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="ratingName" class="form-label">Nome (opcional)</label>
|
||||||
|
<input type="text" class="form-control" id="ratingName" name="name" maxlength="100" placeholder="Seu nome">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="ratingEmail" class="form-label">Email (opcional)</label>
|
||||||
|
<input type="email" class="form-control" id="ratingEmail" name="email" placeholder="seu@email.com">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="ratingComment" class="form-label">Comentário (opcional)</label>
|
||||||
|
<textarea class="form-control" id="ratingComment" name="comment" rows="3" maxlength="500" placeholder="Conte-nos sobre sua experiência..."></textarea>
|
||||||
|
<small class="form-text text-muted">
|
||||||
|
<span id="commentCounter">0</span>/500 caracteres
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="alert alert-success d-none" id="ratingSuccessAlert" role="alert">
|
||||||
|
<i class="fas fa-check-circle"></i> Avaliação enviada com sucesso! Obrigado pelo feedback.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="alert alert-danger d-none" id="ratingErrorAlert" role="alert">
|
||||||
|
<i class="fas fa-exclamation-triangle"></i> <span id="ratingErrorMessage">Erro ao enviar avaliação. Tente novamente.</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="d-grid">
|
||||||
|
<button type="submit" class="btn btn-primary" id="submitRatingBtn">
|
||||||
|
<i class="fas fa-paper-plane"></i> Enviar Avaliação
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@section Scripts {
|
||||||
|
<link rel="stylesheet" href="~/css/support-fab.css" asp-append-version="true" />
|
||||||
|
<link rel="stylesheet" href="~/css/rating.css" asp-append-version="true" />
|
||||||
|
<script src="~/js/support-fab.js" asp-append-version="true"></script>
|
||||||
|
<script src="~/js/rating.js" asp-append-version="true"></script>
|
||||||
|
}
|
||||||
155
src/BCards.Web/Areas/Support/Views/Support/ContactForm.cshtml
Normal file
155
src/BCards.Web/Areas/Support/Views/Support/ContactForm.cshtml
Normal file
@ -0,0 +1,155 @@
|
|||||||
|
@model BCards.Web.Areas.Support.Models.SupportOptions
|
||||||
|
@{
|
||||||
|
ViewData["Title"] = "Formulário de Contato";
|
||||||
|
Layout = "_Layout";
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="container my-5">
|
||||||
|
<div class="row justify-content-center">
|
||||||
|
<div class="col-lg-8">
|
||||||
|
<div class="card shadow-sm">
|
||||||
|
<div class="card-body p-4">
|
||||||
|
<h2 class="card-title mb-4">
|
||||||
|
<i class="fas fa-envelope text-primary"></i> Fale Conosco
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div class="alert alert-info mb-4">
|
||||||
|
<i class="fas fa-info-circle"></i>
|
||||||
|
<strong>Tempo de resposta:</strong> Normalmente respondemos em até 24-48 horas.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form action="@Model.FormspreeUrl" method="POST">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="name" class="form-label">
|
||||||
|
Nome <span class="text-danger">*</span>
|
||||||
|
</label>
|
||||||
|
<input type="text" class="form-control" id="name" name="name" required maxlength="100">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="email" class="form-label">
|
||||||
|
Email <span class="text-danger">*</span>
|
||||||
|
</label>
|
||||||
|
<input type="email" class="form-control" id="email" name="email" required>
|
||||||
|
<small class="form-text text-muted">Usaremos este email para responder sua mensagem.</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="subject" class="form-label">
|
||||||
|
Assunto <span class="text-danger">*</span>
|
||||||
|
</label>
|
||||||
|
<select class="form-select" id="subject" name="subject" required>
|
||||||
|
<option value="">Selecione um assunto</option>
|
||||||
|
<option value="Suporte Técnico">Suporte Técnico</option>
|
||||||
|
<option value="Dúvida sobre Planos">Dúvida sobre Planos</option>
|
||||||
|
<option value="Problema de Pagamento">Problema de Pagamento</option>
|
||||||
|
<option value="Sugestão de Melhoria">Sugestão de Melhoria</option>
|
||||||
|
<option value="Reportar Bug">Reportar Bug</option>
|
||||||
|
<option value="Outro">Outro</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (Model.CanAccessTelegram)
|
||||||
|
{
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="preferredContact" class="form-label">
|
||||||
|
Canal de contato preferido
|
||||||
|
</label>
|
||||||
|
<select class="form-select" id="preferredContact" name="preferredContact">
|
||||||
|
<option value="email" selected>Email</option>
|
||||||
|
<option value="telegram">Telegram</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<input type="hidden" name="preferredContact" value="email">
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="message" class="form-label">
|
||||||
|
Mensagem <span class="text-danger">*</span>
|
||||||
|
</label>
|
||||||
|
<textarea class="form-control" id="message" name="message" rows="6" required maxlength="2000" placeholder="Descreva sua dúvida ou problema em detalhes..."></textarea>
|
||||||
|
<small class="form-text text-muted">
|
||||||
|
<span id="messageCounter">0</span>/2000 caracteres
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<input type="hidden" name="_language" value="pt-BR">
|
||||||
|
<input type="hidden" name="_subject" value="Novo contato BCards">
|
||||||
|
<input type="hidden" name="userPlan" value="@Model.UserPlan">
|
||||||
|
|
||||||
|
<div class="d-grid gap-2">
|
||||||
|
<button type="submit" class="btn btn-primary btn-lg">
|
||||||
|
<i class="fas fa-paper-plane"></i> Enviar Mensagem
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
@if (Model.CanAccessTelegram)
|
||||||
|
{
|
||||||
|
<hr class="my-4">
|
||||||
|
<div class="text-center">
|
||||||
|
<p class="text-muted mb-3">Ou se preferir, entre em contato direto via Telegram:</p>
|
||||||
|
<a href="@Model.TelegramUrl" target="_blank" rel="noopener noreferrer" class="btn btn-outline-primary">
|
||||||
|
<i class="fab fa-telegram"></i> Abrir Telegram
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-4">
|
||||||
|
<h4 class="mb-3">Perguntas Frequentes</h4>
|
||||||
|
<div class="accordion" id="faqAccordion">
|
||||||
|
<div class="accordion-item">
|
||||||
|
<h2 class="accordion-header">
|
||||||
|
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#faq1">
|
||||||
|
Como faço upgrade do meu plano?
|
||||||
|
</button>
|
||||||
|
</h2>
|
||||||
|
<div id="faq1" class="accordion-collapse collapse" data-bs-parent="#faqAccordion">
|
||||||
|
<div class="accordion-body">
|
||||||
|
Você pode fazer upgrade a qualquer momento através da página de <a href="@Url.Action("Index", "Payment", new { area = "" })">Planos e Preços</a>.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="accordion-item">
|
||||||
|
<h2 class="accordion-header">
|
||||||
|
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#faq2">
|
||||||
|
Posso cancelar minha assinatura a qualquer momento?
|
||||||
|
</button>
|
||||||
|
</h2>
|
||||||
|
<div id="faq2" class="accordion-collapse collapse" data-bs-parent="#faqAccordion">
|
||||||
|
<div class="accordion-body">
|
||||||
|
Sim! Você pode cancelar sua assinatura a qualquer momento sem multas ou taxas adicionais.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="accordion-item">
|
||||||
|
<h2 class="accordion-header">
|
||||||
|
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#faq3">
|
||||||
|
Qual o tempo de resposta do suporte?
|
||||||
|
</button>
|
||||||
|
</h2>
|
||||||
|
<div id="faq3" class="accordion-collapse collapse" data-bs-parent="#faqAccordion">
|
||||||
|
<div class="accordion-body">
|
||||||
|
Respondemos em até 24-48h para planos Básico. Usuários Premium têm suporte prioritário com resposta em até 12h.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@section Scripts {
|
||||||
|
<script>
|
||||||
|
// Character counter for message
|
||||||
|
document.getElementById('message').addEventListener('input', function() {
|
||||||
|
document.getElementById('messageCounter').textContent = this.value.length;
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
}
|
||||||
90
src/BCards.Web/Areas/Support/Views/Support/Index.cshtml
Normal file
90
src/BCards.Web/Areas/Support/Views/Support/Index.cshtml
Normal file
@ -0,0 +1,90 @@
|
|||||||
|
@model BCards.Web.Areas.Support.Models.SupportOptions
|
||||||
|
@{
|
||||||
|
ViewData["Title"] = "Central de Suporte";
|
||||||
|
Layout = "_Layout";
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="container my-5">
|
||||||
|
<div class="row justify-content-center">
|
||||||
|
<div class="col-lg-10">
|
||||||
|
<h1 class="mb-4">
|
||||||
|
<i class="fas fa-headset text-primary"></i> Central de Suporte
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<div class="alert alert-info">
|
||||||
|
<strong>Plano Atual:</strong> @Model.UserPlan
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row g-4 mt-3">
|
||||||
|
@if (Model.CanAccessTelegram)
|
||||||
|
{
|
||||||
|
<div class="col-md-4">
|
||||||
|
<div class="card h-100 text-center">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="display-4 text-primary mb-3">
|
||||||
|
<i class="fab fa-telegram"></i>
|
||||||
|
</div>
|
||||||
|
<h5 class="card-title">Telegram</h5>
|
||||||
|
<p class="card-text">Fale conosco diretamente pelo Telegram para suporte prioritário.</p>
|
||||||
|
<a href="@Model.TelegramUrl" target="_blank" rel="noopener noreferrer" class="btn btn-primary">
|
||||||
|
Abrir Telegram
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (Model.CanUseContactForm)
|
||||||
|
{
|
||||||
|
<div class="col-md-4">
|
||||||
|
<div class="card h-100 text-center">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="display-4 text-success mb-3">
|
||||||
|
<i class="fas fa-envelope"></i>
|
||||||
|
</div>
|
||||||
|
<h5 class="card-title">Formulário de Contato</h5>
|
||||||
|
<p class="card-text">Envie sua dúvida ou problema através do nosso formulário.</p>
|
||||||
|
<a href="@Url.Action("ContactForm", "Support", new { area = "Support" })" class="btn btn-success">
|
||||||
|
Enviar Mensagem
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (Model.CanRate)
|
||||||
|
{
|
||||||
|
<div class="col-md-4">
|
||||||
|
<div class="card h-100 text-center">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="display-4 text-warning mb-3">
|
||||||
|
<i class="fas fa-star"></i>
|
||||||
|
</div>
|
||||||
|
<h5 class="card-title">Avalie-nos</h5>
|
||||||
|
<p class="card-text">Conte-nos sobre sua experiência com a plataforma.</p>
|
||||||
|
<button type="button" class="btn btn-warning" data-bs-toggle="modal" data-bs-target="#ratingModal">
|
||||||
|
Avaliar Agora
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (!Model.CanUseContactForm && !Model.CanAccessTelegram)
|
||||||
|
{
|
||||||
|
<div class="alert alert-warning mt-4">
|
||||||
|
<h5><i class="fas fa-lock"></i> Desbloqueie Mais Recursos de Suporte!</h5>
|
||||||
|
<p class="mb-2">Seu plano atual tem acesso limitado. Faça upgrade para:</p>
|
||||||
|
<ul>
|
||||||
|
<li><strong>Plano Básico:</strong> Formulário de contato + Avaliações</li>
|
||||||
|
<li><strong>Plano Premium:</strong> Telegram + Formulário + Suporte Prioritário</li>
|
||||||
|
</ul>
|
||||||
|
<a href="@Url.Action("Index", "Payment", new { area = "" })" class="btn btn-primary mt-2">
|
||||||
|
Ver Planos e Preços
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@ -0,0 +1,111 @@
|
|||||||
|
using BCards.Web.Areas.Tutoriais.Models;
|
||||||
|
using BCards.Web.Areas.Tutoriais.Services;
|
||||||
|
using BCards.Web.Repositories;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
|
namespace BCards.Web.Areas.Tutoriais.Controllers;
|
||||||
|
|
||||||
|
[Area("Tutoriais")]
|
||||||
|
public class TutoriaisController : Controller
|
||||||
|
{
|
||||||
|
private readonly IMarkdownService _markdownService;
|
||||||
|
private readonly ICategoryRepository _categoryRepository;
|
||||||
|
private readonly ILogger<TutoriaisController> _logger;
|
||||||
|
|
||||||
|
public TutoriaisController(
|
||||||
|
IMarkdownService markdownService,
|
||||||
|
ICategoryRepository categoryRepository,
|
||||||
|
ILogger<TutoriaisController> logger)
|
||||||
|
{
|
||||||
|
_markdownService = markdownService;
|
||||||
|
_categoryRepository = categoryRepository;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /tutoriais
|
||||||
|
public async Task<IActionResult> Index()
|
||||||
|
{
|
||||||
|
var categories = await _categoryRepository.GetAllActiveAsync();
|
||||||
|
var tutoriaisPorCategoria = new Dictionary<string, List<ArticleMetadata>>();
|
||||||
|
|
||||||
|
foreach (var category in categories)
|
||||||
|
{
|
||||||
|
var artigos = await _markdownService
|
||||||
|
.GetArticlesByCategoryAsync(category.Slug, "pt-BR");
|
||||||
|
|
||||||
|
if (artigos.Any())
|
||||||
|
{
|
||||||
|
tutoriaisPorCategoria[category.Slug] = artigos;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ViewBag.Categories = categories;
|
||||||
|
return View(tutoriaisPorCategoria);
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /tutoriais/{categoria}
|
||||||
|
public async Task<IActionResult> Category(string categoria)
|
||||||
|
{
|
||||||
|
// Validar categoria existe
|
||||||
|
var category = await _categoryRepository.GetBySlugAsync(categoria);
|
||||||
|
if (category == null)
|
||||||
|
{
|
||||||
|
_logger.LogWarning("Categoria não encontrada: {Categoria}", categoria);
|
||||||
|
return NotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
var artigos = await _markdownService
|
||||||
|
.GetArticlesByCategoryAsync(categoria, "pt-BR");
|
||||||
|
|
||||||
|
ViewBag.Category = category;
|
||||||
|
return View(artigos);
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /tutoriais/{categoria}/{slug}
|
||||||
|
public async Task<IActionResult> Article(string categoria, string slug)
|
||||||
|
{
|
||||||
|
// Sanitização (segurança contra path traversal)
|
||||||
|
categoria = categoria.Replace("..", "").Replace("/", "").Replace("\\", "");
|
||||||
|
slug = slug.Replace("..", "").Replace("/", "").Replace("\\", "");
|
||||||
|
|
||||||
|
// Validar categoria existe
|
||||||
|
var category = await _categoryRepository.GetBySlugAsync(categoria);
|
||||||
|
if (category == null)
|
||||||
|
{
|
||||||
|
_logger.LogWarning("Categoria não encontrada: {Categoria}", categoria);
|
||||||
|
return NotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var article = await _markdownService.GetArticleAsync(
|
||||||
|
$"Tutoriais/{categoria}/{slug}",
|
||||||
|
"pt-BR"
|
||||||
|
);
|
||||||
|
|
||||||
|
if (article == null)
|
||||||
|
{
|
||||||
|
_logger.LogWarning("Artigo não encontrado: {Categoria}/{Slug}", categoria, slug);
|
||||||
|
return NotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Buscar artigos relacionados da mesma categoria
|
||||||
|
article.RelatedArticles = await _markdownService
|
||||||
|
.GetArticlesByCategoryAsync(categoria, "pt-BR");
|
||||||
|
|
||||||
|
// Remover o artigo atual dos relacionados
|
||||||
|
article.RelatedArticles = article.RelatedArticles
|
||||||
|
.Where(a => a.Slug != slug)
|
||||||
|
.Take(3)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
ViewBag.Category = category;
|
||||||
|
return View(article);
|
||||||
|
}
|
||||||
|
catch (FileNotFoundException)
|
||||||
|
{
|
||||||
|
_logger.LogWarning("Arquivo markdown não encontrado: {Categoria}/{Slug}", categoria, slug);
|
||||||
|
return NotFound();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
16
src/BCards.Web/Areas/Tutoriais/Models/ArticleMetadata.cs
Normal file
16
src/BCards.Web/Areas/Tutoriais/Models/ArticleMetadata.cs
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
namespace BCards.Web.Areas.Tutoriais.Models;
|
||||||
|
|
||||||
|
public class ArticleMetadata
|
||||||
|
{
|
||||||
|
public string Title { get; set; } = string.Empty;
|
||||||
|
public string Description { get; set; } = string.Empty;
|
||||||
|
public string Keywords { get; set; } = string.Empty;
|
||||||
|
public string Author { get; set; } = "BCards";
|
||||||
|
public DateTime Date { get; set; }
|
||||||
|
public DateTime LastMod { get; set; }
|
||||||
|
public string Image { get; set; } = string.Empty;
|
||||||
|
public string Culture { get; set; } = "pt-BR";
|
||||||
|
public string? Category { get; set; } // Apenas para tutoriais
|
||||||
|
public int ReadingTimeMinutes { get; set; }
|
||||||
|
public string Slug { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
@ -0,0 +1,10 @@
|
|||||||
|
namespace BCards.Web.Areas.Tutoriais.Models.ViewModels;
|
||||||
|
|
||||||
|
public class ArticleViewModel
|
||||||
|
{
|
||||||
|
public ArticleMetadata Metadata { get; set; } = new();
|
||||||
|
public string HtmlContent { get; set; } = string.Empty;
|
||||||
|
public string Slug { get; set; } = string.Empty;
|
||||||
|
public DateTime LastModified { get; set; }
|
||||||
|
public List<ArticleMetadata> RelatedArticles { get; set; } = new();
|
||||||
|
}
|
||||||
11
src/BCards.Web/Areas/Tutoriais/Services/IMarkdownService.cs
Normal file
11
src/BCards.Web/Areas/Tutoriais/Services/IMarkdownService.cs
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
using BCards.Web.Areas.Tutoriais.Models;
|
||||||
|
using BCards.Web.Areas.Tutoriais.Models.ViewModels;
|
||||||
|
|
||||||
|
namespace BCards.Web.Areas.Tutoriais.Services;
|
||||||
|
|
||||||
|
public interface IMarkdownService
|
||||||
|
{
|
||||||
|
Task<ArticleViewModel?> GetArticleAsync(string relativePath, string culture);
|
||||||
|
Task<List<ArticleMetadata>> GetArticlesByCategoryAsync(string category, string culture);
|
||||||
|
Task<List<ArticleMetadata>> GetAllArticlesAsync(string baseFolder, string culture);
|
||||||
|
}
|
||||||
240
src/BCards.Web/Areas/Tutoriais/Services/MarkdownService.cs
Normal file
240
src/BCards.Web/Areas/Tutoriais/Services/MarkdownService.cs
Normal file
@ -0,0 +1,240 @@
|
|||||||
|
using BCards.Web.Areas.Tutoriais.Models;
|
||||||
|
using BCards.Web.Areas.Tutoriais.Models.ViewModels;
|
||||||
|
using Markdig;
|
||||||
|
using Microsoft.Extensions.Caching.Memory;
|
||||||
|
using YamlDotNet.Serialization;
|
||||||
|
using YamlDotNet.Serialization.NamingConventions;
|
||||||
|
|
||||||
|
namespace BCards.Web.Areas.Tutoriais.Services;
|
||||||
|
|
||||||
|
public class MarkdownService : IMarkdownService
|
||||||
|
{
|
||||||
|
private readonly IMemoryCache _cache;
|
||||||
|
private readonly ILogger<MarkdownService> _logger;
|
||||||
|
private readonly string _contentBasePath;
|
||||||
|
private readonly MarkdownPipeline _markdownPipeline;
|
||||||
|
private readonly IDeserializer _yamlDeserializer;
|
||||||
|
|
||||||
|
public MarkdownService(
|
||||||
|
IMemoryCache cache,
|
||||||
|
ILogger<MarkdownService> logger,
|
||||||
|
IWebHostEnvironment environment)
|
||||||
|
{
|
||||||
|
_cache = cache;
|
||||||
|
_logger = logger;
|
||||||
|
_contentBasePath = Path.Combine(environment.ContentRootPath, "Content");
|
||||||
|
|
||||||
|
// Pipeline Markdig com extensões avançadas
|
||||||
|
_markdownPipeline = new MarkdownPipelineBuilder()
|
||||||
|
.UseAdvancedExtensions() // Tables, footnotes, etc.
|
||||||
|
.UseAutoLinks() // Auto-link URLs
|
||||||
|
.UseEmphasisExtras() // ~~strikethrough~~
|
||||||
|
.UseGenericAttributes() // {#id .class}
|
||||||
|
.DisableHtml() // Segurança: bloqueia HTML inline
|
||||||
|
.Build();
|
||||||
|
|
||||||
|
// Deserializador YAML
|
||||||
|
_yamlDeserializer = new DeserializerBuilder()
|
||||||
|
.WithNamingConvention(CamelCaseNamingConvention.Instance)
|
||||||
|
.IgnoreUnmatchedProperties()
|
||||||
|
.Build();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<ArticleViewModel?> GetArticleAsync(string relativePath, string culture)
|
||||||
|
{
|
||||||
|
var cacheKey = $"Article_{relativePath}_{culture}";
|
||||||
|
|
||||||
|
// Verificar cache
|
||||||
|
if (_cache.TryGetValue(cacheKey, out ArticleViewModel? cachedArticle))
|
||||||
|
{
|
||||||
|
_logger.LogDebug("Artigo encontrado no cache: {Path}", relativePath);
|
||||||
|
return cachedArticle;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Construir caminho completo
|
||||||
|
var fullPath = Path.Combine(_contentBasePath, $"{relativePath}.{culture}.md");
|
||||||
|
|
||||||
|
if (!File.Exists(fullPath))
|
||||||
|
{
|
||||||
|
_logger.LogWarning("Arquivo não encontrado: {Path}", fullPath);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var content = await File.ReadAllTextAsync(fullPath);
|
||||||
|
var (metadata, markdownContent) = ExtractFrontmatter(content);
|
||||||
|
|
||||||
|
if (metadata == null)
|
||||||
|
{
|
||||||
|
_logger.LogError("Frontmatter inválido em: {Path}", fullPath);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Processar markdown → HTML
|
||||||
|
var htmlContent = Markdown.ToHtml(markdownContent, _markdownPipeline);
|
||||||
|
|
||||||
|
// Calcular tempo de leitura (200 palavras/minuto)
|
||||||
|
var wordCount = markdownContent.Split(' ', StringSplitOptions.RemoveEmptyEntries).Length;
|
||||||
|
metadata.ReadingTimeMinutes = Math.Max(1, wordCount / 200);
|
||||||
|
|
||||||
|
// Extrair slug do path
|
||||||
|
metadata.Slug = Path.GetFileNameWithoutExtension(
|
||||||
|
Path.GetFileNameWithoutExtension(relativePath.Split('/').Last())
|
||||||
|
);
|
||||||
|
metadata.Culture = culture;
|
||||||
|
|
||||||
|
var article = new ArticleViewModel
|
||||||
|
{
|
||||||
|
Metadata = metadata,
|
||||||
|
HtmlContent = htmlContent,
|
||||||
|
Slug = metadata.Slug,
|
||||||
|
LastModified = File.GetLastWriteTimeUtc(fullPath)
|
||||||
|
};
|
||||||
|
|
||||||
|
// Cache por 1 hora
|
||||||
|
_cache.Set(cacheKey, article, TimeSpan.FromHours(1));
|
||||||
|
|
||||||
|
_logger.LogInformation("Artigo processado e cacheado: {Path}", relativePath);
|
||||||
|
return article;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Erro ao processar artigo: {Path}", fullPath);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<List<ArticleMetadata>> GetArticlesByCategoryAsync(string category, string culture)
|
||||||
|
{
|
||||||
|
var cacheKey = $"CategoryArticles_{category}_{culture}";
|
||||||
|
|
||||||
|
if (_cache.TryGetValue(cacheKey, out List<ArticleMetadata>? cached))
|
||||||
|
{
|
||||||
|
return cached ?? new List<ArticleMetadata>();
|
||||||
|
}
|
||||||
|
|
||||||
|
var categoryPath = Path.Combine(_contentBasePath, "Tutoriais", category);
|
||||||
|
|
||||||
|
if (!Directory.Exists(categoryPath))
|
||||||
|
{
|
||||||
|
_logger.LogWarning("Diretório de categoria não encontrado: {Path}", categoryPath);
|
||||||
|
return new List<ArticleMetadata>();
|
||||||
|
}
|
||||||
|
|
||||||
|
var articles = new List<ArticleMetadata>();
|
||||||
|
var files = Directory.GetFiles(categoryPath, $"*.{culture}.md");
|
||||||
|
|
||||||
|
foreach (var file in files)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var content = await File.ReadAllTextAsync(file);
|
||||||
|
var (metadata, _) = ExtractFrontmatter(content);
|
||||||
|
|
||||||
|
if (metadata != null)
|
||||||
|
{
|
||||||
|
var slug = Path.GetFileNameWithoutExtension(
|
||||||
|
Path.GetFileNameWithoutExtension(file)
|
||||||
|
);
|
||||||
|
metadata.Slug = slug;
|
||||||
|
metadata.Culture = culture;
|
||||||
|
metadata.Category = category;
|
||||||
|
articles.Add(metadata);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Erro ao processar arquivo: {File}", file);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ordenar por data (mais recentes primeiro)
|
||||||
|
articles = articles.OrderByDescending(a => a.Date).ToList();
|
||||||
|
|
||||||
|
_cache.Set(cacheKey, articles, TimeSpan.FromHours(1));
|
||||||
|
return articles;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<List<ArticleMetadata>> GetAllArticlesAsync(string baseFolder, string culture)
|
||||||
|
{
|
||||||
|
var cacheKey = $"AllArticles_{baseFolder}_{culture}";
|
||||||
|
|
||||||
|
if (_cache.TryGetValue(cacheKey, out List<ArticleMetadata>? cached))
|
||||||
|
{
|
||||||
|
return cached ?? new List<ArticleMetadata>();
|
||||||
|
}
|
||||||
|
|
||||||
|
var folderPath = Path.Combine(_contentBasePath, baseFolder);
|
||||||
|
|
||||||
|
if (!Directory.Exists(folderPath))
|
||||||
|
{
|
||||||
|
_logger.LogWarning("Pasta não encontrada: {Path}", folderPath);
|
||||||
|
return new List<ArticleMetadata>();
|
||||||
|
}
|
||||||
|
|
||||||
|
var articles = new List<ArticleMetadata>();
|
||||||
|
var files = Directory.GetFiles(folderPath, $"*.{culture}.md", SearchOption.AllDirectories);
|
||||||
|
|
||||||
|
foreach (var file in files)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var content = await File.ReadAllTextAsync(file);
|
||||||
|
var (metadata, _) = ExtractFrontmatter(content);
|
||||||
|
|
||||||
|
if (metadata != null)
|
||||||
|
{
|
||||||
|
var slug = Path.GetFileNameWithoutExtension(
|
||||||
|
Path.GetFileNameWithoutExtension(file)
|
||||||
|
);
|
||||||
|
metadata.Slug = slug;
|
||||||
|
metadata.Culture = culture;
|
||||||
|
articles.Add(metadata);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Erro ao processar arquivo: {File}", file);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
articles = articles.OrderByDescending(a => a.Date).ToList();
|
||||||
|
_cache.Set(cacheKey, articles, TimeSpan.FromHours(1));
|
||||||
|
return articles;
|
||||||
|
}
|
||||||
|
|
||||||
|
private (ArticleMetadata? metadata, string content) ExtractFrontmatter(string fileContent)
|
||||||
|
{
|
||||||
|
var lines = fileContent.Split('\n');
|
||||||
|
|
||||||
|
if (lines.Length < 3 || !lines[0].Trim().Equals("---"))
|
||||||
|
{
|
||||||
|
_logger.LogWarning("Frontmatter não encontrado (deve começar com ---)");
|
||||||
|
return (null, fileContent);
|
||||||
|
}
|
||||||
|
|
||||||
|
var endIndex = Array.FindIndex(lines, 1, line => line.Trim().Equals("---"));
|
||||||
|
|
||||||
|
if (endIndex == -1)
|
||||||
|
{
|
||||||
|
_logger.LogWarning("Frontmatter mal formatado (falta --- de fechamento)");
|
||||||
|
return (null, fileContent);
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var yamlContent = string.Join('\n', lines[1..endIndex]);
|
||||||
|
var metadata = _yamlDeserializer.Deserialize<ArticleMetadata>(yamlContent);
|
||||||
|
|
||||||
|
var markdownContent = string.Join('\n', lines[(endIndex + 1)..]);
|
||||||
|
|
||||||
|
return (metadata, markdownContent);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Erro ao deserializar YAML frontmatter");
|
||||||
|
return (null, fileContent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
251
src/BCards.Web/Areas/Tutoriais/Views/Tutoriais/Article.cshtml
Normal file
251
src/BCards.Web/Areas/Tutoriais/Views/Tutoriais/Article.cshtml
Normal file
@ -0,0 +1,251 @@
|
|||||||
|
@model BCards.Web.Areas.Tutoriais.Models.ViewModels.ArticleViewModel
|
||||||
|
@{
|
||||||
|
var category = ViewBag.Category as BCards.Web.Models.Category;
|
||||||
|
ViewData["Title"] = Model.Metadata.Title;
|
||||||
|
}
|
||||||
|
|
||||||
|
@section Head {
|
||||||
|
<!-- Meta Tags SEO -->
|
||||||
|
<meta name="description" content="@Model.Metadata.Description">
|
||||||
|
<meta name="keywords" content="@Model.Metadata.Keywords">
|
||||||
|
<meta name="author" content="@Model.Metadata.Author">
|
||||||
|
<meta name="robots" content="index, follow">
|
||||||
|
<link rel="canonical" href="@Url.Action("Article", "Tutoriais", new { area = "Tutoriais", categoria = category?.Slug, slug = Model.Slug }, Context.Request.Scheme)">
|
||||||
|
|
||||||
|
<!-- Open Graph -->
|
||||||
|
<meta property="og:type" content="article">
|
||||||
|
<meta property="og:title" content="@Model.Metadata.Title">
|
||||||
|
<meta property="og:description" content="@Model.Metadata.Description">
|
||||||
|
<meta property="og:image" content="@Model.Metadata.Image">
|
||||||
|
<meta property="og:url" content="@Url.Action("Article", "Tutoriais", new { area = "Tutoriais", categoria = category?.Slug, slug = Model.Slug }, Context.Request.Scheme)">
|
||||||
|
<meta property="article:published_time" content="@Model.Metadata.Date.ToString("yyyy-MM-ddTHH:mm:ssZ")">
|
||||||
|
<meta property="article:modified_time" content="@Model.Metadata.LastMod.ToString("yyyy-MM-ddTHH:mm:ssZ")">
|
||||||
|
<meta property="article:author" content="@Model.Metadata.Author">
|
||||||
|
|
||||||
|
<!-- Twitter Card -->
|
||||||
|
<meta name="twitter:card" content="summary_large_image">
|
||||||
|
<meta name="twitter:title" content="@Model.Metadata.Title">
|
||||||
|
<meta name="twitter:description" content="@Model.Metadata.Description">
|
||||||
|
<meta name="twitter:image" content="@Model.Metadata.Image">
|
||||||
|
|
||||||
|
<!-- Schema.org JSON-LD -->
|
||||||
|
<script type="application/ld+json">
|
||||||
|
{
|
||||||
|
"@@context": "https://schema.org",
|
||||||
|
"@@type": "Article",
|
||||||
|
"headline": "@Model.Metadata.Title",
|
||||||
|
"description": "@Model.Metadata.Description",
|
||||||
|
"image": "@Model.Metadata.Image",
|
||||||
|
"datePublished": "@Model.Metadata.Date.ToString("yyyy-MM-dd")",
|
||||||
|
"dateModified": "@Model.Metadata.LastMod.ToString("yyyy-MM-dd")",
|
||||||
|
"author": {
|
||||||
|
"@@type": "Person",
|
||||||
|
"name": "@Model.Metadata.Author"
|
||||||
|
},
|
||||||
|
"publisher": {
|
||||||
|
"@@type": "Organization",
|
||||||
|
"name": "BCards",
|
||||||
|
"logo": {
|
||||||
|
"@@type": "ImageObject",
|
||||||
|
"url": "https://bcards.site/logo.png"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<!-- BreadcrumbList Schema -->
|
||||||
|
<script type="application/ld+json">
|
||||||
|
{
|
||||||
|
"@@context": "https://schema.org",
|
||||||
|
"@@type": "BreadcrumbList",
|
||||||
|
"itemListElement": [
|
||||||
|
{
|
||||||
|
"@@type": "ListItem",
|
||||||
|
"position": 1,
|
||||||
|
"name": "Início",
|
||||||
|
"item": "https://bcards.site"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"@@type": "ListItem",
|
||||||
|
"position": 2,
|
||||||
|
"name": "Tutoriais",
|
||||||
|
"item": "https://bcards.site/tutoriais"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"@@type": "ListItem",
|
||||||
|
"position": 3,
|
||||||
|
"name": "@category?.Name",
|
||||||
|
"item": "https://bcards.site/tutoriais/@category?.Slug"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"@@type": "ListItem",
|
||||||
|
"position": 4,
|
||||||
|
"name": "@Model.Metadata.Title"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="container py-5">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-lg-8">
|
||||||
|
<!-- Breadcrumb -->
|
||||||
|
<nav aria-label="breadcrumb" class="mb-4">
|
||||||
|
<ol class="breadcrumb">
|
||||||
|
<li class="breadcrumb-item"><a href="/">Início</a></li>
|
||||||
|
<li class="breadcrumb-item"><a href="@Url.Action("Index", "Tutoriais", new { area = "Tutoriais" })">Tutoriais</a></li>
|
||||||
|
<li class="breadcrumb-item"><a href="@Url.Action("Category", "Tutoriais", new { area = "Tutoriais", categoria = category?.Slug })">@category?.Name</a></li>
|
||||||
|
<li class="breadcrumb-item active" aria-current="page">@Model.Metadata.Title</li>
|
||||||
|
</ol>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<!-- Article Header -->
|
||||||
|
<article>
|
||||||
|
<header class="mb-4">
|
||||||
|
<div class="d-flex align-items-center mb-2">
|
||||||
|
<span class="badge bg-primary me-2">@category?.Icon @category?.Name</span>
|
||||||
|
<span class="text-muted small"><i class="far fa-clock me-1"></i> @Model.Metadata.ReadingTimeMinutes min de leitura</span>
|
||||||
|
</div>
|
||||||
|
<h1 class="display-5 mb-3">@Model.Metadata.Title</h1>
|
||||||
|
<p class="lead text-muted">@Model.Metadata.Description</p>
|
||||||
|
<div class="d-flex align-items-center text-muted small mb-3">
|
||||||
|
<span class="me-3"><i class="fas fa-user me-1"></i> @Model.Metadata.Author</span>
|
||||||
|
<span class="me-3"><i class="fas fa-calendar me-1"></i> @Model.Metadata.Date.ToString("dd/MM/yyyy")</span>
|
||||||
|
<span><i class="fas fa-sync me-1"></i> Atualizado em @Model.Metadata.LastMod.ToString("dd/MM/yyyy")</span>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
@if (!string.IsNullOrEmpty(Model.Metadata.Image))
|
||||||
|
{
|
||||||
|
<img src="@Model.Metadata.Image" class="img-fluid rounded mb-4" alt="@Model.Metadata.Title">
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- Article Content -->
|
||||||
|
<div class="article-content">
|
||||||
|
@Html.Raw(Model.HtmlContent)
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<!-- CTA -->
|
||||||
|
<div class="alert alert-primary mt-5" role="alert">
|
||||||
|
<h4 class="alert-heading"><i class="fas fa-rocket me-2"></i> Pronto para criar seu BCard?</h4>
|
||||||
|
<p class="mb-3">Agora que você aprendeu como funciona, que tal criar seu próprio cartão digital profissional?</p>
|
||||||
|
<a href="/" class="btn btn-primary">Criar meu BCard grátis</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Sidebar -->
|
||||||
|
<div class="col-lg-4">
|
||||||
|
<div class="sticky-top" style="top: 20px;">
|
||||||
|
<!-- Related Articles -->
|
||||||
|
@if (Model.RelatedArticles.Any())
|
||||||
|
{
|
||||||
|
<div class="card border-0 shadow-sm mb-4">
|
||||||
|
<div class="card-body">
|
||||||
|
<h5 class="card-title mb-3"><i class="fas fa-book-open me-2"></i> Tutoriais Relacionados</h5>
|
||||||
|
<div class="list-group list-group-flush">
|
||||||
|
@foreach (var related in Model.RelatedArticles)
|
||||||
|
{
|
||||||
|
<a href="@Url.Action("Article", "Tutoriais", new { area = "Tutoriais", categoria = category?.Slug, slug = related.Slug })" class="list-group-item list-group-item-action border-0 px-0">
|
||||||
|
<div class="d-flex w-100 justify-content-between">
|
||||||
|
<h6 class="mb-1">@related.Title</h6>
|
||||||
|
</div>
|
||||||
|
<small class="text-muted"><i class="far fa-clock me-1"></i> @related.ReadingTimeMinutes min</small>
|
||||||
|
</a>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- Help Card -->
|
||||||
|
<div class="card border-0 shadow-sm bg-light">
|
||||||
|
<div class="card-body text-center">
|
||||||
|
<i class="fas fa-question-circle fa-3x text-primary mb-3"></i>
|
||||||
|
<h5 class="card-title">Precisa de ajuda?</h5>
|
||||||
|
<p class="card-text small text-muted">Entre em contato com nosso suporte se tiver dúvidas</p>
|
||||||
|
<a href="/Support" class="btn btn-sm btn-outline-primary">Falar com suporte</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@section Styles {
|
||||||
|
<style>
|
||||||
|
.article-content {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
line-height: 1.8;
|
||||||
|
}
|
||||||
|
.article-content h1,
|
||||||
|
.article-content h2,
|
||||||
|
.article-content h3,
|
||||||
|
.article-content h4,
|
||||||
|
.article-content h5,
|
||||||
|
.article-content h6 {
|
||||||
|
margin-top: 2rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.article-content h2 {
|
||||||
|
font-size: 1.75rem;
|
||||||
|
padding-bottom: 0.5rem;
|
||||||
|
border-bottom: 2px solid #dee2e6;
|
||||||
|
}
|
||||||
|
.article-content h3 {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
}
|
||||||
|
.article-content p {
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
.article-content img {
|
||||||
|
max-width: 100%;
|
||||||
|
height: auto;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
margin: 1.5rem 0;
|
||||||
|
}
|
||||||
|
.article-content ul,
|
||||||
|
.article-content ol {
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
padding-left: 2rem;
|
||||||
|
}
|
||||||
|
.article-content li {
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
.article-content code {
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
padding: 0.2rem 0.4rem;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
font-size: 0.9em;
|
||||||
|
}
|
||||||
|
.article-content pre {
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
.article-content blockquote {
|
||||||
|
border-left: 4px solid #0d6efd;
|
||||||
|
padding-left: 1rem;
|
||||||
|
margin-left: 0;
|
||||||
|
font-style: italic;
|
||||||
|
color: #6c757d;
|
||||||
|
}
|
||||||
|
.article-content table {
|
||||||
|
width: 100%;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
border-collapse: collapse;
|
||||||
|
}
|
||||||
|
.article-content table th,
|
||||||
|
.article-content table td {
|
||||||
|
padding: 0.75rem;
|
||||||
|
border: 1px solid #dee2e6;
|
||||||
|
}
|
||||||
|
.article-content table th {
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
}
|
||||||
@ -0,0 +1,74 @@
|
|||||||
|
@model List<BCards.Web.Areas.Tutoriais.Models.ArticleMetadata>
|
||||||
|
@{
|
||||||
|
var category = ViewBag.Category as BCards.Web.Models.Category;
|
||||||
|
ViewData["Title"] = $"Tutoriais de {category?.Name} - BCards";
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="container py-5">
|
||||||
|
<div class="row mb-5">
|
||||||
|
<div class="col-lg-8 mx-auto text-center">
|
||||||
|
<span class="display-1">@category?.Icon</span>
|
||||||
|
<h1 class="display-5 mb-3">Tutoriais de @category?.Name</h1>
|
||||||
|
<p class="lead text-muted">@category?.Description</p>
|
||||||
|
<nav aria-label="breadcrumb">
|
||||||
|
<ol class="breadcrumb justify-content-center">
|
||||||
|
<li class="breadcrumb-item"><a href="/">Início</a></li>
|
||||||
|
<li class="breadcrumb-item"><a href="@Url.Action("Index", "Tutoriais", new { area = "Tutoriais" })">Tutoriais</a></li>
|
||||||
|
<li class="breadcrumb-item active" aria-current="page">@category?.Name</li>
|
||||||
|
</ol>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (Model.Any())
|
||||||
|
{
|
||||||
|
<div class="row g-4">
|
||||||
|
@foreach (var artigo in Model)
|
||||||
|
{
|
||||||
|
<div class="col-md-6 col-lg-4">
|
||||||
|
<div class="card h-100 border-0 shadow-sm hover-shadow">
|
||||||
|
@if (!string.IsNullOrEmpty(artigo.Image))
|
||||||
|
{
|
||||||
|
<img src="@artigo.Image" class="card-img-top" alt="@artigo.Title" style="height: 200px; object-fit: cover;">
|
||||||
|
}
|
||||||
|
<div class="card-body">
|
||||||
|
<h5 class="card-title">@artigo.Title</h5>
|
||||||
|
<p class="card-text text-muted small">@artigo.Description</p>
|
||||||
|
<div class="d-flex justify-content-between align-items-center text-muted small">
|
||||||
|
<span><i class="far fa-clock me-1"></i> @artigo.ReadingTimeMinutes min</span>
|
||||||
|
<span>@artigo.Date.ToString("dd/MM/yyyy")</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-footer bg-transparent border-0">
|
||||||
|
<a href="@Url.Action("Article", "Tutoriais", new { area = "Tutoriais", categoria = category?.Slug, slug = artigo.Slug })" class="btn btn-sm btn-primary w-100">
|
||||||
|
Ler tutorial <i class="fas fa-arrow-right ms-1"></i>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<div class="text-center py-5">
|
||||||
|
<i class="fas fa-book-open fa-4x text-muted mb-3"></i>
|
||||||
|
<h3 class="text-muted">Nenhum tutorial disponível nesta categoria</h3>
|
||||||
|
<p class="text-muted">Em breve teremos tutoriais para @category?.Name!</p>
|
||||||
|
<a href="@Url.Action("Index", "Tutoriais", new { area = "Tutoriais" })" class="btn btn-primary mt-3">
|
||||||
|
<i class="fas fa-arrow-left me-2"></i> Voltar para tutoriais
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@section Styles {
|
||||||
|
<style>
|
||||||
|
.hover-shadow {
|
||||||
|
transition: box-shadow 0.3s ease-in-out;
|
||||||
|
}
|
||||||
|
.hover-shadow:hover {
|
||||||
|
box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15) !important;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
}
|
||||||
84
src/BCards.Web/Areas/Tutoriais/Views/Tutoriais/Index.cshtml
Normal file
84
src/BCards.Web/Areas/Tutoriais/Views/Tutoriais/Index.cshtml
Normal file
@ -0,0 +1,84 @@
|
|||||||
|
@model Dictionary<string, List<BCards.Web.Areas.Tutoriais.Models.ArticleMetadata>>
|
||||||
|
@{
|
||||||
|
ViewData["Title"] = "Tutoriais BCards - Aprenda a usar o BCards";
|
||||||
|
var categories = ViewBag.Categories as List<BCards.Web.Models.Category>;
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="container py-5">
|
||||||
|
<div class="row mb-5">
|
||||||
|
<div class="col-lg-8 mx-auto text-center">
|
||||||
|
<h1 class="display-4 mb-3">📚 Tutoriais BCards</h1>
|
||||||
|
<p class="lead text-muted">Aprenda a usar o BCards e maximize seus resultados com guias práticos por categoria</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (Model.Any())
|
||||||
|
{
|
||||||
|
@foreach (var categorySlug in Model.Keys)
|
||||||
|
{
|
||||||
|
var category = categories?.FirstOrDefault(c => c.Slug == categorySlug);
|
||||||
|
var artigos = Model[categorySlug];
|
||||||
|
|
||||||
|
if (category != null && artigos.Any())
|
||||||
|
{
|
||||||
|
<div class="mb-5">
|
||||||
|
<div class="d-flex align-items-center mb-3">
|
||||||
|
<span class="fs-2 me-2">@category.Icon</span>
|
||||||
|
<h2 class="mb-0">@category.Name</h2>
|
||||||
|
<a href="@Url.Action("Category", "Tutoriais", new { area = "Tutoriais", categoria = category.Slug })" class="ms-auto btn btn-sm btn-outline-primary">
|
||||||
|
Ver todos <i class="fas fa-arrow-right ms-1"></i>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<p class="text-muted mb-4">@category.Description</p>
|
||||||
|
|
||||||
|
<div class="row g-4">
|
||||||
|
@foreach (var artigo in artigos.Take(3))
|
||||||
|
{
|
||||||
|
<div class="col-md-4">
|
||||||
|
<div class="card h-100 border-0 shadow-sm hover-shadow">
|
||||||
|
@if (!string.IsNullOrEmpty(artigo.Image))
|
||||||
|
{
|
||||||
|
<img src="@artigo.Image" class="card-img-top" alt="@artigo.Title" style="height: 200px; object-fit: cover;">
|
||||||
|
}
|
||||||
|
<div class="card-body">
|
||||||
|
<h5 class="card-title">@artigo.Title</h5>
|
||||||
|
<p class="card-text text-muted small">@artigo.Description</p>
|
||||||
|
<div class="d-flex justify-content-between align-items-center text-muted small">
|
||||||
|
<span><i class="far fa-clock me-1"></i> @artigo.ReadingTimeMinutes min</span>
|
||||||
|
<span>@artigo.Date.ToString("dd/MM/yyyy")</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-footer bg-transparent border-0">
|
||||||
|
<a href="@Url.Action("Article", "Tutoriais", new { area = "Tutoriais", categoria = category.Slug, slug = artigo.Slug })" class="btn btn-sm btn-primary w-100">
|
||||||
|
Ler tutorial <i class="fas fa-arrow-right ms-1"></i>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<hr class="my-5">
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<div class="text-center py-5">
|
||||||
|
<i class="fas fa-book-open fa-4x text-muted mb-3"></i>
|
||||||
|
<h3 class="text-muted">Nenhum tutorial disponível ainda</h3>
|
||||||
|
<p class="text-muted">Em breve teremos tutoriais incríveis para você!</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@section Styles {
|
||||||
|
<style>
|
||||||
|
.hover-shadow {
|
||||||
|
transition: box-shadow 0.3s ease-in-out;
|
||||||
|
}
|
||||||
|
.hover-shadow:hover {
|
||||||
|
box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15) !important;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
}
|
||||||
3
src/BCards.Web/Areas/Tutoriais/Views/_ViewStart.cshtml
Normal file
3
src/BCards.Web/Areas/Tutoriais/Views/_ViewStart.cshtml
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
@{
|
||||||
|
Layout = "_Layout";
|
||||||
|
}
|
||||||
@ -1,33 +1,33 @@
|
|||||||
using BCards.Web.Services;
|
using BCards.Web.Services;
|
||||||
using Microsoft.AspNetCore.Mvc.Filters;
|
using Microsoft.AspNetCore.Mvc.Filters;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
namespace BCards.Web.Attributes
|
namespace BCards.Web.Attributes
|
||||||
{
|
{
|
||||||
public class ModeratorAuthorizeAttribute : Attribute, IAuthorizationFilter
|
public class ModeratorAuthorizeAttribute : Attribute, IAuthorizationFilter
|
||||||
{
|
{
|
||||||
public void OnAuthorization(AuthorizationFilterContext context)
|
public void OnAuthorization(AuthorizationFilterContext context)
|
||||||
{
|
{
|
||||||
var user = context.HttpContext.User;
|
var user = context.HttpContext.User;
|
||||||
|
|
||||||
if (!user.Identity?.IsAuthenticated == true)
|
if (!user.Identity?.IsAuthenticated == true)
|
||||||
{
|
{
|
||||||
context.Result = new RedirectToActionResult("Login", "Auth",
|
context.Result = new RedirectToActionResult("Login", "Auth",
|
||||||
new { returnUrl = context.HttpContext.Request.Path });
|
new { returnUrl = context.HttpContext.Request.Path });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var moderationAuth = context.HttpContext.RequestServices
|
var moderationAuth = context.HttpContext.RequestServices
|
||||||
.GetRequiredService<IModerationAuthService>();
|
.GetRequiredService<IModerationAuthService>();
|
||||||
|
|
||||||
if (!moderationAuth.IsUserModerator(user))
|
if (!moderationAuth.IsUserModerator(user))
|
||||||
{
|
{
|
||||||
context.Result = new ForbidResult();
|
context.Result = new ForbidResult();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Adicionar flag para views
|
// Adicionar flag para views
|
||||||
context.HttpContext.Items["IsModerator"] = true;
|
context.HttpContext.Items["IsModerator"] = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,29 +1,52 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<TargetFramework>net8.0</TargetFramework>
|
<TargetFramework>net8.0</TargetFramework>
|
||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
<EnableDefaultEmbeddedResourceItems>false</EnableDefaultEmbeddedResourceItems>
|
<EnableDefaultEmbeddedResourceItems>false</EnableDefaultEmbeddedResourceItems>
|
||||||
<RuntimeIdentifiers>linux-x64;linux-arm64</RuntimeIdentifiers>
|
<RuntimeIdentifiers>linux-x64;linux-arm64</RuntimeIdentifiers>
|
||||||
</PropertyGroup>
|
<Configurations>Debug;Release;Testing</Configurations>
|
||||||
|
</PropertyGroup>
|
||||||
<ItemGroup>
|
|
||||||
<PackageReference Include="MongoDB.Driver" Version="2.25.0" />
|
<ItemGroup>
|
||||||
<PackageReference Include="Stripe.net" Version="44.7.0" />
|
<PackageReference Include="Markdig" Version="0.43.0" />
|
||||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.Google" Version="8.0.4" />
|
<PackageReference Include="MongoDB.Driver" Version="3.4.2" />
|
||||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.MicrosoftAccount" Version="8.0.4" />
|
<PackageReference Include="Stripe.net" Version="48.4.0" />
|
||||||
<PackageReference Include="Microsoft.AspNetCore.Localization" Version="2.2.0" />
|
<PackageReference Include="Microsoft.AspNetCore.Authentication.Google" Version="8.0.4" />
|
||||||
<PackageReference Include="Microsoft.Extensions.Localization" Version="8.0.4" />
|
<PackageReference Include="Microsoft.AspNetCore.Authentication.MicrosoftAccount" Version="8.0.4" />
|
||||||
<PackageReference Include="Microsoft.AspNetCore.Mvc.Razor.RuntimeCompilation" Version="8.0.4" />
|
<PackageReference Include="Microsoft.AspNetCore.Localization" Version="2.2.0" />
|
||||||
<PackageReference Include="SixLabors.ImageSharp.Web" Version="3.1.0" />
|
<PackageReference Include="Microsoft.Extensions.Localization" Version="8.0.4" />
|
||||||
<PackageReference Include="Microsoft.AspNetCore.ResponseCaching" Version="2.2.0" />
|
<PackageReference Include="Microsoft.AspNetCore.Mvc.Razor.RuntimeCompilation" Version="8.0.4" />
|
||||||
<PackageReference Include="HtmlAgilityPack" Version="1.11.54" />
|
<PackageReference Include="SixLabors.ImageSharp.Web" Version="3.1.0" />
|
||||||
<PackageReference Include="SendGrid" Version="9.29.3" />
|
<PackageReference Include="Microsoft.AspNetCore.ResponseCaching" Version="2.2.0" />
|
||||||
</ItemGroup>
|
<PackageReference Include="HtmlAgilityPack" Version="1.11.54" />
|
||||||
|
<PackageReference Include="SendGrid" Version="9.29.3" />
|
||||||
<ItemGroup>
|
<PackageReference Include="Serilog.AspNetCore" Version="8.0.0" />
|
||||||
<EmbeddedResource Include="Resources\**\*.resx" />
|
<PackageReference Include="Serilog.Sinks.Console" Version="5.0.0" />
|
||||||
</ItemGroup>
|
<PackageReference Include="Serilog.Sinks.OpenSearch" Version="1.3.0" />
|
||||||
|
<PackageReference Include="Serilog.Enrichers.Environment" Version="3.0.0" />
|
||||||
</Project>
|
<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" />
|
||||||
|
<PackageReference Include="AspNetCore.DataProtection.MongoDB" Version="8.0.0" />
|
||||||
|
<PackageReference Include="YamlDotNet" Version="16.3.0" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<EmbeddedResource Include="Resources\**\*.resx" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<None Update="Content\**\*">
|
||||||
|
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||||
|
</None>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<PropertyGroup Condition="'$(Configuration)'=='Testing'">
|
||||||
|
<DefineConstants>$(DefineConstants);TESTING</DefineConstants>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
|
|||||||
@ -1,10 +1,10 @@
|
|||||||
namespace BCards.Web.Configuration
|
namespace BCards.Web.Configuration
|
||||||
{
|
{
|
||||||
public class ModerationSettings
|
public class ModerationSettings
|
||||||
{
|
{
|
||||||
public Dictionary<string, TimeSpan> PriorityTimeframes { get; set; } = new();
|
public Dictionary<string, TimeSpan> PriorityTimeframes { get; set; } = new();
|
||||||
public int MaxAttempts { get; set; } = 3;
|
public int MaxAttempts { get; set; } = 3;
|
||||||
public string ModeratorEmail { get; set; } = "";
|
public string ModeratorEmail { get; set; } = "";
|
||||||
public List<string> ModeratorEmails { get; set; } = new();
|
public List<string> ModeratorEmails { get; set; } = new();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -5,4 +5,8 @@ public class StripeSettings
|
|||||||
public string PublishableKey { get; set; } = string.Empty;
|
public string PublishableKey { get; set; } = string.Empty;
|
||||||
public string SecretKey { get; set; } = string.Empty;
|
public string SecretKey { get; set; } = string.Empty;
|
||||||
public string WebhookSecret { get; set; } = string.Empty;
|
public string WebhookSecret { get; set; } = string.Empty;
|
||||||
|
public string Environment { get; set; } = "test";
|
||||||
|
|
||||||
|
public bool IsTestMode => Environment.ToLowerInvariant() == "test";
|
||||||
|
public bool IsLiveMode => Environment.ToLowerInvariant() == "live";
|
||||||
}
|
}
|
||||||
10
src/BCards.Web/Configuration/SupportSettings.cs
Normal file
10
src/BCards.Web/Configuration/SupportSettings.cs
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
namespace BCards.Web.Configuration;
|
||||||
|
|
||||||
|
public class SupportSettings
|
||||||
|
{
|
||||||
|
public string TelegramUrl { get; set; } = string.Empty;
|
||||||
|
public string FormspreeUrl { get; set; } = string.Empty;
|
||||||
|
public List<string> EnableTelegramForPlans { get; set; } = new();
|
||||||
|
public List<string> EnableFormForPlans { get; set; } = new();
|
||||||
|
public bool EnableRatingForAllUsers { get; set; } = true;
|
||||||
|
}
|
||||||
395
src/BCards.Web/Content/Artigos/bcards-vs-linktree.pt-BR.md
Normal file
395
src/BCards.Web/Content/Artigos/bcards-vs-linktree.pt-BR.md
Normal file
@ -0,0 +1,395 @@
|
|||||||
|
---
|
||||||
|
title: "BCards vs LinkTree: Compare e Escolha a Melhor Alternativa Brasileira"
|
||||||
|
description: "Comparação completa entre BCards e LinkTree. Descubra qual plataforma oferece melhor custo-benefício, mais funcionalidades e suporte em português para sua página de links profissional."
|
||||||
|
keywords: "linktree, alternativa ao linktree, bcards, página de links, linktree brasil, comparação linktree, melhor que linktree"
|
||||||
|
author: "Equipe BCards"
|
||||||
|
date: 2025-01-15
|
||||||
|
lastMod: 2025-01-15
|
||||||
|
image: "/images/artigos/bcards-vs-linktree.jpg"
|
||||||
|
culture: "pt-BR"
|
||||||
|
---
|
||||||
|
|
||||||
|
# BCards vs LinkTree: Compare e Escolha a Melhor Alternativa Brasileira
|
||||||
|
|
||||||
|
Se você está procurando uma alternativa ao LinkTree, provavelmente já percebeu que existem diversas opções no mercado. Mas qual delas oferece o melhor custo-benefício para profissionais e empresas brasileiras?
|
||||||
|
|
||||||
|
Neste artigo, vamos fazer uma comparação honesta e detalhada entre **BCards** e **LinkTree**, analisando funcionalidades, preços, suporte e muito mais para ajudá-lo a tomar a melhor decisão.
|
||||||
|
|
||||||
|
## O Que São Plataformas de Bio Links?
|
||||||
|
|
||||||
|
Antes de mergulharmos na comparação, vamos entender o conceito. Plataformas como LinkTree e BCards permitem que você crie uma página única com múltiplos links, perfeita para compartilhar em suas redes sociais (especialmente Instagram, TikTok e Twitter, onde você tem espaço limitado para links).
|
||||||
|
|
||||||
|
Em vez de escolher apenas um link na sua bio, você direciona seus seguidores para uma página centralizada com todos os seus links importantes: site, blog, produtos, redes sociais, portfólio e muito mais.
|
||||||
|
|
||||||
|
## Visão Geral: BCards vs LinkTree
|
||||||
|
|
||||||
|
### LinkTree: O Pioneer Global
|
||||||
|
|
||||||
|
O LinkTree foi uma das primeiras plataformas de bio links a ganhar popularidade mundial. Fundado na Austrália, hoje é usado por milhões de criadores de conteúdo, influenciadores e empresas ao redor do mundo.
|
||||||
|
|
||||||
|
**Principais Características:**
|
||||||
|
- Interface simples e intuitiva
|
||||||
|
- Grande reconhecimento de marca internacional
|
||||||
|
- Diversas integrações com plataformas globais
|
||||||
|
- Suporte em inglês
|
||||||
|
|
||||||
|
### BCards: A Alternativa Brasileira
|
||||||
|
|
||||||
|
O BCards é uma plataforma brasileira desenvolvida especificamente para atender às necessidades do mercado nacional. Criada por profissionais que entendem os desafios locais, oferece funcionalidades pensadas para o público brasileiro.
|
||||||
|
|
||||||
|
**Principais Características:**
|
||||||
|
- Totalmente em português
|
||||||
|
- Suporte local e personalizado
|
||||||
|
- Preços em reais (sem variação cambial)
|
||||||
|
- URLs organizadas por categoria profissional
|
||||||
|
- Foco no mercado brasileiro e latino-americano
|
||||||
|
|
||||||
|
## Comparação Detalhada de Funcionalidades
|
||||||
|
|
||||||
|
### 1. Estrutura de URLs
|
||||||
|
|
||||||
|
**LinkTree:**
|
||||||
|
- Formato: `linktr.ee/seuusuario`
|
||||||
|
- URL genérica para todos os usuários
|
||||||
|
|
||||||
|
**BCards:**
|
||||||
|
- Formato: `bcards.site/page/{categoria}/{seu-slug}`
|
||||||
|
- Exemplos:
|
||||||
|
- `bcards.site/page/advocacia/maria-silva`
|
||||||
|
- `bcards.site/page/tecnologia/joao-dev`
|
||||||
|
- `bcards.site/page/saude/dra-ana-cardiologista`
|
||||||
|
|
||||||
|
**Vantagem BCards:** URLs hierárquicas melhoram significativamente o SEO e a credibilidade profissional. Quando alguém visita seu link, já sabe qual é sua área de atuação antes mesmo de abrir a página.
|
||||||
|
|
||||||
|
### 2. Personalização Visual
|
||||||
|
|
||||||
|
**LinkTree:**
|
||||||
|
- Plano gratuito: Temas básicos limitados
|
||||||
|
- Planos pagos: Mais opções de personalização
|
||||||
|
- Temas pré-definidos
|
||||||
|
|
||||||
|
**BCards:**
|
||||||
|
- Plano Básico: 5+ temas profissionais
|
||||||
|
- Plano Profissional: 10+ temas premium
|
||||||
|
- Plano Premium: Temas customizáveis + editor CSS
|
||||||
|
|
||||||
|
**Vantagem BCards:** Maior flexibilidade de personalização mesmo nos planos mais acessíveis, permitindo que sua página reflita sua identidade visual.
|
||||||
|
|
||||||
|
### 3. Quantidade de Links
|
||||||
|
|
||||||
|
**LinkTree:**
|
||||||
|
- Plano gratuito: Links ilimitados
|
||||||
|
- Destaque para 1 link por vez (feature paga)
|
||||||
|
|
||||||
|
**BCards:**
|
||||||
|
- Plano Básico (R$ 12,90): 5 links
|
||||||
|
- Plano Profissional (R$ 25,90): 15 links
|
||||||
|
- Plano Premium (R$ 29,90): Links ilimitados
|
||||||
|
|
||||||
|
**Empate:** LinkTree oferece links ilimitados gratuitamente, mas limita recursos de destaque. BCards oferece estrutura mais organizada com categorias, mas limita quantidade nos planos básicos.
|
||||||
|
|
||||||
|
### 4. Analytics e Métricas
|
||||||
|
|
||||||
|
**LinkTree:**
|
||||||
|
- Plano gratuito: Métricas básicas de cliques
|
||||||
|
- Planos pagos: Analytics avançado, integração com Google Analytics, rastreamento de conversões
|
||||||
|
|
||||||
|
**BCards:**
|
||||||
|
- Todos os planos: Contadores de cliques por link
|
||||||
|
- Dashboards com métricas de desempenho
|
||||||
|
- Relatórios de visitantes
|
||||||
|
|
||||||
|
**Empate:** Ambas as plataformas oferecem analytics suficientes para a maioria dos usuários. LinkTree tem vantagem em integrações avançadas, BCards oferece simplicidade e clareza nos dados.
|
||||||
|
|
||||||
|
### 5. Integrações
|
||||||
|
|
||||||
|
**LinkTree:**
|
||||||
|
- Integrações com diversas plataformas globais
|
||||||
|
- Facebook Pixel, Google Analytics, TikTok, Spotify, Apple Music
|
||||||
|
- Mais de 100 integrações
|
||||||
|
|
||||||
|
**BCards:**
|
||||||
|
- Integrações com principais plataformas brasileiras
|
||||||
|
- Google Analytics
|
||||||
|
- Redes sociais principais (Instagram, Facebook, WhatsApp)
|
||||||
|
- Foco em ferramentas relevantes para o mercado brasileiro
|
||||||
|
|
||||||
|
**Vantagem LinkTree:** Maior quantidade de integrações com plataformas internacionais, ideal para criadores de conteúdo global.
|
||||||
|
|
||||||
|
### 6. Sistema de Moderação e Qualidade
|
||||||
|
|
||||||
|
**LinkTree:**
|
||||||
|
- Moderação automatizada
|
||||||
|
- Políticas de uso global
|
||||||
|
|
||||||
|
**BCards:**
|
||||||
|
- Sistema de moderação humanizada
|
||||||
|
- Análise de conteúdo antes da ativação
|
||||||
|
- Garantia de qualidade das páginas públicas
|
||||||
|
|
||||||
|
**Vantagem BCards:** A moderação manual garante que todas as páginas ativas mantenham um padrão de qualidade, protegendo a reputação da plataforma e dos usuários.
|
||||||
|
|
||||||
|
## Comparação de Preços (Janeiro 2025)
|
||||||
|
|
||||||
|
### LinkTree
|
||||||
|
|
||||||
|
**Free (Gratuito):**
|
||||||
|
- Links ilimitados
|
||||||
|
- Temas básicos
|
||||||
|
- Métricas limitadas
|
||||||
|
- Branding LinkTree visível
|
||||||
|
|
||||||
|
**Starter (US$ 5/mês = ~R$ 25-30/mês*):**
|
||||||
|
- Remove branding
|
||||||
|
- Mais opções de temas
|
||||||
|
- Agendamento de links
|
||||||
|
- Analytics básico
|
||||||
|
|
||||||
|
**Pro (US$ 9/mês = ~R$ 45-55/mês*):**
|
||||||
|
- Analytics avançado
|
||||||
|
- Priorização de links
|
||||||
|
- Vídeos de fundo
|
||||||
|
- Integrações avançadas
|
||||||
|
|
||||||
|
**Premium (US$ 24/mês = ~R$ 120-145/mês*):**
|
||||||
|
- Todas as features Pro
|
||||||
|
- Suporte prioritário
|
||||||
|
- Features de e-commerce
|
||||||
|
- Mais integrações
|
||||||
|
|
||||||
|
*Valores aproximados sujeitos à variação cambial
|
||||||
|
|
||||||
|
### BCards
|
||||||
|
|
||||||
|
**Básico (R$ 12,90/mês):**
|
||||||
|
- 5 links organizados
|
||||||
|
- Temas básicos
|
||||||
|
- Analytics essenciais
|
||||||
|
- URL categorizada
|
||||||
|
- Suporte em português
|
||||||
|
|
||||||
|
**Profissional (R$ 25,90/mês):**
|
||||||
|
- 15 links organizados
|
||||||
|
- Todos os temas premium
|
||||||
|
- Analytics completo
|
||||||
|
- Suporte prioritário
|
||||||
|
|
||||||
|
**Premium (R$ 29,90/mês):**
|
||||||
|
- Links ilimitados
|
||||||
|
- Temas customizáveis
|
||||||
|
- Editor CSS avançado
|
||||||
|
- Analytics detalhado
|
||||||
|
- Suporte VIP
|
||||||
|
|
||||||
|
### Análise de Custo-Benefício
|
||||||
|
|
||||||
|
Para usuários brasileiros, o BCards oferece vantagens significativas:
|
||||||
|
|
||||||
|
1. **Sem variação cambial**: Preços fixos em reais
|
||||||
|
2. **Custo inicial menor**: R$ 12,90 vs ~R$ 25-30 (Starter do LinkTree)
|
||||||
|
3. **Plano Premium mais acessível**: R$ 29,90 vs ~R$ 120-145 (Premium do LinkTree)
|
||||||
|
4. **Suporte em português**: Sem barreira linguística
|
||||||
|
|
||||||
|
**Exemplo prático:**
|
||||||
|
|
||||||
|
Um advogado que precisa de uma página profissional com 10 links:
|
||||||
|
- **LinkTree Pro**: ~R$ 45-55/mês (R$ 540-660/ano)
|
||||||
|
- **BCards Profissional**: R$ 25,90/mês (R$ 310,80/ano)
|
||||||
|
- **Economia**: R$ 241-361/ano (44-55% de economia)
|
||||||
|
|
||||||
|
## Quando Escolher LinkTree?
|
||||||
|
|
||||||
|
O LinkTree pode ser a melhor opção se você:
|
||||||
|
|
||||||
|
1. **Precisa de visibilidade internacional**: Marca reconhecida globalmente
|
||||||
|
2. **Cria conteúdo em inglês**: Audiência internacional
|
||||||
|
3. **Necessita de integrações específicas**: Plataformas não populares no Brasil
|
||||||
|
4. **Quer começar gratuitamente**: Plano free com links ilimitados
|
||||||
|
5. **Trabalha com e-commerce global**: Integrações avançadas de vendas
|
||||||
|
|
||||||
|
## Quando Escolher BCards?
|
||||||
|
|
||||||
|
O BCards é ideal se você:
|
||||||
|
|
||||||
|
1. **Atua no mercado brasileiro**: Profissionais liberais, empresas locais
|
||||||
|
2. **Valoriza suporte em português**: Comunicação clara e rápida
|
||||||
|
3. **Busca melhor custo-benefício**: Preços competitivos em reais
|
||||||
|
4. **Quer URLs profissionais**: Estrutura categorizada melhora SEO
|
||||||
|
5. **Precisa de personalização**: Temas customizáveis por preço acessível
|
||||||
|
6. **Valoriza qualidade**: Sistema de moderação garante padrão elevado
|
||||||
|
|
||||||
|
## Casos de Uso Práticos
|
||||||
|
|
||||||
|
### Caso 1: Advogada Especializada em Direito de Família
|
||||||
|
|
||||||
|
**Escolha: BCards**
|
||||||
|
|
||||||
|
Maria Silva, advogada em São Paulo, escolheu BCards porque:
|
||||||
|
- URL profissional: `bcards.site/page/advocacia/maria-silva-direito-familia`
|
||||||
|
- Preço fixo em reais (sem surpresas)
|
||||||
|
- Suporte em português para ajustar sua página
|
||||||
|
- Sistema de moderação garante credibilidade profissional
|
||||||
|
|
||||||
|
**Resultado:** Aumento de 40% em consultas via página de bio nos primeiros 3 meses.
|
||||||
|
|
||||||
|
### Caso 2: Influenciador de Tecnologia Global
|
||||||
|
|
||||||
|
**Escolha: LinkTree**
|
||||||
|
|
||||||
|
João Tech, criador de conteúdo com audiência internacional, escolheu LinkTree porque:
|
||||||
|
- Marca reconhecida globalmente
|
||||||
|
- Integrações com Patreon, Ko-fi, e outras plataformas internacionais
|
||||||
|
- Audiência em múltiplos países
|
||||||
|
- Necessidade de features de e-commerce global
|
||||||
|
|
||||||
|
**Resultado:** Facilidade para monetização internacional e reconhecimento da marca LinkTree entre seguidores estrangeiros.
|
||||||
|
|
||||||
|
### Caso 3: Personal Trainer Local
|
||||||
|
|
||||||
|
**Escolha: BCards**
|
||||||
|
|
||||||
|
Ana Fitness, personal trainer em Belo Horizonte, escolheu BCards porque:
|
||||||
|
- Atende apenas clientes locais
|
||||||
|
- Precisava de página profissional sem custo alto
|
||||||
|
- URL categorizada: `bcards.site/page/saude/ana-personal-trainer-bh`
|
||||||
|
- Todos os clientes falam português
|
||||||
|
|
||||||
|
**Resultado:** Redução de 60% no custo mensal comparado ao LinkTree Pro, mantendo todas as funcionalidades necessárias.
|
||||||
|
|
||||||
|
## Fatores Técnicos: SEO e Performance
|
||||||
|
|
||||||
|
### SEO (Otimização para Motores de Busca)
|
||||||
|
|
||||||
|
**BCards vantagens:**
|
||||||
|
- URLs hierárquicas descritivas
|
||||||
|
- Estrutura de categorias melhora indexação
|
||||||
|
- Conteúdo em português nativo
|
||||||
|
- Menor concorrência em buscas locais
|
||||||
|
|
||||||
|
**LinkTree vantagens:**
|
||||||
|
- Autoridade de domínio global mais alta
|
||||||
|
- Maior reconhecimento de marca
|
||||||
|
- Backlinks naturais de usuários internacionais
|
||||||
|
|
||||||
|
### Performance e Velocidade
|
||||||
|
|
||||||
|
Ambas as plataformas oferecem:
|
||||||
|
- Carregamento rápido (< 2 segundos)
|
||||||
|
- Responsividade mobile
|
||||||
|
- Uptime confiável (99%+)
|
||||||
|
|
||||||
|
## Suporte ao Cliente
|
||||||
|
|
||||||
|
### LinkTree
|
||||||
|
- Suporte em inglês
|
||||||
|
- Base de conhecimento extensa
|
||||||
|
- Comunidade global ativa
|
||||||
|
- Suporte prioritário apenas em planos premium
|
||||||
|
|
||||||
|
### BCards
|
||||||
|
- Suporte em português
|
||||||
|
- Atendimento personalizado
|
||||||
|
- Suporte prioritário desde plano Profissional
|
||||||
|
- Compreensão do contexto local brasileiro
|
||||||
|
|
||||||
|
**Vantagem BCards:** Para usuários que não dominam inglês ou preferem suporte local, o BCards oferece experiência significativamente melhor.
|
||||||
|
|
||||||
|
## Segurança e Privacidade
|
||||||
|
|
||||||
|
### LinkTree
|
||||||
|
- Certificado SSL
|
||||||
|
- Conformidade com GDPR (Europa)
|
||||||
|
- Políticas de privacidade internacionais
|
||||||
|
|
||||||
|
### BCards
|
||||||
|
- Certificado SSL
|
||||||
|
- Políticas alinhadas com LGPD (Brasil)
|
||||||
|
- Dados hospedados no Brasil
|
||||||
|
|
||||||
|
**Vantagem BCards:** Para empresas que precisam estar em conformidade com LGPD, ter dados hospedados no Brasil pode ser uma vantagem regulatória.
|
||||||
|
|
||||||
|
## Limitações de Cada Plataforma
|
||||||
|
|
||||||
|
### LinkTree Limitações
|
||||||
|
- Custos em dólar (variação cambial)
|
||||||
|
- Suporte não é em português
|
||||||
|
- Alguns recursos avançados são muito caros
|
||||||
|
- Foco global pode não atender necessidades locais
|
||||||
|
|
||||||
|
### BCards Limitações
|
||||||
|
- Menor reconhecimento internacional
|
||||||
|
- Menos integrações com plataformas globais
|
||||||
|
- Comunidade menor (plataforma mais nova)
|
||||||
|
- Não tem plano gratuito com links ilimitados
|
||||||
|
|
||||||
|
## Tabela Comparativa Resumida
|
||||||
|
|
||||||
|
| Característica | LinkTree | BCards |
|
||||||
|
|---------------|----------|---------|
|
||||||
|
| **Preço inicial** | Gratuito (limitado) | R$ 12,90/mês |
|
||||||
|
| **Plano Pro** | ~R$ 45-55/mês | R$ 25,90/mês |
|
||||||
|
| **Plano Premium** | ~R$ 120-145/mês | R$ 29,90/mês |
|
||||||
|
| **Moeda** | Dólar (USD) | Real (BRL) |
|
||||||
|
| **Links ilimitados** | Grátis | R$ 29,90/mês |
|
||||||
|
| **Suporte** | Inglês | Português |
|
||||||
|
| **URL** | linktr.ee/usuario | bcards.site/page/categoria/usuario |
|
||||||
|
| **Moderação** | Automatizada | Humanizada |
|
||||||
|
| **Foco** | Global | Brasil/América Latina |
|
||||||
|
| **Integrações** | 100+ | Principais |
|
||||||
|
| **Personalização** | Boa (paga) | Excelente (todos planos) |
|
||||||
|
| **SEO** | Autoridade global | URLs categorizadas |
|
||||||
|
|
||||||
|
## Migração: Como Trocar do LinkTree para BCards
|
||||||
|
|
||||||
|
Se você já usa LinkTree e está considerando migrar para BCards, o processo é simples:
|
||||||
|
|
||||||
|
1. **Exporte seus links**: Copie títulos e URLs
|
||||||
|
2. **Crie conta no BCards**: Escolha sua categoria profissional
|
||||||
|
3. **Configure sua página**: Adicione links, escolha tema
|
||||||
|
4. **Submeta para moderação**: Aguarde aprovação (24-48h)
|
||||||
|
5. **Atualize suas redes sociais**: Troque o link da bio
|
||||||
|
|
||||||
|
**Dica:** Mantenha ambas as páginas ativas durante 1-2 semanas de transição para garantir que todos os seguidores vejam o novo link.
|
||||||
|
|
||||||
|
## Conclusão: Qual Escolher?
|
||||||
|
|
||||||
|
Não existe resposta única. A melhor escolha depende do seu contexto:
|
||||||
|
|
||||||
|
**Escolha LinkTree se:**
|
||||||
|
- Você tem audiência internacional significativa
|
||||||
|
- Cria conteúdo em inglês
|
||||||
|
- Necessita de integrações específicas globais
|
||||||
|
- Quer começar gratuitamente (com limitações)
|
||||||
|
- Reconhecimento de marca internacional é importante
|
||||||
|
|
||||||
|
**Escolha BCards se:**
|
||||||
|
- Você atua principalmente no Brasil
|
||||||
|
- Valoriza suporte em português
|
||||||
|
- Busca melhor custo-benefício
|
||||||
|
- Quer URL profissional categorizada
|
||||||
|
- Prefere moderação humanizada
|
||||||
|
- Deseja evitar variação cambial
|
||||||
|
|
||||||
|
Para a maioria dos **profissionais brasileiros, pequenas empresas e criadores de conteúdo local**, o **BCards oferece melhor custo-benefício, suporte mais personalizado e funcionalidades adequadas às necessidades do mercado nacional**.
|
||||||
|
|
||||||
|
Para **criadores de conteúdo global, influenciadores internacionais e empresas que atuam em múltiplos países**, o **LinkTree pode ser a escolha mais adequada** devido ao reconhecimento internacional da marca.
|
||||||
|
|
||||||
|
## Próximos Passos
|
||||||
|
|
||||||
|
Pronto para criar sua página de links profissional?
|
||||||
|
|
||||||
|
1. **Defina suas necessidades**: Quantos links? Qual seu público?
|
||||||
|
2. **Avalie seu orçamento**: Preço fixo ou variável?
|
||||||
|
3. **Considere suporte**: Português ou inglês?
|
||||||
|
4. **Teste a plataforma**: Crie uma página e veja qual interface prefere
|
||||||
|
5. **Decida e comece**: Ambas são boas opções, escolha a melhor para você
|
||||||
|
|
||||||
|
**Experimente BCards gratuitamente** - [Criar conta agora](https://bcards.site/)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Última atualização:** Janeiro 2025
|
||||||
|
|
||||||
|
*Este artigo é baseado em informações públicas das plataformas e nossa análise independente. Os preços podem variar. Consulte os sites oficiais para informações atualizadas.*
|
||||||
|
|
||||||
|
*Disclaimer: Somos a equipe BCards, mas nos esforçamos para apresentar uma comparação honesta e imparcial. Apresentamos vantagens e limitações de ambas as plataformas para ajudá-lo a tomar a melhor decisão para seu caso específico.*
|
||||||
@ -0,0 +1,339 @@
|
|||||||
|
---
|
||||||
|
title: "Transformação Digital para Pequenos Negócios: Comece Hoje Mesmo"
|
||||||
|
description: "Guia prático sobre como pequenos negócios podem iniciar sua jornada de transformação digital sem grandes investimentos. Aprenda estratégias simples e eficazes."
|
||||||
|
keywords: "transformação digital, pequenos negócios, digitalização, presença online, marketing digital"
|
||||||
|
author: "Equipe BCards"
|
||||||
|
date: 2025-01-10
|
||||||
|
lastMod: 2025-01-10
|
||||||
|
image: "/images/artigos/transformacao-digital.jpg"
|
||||||
|
culture: "pt-BR"
|
||||||
|
---
|
||||||
|
|
||||||
|
# Transformação Digital para Pequenos Negócios: Comece Hoje Mesmo
|
||||||
|
|
||||||
|
A transformação digital não é mais um luxo reservado apenas para grandes empresas. Hoje, pequenos negócios podem (e devem) aproveitar as ferramentas digitais para crescer, alcançar novos clientes e competir no mercado moderno.
|
||||||
|
|
||||||
|
Neste artigo, você vai descobrir como iniciar sua jornada digital com investimento mínimo e resultados máximos.
|
||||||
|
|
||||||
|
## O Que É Transformação Digital?
|
||||||
|
|
||||||
|
Transformação digital é o processo de integrar tecnologias digitais em todas as áreas do seu negócio, mudando fundamentalmente como você opera e entrega valor aos clientes.
|
||||||
|
|
||||||
|
Para pequenos negócios, isso não significa gastar milhões em sistemas complexos. Significa:
|
||||||
|
- Ter presença online profissional
|
||||||
|
- Facilitar o contato com clientes
|
||||||
|
- Organizar informações de forma acessível
|
||||||
|
- Automatizar processos simples
|
||||||
|
- Usar dados para tomar decisões melhores
|
||||||
|
|
||||||
|
## Por Que Pequenos Negócios Precisam se Digitalizar?
|
||||||
|
|
||||||
|
### 1. Seus Clientes Estão Online
|
||||||
|
|
||||||
|
Mais de 70% dos brasileiros usam internet diariamente. Quando precisam de um produto ou serviço, a primeira ação é pesquisar online. Se você não está lá, está invisível.
|
||||||
|
|
||||||
|
### 2. Competitividade
|
||||||
|
|
||||||
|
Seus concorrentes já estão se digitalizando. Ficar de fora significa perder espaço no mercado.
|
||||||
|
|
||||||
|
### 3. Redução de Custos
|
||||||
|
|
||||||
|
Ferramentas digitais frequentemente custam menos que métodos tradicionais e alcançam mais pessoas.
|
||||||
|
|
||||||
|
### 4. Melhor Experiência do Cliente
|
||||||
|
|
||||||
|
Clientes valorizam conveniência: encontrar informações rapidamente, entrar em contato facilmente, agendar serviços online.
|
||||||
|
|
||||||
|
## Os 5 Pilares da Transformação Digital para Pequenos Negócios
|
||||||
|
|
||||||
|
### 1. Presença Online Profissional
|
||||||
|
|
||||||
|
**Problema:** Você não tem site, ou seu site está desatualizado.
|
||||||
|
|
||||||
|
**Solução Simples:**
|
||||||
|
- Crie uma página profissional de links (bio link)
|
||||||
|
- Organize todos seus canais em um único lugar
|
||||||
|
- Facilite o acesso a WhatsApp, Instagram, serviços
|
||||||
|
|
||||||
|
**Custo:** A partir de R$ 12,90/mês
|
||||||
|
|
||||||
|
**Resultado:** Clientes encontram suas informações facilmente, você parece mais profissional.
|
||||||
|
|
||||||
|
### 2. Relacionamento com Clientes
|
||||||
|
|
||||||
|
**Problema:** Dificuldade em manter contato constante com clientes.
|
||||||
|
|
||||||
|
**Solução Simples:**
|
||||||
|
- Use WhatsApp Business (gratuito)
|
||||||
|
- Organize contatos com etiquetas
|
||||||
|
- Configure mensagens automáticas
|
||||||
|
- Crie catálogo de produtos
|
||||||
|
|
||||||
|
**Custo:** Gratuito
|
||||||
|
|
||||||
|
**Resultado:** Comunicação mais profissional e organizada.
|
||||||
|
|
||||||
|
### 3. Divulgação Estratégica
|
||||||
|
|
||||||
|
**Problema:** Propaganda cara e ineficiente.
|
||||||
|
|
||||||
|
**Solução Simples:**
|
||||||
|
- Crie perfis profissionais em redes sociais
|
||||||
|
- Poste conteúdo relevante regularmente
|
||||||
|
- Use Instagram e Facebook Ads (começando com R$ 5/dia)
|
||||||
|
- Peça avaliações no Google Meu Negócio
|
||||||
|
|
||||||
|
**Custo:** Pode começar com R$ 0 (orgânico) ou R$ 150/mês (anúncios básicos)
|
||||||
|
|
||||||
|
**Resultado:** Mais visibilidade, novos clientes, crescimento constante.
|
||||||
|
|
||||||
|
### 4. Gestão e Organização
|
||||||
|
|
||||||
|
**Problema:** Controle manual de vendas, estoque, finanças.
|
||||||
|
|
||||||
|
**Solução Simples:**
|
||||||
|
- Use planilhas Google (gratuito)
|
||||||
|
- Adote um sistema de gestão simples (muitos têm versão gratuita)
|
||||||
|
- Organize documentos na nuvem (Google Drive/Dropbox)
|
||||||
|
|
||||||
|
**Custo:** Gratuito a R$ 30/mês
|
||||||
|
|
||||||
|
**Resultado:** Menos tempo perdido, mais controle, decisões baseadas em dados.
|
||||||
|
|
||||||
|
### 5. Pagamentos Digitais
|
||||||
|
|
||||||
|
**Problema:** Perder vendas por aceitar apenas dinheiro.
|
||||||
|
|
||||||
|
**Solução Simples:**
|
||||||
|
- Aceite Pix (gratuito)
|
||||||
|
- Use maquininha de cartão mobile
|
||||||
|
- Crie links de pagamento online
|
||||||
|
|
||||||
|
**Custo:** Taxas apenas sobre vendas (2-5%)
|
||||||
|
|
||||||
|
**Resultado:** Venda mais, facilite a vida dos clientes, profissionalize seu negócio.
|
||||||
|
|
||||||
|
## Passo a Passo: Comece Sua Transformação Digital Hoje
|
||||||
|
|
||||||
|
### Semana 1: Presença Online Básica
|
||||||
|
|
||||||
|
**Dia 1-2: Configure WhatsApp Business**
|
||||||
|
- Baixe o app WhatsApp Business
|
||||||
|
- Configure perfil profissional com horário de atendimento
|
||||||
|
- Crie mensagens automáticas
|
||||||
|
- Adicione catálogo de produtos/serviços
|
||||||
|
|
||||||
|
**Dia 3-4: Crie Perfis Profissionais**
|
||||||
|
- Instagram Business
|
||||||
|
- Facebook Page
|
||||||
|
- Google Meu Negócio
|
||||||
|
|
||||||
|
**Dia 5-7: Organize Seus Links**
|
||||||
|
- Crie página profissional de links (ex: BCards)
|
||||||
|
- Adicione todos os canais de contato
|
||||||
|
- Coloque o link na bio de todas as redes sociais
|
||||||
|
|
||||||
|
### Semana 2: Conteúdo e Engajamento
|
||||||
|
|
||||||
|
**Dias 8-10: Planeje Conteúdo**
|
||||||
|
- Defina 3 tipos de conteúdo para postar
|
||||||
|
- Exemplos: Dicas, bastidores, promoções
|
||||||
|
- Crie calendário simples (3 posts por semana)
|
||||||
|
|
||||||
|
**Dias 11-14: Comece a Postar**
|
||||||
|
- Publique primeiro conteúdo
|
||||||
|
- Responda todos os comentários
|
||||||
|
- Peça para amigos e clientes seguirem
|
||||||
|
|
||||||
|
### Semana 3: Organização Interna
|
||||||
|
|
||||||
|
**Dias 15-17: Digitalize Processos**
|
||||||
|
- Crie planilha de vendas no Google Sheets
|
||||||
|
- Liste todos os clientes em planilha de contatos
|
||||||
|
- Configure backup de fotos importantes na nuvem
|
||||||
|
|
||||||
|
**Dias 18-21: Configure Pagamentos**
|
||||||
|
- Cadastre Pix
|
||||||
|
- Avalie opções de maquininha mobile
|
||||||
|
- Crie links de pagamento
|
||||||
|
|
||||||
|
### Semana 4: Primeiras Campanhas
|
||||||
|
|
||||||
|
**Dias 22-25: Marketing Digital Básico**
|
||||||
|
- Poste sobre promoção especial
|
||||||
|
- Impulsione post no Instagram/Facebook (R$ 20)
|
||||||
|
- Monitore resultados
|
||||||
|
|
||||||
|
**Dias 26-30: Analise e Ajuste**
|
||||||
|
- Veja quais posts tiveram mais engajamento
|
||||||
|
- Identifique de onde vieram novos clientes
|
||||||
|
- Planeje próximo mês
|
||||||
|
|
||||||
|
## Ferramentas Essenciais (Maioria Gratuitas)
|
||||||
|
|
||||||
|
### Comunicação
|
||||||
|
- **WhatsApp Business**: Grátis
|
||||||
|
- **Google Workspace** (e-mail profissional): R$ 12/usuário/mês
|
||||||
|
|
||||||
|
### Organização
|
||||||
|
- **Google Drive**: 15GB grátis
|
||||||
|
- **Trello** (gestão de tarefas): Grátis
|
||||||
|
- **Google Sheets**: Grátis
|
||||||
|
|
||||||
|
### Presença Online
|
||||||
|
- **BCards** (página de links): A partir de R$ 12,90/mês
|
||||||
|
- **Instagram/Facebook**: Grátis
|
||||||
|
- **Google Meu Negócio**: Grátis
|
||||||
|
|
||||||
|
### Marketing
|
||||||
|
- **Canva** (design): Plano gratuito robusto
|
||||||
|
- **Meta Business Suite**: Grátis
|
||||||
|
- **Google Analytics**: Grátis
|
||||||
|
|
||||||
|
### Pagamentos
|
||||||
|
- **Pix**: Grátis
|
||||||
|
- **Mercado Pago**: Taxas sobre vendas
|
||||||
|
- **PagSeguro**: Taxas sobre vendas
|
||||||
|
|
||||||
|
**Investimento total inicial:** R$ 0 a R$ 50/mês
|
||||||
|
|
||||||
|
## Erros Comuns a Evitar
|
||||||
|
|
||||||
|
### 1. Tentar Fazer Tudo de Uma Vez
|
||||||
|
**Erro:** Criar 10 perfis, começar blog, loja virtual, tudo junto.
|
||||||
|
**Solução:** Comece com o básico, domine, depois expanda.
|
||||||
|
|
||||||
|
### 2. Não Ter Consistência
|
||||||
|
**Erro:** Postar muito uma semana, depois sumir por meses.
|
||||||
|
**Solução:** Defina frequência realista e mantenha.
|
||||||
|
|
||||||
|
### 3. Ignorar Clientes Online
|
||||||
|
**Erro:** Não responder comentários e mensagens rapidamente.
|
||||||
|
**Solução:** Defina horários para checar e responder (3x ao dia).
|
||||||
|
|
||||||
|
### 4. Não Mensurar Resultados
|
||||||
|
**Erro:** Investir sem saber o que funciona.
|
||||||
|
**Solução:** Acompanhe métricas básicas (seguidores, curtidas, vendas).
|
||||||
|
|
||||||
|
### 5. Copiar Concorrentes sem Personalidade
|
||||||
|
**Erro:** Ser uma cópia genérica.
|
||||||
|
**Solução:** Mostre sua personalidade, conte sua história única.
|
||||||
|
|
||||||
|
## Casos de Sucesso Reais
|
||||||
|
|
||||||
|
### Caso 1: Doceria da Dona Maria
|
||||||
|
|
||||||
|
**Antes:**
|
||||||
|
- Vendia apenas para vizinhos
|
||||||
|
- Divulgação boca a boca
|
||||||
|
- Faturamento: R$ 2.000/mês
|
||||||
|
|
||||||
|
**Ações:**
|
||||||
|
- Criou Instagram com fotos profissionais dos doces
|
||||||
|
- Configurou WhatsApp Business com catálogo
|
||||||
|
- Começou a aceitar encomendas por mensagem
|
||||||
|
- Criou página de links para facilitar contato
|
||||||
|
|
||||||
|
**Depois (6 meses):**
|
||||||
|
- Alcance em 3 bairros diferentes
|
||||||
|
- 1.200 seguidores no Instagram
|
||||||
|
- Faturamento: R$ 6.500/mês (+225%)
|
||||||
|
|
||||||
|
**Investimento:** R$ 30/mês (internet + ferramentas)
|
||||||
|
|
||||||
|
### Caso 2: Oficina do João
|
||||||
|
|
||||||
|
**Antes:**
|
||||||
|
- Clientes apenas por indicação
|
||||||
|
- Sem presença online
|
||||||
|
- Dificuldade em mostrar serviços
|
||||||
|
|
||||||
|
**Ações:**
|
||||||
|
- Criou Google Meu Negócio
|
||||||
|
- Pediu avaliações dos clientes satisfeitos
|
||||||
|
- Criou página de links com serviços e preços
|
||||||
|
- Postou fotos de antes/depois dos carros
|
||||||
|
|
||||||
|
**Depois (4 meses):**
|
||||||
|
- 5-7 novos clientes por mês via Google
|
||||||
|
- 4.8 estrelas no Google (15 avaliações)
|
||||||
|
- Aumento de 40% no faturamento
|
||||||
|
|
||||||
|
**Investimento:** R$ 0 (todas ferramentas gratuitas)
|
||||||
|
|
||||||
|
## Próximos Passos Após os Primeiros 30 Dias
|
||||||
|
|
||||||
|
### Mês 2-3: Consolidação
|
||||||
|
- Refine processos que funcionaram
|
||||||
|
- Descarte o que não deu resultado
|
||||||
|
- Aumente frequência de posts
|
||||||
|
- Comece a investir pequenas quantias em anúncios
|
||||||
|
|
||||||
|
### Mês 4-6: Expansão
|
||||||
|
- Considere criar site próprio
|
||||||
|
- Expanda para novas plataformas (TikTok, LinkedIn)
|
||||||
|
- Crie programa de fidelidade digital
|
||||||
|
- Automatize mais processos
|
||||||
|
|
||||||
|
### Mês 7-12: Maturidade
|
||||||
|
- Analise dados para decisões estratégicas
|
||||||
|
- Invista em ferramentas mais robustas
|
||||||
|
- Contrate especialistas para áreas específicas
|
||||||
|
- Expanda equipe digital se necessário
|
||||||
|
|
||||||
|
## Checklist da Transformação Digital
|
||||||
|
|
||||||
|
Use esta checklist para acompanhar seu progresso:
|
||||||
|
|
||||||
|
**Fundamentos (Primeiras 2 semanas):**
|
||||||
|
- [ ] WhatsApp Business configurado
|
||||||
|
- [ ] Instagram Business criado
|
||||||
|
- [ ] Facebook Page ativa
|
||||||
|
- [ ] Google Meu Negócio cadastrado
|
||||||
|
- [ ] Página de links profissional criada
|
||||||
|
- [ ] Todos os links atualizados nas redes sociais
|
||||||
|
|
||||||
|
**Conteúdo (Primeiro mês):**
|
||||||
|
- [ ] Calendário de conteúdo criado
|
||||||
|
- [ ] Pelo menos 12 posts publicados
|
||||||
|
- [ ] Todas as mensagens respondidas em até 24h
|
||||||
|
- [ ] Primeiras avaliações recebidas
|
||||||
|
|
||||||
|
**Organização (Primeiros 2 meses):**
|
||||||
|
- [ ] Planilha de vendas funcionando
|
||||||
|
- [ ] Backup de arquivos na nuvem
|
||||||
|
- [ ] Processos principais documentados
|
||||||
|
- [ ] Sistema de pagamento digital ativo
|
||||||
|
|
||||||
|
**Marketing (Primeiros 3 meses):**
|
||||||
|
- [ ] Pelo menos 1 campanha paga testada
|
||||||
|
- [ ] Análise de métricas semanalmente
|
||||||
|
- [ ] Estratégia de conteúdo refinada
|
||||||
|
- [ ] Base de clientes digitais crescendo
|
||||||
|
|
||||||
|
## Conclusão: O Momento É Agora
|
||||||
|
|
||||||
|
A transformação digital não é sobre tecnologia complicada ou investimentos massivos. É sobre adaptar seu negócio para o mundo moderno, onde clientes esperam:
|
||||||
|
|
||||||
|
- **Encontrar você facilmente online**
|
||||||
|
- **Entrar em contato rapidamente**
|
||||||
|
- **Ver seu trabalho/produtos**
|
||||||
|
- **Fazer negócio de forma conveniente**
|
||||||
|
|
||||||
|
Você não precisa fazer tudo perfeitamente desde o início. Precisa começar.
|
||||||
|
|
||||||
|
Cada pequeno passo digital é um passo na direção certa:
|
||||||
|
- Primeira postagem no Instagram
|
||||||
|
- Primeiro cliente que te encontrou online
|
||||||
|
- Primeira venda via mensagem
|
||||||
|
- Primeira avaliação positiva
|
||||||
|
|
||||||
|
Esses pequenos passos se acumulam. Em 6 meses, você olhará para trás e ficará surpreso com a transformação.
|
||||||
|
|
||||||
|
**Comece hoje. Seu futuro digital está a um clique de distância.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Sobre o BCards:** Ajudamos pequenos negócios e profissionais a terem presença online profissional de forma simples e acessível. Crie sua página de links agora mesmo.
|
||||||
|
|
||||||
|
[Começar minha transformação digital →](https://bcards.site/)
|
||||||
63
src/BCards.Web/Content/ModerationRules.md
Normal file
63
src/BCards.Web/Content/ModerationRules.md
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
# Regras de Moderação e Diretrizes da Comunidade BCards
|
||||||
|
|
||||||
|
**Última atualização:** 31 de agosto de 2025
|
||||||
|
|
||||||
|
O BCards se dedica a manter uma comunidade segura, profissional e respeitosa. Todas as páginas e conteúdos criados em nossa plataforma devem seguir estas regras. O descumprimento pode levar à remoção do conteúdo, suspensão da página ou banimento da conta.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 1. Conteúdo Estritamente Proibido
|
||||||
|
|
||||||
|
O seguinte conteúdo será removido imediatamente e pode resultar no banimento da conta sem aviso prévio:
|
||||||
|
|
||||||
|
- **Conteúdo Adulto e Sexual Explícito:**
|
||||||
|
- Nudez, pornografia, ou qualquer conteúdo sexualmente sugestivo.
|
||||||
|
- Serviços de acompanhantes, prostituição ou conteúdo relacionado.
|
||||||
|
|
||||||
|
- **Violência, Ódio e Discriminação:**
|
||||||
|
- Ameaças diretas ou indiretas de violência contra indivíduos ou grupos.
|
||||||
|
- Discurso de ódio baseado em raça, etnia, religião, nacionalidade, gênero, orientação sexual, deficiência ou qualquer outra característica protegida.
|
||||||
|
- Glorificação de violência, terrorismo ou organizações extremistas.
|
||||||
|
|
||||||
|
- **Atividades Ilegais:**
|
||||||
|
- Venda, promoção ou facilitação de drogas ilegais, substâncias controladas e armas de fogo.
|
||||||
|
- Promoção de qualquer atividade ilegal, como jogos de azar não regulamentados, esquemas de pirâmide ou fraude.
|
||||||
|
|
||||||
|
- **Spam, Phishing e Golpes:**
|
||||||
|
- Links que levam a sites maliciosos, phishing ou que tentam enganar os usuários para obter informações pessoais.
|
||||||
|
- Páginas criadas com o único propósito de gerar tráfego de spam ou manipular SEO.
|
||||||
|
|
||||||
|
- **Violação de Direitos Autorais e Propriedade Intelectual:**
|
||||||
|
- Uso não autorizado de marcas registradas, logotipos, ou material protegido por direitos autorais.
|
||||||
|
- Venda ou distribuição de conteúdo pirateado.
|
||||||
|
|
||||||
|
- **Informações Falsas e Desinformação Perigosa:**
|
||||||
|
- Disseminação de informações comprovadamente falsas que possam causar dano real (ex: desinformação médica perigosa, teorias da conspiração violentas).
|
||||||
|
|
||||||
|
- **Conteúdo que Explora ou Prejudica Menores:**
|
||||||
|
- Qualquer conteúdo que explore, sexualize ou coloque menores em risco. Denunciaremos tal conteúdo às autoridades competentes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2. Conteúdo Permitido com Restrições
|
||||||
|
|
||||||
|
Este conteúdo pode ser permitido, mas está sujeito a uma análise mais rigorosa e deve cumprir todas as leis locais aplicáveis:
|
||||||
|
|
||||||
|
- **Bebidas Alcoólicas:** Permitido apenas se o criador da página tiver a licença apropriada para vender e se o conteúdo for direcionado a um público adulto, com as devidas restrições de idade.
|
||||||
|
- **Jogos e Apostas:** Permitido apenas em jurisdições onde tal atividade é legal e regulamentada. A página deve exibir claramente as licenças e avisos legais necessários.
|
||||||
|
- **Conteúdo Político:** Permitido, desde que não incite ódio, violência ou extremismo. Não é permitido o uso da plataforma para campanhas de desinformação.
|
||||||
|
- **Produtos ou Serviços Financeiros:** Deve cumprir todas as regulamentações financeiras locais e ser transparente sobre riscos.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3. Critérios de Aprovação e Boas Práticas
|
||||||
|
|
||||||
|
Para que sua página seja aprovada e tenha um bom desempenho, siga estas diretrizes:
|
||||||
|
|
||||||
|
- **Links Funcionais:** Todos os links devem estar funcionando e levar ao destino prometido.
|
||||||
|
- **Informações Claras e Verdadeiras:** A biografia, títulos e descrições devem representar com precisão o propósito da sua página.
|
||||||
|
- **Imagens Apropriadas:** As imagens de perfil e de fundo devem ser de alta qualidade e não violar nenhuma das regras acima.
|
||||||
|
- **Idioma:** O conteúdo principal deve estar em português ou espanhol, de acordo com o público-alvo.
|
||||||
|
- **Respeito às Leis:** O conteúdo da sua página deve respeitar todas as leis e regulamentações do Brasil e dos países onde você atua.
|
||||||
|
|
||||||
|
Nosso objetivo é capacitar criadores e profissionais. Estas regras nos ajudam a garantir que o BCards continue sendo uma plataforma confiável e valiosa para todos.
|
||||||
@ -0,0 +1,435 @@
|
|||||||
|
---
|
||||||
|
title: "BCards para Advogados: Guia Completo de Marketing Digital Ético"
|
||||||
|
description: "Como advogados podem usar o BCards de forma profissional e ética, respeitando as normas da OAB sobre publicidade jurídica digital."
|
||||||
|
keywords: "advogados, marketing jurídico, publicidade advocacia, OAB, marketing digital advogados"
|
||||||
|
author: "Equipe BCards"
|
||||||
|
date: 2025-01-14
|
||||||
|
lastMod: 2025-01-14
|
||||||
|
image: "/images/tutoriais/advogados-bcards.jpg"
|
||||||
|
culture: "pt-BR"
|
||||||
|
category: "advocacia"
|
||||||
|
---
|
||||||
|
|
||||||
|
# BCards para Advogados: Guia Completo de Marketing Digital Ético
|
||||||
|
|
||||||
|
O marketing digital se tornou essencial para advogados que desejam expandir sua base de clientes. No entanto, a advocacia possui regras específicas estabelecidas pela OAB sobre publicidade.
|
||||||
|
|
||||||
|
Neste guia, você aprenderá como usar o BCards de forma profissional, ética e em conformidade com o Código de Ética da OAB.
|
||||||
|
|
||||||
|
## Por Que Advogados Precisam de Presença Digital?
|
||||||
|
|
||||||
|
### Seus Clientes Pesquisam Online
|
||||||
|
|
||||||
|
Quando alguém precisa de um advogado:
|
||||||
|
1. Pesquisa no Google
|
||||||
|
2. Pede indicações em redes sociais
|
||||||
|
3. Verifica perfis e avaliações online
|
||||||
|
|
||||||
|
Se você não está online, está invisível para potenciais clientes.
|
||||||
|
|
||||||
|
### Credibilidade Profissional
|
||||||
|
|
||||||
|
Uma presença digital organizada transmite:
|
||||||
|
- Profissionalismo
|
||||||
|
- Confiabilidade
|
||||||
|
- Acessibilidade
|
||||||
|
- Modernidade
|
||||||
|
|
||||||
|
### Concorrência
|
||||||
|
|
||||||
|
Muitos advogados já estão online. Ficar de fora significa perder espaço no mercado.
|
||||||
|
|
||||||
|
## Regras da OAB Sobre Publicidade Digital
|
||||||
|
|
||||||
|
Antes de criar sua página, é fundamental conhecer as regras do **Provimento n° 205/2021** da OAB:
|
||||||
|
|
||||||
|
### ✅ Permitido:
|
||||||
|
- Informar sobre áreas de atuação
|
||||||
|
- Divulgar títulos e especializações
|
||||||
|
- Publicar conteúdo educativo
|
||||||
|
- Compartilhar experiência profissional
|
||||||
|
- Indicar formas de contato
|
||||||
|
|
||||||
|
### ❌ Proibido:
|
||||||
|
- Garantir resultados
|
||||||
|
- Captação de clientela (mercantilização)
|
||||||
|
- Publicidade agressiva ou sensacionalista
|
||||||
|
- Promessas enganosas
|
||||||
|
- Orçamento sem análise do caso
|
||||||
|
- Comparações depreciativas com colegas
|
||||||
|
|
||||||
|
**Princípio fundamental:** Discrição, sobriedade e informação precisa.
|
||||||
|
|
||||||
|
## Como Configurar Seu BCards Profissional
|
||||||
|
|
||||||
|
### 1. Informações Básicas
|
||||||
|
|
||||||
|
#### Nome da Página
|
||||||
|
Use seu nome profissional completo seguido de "Advogado(a)" ou sua especialização:
|
||||||
|
|
||||||
|
**Exemplos adequados:**
|
||||||
|
- Dra. Maria Silva - Advogada
|
||||||
|
- João Santos | Direito Empresarial
|
||||||
|
- Ana Costa - Advocacia Trabalhista
|
||||||
|
|
||||||
|
**Evite:**
|
||||||
|
- "O Melhor Advogado"
|
||||||
|
- "Ganhe Sua Causa Garantido"
|
||||||
|
- "Advogado Nota 10"
|
||||||
|
|
||||||
|
#### Slug (URL)
|
||||||
|
Crie uma URL profissional:
|
||||||
|
|
||||||
|
**Bons exemplos:**
|
||||||
|
- `bcards.site/page/advocacia/maria-silva-advogada`
|
||||||
|
- `bcards.site/page/advocacia/joao-santos-empresarial`
|
||||||
|
- `bcards.site/page/advocacia/dra-ana-costa`
|
||||||
|
|
||||||
|
#### Descrição
|
||||||
|
Seja claro, objetivo e profissional:
|
||||||
|
|
||||||
|
**Exemplo adequado:**
|
||||||
|
```
|
||||||
|
Advogada especializada em Direito de Família e Sucessões.
|
||||||
|
OAB/SP 123.456 | Mestre em Direito Civil pela USP.
|
||||||
|
Atendimento presencial e online.
|
||||||
|
```
|
||||||
|
|
||||||
|
**Evite:**
|
||||||
|
```
|
||||||
|
A melhor advogada! Ganho 99% dos casos!
|
||||||
|
Atendo urgências a qualquer hora!
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Links Profissionais
|
||||||
|
|
||||||
|
#### Links Essenciais para Advogados
|
||||||
|
|
||||||
|
**1. WhatsApp Business**
|
||||||
|
```
|
||||||
|
Título: Agendar Consulta
|
||||||
|
URL: https://wa.me/5511999999999?text=Olá, gostaria de agendar uma consulta
|
||||||
|
```
|
||||||
|
|
||||||
|
**2. Email Profissional**
|
||||||
|
```
|
||||||
|
Título: Contato por Email
|
||||||
|
URL: mailto:contato@seuescritorio.com.br
|
||||||
|
```
|
||||||
|
|
||||||
|
**3. Site ou Blog**
|
||||||
|
```
|
||||||
|
Título: Nosso Site
|
||||||
|
URL: https://seuescritorio.com.br
|
||||||
|
```
|
||||||
|
|
||||||
|
**4. LinkedIn**
|
||||||
|
```
|
||||||
|
Título: Perfil Profissional
|
||||||
|
URL: https://linkedin.com/in/seu-perfil
|
||||||
|
```
|
||||||
|
|
||||||
|
**5. Instagram Profissional**
|
||||||
|
```
|
||||||
|
Título: Instagram Jurídico
|
||||||
|
URL: https://instagram.com/seu.escritorio
|
||||||
|
```
|
||||||
|
|
||||||
|
**6. Artigos Jurídicos**
|
||||||
|
```
|
||||||
|
Título: Artigos e Publicações
|
||||||
|
URL: Link para seu blog ou Medium
|
||||||
|
```
|
||||||
|
|
||||||
|
**7. Google Meu Negócio**
|
||||||
|
```
|
||||||
|
Título: Avaliações e Localização
|
||||||
|
URL: Link do Google Maps do escritório
|
||||||
|
```
|
||||||
|
|
||||||
|
**8. Agendamento Online** (se usar)
|
||||||
|
```
|
||||||
|
Título: Agendar Horário
|
||||||
|
URL: Link do Calendly ou sistema de agenda
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Escolha do Tema
|
||||||
|
|
||||||
|
Para advogados, recomendamos temas sóbrios e profissionais:
|
||||||
|
|
||||||
|
- **Clássico**: Fundo branco, clean
|
||||||
|
- **Profissional**: Tons de azul marinho ou cinza
|
||||||
|
- **Minimalista**: Ultra clean e objetivo
|
||||||
|
|
||||||
|
**Evite:**
|
||||||
|
- Cores muito vibrantes
|
||||||
|
- Animações excessivas
|
||||||
|
- Designs infantis ou descontraídos demais
|
||||||
|
|
||||||
|
### 4. Foto de Perfil
|
||||||
|
|
||||||
|
Use uma foto profissional:
|
||||||
|
- Fundo neutro
|
||||||
|
- Vestimenta formal
|
||||||
|
- Boa iluminação
|
||||||
|
- Expressão profissional mas acessível
|
||||||
|
|
||||||
|
**Evite:**
|
||||||
|
- Fotos de festas
|
||||||
|
- Selfies casuais
|
||||||
|
- Fotos em ambientes inadequados
|
||||||
|
|
||||||
|
## Conteúdo para Redes Sociais (Integrando com BCards)
|
||||||
|
|
||||||
|
Seu BCards será o hub central. Nas redes sociais, compartilhe:
|
||||||
|
|
||||||
|
### Conteúdo Educativo Permitido
|
||||||
|
|
||||||
|
**1. Dicas Jurídicas Gerais**
|
||||||
|
```
|
||||||
|
"Você sabia que tem até 2 anos para reclamar de vícios aparentes
|
||||||
|
no imóvel comprado? Entenda seus direitos!"
|
||||||
|
```
|
||||||
|
|
||||||
|
**2. Explicações de Leis**
|
||||||
|
```
|
||||||
|
"A Nova Lei de Licitações trouxe mudanças importantes.
|
||||||
|
Resumo das principais alterações: [thread]"
|
||||||
|
```
|
||||||
|
|
||||||
|
**3. Mitos e Verdades**
|
||||||
|
```
|
||||||
|
"Mito ou Verdade: É preciso registrar união estável em cartório?
|
||||||
|
MITO. Entenda por quê..."
|
||||||
|
```
|
||||||
|
|
||||||
|
**4. Casos Anônimos (com aprendizado)**
|
||||||
|
```
|
||||||
|
"Caso recente (sem identificação): Cliente conseguiu reaver valor
|
||||||
|
pago indevidamente em plano de saúde. Como? [explica processo genérico]"
|
||||||
|
```
|
||||||
|
|
||||||
|
### O Que NUNCA Postar
|
||||||
|
|
||||||
|
❌ "Acabei de ganhar mais uma causa! Somos imbatíveis!"
|
||||||
|
❌ "100% de sucesso em processos trabalhistas!"
|
||||||
|
❌ "Seu processo pode valer R$ 50.000! Entre em contato!"
|
||||||
|
❌ "Melhor advogado da cidade! Comprovado!"
|
||||||
|
❌ Fotos de audiências ou fóruns sem autorização
|
||||||
|
|
||||||
|
## Estratégia de Marketing Digital Ético
|
||||||
|
|
||||||
|
### 1. Marketing de Conteúdo
|
||||||
|
|
||||||
|
Crie conteúdo valioso:
|
||||||
|
- Artigos em blog
|
||||||
|
- Posts educativos em redes
|
||||||
|
- E-books sobre temas específicos
|
||||||
|
- Vídeos explicativos (YouTube, Instagram)
|
||||||
|
|
||||||
|
**Exemplo de calendário mensal:**
|
||||||
|
- Semana 1: Post sobre mudança legislativa
|
||||||
|
- Semana 2: Dica prática sobre direitos
|
||||||
|
- Semana 3: Explicação de conceito jurídico
|
||||||
|
- Semana 4: Resposta a dúvida comum
|
||||||
|
|
||||||
|
### 2. SEO Local para Advogados
|
||||||
|
|
||||||
|
Otimize para buscas locais:
|
||||||
|
- Google Meu Negócio completo
|
||||||
|
- URL no BCards com sua cidade (se relevante)
|
||||||
|
- Conteúdo mencionando região de atuação
|
||||||
|
|
||||||
|
**Exemplo:**
|
||||||
|
```
|
||||||
|
Advogada especializada em Direito de Família em São Paulo.
|
||||||
|
Atendimento na Zona Sul e Centro.
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Peça Avaliações (com Cuidado)
|
||||||
|
|
||||||
|
É permitido ter avaliações no Google Meu Negócio, desde que:
|
||||||
|
- Sejam espontâneas (não solicite diretamente)
|
||||||
|
- Não há oferecimento de benefícios por avaliação
|
||||||
|
- Não são fabricadas
|
||||||
|
|
||||||
|
**Forma adequada:**
|
||||||
|
Após finalizar caso bem-sucedido, você pode:
|
||||||
|
```
|
||||||
|
"Ficamos felizes em ajudá-lo(a). Caso queira, sua opinião
|
||||||
|
sobre nosso atendimento é muito valiosa."
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Networking Digital
|
||||||
|
|
||||||
|
Conecte-se com:
|
||||||
|
- Outros advogados (não concorrentes diretos)
|
||||||
|
- Profissionais complementares (contadores, corretores)
|
||||||
|
- Associações e entidades de classe
|
||||||
|
|
||||||
|
## Estrutura Completa: Exemplo Real
|
||||||
|
|
||||||
|
### Dra. Ana Costa - Direito Trabalhista
|
||||||
|
|
||||||
|
**URL BCards:**
|
||||||
|
`bcards.site/page/advocacia/dra-ana-costa-trabalhista`
|
||||||
|
|
||||||
|
**Descrição:**
|
||||||
|
```
|
||||||
|
Advogada Trabalhista | OAB/SP 234.567
|
||||||
|
Especialista em Direitos do Trabalhador
|
||||||
|
Mestre em Direito do Trabalho pela PUC-SP
|
||||||
|
Atendimento em São Paulo e online
|
||||||
|
```
|
||||||
|
|
||||||
|
**Links na página:**
|
||||||
|
1. 📱 WhatsApp: Agendar Consulta
|
||||||
|
2. 📧 Email: contato@anacosta.adv.br
|
||||||
|
3. 🌐 Site: www.anacosta.adv.br
|
||||||
|
4. 💼 LinkedIn: Perfil Profissional
|
||||||
|
5. 📸 Instagram: @dra.anacosta.trabalhista
|
||||||
|
6. 📄 Blog: Artigos sobre Direito Trabalhista
|
||||||
|
7. 📍 Localização: Escritório no Google Maps
|
||||||
|
8. 🕒 Agendar: Sistema de agendamento online
|
||||||
|
|
||||||
|
**Bio nas redes sociais:**
|
||||||
|
```
|
||||||
|
Dra. Ana Costa | Advogada Trabalhista 👩⚖️
|
||||||
|
OAB/SP 234.567
|
||||||
|
Defendendo direitos dos trabalhadores
|
||||||
|
📍 São Paulo | Atendimento presencial e online
|
||||||
|
🔗 Todos os contatos: [link BCards]
|
||||||
|
```
|
||||||
|
|
||||||
|
## Ferramentas Complementares
|
||||||
|
|
||||||
|
### 1. Gestão de Processos
|
||||||
|
- Projuris (jurídico)
|
||||||
|
- Astrea (gestão processual)
|
||||||
|
|
||||||
|
### 2. Agendamento
|
||||||
|
- Calendly
|
||||||
|
- Google Calendar
|
||||||
|
|
||||||
|
### 3. Comunicação
|
||||||
|
- WhatsApp Business
|
||||||
|
- Email profissional (Gmail Workspace)
|
||||||
|
|
||||||
|
### 4. Conteúdo
|
||||||
|
- Canva (design)
|
||||||
|
- Grammarly (revisão de texto)
|
||||||
|
|
||||||
|
### 5. Analytics
|
||||||
|
- Google Analytics (site)
|
||||||
|
- Meta Business Suite (redes sociais)
|
||||||
|
- BCards Analytics (cliques nos links)
|
||||||
|
|
||||||
|
## Monitoramento e Métricas
|
||||||
|
|
||||||
|
Acompanhe mensalmente:
|
||||||
|
|
||||||
|
**No BCards:**
|
||||||
|
- Número de visitantes
|
||||||
|
- Cliques em cada link
|
||||||
|
- Horários de maior acesso
|
||||||
|
|
||||||
|
**Nas Redes Sociais:**
|
||||||
|
- Crescimento de seguidores
|
||||||
|
- Engajamento (curtidas, comentários, compartilhamentos)
|
||||||
|
- Alcance de posts
|
||||||
|
|
||||||
|
**No Negócio:**
|
||||||
|
- Novos clientes vindos do online
|
||||||
|
- Taxa de conversão (visita → consulta)
|
||||||
|
- ROI (retorno sobre investimento em ads)
|
||||||
|
|
||||||
|
## Riscos e Como Evitá-los
|
||||||
|
|
||||||
|
### Risco 1: Infração Ética
|
||||||
|
**Como evitar:**
|
||||||
|
- Revise tudo antes de publicar
|
||||||
|
- Não garanta resultados
|
||||||
|
- Seja discreto e profissional
|
||||||
|
|
||||||
|
### Risco 2: Exposição Inadequada
|
||||||
|
**Como evitar:**
|
||||||
|
- Nunca divulgue detalhes de casos reais identificáveis
|
||||||
|
- Proteja o sigilo profissional sempre
|
||||||
|
- Peça autorização antes de mencionar qualquer caso
|
||||||
|
|
||||||
|
### Risco 3: Comentários Negativos
|
||||||
|
**Como evitar:**
|
||||||
|
- Responda sempre com profissionalismo
|
||||||
|
- Não discuta casos públicos
|
||||||
|
- Se necessário, leve discussão para privado
|
||||||
|
|
||||||
|
### Risco 4: Concorrência Desleal
|
||||||
|
**Como evitar:**
|
||||||
|
- Nunca critique colegas
|
||||||
|
- Foque em seu diferencial, não em defeitos alheios
|
||||||
|
- Colabore, não compita de forma negativa
|
||||||
|
|
||||||
|
## Checklist de Conformidade OAB
|
||||||
|
|
||||||
|
Antes de publicar sua página, verifique:
|
||||||
|
|
||||||
|
- [ ] Informações são verídicas?
|
||||||
|
- [ ] Títulos e especializações estão corretos?
|
||||||
|
- [ ] Não há promessas de resultado?
|
||||||
|
- [ ] Linguagem é sóbria e profissional?
|
||||||
|
- [ ] Não há captação mercantil de clientela?
|
||||||
|
- [ ] Fotos são profissionais?
|
||||||
|
- [ ] Links levam a conteúdo apropriado?
|
||||||
|
- [ ] OAB e número de inscrição estão visíveis?
|
||||||
|
- [ ] Especialização é reconhecida pela OAB (se mencionar)?
|
||||||
|
|
||||||
|
## Dúvidas Frequentes de Advogados
|
||||||
|
|
||||||
|
**P: Posso oferecer primeira consulta gratuita?**
|
||||||
|
R: Sim, desde que seja informação verdadeira e não caracterize captação irregular.
|
||||||
|
|
||||||
|
**P: Posso mencionar clientes famosos que atendi?**
|
||||||
|
R: Não, a menos que tenha autorização expressa e por escrito do cliente.
|
||||||
|
|
||||||
|
**P: Posso colocar preços no BCards?**
|
||||||
|
R: Não é recomendado. Orçamentos devem ser feitos após análise do caso específico.
|
||||||
|
|
||||||
|
**P: Posso fazer anúncios pagos no Google/Instagram?**
|
||||||
|
R: Sim, desde que o conteúdo respeite as normas éticas da OAB.
|
||||||
|
|
||||||
|
**P: Preciso colocar minha OAB no BCards?**
|
||||||
|
R: Sim, é obrigatório identificar número e seccional da OAB.
|
||||||
|
|
||||||
|
## Conclusão
|
||||||
|
|
||||||
|
O BCards é uma ferramenta poderosa para advogados modernizarem sua presença digital mantendo a ética profissional.
|
||||||
|
|
||||||
|
**Principais vantagens para advogados:**
|
||||||
|
- ✅ URL profissional e categorizada
|
||||||
|
- ✅ Centralização de todos os contatos
|
||||||
|
- ✅ Facilita marketing de conteúdo ético
|
||||||
|
- ✅ Transmite credibilidade
|
||||||
|
- ✅ Compatível com normas da OAB
|
||||||
|
- ✅ Analytics para medir resultados
|
||||||
|
|
||||||
|
**Lembre-se:**
|
||||||
|
- Priorize discrição e sobriedade
|
||||||
|
- Foque em educar, não em vender
|
||||||
|
- Seja transparente e honesto
|
||||||
|
- Respeite sempre as normas da OAB
|
||||||
|
|
||||||
|
Sua presença digital pode ser profissional, eficiente e ética ao mesmo tempo.
|
||||||
|
|
||||||
|
**Pronto para criar sua página profissional?**
|
||||||
|
|
||||||
|
[Criar meu BCards para Advocacia →](https://bcards.site/)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Referências:**
|
||||||
|
- Provimento OAB n° 205/2021
|
||||||
|
- Código de Ética e Disciplina da OAB
|
||||||
|
|
||||||
|
**Disclaimer:** Este artigo tem caráter informativo. Para dúvidas específicas sobre ética profissional, consulte a OAB de sua seccional.
|
||||||
|
|
||||||
|
**Última atualização:** Janeiro 2025
|
||||||
@ -0,0 +1,359 @@
|
|||||||
|
---
|
||||||
|
title: "Como Criar seu BCard em 5 Minutos: Tutorial Completo"
|
||||||
|
description: "Aprenda passo a passo como criar sua página profissional de links no BCards. Tutorial completo para iniciantes com capturas de tela e dicas práticas."
|
||||||
|
keywords: "tutorial bcards, criar página de links, bio link, tutorial passo a passo"
|
||||||
|
author: "Equipe BCards"
|
||||||
|
date: 2025-01-12
|
||||||
|
lastMod: 2025-01-12
|
||||||
|
image: "/images/tutoriais/criar-bcard.jpg"
|
||||||
|
culture: "pt-BR"
|
||||||
|
category: "tecnologia"
|
||||||
|
---
|
||||||
|
|
||||||
|
# Como Criar seu BCard em 5 Minutos: Tutorial Completo
|
||||||
|
|
||||||
|
Criar sua página profissional de links no BCards é mais fácil do que você imagina. Neste tutorial, vou te guiar passo a passo desde o cadastro até a publicação da sua página.
|
||||||
|
|
||||||
|
## O Que Você Vai Precisar
|
||||||
|
|
||||||
|
- Email válido
|
||||||
|
- Conta no Google ou Microsoft (para login rápido)
|
||||||
|
- 5 minutos do seu tempo
|
||||||
|
- Links que você quer compartilhar
|
||||||
|
|
||||||
|
## Passo 1: Criar Sua Conta
|
||||||
|
|
||||||
|
### 1.1. Acesse o Site
|
||||||
|
|
||||||
|
Vá para [bcards.site](https://bcards.site) e clique em **"Entrar"** no menu superior.
|
||||||
|
|
||||||
|
### 1.2. Escolha o Método de Login
|
||||||
|
|
||||||
|
Você tem duas opções:
|
||||||
|
|
||||||
|
**Opção A: Login com Google**
|
||||||
|
- Clique em "Entrar com Google"
|
||||||
|
- Selecione sua conta
|
||||||
|
- Autorize o acesso
|
||||||
|
|
||||||
|
**Opção B: Login com Microsoft**
|
||||||
|
- Clique em "Entrar com Microsoft"
|
||||||
|
- Insira suas credenciais
|
||||||
|
- Autorize o acesso
|
||||||
|
|
||||||
|
✅ **Dica:** Usar login social é mais rápido e seguro (sem necessidade de criar nova senha).
|
||||||
|
|
||||||
|
## Passo 2: Acessar o Dashboard
|
||||||
|
|
||||||
|
Após fazer login, você será redirecionado para seu **Dashboard**. Este é o painel de controle onde você gerencia sua página.
|
||||||
|
|
||||||
|
No Dashboard você verá:
|
||||||
|
- Botão "Criar Minha Página" (se é sua primeira vez)
|
||||||
|
- Estatísticas (após criar a página)
|
||||||
|
- Links para editar sua página
|
||||||
|
|
||||||
|
## Passo 3: Criar Sua Página
|
||||||
|
|
||||||
|
### 3.1. Clique em "Criar Minha Página"
|
||||||
|
|
||||||
|
Você verá um formulário com vários campos. Vamos preencher cada um:
|
||||||
|
|
||||||
|
### 3.2. Informações Básicas
|
||||||
|
|
||||||
|
**Nome da Página:**
|
||||||
|
- Digite como você quer ser identificado
|
||||||
|
- Exemplo: "João Silva", "Maria Design", "Advocacia Santos"
|
||||||
|
- Este será o título principal da sua página
|
||||||
|
|
||||||
|
**Categoria:**
|
||||||
|
- Selecione a categoria que melhor representa sua atividade
|
||||||
|
- Exemplos disponíveis:
|
||||||
|
- Tecnologia
|
||||||
|
- Advocacia
|
||||||
|
- Saúde
|
||||||
|
- Educação
|
||||||
|
- Marketing
|
||||||
|
- E muitas outras...
|
||||||
|
|
||||||
|
✅ **Dica:** A categoria fará parte da sua URL e ajuda no SEO.
|
||||||
|
|
||||||
|
**Slug (URL Personalizada):**
|
||||||
|
- Este será o final da sua URL
|
||||||
|
- Formato final: `bcards.site/page/{categoria}/{seu-slug}`
|
||||||
|
- Use apenas letras minúsculas, números e hifens
|
||||||
|
- Sem espaços, acentos ou caracteres especiais
|
||||||
|
|
||||||
|
**Exemplos:**
|
||||||
|
- `joao-silva-dev` → bcards.site/page/tecnologia/joao-silva-dev
|
||||||
|
- `maria-designer` → bcards.site/page/design/maria-designer
|
||||||
|
- `dra-ana-pediatra` → bcards.site/page/saude/dra-ana-pediatra
|
||||||
|
|
||||||
|
### 3.3. Descrição
|
||||||
|
|
||||||
|
Digite uma breve descrição sobre você ou seu negócio:
|
||||||
|
|
||||||
|
**Bom exemplo:**
|
||||||
|
```
|
||||||
|
Desenvolvedora Full Stack especializada em React e Node.js.
|
||||||
|
Ajudo empresas a criarem aplicações web modernas e escaláveis.
|
||||||
|
```
|
||||||
|
|
||||||
|
**Evite:**
|
||||||
|
```
|
||||||
|
Desenvolvedora
|
||||||
|
```
|
||||||
|
(muito curto, sem contexto)
|
||||||
|
|
||||||
|
✅ **Dica:** Use 2-3 frases. Seja claro e direto.
|
||||||
|
|
||||||
|
### 3.4. Escolha do Tema Visual
|
||||||
|
|
||||||
|
Role até a seção "Tema" e selecione o visual da sua página:
|
||||||
|
|
||||||
|
**Temas disponíveis (variam por plano):**
|
||||||
|
- **Clássico**: Fundo branco limpo, ideal para profissionais
|
||||||
|
- **Dark**: Fundo escuro, moderno
|
||||||
|
- **Gradiente**: Cores vibrantes em degradê
|
||||||
|
- **Minimalista**: Ultra clean
|
||||||
|
- **Profissional**: Sóbrio e corporativo
|
||||||
|
|
||||||
|
Você pode trocar o tema depois a qualquer momento.
|
||||||
|
|
||||||
|
### 3.5. Adicionar Links
|
||||||
|
|
||||||
|
Agora vamos adicionar os links que aparecerão na sua página.
|
||||||
|
|
||||||
|
**Para adicionar um link:**
|
||||||
|
|
||||||
|
1. Clique em **"Adicionar Link"**
|
||||||
|
2. Preencha os campos:
|
||||||
|
- **Título**: Nome que aparecerá no botão
|
||||||
|
- **URL**: Endereço completo (começando com https://)
|
||||||
|
- **Ícone** (opcional): Escolha um ícone para o link
|
||||||
|
|
||||||
|
**Exemplo de Link:**
|
||||||
|
```
|
||||||
|
Título: Meu Portfólio
|
||||||
|
URL: https://meusite.com.br
|
||||||
|
Ícone: fa-briefcase
|
||||||
|
```
|
||||||
|
|
||||||
|
**Tipos de Links Comuns:**
|
||||||
|
|
||||||
|
- WhatsApp: `https://wa.me/5511999999999`
|
||||||
|
- Instagram: `https://instagram.com/seuusuario`
|
||||||
|
- Facebook: `https://facebook.com/suapagina`
|
||||||
|
- LinkedIn: `https://linkedin.com/in/seuusuario`
|
||||||
|
- YouTube: `https://youtube.com/@seucanal`
|
||||||
|
- Site: `https://seusite.com.br`
|
||||||
|
- Email: `mailto:seu@email.com`
|
||||||
|
|
||||||
|
✅ **Dica:** Organize links por importância. Os primeiros aparecem no topo da página.
|
||||||
|
|
||||||
|
### 3.6. Reordenar Links
|
||||||
|
|
||||||
|
Você pode arrastar e soltar os links para mudar a ordem de exibição. Os links mais importantes devem ficar no topo.
|
||||||
|
|
||||||
|
## Passo 4: Personalização Avançada (Opcional)
|
||||||
|
|
||||||
|
### 4.1. Foto de Perfil
|
||||||
|
|
||||||
|
Upload de uma foto profissional (recomendado):
|
||||||
|
- Tamanho ideal: 400x400 pixels
|
||||||
|
- Formato: JPG ou PNG
|
||||||
|
- Peso: Até 2MB
|
||||||
|
|
||||||
|
### 4.2. Cores Personalizadas (Plano Premium)
|
||||||
|
|
||||||
|
Se você tem plano Premium, pode personalizar:
|
||||||
|
- Cor dos botões
|
||||||
|
- Cor do fundo
|
||||||
|
- Cor do texto
|
||||||
|
- Fontes
|
||||||
|
|
||||||
|
### 4.3. Redes Sociais
|
||||||
|
|
||||||
|
Adicione seus perfis sociais na seção específica. Eles aparecerão como ícones na sua página.
|
||||||
|
|
||||||
|
## Passo 5: Salvar e Submeter para Moderação
|
||||||
|
|
||||||
|
### 5.1. Revisar Tudo
|
||||||
|
|
||||||
|
Antes de salvar, revise:
|
||||||
|
- ✅ Nome está correto?
|
||||||
|
- ✅ URL (slug) está como você quer?
|
||||||
|
- ✅ Todos os links funcionam?
|
||||||
|
- ✅ Descrição está clara?
|
||||||
|
- ✅ Tema escolhido te agrada?
|
||||||
|
|
||||||
|
### 5.2. Salvar
|
||||||
|
|
||||||
|
Clique no botão **"Salvar Página"** no final do formulário.
|
||||||
|
|
||||||
|
### 5.3. Submeter para Moderação
|
||||||
|
|
||||||
|
Após salvar, você verá sua página em modo de **preview** (visualização).
|
||||||
|
|
||||||
|
Para torná-la pública, clique em **"Submeter para Moderação"**.
|
||||||
|
|
||||||
|
**O Que Acontece Agora?**
|
||||||
|
1. Nossa equipe revisa sua página (24-48 horas)
|
||||||
|
2. Verificamos se segue as diretrizes da comunidade
|
||||||
|
3. Você recebe email quando for aprovada
|
||||||
|
4. Sua página fica pública!
|
||||||
|
|
||||||
|
## Passo 6: Visualizar e Compartilhar
|
||||||
|
|
||||||
|
### 6.1. Token de Preview
|
||||||
|
|
||||||
|
Enquanto aguarda aprovação, você pode visualizar sua página usando o **token de preview**:
|
||||||
|
|
||||||
|
1. No Dashboard, clique em "Gerar Token de Preview"
|
||||||
|
2. Copie o link gerado
|
||||||
|
3. Abra em uma aba privada ou compartilhe com amigos
|
||||||
|
|
||||||
|
**Exemplo de link de preview:**
|
||||||
|
```
|
||||||
|
bcards.site/page/tecnologia/joao-dev?preview=ABC123XYZ
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.2. Após Aprovação
|
||||||
|
|
||||||
|
Quando sua página for aprovada:
|
||||||
|
- Ela estará acessível publicamente
|
||||||
|
- URL final: `bcards.site/page/{categoria}/{seu-slug}`
|
||||||
|
- Você pode compartilhar em suas redes sociais!
|
||||||
|
|
||||||
|
## Passo 7: Atualizar Bio das Redes Sociais
|
||||||
|
|
||||||
|
Com sua página aprovada, atualize a bio de todas as suas redes:
|
||||||
|
|
||||||
|
### Instagram
|
||||||
|
1. Vá em "Editar Perfil"
|
||||||
|
2. Cole seu link BCards no campo "Website"
|
||||||
|
3. Salve
|
||||||
|
|
||||||
|
### TikTok
|
||||||
|
1. Editar Perfil
|
||||||
|
2. Cole o link em "Bio"
|
||||||
|
3. Salve
|
||||||
|
|
||||||
|
### Twitter/X
|
||||||
|
1. Editar Perfil
|
||||||
|
2. Cole o link em "Website"
|
||||||
|
3. Salve
|
||||||
|
|
||||||
|
### LinkedIn
|
||||||
|
1. Editar Perfil
|
||||||
|
2. Cole na seção "Informações de Contato"
|
||||||
|
3. Salve
|
||||||
|
|
||||||
|
## Dicas de Boas Práticas
|
||||||
|
|
||||||
|
### ✅ Faça:
|
||||||
|
- Use foto profissional nítida
|
||||||
|
- Escreva descrição clara e objetiva
|
||||||
|
- Teste todos os links antes de publicar
|
||||||
|
- Mantenha a página atualizada
|
||||||
|
- Responda mensagens rapidamente
|
||||||
|
- Use call-to-action nos títulos dos links
|
||||||
|
|
||||||
|
### ❌ Evite:
|
||||||
|
- Links quebrados ou incorretos
|
||||||
|
- Descrição muito longa ou muito curta
|
||||||
|
- Excesso de links (foco no essencial)
|
||||||
|
- Informações enganosas
|
||||||
|
- Conteúdo que viola diretrizes
|
||||||
|
|
||||||
|
## Atualizando Sua Página Depois
|
||||||
|
|
||||||
|
Você pode editar sua página a qualquer momento:
|
||||||
|
|
||||||
|
1. Acesse o Dashboard
|
||||||
|
2. Clique em "Editar Página"
|
||||||
|
3. Faça as alterações
|
||||||
|
4. Salve
|
||||||
|
|
||||||
|
**Importante:** Mudanças significativas podem exigir nova moderação.
|
||||||
|
|
||||||
|
## Planos e Upgrades
|
||||||
|
|
||||||
|
### Plano Básico (R$ 12,90/mês)
|
||||||
|
- 5 links
|
||||||
|
- Temas básicos
|
||||||
|
- Analytics essenciais
|
||||||
|
|
||||||
|
### Plano Profissional (R$ 25,90/mês)
|
||||||
|
- 15 links
|
||||||
|
- Todos os temas
|
||||||
|
- Analytics completo
|
||||||
|
- Suporte prioritário
|
||||||
|
|
||||||
|
### Plano Premium (R$ 29,90/mês)
|
||||||
|
- Links ilimitados
|
||||||
|
- Customização total
|
||||||
|
- Temas exclusivos
|
||||||
|
- Editor CSS
|
||||||
|
- Suporte VIP
|
||||||
|
|
||||||
|
Para fazer upgrade:
|
||||||
|
1. Dashboard → "Meu Plano"
|
||||||
|
2. Escolha o plano desejado
|
||||||
|
3. Preencha dados de pagamento
|
||||||
|
4. Confirme
|
||||||
|
|
||||||
|
## Analisando Resultados
|
||||||
|
|
||||||
|
Após sua página estar ativa, acompanhe as métricas no Dashboard:
|
||||||
|
|
||||||
|
- **Total de visitantes**: Quantas pessoas acessaram
|
||||||
|
- **Cliques por link**: Quais links são mais populares
|
||||||
|
- **Origem do tráfico**: De onde vieram os visitantes
|
||||||
|
|
||||||
|
Use esses dados para otimizar sua página!
|
||||||
|
|
||||||
|
## Problemas Comuns e Soluções
|
||||||
|
|
||||||
|
### "Meu slug já está em uso"
|
||||||
|
**Solução:** Escolha outro slug. Tente adicionar seu nome, cidade ou especialidade.
|
||||||
|
|
||||||
|
### "Link não funciona"
|
||||||
|
**Solução:** Verifique se a URL começa com `https://` e está digitada corretamente.
|
||||||
|
|
||||||
|
### "Página foi rejeitada na moderação"
|
||||||
|
**Solução:** Leia o email com o motivo da rejeição, ajuste conforme orientações e resubmeta.
|
||||||
|
|
||||||
|
### "Não recebi email de aprovação"
|
||||||
|
**Solução:** Verifique spam/lixo eletrônico ou entre em contato com suporte.
|
||||||
|
|
||||||
|
## Suporte
|
||||||
|
|
||||||
|
Precisa de ajuda?
|
||||||
|
|
||||||
|
- **Email**: suporte@bcards.site
|
||||||
|
- **Horário**: Segunda a sexta, 9h às 18h
|
||||||
|
- **FAQ**: bcards.site/ajuda
|
||||||
|
- **WhatsApp**: (Link no site)
|
||||||
|
|
||||||
|
## Conclusão
|
||||||
|
|
||||||
|
Parabéns! 🎉 Agora você sabe criar sua página profissional no BCards do zero.
|
||||||
|
|
||||||
|
**Recapitulando:**
|
||||||
|
1. ✅ Criar conta
|
||||||
|
2. ✅ Preencher informações
|
||||||
|
3. ✅ Escolher tema
|
||||||
|
4. ✅ Adicionar links
|
||||||
|
5. ✅ Salvar e submeter
|
||||||
|
6. ✅ Aguardar aprovação
|
||||||
|
7. ✅ Compartilhar!
|
||||||
|
|
||||||
|
Sua presença online profissional está a apenas alguns cliques de distância.
|
||||||
|
|
||||||
|
**Pronto para começar?** [Criar meu BCard agora →](https://bcards.site/)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Tempo de leitura:** 8 minutos
|
||||||
|
**Dificuldade:** Iniciante
|
||||||
|
**Última atualização:** Janeiro 2025
|
||||||
File diff suppressed because it is too large
Load Diff
@ -6,6 +6,9 @@ using Microsoft.AspNetCore.Authentication.MicrosoftAccount;
|
|||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using System.Security.Claims;
|
using System.Security.Claims;
|
||||||
|
#if TESTING
|
||||||
|
using BCards.Web.TestSupport;
|
||||||
|
#endif
|
||||||
|
|
||||||
namespace BCards.Web.Controllers;
|
namespace BCards.Web.Controllers;
|
||||||
|
|
||||||
@ -13,24 +16,106 @@ namespace BCards.Web.Controllers;
|
|||||||
public class AuthController : Controller
|
public class AuthController : Controller
|
||||||
{
|
{
|
||||||
private readonly IAuthService _authService;
|
private readonly IAuthService _authService;
|
||||||
|
private readonly IOAuthHealthService _oauthHealthService;
|
||||||
|
private readonly ILogger<AuthController> _logger;
|
||||||
|
private readonly IWebHostEnvironment _env;
|
||||||
|
|
||||||
public AuthController(IAuthService authService)
|
public AuthController(
|
||||||
|
IAuthService authService,
|
||||||
|
IOAuthHealthService oauthHealthService,
|
||||||
|
ILogger<AuthController> logger,
|
||||||
|
IWebHostEnvironment env)
|
||||||
{
|
{
|
||||||
_authService = authService;
|
_authService = authService;
|
||||||
|
_oauthHealthService = oauthHealthService;
|
||||||
|
_logger = logger;
|
||||||
|
_env = env;
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
[Route("Login")]
|
[Route("Login")]
|
||||||
public IActionResult Login(string? returnUrl = null)
|
public async Task<IActionResult> Login(string? returnUrl = null)
|
||||||
{
|
{
|
||||||
ViewBag.ReturnUrl = returnUrl;
|
ViewBag.ReturnUrl = returnUrl;
|
||||||
|
ViewBag.IsTestingEnvironment = _env.IsEnvironment("Testing");
|
||||||
|
|
||||||
|
// Verificar status dos OAuth providers e passar para a view
|
||||||
|
var oauthStatus = await _oauthHealthService.CheckOAuthProvidersAsync();
|
||||||
|
ViewBag.OAuthStatus = oauthStatus;
|
||||||
|
|
||||||
return View();
|
return View();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Endpoint AJAX para verificar status dos OAuth providers
|
||||||
|
/// </summary>
|
||||||
|
[HttpGet]
|
||||||
|
[Route("oauth-status")]
|
||||||
|
public async Task<IActionResult> GetOAuthStatus()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var status = await _oauthHealthService.CheckOAuthProvidersAsync();
|
||||||
|
return Json(new
|
||||||
|
{
|
||||||
|
googleAvailable = status.GoogleAvailable,
|
||||||
|
microsoftAvailable = status.MicrosoftAvailable,
|
||||||
|
allProvidersHealthy = status.AllProvidersHealthy,
|
||||||
|
anyProviderHealthy = status.AnyProviderHealthy,
|
||||||
|
message = GetOAuthStatusMessage(status)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Erro ao verificar status OAuth");
|
||||||
|
return Json(new { error = "Erro ao verificar disponibilidade de login" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private string GetOAuthStatusMessage(OAuthHealthStatus status)
|
||||||
|
{
|
||||||
|
if (status.AllProvidersHealthy)
|
||||||
|
return "Todos os métodos de login estão funcionando normalmente";
|
||||||
|
|
||||||
|
if (!status.AnyProviderHealthy)
|
||||||
|
return "⚠️ Login temporariamente indisponível. Tente novamente em alguns minutos.";
|
||||||
|
|
||||||
|
var available = new List<string>();
|
||||||
|
var unavailable = new List<string>();
|
||||||
|
|
||||||
|
if (status.GoogleAvailable) available.Add("Google");
|
||||||
|
else unavailable.Add("Google");
|
||||||
|
|
||||||
|
if (status.MicrosoftAvailable) available.Add("Microsoft");
|
||||||
|
else unavailable.Add("Microsoft");
|
||||||
|
|
||||||
|
return $"⚠️ Login com {string.Join(" e ", unavailable)} temporariamente indisponível. Use {string.Join(" ou ", available)}.";
|
||||||
|
}
|
||||||
|
|
||||||
[HttpPost]
|
[HttpPost]
|
||||||
[Route("LoginWithGoogle")]
|
[Route("LoginWithGoogle")]
|
||||||
public IActionResult LoginWithGoogle(string? returnUrl = null)
|
public async Task<IActionResult> LoginWithGoogle(string? returnUrl = null)
|
||||||
{
|
{
|
||||||
|
// TEMPORARIAMENTE DESABILITADO - para testar se a verificação OAuth está causando problemas
|
||||||
|
/*
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Verificar se Google está disponível (com timeout curto para não travar UX)
|
||||||
|
var isGoogleAvailable = await _oauthHealthService.IsGoogleAvailableAsync();
|
||||||
|
if (!isGoogleAvailable)
|
||||||
|
{
|
||||||
|
_logger.LogWarning("🟡 Usuário tentou fazer login com Google, mas o serviço parece estar offline");
|
||||||
|
TempData["LoginError"] = "Login com Google pode estar temporariamente indisponível. Se o problema persistir, tente Microsoft.";
|
||||||
|
// Mas PERMITA o login mesmo assim - o próprio Google vai dar erro se estiver offline
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex, "Erro ao verificar status Google - permitindo login mesmo assim");
|
||||||
|
// Se não conseguir verificar, permitir login
|
||||||
|
}
|
||||||
|
*/
|
||||||
|
|
||||||
var redirectUrl = Url.Action("GoogleCallback", "Auth", new { returnUrl });
|
var redirectUrl = Url.Action("GoogleCallback", "Auth", new { returnUrl });
|
||||||
var properties = new AuthenticationProperties { RedirectUri = redirectUrl };
|
var properties = new AuthenticationProperties { RedirectUri = redirectUrl };
|
||||||
return Challenge(properties, GoogleDefaults.AuthenticationScheme);
|
return Challenge(properties, GoogleDefaults.AuthenticationScheme);
|
||||||
@ -38,13 +123,59 @@ public class AuthController : Controller
|
|||||||
|
|
||||||
[HttpPost]
|
[HttpPost]
|
||||||
[Route("LoginWithMicrosoft")]
|
[Route("LoginWithMicrosoft")]
|
||||||
public IActionResult LoginWithMicrosoft(string? returnUrl = null)
|
public async Task<IActionResult> LoginWithMicrosoft(string? returnUrl = null)
|
||||||
{
|
{
|
||||||
|
// TEMPORARIAMENTE DESABILITADO - para testar se a verificação OAuth está causando problemas
|
||||||
|
/*
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Verificar se Microsoft está disponível (com timeout curto para não travar UX)
|
||||||
|
var isMicrosoftAvailable = await _oauthHealthService.IsMicrosoftAvailableAsync();
|
||||||
|
if (!isMicrosoftAvailable)
|
||||||
|
{
|
||||||
|
_logger.LogWarning("🟡 Usuário tentou fazer login com Microsoft, mas o serviço parece estar offline");
|
||||||
|
TempData["LoginError"] = "Login com Microsoft pode estar temporariamente indisponível. Se o problema persistir, tente Google.";
|
||||||
|
// Mas PERMITA o login mesmo assim - o próprio Microsoft vai dar erro se estiver offline
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex, "Erro ao verificar status Microsoft - permitindo login mesmo assim");
|
||||||
|
// Se não conseguir verificar, permitir login
|
||||||
|
}
|
||||||
|
*/
|
||||||
|
|
||||||
var redirectUrl = Url.Action("MicrosoftCallback", "Auth", new { returnUrl });
|
var redirectUrl = Url.Action("MicrosoftCallback", "Auth", new { returnUrl });
|
||||||
var properties = new AuthenticationProperties { RedirectUri = redirectUrl };
|
var properties = new AuthenticationProperties { RedirectUri = redirectUrl };
|
||||||
return Challenge(properties, MicrosoftAccountDefaults.AuthenticationScheme);
|
return Challenge(properties, MicrosoftAccountDefaults.AuthenticationScheme);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#if TESTING
|
||||||
|
[HttpPost]
|
||||||
|
[Route("LoginWithTest")]
|
||||||
|
|
||||||
|
public IActionResult LoginWithTest(string? returnUrl = null)
|
||||||
|
{
|
||||||
|
if (!_env.IsEnvironment("Testing"))
|
||||||
|
{
|
||||||
|
return NotFound(); // Endpoint de teste só funciona no ambiente de Testing
|
||||||
|
}
|
||||||
|
|
||||||
|
string redirectUrlString;
|
||||||
|
if (Url.IsLocalUrl(returnUrl))
|
||||||
|
{
|
||||||
|
redirectUrlString = returnUrl;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
redirectUrlString = Url.Action("Dashboard", "Admin");
|
||||||
|
}
|
||||||
|
|
||||||
|
var properties = new AuthenticationProperties { RedirectUri = redirectUrlString };
|
||||||
|
return Challenge(properties, TestAuthConstants.AuthenticationScheme);
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
[Route("GoogleCallback")]
|
[Route("GoogleCallback")]
|
||||||
public async Task<IActionResult> GoogleCallback(string? returnUrl = null)
|
public async Task<IActionResult> GoogleCallback(string? returnUrl = null)
|
||||||
@ -52,7 +183,7 @@ public class AuthController : Controller
|
|||||||
var result = await HttpContext.AuthenticateAsync(GoogleDefaults.AuthenticationScheme);
|
var result = await HttpContext.AuthenticateAsync(GoogleDefaults.AuthenticationScheme);
|
||||||
if (!result.Succeeded)
|
if (!result.Succeeded)
|
||||||
{
|
{
|
||||||
TempData["Error"] = "Falha na autenticação com Google";
|
TempData["Error"] = "Falha na autentica<EFBFBD><EFBFBD>o com Google";
|
||||||
return RedirectToAction("Login");
|
return RedirectToAction("Login");
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -82,7 +213,7 @@ public class AuthController : Controller
|
|||||||
var result = await HttpContext.AuthenticateAsync(MicrosoftAccountDefaults.AuthenticationScheme);
|
var result = await HttpContext.AuthenticateAsync(MicrosoftAccountDefaults.AuthenticationScheme);
|
||||||
if (!result.Succeeded)
|
if (!result.Succeeded)
|
||||||
{
|
{
|
||||||
TempData["Error"] = "Falha na autenticação com Microsoft";
|
TempData["Error"] = "Falha na autentica<EFBFBD><EFBFBD>o com Microsoft";
|
||||||
return RedirectToAction("Login");
|
return RedirectToAction("Login");
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -105,31 +236,16 @@ public class AuthController : Controller
|
|||||||
return RedirectToLocal(returnUrl);
|
return RedirectToLocal(returnUrl);
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet]
|
[HttpPost]
|
||||||
[Route("Logout")]
|
[Route("Logout")]
|
||||||
[Authorize]
|
[Authorize]
|
||||||
|
[ValidateAntiForgeryToken]
|
||||||
public async Task<IActionResult> Logout()
|
public async Task<IActionResult> Logout()
|
||||||
{
|
{
|
||||||
// Identifica qual provedor foi usado
|
|
||||||
var authResult = await HttpContext.AuthenticateAsync(CookieAuthenticationDefaults.AuthenticationScheme);
|
|
||||||
var loginProvider = authResult.Principal?.FindFirst("LoginProvider")?.Value;
|
|
||||||
|
|
||||||
// Faz logout local primeiro
|
|
||||||
await HttpContext.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);
|
await HttpContext.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);
|
||||||
|
|
||||||
TempData["Success"] = "Logout realizado com sucesso";
|
TempData["Success"] = "Você saiu com segurança.";
|
||||||
|
|
||||||
// Se foi Microsoft, faz logout completo no provedor
|
|
||||||
if (loginProvider == "Microsoft")
|
|
||||||
{
|
|
||||||
return SignOut(MicrosoftAccountDefaults.AuthenticationScheme);
|
|
||||||
}
|
|
||||||
// Se foi Google, faz logout completo no provedor
|
|
||||||
else if (loginProvider == "Google")
|
|
||||||
{
|
|
||||||
return SignOut(GoogleDefaults.AuthenticationScheme);
|
|
||||||
}
|
|
||||||
|
|
||||||
return RedirectToAction("Index", "Home");
|
return RedirectToAction("Index", "Home");
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -140,4 +256,4 @@ public class AuthController : Controller
|
|||||||
|
|
||||||
return RedirectToAction("Dashboard", "Admin");
|
return RedirectToAction("Dashboard", "Admin");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
64
src/BCards.Web/Controllers/DocumentController.cs
Normal file
64
src/BCards.Web/Controllers/DocumentController.cs
Normal file
@ -0,0 +1,64 @@
|
|||||||
|
using BCards.Web.Services;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
|
namespace BCards.Web.Controllers;
|
||||||
|
|
||||||
|
[Route("api/[controller]")]
|
||||||
|
[ApiController]
|
||||||
|
public class DocumentController : ControllerBase
|
||||||
|
{
|
||||||
|
private readonly IDocumentStorageService _documentStorage;
|
||||||
|
private readonly ILogger<DocumentController> _logger;
|
||||||
|
|
||||||
|
public DocumentController(IDocumentStorageService documentStorage, ILogger<DocumentController> logger)
|
||||||
|
{
|
||||||
|
_documentStorage = documentStorage;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("{documentId}")]
|
||||||
|
[ResponseCache(Duration = 3600, Location = ResponseCacheLocation.Any)]
|
||||||
|
public async Task<IActionResult> GetDocument(string documentId)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(documentId))
|
||||||
|
return BadRequest("Documento inválido.");
|
||||||
|
|
||||||
|
var documentBytes = await _documentStorage.GetDocumentAsync(documentId);
|
||||||
|
if (documentBytes == null)
|
||||||
|
return NotFound();
|
||||||
|
|
||||||
|
Response.Headers["Cache-Control"] = "public, max-age=31536000";
|
||||||
|
Response.Headers["Expires"] = DateTime.UtcNow.AddYears(1).ToString("R");
|
||||||
|
Response.Headers["ETag"] = $"\"{documentId}\"";
|
||||||
|
|
||||||
|
return File(documentBytes, "application/pdf");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Erro ao obter documento {DocumentId}", documentId);
|
||||||
|
return NotFound();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpDelete("{documentId}")]
|
||||||
|
public async Task<IActionResult> DeleteDocument(string documentId)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(documentId))
|
||||||
|
return BadRequest();
|
||||||
|
|
||||||
|
var deleted = await _documentStorage.DeleteDocumentAsync(documentId);
|
||||||
|
return deleted ? Ok(new { success = true }) : NotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpHead("{documentId}")]
|
||||||
|
public async Task<IActionResult> DocumentExists(string documentId)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(documentId))
|
||||||
|
return BadRequest();
|
||||||
|
|
||||||
|
var exists = await _documentStorage.DocumentExistsAsync(documentId);
|
||||||
|
return exists ? Ok() : NotFound();
|
||||||
|
}
|
||||||
|
}
|
||||||
305
src/BCards.Web/Controllers/HealthController.cs
Normal file
305
src/BCards.Web/Controllers/HealthController.cs
Normal file
@ -0,0 +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";
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,60 +1,108 @@
|
|||||||
using BCards.Web.Services;
|
using BCards.Web.Services;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using BCards.Web.Configuration;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
namespace BCards.Web.Controllers;
|
using Microsoft.Extensions.Options;
|
||||||
|
|
||||||
public class HomeController : Controller
|
namespace BCards.Web.Controllers;
|
||||||
{
|
|
||||||
private readonly ICategoryService _categoryService;
|
public class HomeController : Controller
|
||||||
private readonly IUserPageService _userPageService;
|
{
|
||||||
|
private readonly ICategoryService _categoryService;
|
||||||
public HomeController(ICategoryService categoryService, IUserPageService userPageService)
|
private readonly IUserPageService _userPageService;
|
||||||
{
|
private readonly StripeSettings _stripeSettings;
|
||||||
_categoryService = categoryService;
|
|
||||||
_userPageService = userPageService;
|
public HomeController(
|
||||||
}
|
ICategoryService categoryService,
|
||||||
|
IUserPageService userPageService,
|
||||||
public async Task<IActionResult> Index()
|
IOptions<StripeSettings> stripeSettings)
|
||||||
{
|
{
|
||||||
ViewBag.IsHomePage = true; // Flag para identificar home
|
_categoryService = categoryService;
|
||||||
ViewBag.Categories = await _categoryService.GetAllCategoriesAsync();
|
_userPageService = userPageService;
|
||||||
ViewBag.RecentPages = await _userPageService.GetRecentPagesAsync(6);
|
_stripeSettings = stripeSettings.Value;
|
||||||
return View();
|
}
|
||||||
}
|
|
||||||
|
public async Task<IActionResult> Index()
|
||||||
[Route("Privacy")]
|
{
|
||||||
public IActionResult Privacy()
|
// Cache condicional: apenas para usuários não logados
|
||||||
{
|
if (User.Identity?.IsAuthenticated != true)
|
||||||
ViewBag.IsHomePage = true;
|
{
|
||||||
return View();
|
Response.Headers["Cache-Control"] = "public, max-age=600"; // 10 minutos
|
||||||
}
|
}
|
||||||
|
else
|
||||||
[Route("Pricing")]
|
{
|
||||||
public IActionResult Pricing()
|
Response.Headers["Cache-Control"] = "no-cache, must-revalidate";
|
||||||
{
|
Response.Headers["Vary"] = "Cookie";
|
||||||
ViewBag.IsHomePage = true;
|
}
|
||||||
return View();
|
|
||||||
}
|
ViewBag.IsHomePage = true; // Flag para identificar home
|
||||||
|
ViewBag.Categories = await _categoryService.GetAllCategoriesAsync();
|
||||||
[Route("categoria/{categorySlug}")]
|
ViewBag.RecentPages = await _userPageService.GetRecentPagesAsync(6);
|
||||||
public async Task<IActionResult> Category(string categorySlug)
|
return View();
|
||||||
{
|
}
|
||||||
ViewBag.IsHomePage = true;
|
|
||||||
var category = await _categoryService.GetCategoryBySlugAsync(categorySlug);
|
[Route("Privacy")]
|
||||||
if (category == null)
|
[ResponseCache(Duration = 3600, Location = ResponseCacheLocation.Any)] // 1 hora
|
||||||
return NotFound();
|
public IActionResult Privacy()
|
||||||
|
{
|
||||||
var pages = await _userPageService.GetPagesByCategoryAsync(categorySlug, 20);
|
ViewBag.IsHomePage = true;
|
||||||
|
return View();
|
||||||
ViewBag.Category = category;
|
}
|
||||||
ViewBag.Pages = pages;
|
|
||||||
|
[Route("Pricing")]
|
||||||
return View();
|
public IActionResult Pricing()
|
||||||
}
|
{
|
||||||
|
// Cache condicional: apenas para usuários não logados
|
||||||
[ResponseCache(Duration = 0, Location = ResponseCacheLocation.None, NoStore = true)]
|
if (User.Identity?.IsAuthenticated != true)
|
||||||
public IActionResult Error()
|
{
|
||||||
{
|
Response.Headers["Cache-Control"] = "public, max-age=1800"; // 30 minutos
|
||||||
return View();
|
}
|
||||||
}
|
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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
202
src/BCards.Web/Controllers/ImageController.cs
Normal file
202
src/BCards.Web/Controllers/ImageController.cs
Normal file
@ -0,0 +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;
|
||||||
|
}
|
||||||
|
}
|
||||||
90
src/BCards.Web/Controllers/LegalController.cs
Normal file
90
src/BCards.Web/Controllers/LegalController.cs
Normal file
@ -0,0 +1,90 @@
|
|||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using System.Linq;
|
||||||
|
|
||||||
|
namespace BCards.Web.Controllers
|
||||||
|
{
|
||||||
|
public class LegalController : Controller
|
||||||
|
{
|
||||||
|
// GET: /privacidade or /Legal/Privacy
|
||||||
|
public IActionResult Privacy()
|
||||||
|
{
|
||||||
|
var lang = GetUserLanguage();
|
||||||
|
if (lang == "es")
|
||||||
|
{
|
||||||
|
return RedirectToAction("PrivacyES");
|
||||||
|
}
|
||||||
|
return View();
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET: /privacy or /Legal/PrivacyES
|
||||||
|
public IActionResult PrivacyES()
|
||||||
|
{
|
||||||
|
return View();
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET: /termos or /Legal/Terms
|
||||||
|
public IActionResult Terms()
|
||||||
|
{
|
||||||
|
var lang = GetUserLanguage();
|
||||||
|
if (lang == "es")
|
||||||
|
{
|
||||||
|
return RedirectToAction("TermsES");
|
||||||
|
}
|
||||||
|
return View();
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET: /terminos or /Legal/TermsES
|
||||||
|
public IActionResult TermsES()
|
||||||
|
{
|
||||||
|
return View();
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET: /regras or /Legal/CommunityGuidelines
|
||||||
|
public IActionResult CommunityGuidelines()
|
||||||
|
{
|
||||||
|
// As regras da comunidade podem ser um único documento com tradução na própria página
|
||||||
|
// ou também podem ser redirecionadas. Por enquanto, uma única view.
|
||||||
|
return View();
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET: /Legal/RequestData
|
||||||
|
public IActionResult RequestData()
|
||||||
|
{
|
||||||
|
ViewData["Title"] = "Solicitação de Dados Pessoais";
|
||||||
|
return View();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Detecta o idioma do usuário com base em query string, cabeçalhos ou um padrão.
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>"pt" para português ou "es" para espanhol.</returns>
|
||||||
|
private string GetUserLanguage()
|
||||||
|
{
|
||||||
|
// 1. Verificar por parâmetro na URL (?lang=es)
|
||||||
|
if (Request.Query.TryGetValue("lang", out var lang))
|
||||||
|
{
|
||||||
|
if (lang == "es" || lang == "pt")
|
||||||
|
return lang;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Verificar o cabeçalho Accept-Language
|
||||||
|
var acceptLanguage = Request.Headers["Accept-Language"].ToString();
|
||||||
|
if (!string.IsNullOrEmpty(acceptLanguage))
|
||||||
|
{
|
||||||
|
var languages = acceptLanguage.Split(',').Select(x => x.Trim().Split(';')[0]);
|
||||||
|
foreach (var language in languages)
|
||||||
|
{
|
||||||
|
if (language.StartsWith("es", StringComparison.OrdinalIgnoreCase))
|
||||||
|
return "es";
|
||||||
|
if (language.StartsWith("pt", StringComparison.OrdinalIgnoreCase))
|
||||||
|
return "pt";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. TODO: Implementar detecção por GeoIP se necessário
|
||||||
|
|
||||||
|
// 4. Padrão: português
|
||||||
|
return "pt";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,93 +1,97 @@
|
|||||||
using BCards.Web.Services;
|
using BCards.Web.Services;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
namespace BCards.Web.Controllers;
|
namespace BCards.Web.Controllers;
|
||||||
|
|
||||||
[Route("page")]
|
[Route("page")]
|
||||||
public class LivePageController : Controller
|
public class LivePageController : Controller
|
||||||
{
|
{
|
||||||
private readonly ILivePageService _livePageService;
|
private readonly ILivePageService _livePageService;
|
||||||
private readonly ILogger<LivePageController> _logger;
|
private readonly ILogger<LivePageController> _logger;
|
||||||
|
|
||||||
public LivePageController(ILivePageService livePageService, ILogger<LivePageController> logger)
|
public LivePageController(ILivePageService livePageService, ILogger<LivePageController> logger)
|
||||||
{
|
{
|
||||||
_livePageService = livePageService;
|
_livePageService = livePageService;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
}
|
}
|
||||||
|
|
||||||
[Route("{category}/{slug}")]
|
[Route("{category}/{slug}")]
|
||||||
[ResponseCache(Duration = 3600, Location = ResponseCacheLocation.Any, VaryByQueryKeys = new string[] { })]
|
[ResponseCache(Duration = 3600, Location = ResponseCacheLocation.Any, VaryByQueryKeys = new string[] { })]
|
||||||
public async Task<IActionResult> Display(string category, string slug)
|
public async Task<IActionResult> Display(string category, string slug)
|
||||||
{
|
{
|
||||||
// Se tem parâmetro preview, redirecionar para sistema de preview
|
// Se tem parâmetro preview, redirecionar para sistema de preview
|
||||||
if (HttpContext.Request.Query.ContainsKey("preview"))
|
if (HttpContext.Request.Query.ContainsKey("preview"))
|
||||||
{
|
{
|
||||||
_logger.LogInformation("Redirecting preview request for {Category}/{Slug} to UserPageController", category, slug);
|
_logger.LogInformation("Redirecting preview request for {Category}/{Slug} to UserPageController", category, slug);
|
||||||
return RedirectToAction("Display", "UserPage", new {
|
return RedirectToAction("Display", "UserPage", new {
|
||||||
category = category,
|
category = category,
|
||||||
slug = slug,
|
slug = slug,
|
||||||
preview = HttpContext.Request.Query["preview"].ToString()
|
preview = HttpContext.Request.Query["preview"].ToString()
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
var livePage = await _livePageService.GetByCategoryAndSlugAsync(category, slug);
|
var livePage = await _livePageService.GetByCategoryAndSlugAsync(category, slug);
|
||||||
if (livePage == null)
|
if (livePage == null)
|
||||||
{
|
{
|
||||||
_logger.LogInformation("LivePage not found for {Category}/{Slug}, falling back to UserPageController", category, slug);
|
_logger.LogInformation("LivePage not found for {Category}/{Slug}, falling back to UserPageController", category, slug);
|
||||||
// Fallback: tentar no sistema antigo
|
// Fallback: tentar no sistema antigo
|
||||||
return RedirectToAction("Display", "UserPage", new { category = category, slug = slug });
|
return RedirectToAction("Display", "UserPage", new { category = category, slug = slug });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Incrementar view de forma assíncrona (não bloquear response)
|
// Incrementar view de forma assíncrona (não bloquear response)
|
||||||
_ = Task.Run(async () =>
|
_ = IncrementViewSafelyAsync(livePage.Id);
|
||||||
{
|
|
||||||
try
|
// Configurar ViewBag para indicar que é uma live page
|
||||||
{
|
ViewBag.IsLivePage = true;
|
||||||
await _livePageService.IncrementViewAsync(livePage.Id);
|
ViewBag.PageUrl = $"https://bcards.site/page/{category}/{slug}";
|
||||||
}
|
ViewBag.Title = $"{livePage.DisplayName} - {livePage.Category} | BCards";
|
||||||
catch (Exception ex)
|
|
||||||
{
|
_logger.LogInformation("Serving LivePage {LivePageId} for {Category}/{Slug}", livePage.Id, category, slug);
|
||||||
_logger.LogError(ex, "Failed to increment view for LivePage {LivePageId}", livePage.Id);
|
|
||||||
}
|
// Usar a mesma view do UserPage mas com dados da LivePage
|
||||||
});
|
return View("~/Views/UserPage/Display.cshtml", livePage);
|
||||||
|
}
|
||||||
// Configurar ViewBag para indicar que é uma live page
|
|
||||||
ViewBag.IsLivePage = true;
|
[Route("{category}/{slug}/link/{linkIndex}")]
|
||||||
ViewBag.PageUrl = $"https://vcart.me/page/{category}/{slug}";
|
public async Task<IActionResult> TrackLinkClick(string category, string slug, int linkIndex)
|
||||||
ViewBag.Title = $"{livePage.DisplayName} - {livePage.Category} | BCards";
|
{
|
||||||
|
var livePage = await _livePageService.GetByCategoryAndSlugAsync(category, slug);
|
||||||
_logger.LogInformation("Serving LivePage {LivePageId} for {Category}/{Slug}", livePage.Id, category, slug);
|
if (livePage == null || linkIndex < 0 || linkIndex >= livePage.Links.Count)
|
||||||
|
{
|
||||||
// Usar a mesma view do UserPage mas com dados da LivePage
|
return NotFound();
|
||||||
return View("~/Views/UserPage/Display.cshtml", livePage);
|
}
|
||||||
}
|
|
||||||
|
var link = livePage.Links[linkIndex];
|
||||||
[Route("{category}/{slug}/link/{linkIndex}")]
|
|
||||||
public async Task<IActionResult> TrackLinkClick(string category, string slug, int linkIndex)
|
// Track click de forma assíncrona
|
||||||
{
|
_ = IncrementLinkClickSafelyAsync(livePage.Id, linkIndex);
|
||||||
var livePage = await _livePageService.GetByCategoryAndSlugAsync(category, slug);
|
|
||||||
if (livePage == null || linkIndex < 0 || linkIndex >= livePage.Links.Count)
|
_logger.LogInformation("Tracking click for LivePage {LivePageId} link {LinkIndex} -> {Url}", livePage.Id, linkIndex, link.Url);
|
||||||
{
|
|
||||||
return NotFound();
|
return Redirect(link.Url);
|
||||||
}
|
}
|
||||||
|
|
||||||
var link = livePage.Links[linkIndex];
|
private async Task IncrementViewSafelyAsync(string livePageId)
|
||||||
|
{
|
||||||
// Track click de forma assíncrona
|
try
|
||||||
_ = Task.Run(async () =>
|
{
|
||||||
{
|
await _livePageService.IncrementViewAsync(livePageId);
|
||||||
try
|
}
|
||||||
{
|
catch (Exception ex)
|
||||||
await _livePageService.IncrementLinkClickAsync(livePage.Id, linkIndex);
|
{
|
||||||
}
|
_logger.LogError(ex, "Failed to increment view for LivePage {LivePageId}", livePageId);
|
||||||
catch (Exception ex)
|
}
|
||||||
{
|
}
|
||||||
_logger.LogError(ex, "Failed to track click for LivePage {LivePageId} link {LinkIndex}", livePage.Id, linkIndex);
|
|
||||||
}
|
private async Task IncrementLinkClickSafelyAsync(string livePageId, int linkIndex)
|
||||||
});
|
{
|
||||||
|
try
|
||||||
_logger.LogInformation("Tracking click for LivePage {LivePageId} link {LinkIndex} -> {Url}", livePage.Id, linkIndex, link.Url);
|
{
|
||||||
|
await _livePageService.IncrementLinkClickAsync(livePageId, linkIndex);
|
||||||
return Redirect(link.Url);
|
}
|
||||||
}
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Failed to track click for LivePage {LivePageId} link {LinkIndex}", livePageId, linkIndex);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@ -1,230 +1,232 @@
|
|||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using BCards.Web.Services;
|
using BCards.Web.Services;
|
||||||
using BCards.Web.Models;
|
using BCards.Web.Models;
|
||||||
using BCards.Web.ViewModels;
|
using BCards.Web.ViewModels;
|
||||||
using BCards.Web.Repositories;
|
using BCards.Web.Repositories;
|
||||||
using System.Security.Claims;
|
using System.Security.Claims;
|
||||||
using BCards.Web.Attributes;
|
using BCards.Web.Attributes;
|
||||||
|
|
||||||
namespace BCards.Web.Controllers;
|
namespace BCards.Web.Controllers;
|
||||||
|
|
||||||
[ModeratorAuthorize]
|
[ModeratorAuthorize]
|
||||||
[Route("Moderation")]
|
[Route("Moderation")]
|
||||||
public class ModerationController : Controller
|
public class ModerationController : Controller
|
||||||
{
|
{
|
||||||
private readonly IModerationService _moderationService;
|
private readonly IModerationService _moderationService;
|
||||||
private readonly IEmailService _emailService;
|
private readonly IEmailService _emailService;
|
||||||
private readonly IUserRepository _userRepository;
|
private readonly IUserRepository _userRepository;
|
||||||
private readonly ILogger<ModerationController> _logger;
|
private readonly ILogger<ModerationController> _logger;
|
||||||
|
|
||||||
public ModerationController(
|
public ModerationController(
|
||||||
IModerationService moderationService,
|
IModerationService moderationService,
|
||||||
IEmailService emailService,
|
IEmailService emailService,
|
||||||
IUserRepository userRepository,
|
IUserRepository userRepository,
|
||||||
ILogger<ModerationController> logger)
|
ILogger<ModerationController> logger)
|
||||||
{
|
{
|
||||||
_moderationService = moderationService;
|
_moderationService = moderationService;
|
||||||
_emailService = emailService;
|
_emailService = emailService;
|
||||||
_userRepository = userRepository;
|
_userRepository = userRepository;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("Dashboard")]
|
[HttpGet("Dashboard")]
|
||||||
public async Task<IActionResult> Dashboard(int page = 1, int size = 20)
|
public async Task<IActionResult> Dashboard(int page = 1, int size = 20, string? filter = null)
|
||||||
{
|
{
|
||||||
var skip = (page - 1) * size;
|
var skip = (page - 1) * size;
|
||||||
var pendingPages = await _moderationService.GetPendingModerationAsync(skip, size);
|
var pendingPages = await _moderationService.GetPendingModerationAsync(skip, size, filter);
|
||||||
var stats = await _moderationService.GetModerationStatsAsync();
|
var stats = await _moderationService.GetModerationStatsAsync();
|
||||||
|
|
||||||
var viewModel = new ModerationDashboardViewModel
|
var viewModel = new ModerationDashboardViewModel
|
||||||
{
|
{
|
||||||
PendingPages = pendingPages.Select(p => new PendingPageViewModel
|
PendingPages = pendingPages.Select(p => new PendingPageViewModel
|
||||||
{
|
{
|
||||||
Id = p.Id,
|
Id = p.Id,
|
||||||
DisplayName = p.DisplayName,
|
DisplayName = p.DisplayName,
|
||||||
Category = p.Category,
|
Category = p.Category,
|
||||||
Slug = p.Slug,
|
Slug = p.Slug,
|
||||||
CreatedAt = p.CreatedAt,
|
CreatedAt = p.CreatedAt,
|
||||||
ModerationAttempts = p.ModerationAttempts,
|
ModerationAttempts = p.ModerationAttempts,
|
||||||
PlanType = p.PlanLimitations.PlanType.ToString(),
|
PlanType = p.PlanLimitations.PlanType.ToString(),
|
||||||
PreviewUrl = !string.IsNullOrEmpty(p.PreviewToken)
|
IsSpecialModeration = p.PlanLimitations.SpecialModeration ?? false,
|
||||||
? $"/page/{p.Category}/{p.Slug}?preview={p.PreviewToken}"
|
PreviewUrl = !string.IsNullOrEmpty(p.PreviewToken)
|
||||||
: null
|
? $"/page/{p.Category}/{p.Slug}?preview={p.PreviewToken}"
|
||||||
}).ToList(),
|
: null
|
||||||
Stats = stats,
|
}).ToList(),
|
||||||
CurrentPage = page,
|
Stats = stats,
|
||||||
PageSize = size,
|
CurrentPage = page,
|
||||||
HasNextPage = pendingPages.Count == size
|
PageSize = size,
|
||||||
};
|
HasNextPage = pendingPages.Count == size,
|
||||||
|
CurrentFilter = filter ?? "all"
|
||||||
return View(viewModel);
|
};
|
||||||
}
|
|
||||||
|
return View(viewModel);
|
||||||
[HttpGet("Review/{id}")]
|
}
|
||||||
public async Task<IActionResult> Review(string id)
|
|
||||||
{
|
[HttpGet("Review/{id}")]
|
||||||
var page = await _moderationService.GetPageForModerationAsync(id);
|
public async Task<IActionResult> Review(string id)
|
||||||
if (page == null)
|
{
|
||||||
{
|
var page = await _moderationService.GetPageForModerationAsync(id);
|
||||||
TempData["Error"] = "Página não encontrada ou não está pendente de moderação.";
|
if (page == null)
|
||||||
return RedirectToAction("Dashboard");
|
{
|
||||||
}
|
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)
|
|
||||||
{
|
var user = await _userRepository.GetByIdAsync(page.UserId);
|
||||||
TempData["Error"] = "Usuário não encontrado.";
|
if (user == null)
|
||||||
return RedirectToAction("Dashboard");
|
{
|
||||||
}
|
TempData["Error"] = "Usuário não encontrado.";
|
||||||
|
return RedirectToAction("Dashboard");
|
||||||
var viewModel = new ModerationReviewViewModel
|
}
|
||||||
{
|
|
||||||
Page = page,
|
var viewModel = new ModerationReviewViewModel
|
||||||
User = user,
|
{
|
||||||
PreviewUrl = !string.IsNullOrEmpty(page.PreviewToken)
|
Page = page,
|
||||||
? $"/page/{page.Category}/{page.Slug}?preview={page.PreviewToken}"
|
User = user,
|
||||||
: null,
|
PreviewUrl = !string.IsNullOrEmpty(page.PreviewToken)
|
||||||
ModerationCriteria = GetModerationCriteria()
|
? $"/page/{page.Category}/{page.Slug}?preview={page.PreviewToken}"
|
||||||
};
|
: null,
|
||||||
|
ModerationCriteria = GetModerationCriteria()
|
||||||
return View(viewModel);
|
};
|
||||||
}
|
|
||||||
|
return View(viewModel);
|
||||||
[HttpPost("Approve/{id}")]
|
}
|
||||||
[ValidateAntiForgeryToken]
|
|
||||||
public async Task<IActionResult> Approve(string id, string notes)
|
[HttpPost("Approve/{id}")]
|
||||||
{
|
[ValidateAntiForgeryToken]
|
||||||
try
|
public async Task<IActionResult> Approve(string id, string notes)
|
||||||
{
|
{
|
||||||
var page = await _moderationService.GetPageForModerationAsync(id);
|
try
|
||||||
if (page == null)
|
{
|
||||||
{
|
var page = await _moderationService.GetPageForModerationAsync(id);
|
||||||
TempData["Error"] = "Página não encontrada.";
|
if (page == null)
|
||||||
return RedirectToAction("Dashboard");
|
{
|
||||||
}
|
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";
|
|
||||||
|
var user = await _userRepository.GetByIdAsync(page.UserId);
|
||||||
await _moderationService.ApprovePageAsync(id, moderatorId, notes);
|
var moderatorId = User.FindFirst(ClaimTypes.NameIdentifier)?.Value ?? "system";
|
||||||
|
|
||||||
if (user != null)
|
await _moderationService.ApprovePageAsync(id, moderatorId, notes);
|
||||||
{
|
|
||||||
await _emailService.SendModerationStatusAsync(
|
if (user != null)
|
||||||
user.Email,
|
{
|
||||||
user.Name,
|
await _emailService.SendModerationStatusAsync(
|
||||||
page.DisplayName,
|
user.Email,
|
||||||
PageStatus.Active.ToString());
|
user.Name,
|
||||||
}
|
page.DisplayName,
|
||||||
|
PageStatus.Active.ToString());
|
||||||
TempData["Success"] = $"Página '{page.DisplayName}' aprovada com sucesso!";
|
}
|
||||||
return RedirectToAction("Dashboard");
|
|
||||||
}
|
TempData["Success"] = $"Página '{page.DisplayName}' aprovada com sucesso!";
|
||||||
catch (Exception ex)
|
return RedirectToAction("Dashboard");
|
||||||
{
|
}
|
||||||
_logger.LogError(ex, "Error approving page {PageId}", id);
|
catch (Exception ex)
|
||||||
TempData["Error"] = "Erro ao aprovar página.";
|
{
|
||||||
return RedirectToAction("Review", new { id });
|
_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)
|
[HttpPost("Reject/{id}")]
|
||||||
{
|
[ValidateAntiForgeryToken]
|
||||||
try
|
public async Task<IActionResult> Reject(string id, string reason, List<string> issues)
|
||||||
{
|
{
|
||||||
var page = await _moderationService.GetPageForModerationAsync(id);
|
try
|
||||||
if (page == null)
|
{
|
||||||
{
|
var page = await _moderationService.GetPageForModerationAsync(id);
|
||||||
TempData["Error"] = "Página não encontrada.";
|
if (page == null)
|
||||||
return RedirectToAction("Dashboard");
|
{
|
||||||
}
|
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";
|
|
||||||
|
var user = await _userRepository.GetByIdAsync(page.UserId);
|
||||||
await _moderationService.RejectPageAsync(id, moderatorId, reason, issues);
|
var moderatorId = User.FindFirst(ClaimTypes.NameIdentifier)?.Value ?? "system";
|
||||||
|
|
||||||
if (user != null)
|
await _moderationService.RejectPageAsync(id, moderatorId, reason, issues);
|
||||||
{
|
|
||||||
await _emailService.SendModerationStatusAsync(
|
if (user != null)
|
||||||
user.Email,
|
{
|
||||||
user.Name,
|
await _emailService.SendModerationStatusAsync(
|
||||||
page.DisplayName,
|
user.Email,
|
||||||
"rejected",
|
user.Name,
|
||||||
reason);
|
page.DisplayName,
|
||||||
}
|
"rejected",
|
||||||
|
reason);
|
||||||
TempData["Success"] = $"Página '{page.DisplayName}' rejeitada.";
|
}
|
||||||
return RedirectToAction("Dashboard");
|
|
||||||
}
|
TempData["Success"] = $"Página '{page.DisplayName}' rejeitada.";
|
||||||
catch (Exception ex)
|
return RedirectToAction("Dashboard");
|
||||||
{
|
}
|
||||||
_logger.LogError(ex, "Error rejecting page {PageId}", id);
|
catch (Exception ex)
|
||||||
TempData["Error"] = "Erro ao rejeitar página.";
|
{
|
||||||
return RedirectToAction("Review", new { id });
|
_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)
|
|
||||||
{
|
[HttpGet("History")]
|
||||||
var skip = (page - 1) * size;
|
public async Task<IActionResult> History(int page = 1, int size = 20)
|
||||||
var historyPages = await _moderationService.GetModerationHistoryAsync(skip, size);
|
{
|
||||||
|
var skip = (page - 1) * size;
|
||||||
var viewModel = new ModerationHistoryViewModel
|
var historyPages = await _moderationService.GetModerationHistoryAsync(skip, size);
|
||||||
{
|
|
||||||
Pages = historyPages.Select(p => new ModerationPageViewModel
|
var viewModel = new ModerationHistoryViewModel
|
||||||
{
|
{
|
||||||
Id = p.Id,
|
Pages = historyPages.Select(p => new ModerationPageViewModel
|
||||||
DisplayName = p.DisplayName,
|
{
|
||||||
Category = p.Category,
|
Id = p.Id,
|
||||||
Slug = p.Slug,
|
DisplayName = p.DisplayName,
|
||||||
CreatedAt = p.CreatedAt,
|
Category = p.Category,
|
||||||
Status = p.Status.ToString(),
|
Slug = p.Slug,
|
||||||
ModerationAttempts = p.ModerationAttempts,
|
CreatedAt = p.CreatedAt,
|
||||||
PlanType = p.PlanLimitations.PlanType.ToString(),
|
Status = p.Status.ToString(),
|
||||||
ApprovedAt = p.ApprovedAt,
|
ModerationAttempts = p.ModerationAttempts,
|
||||||
LastModerationEntry = p.ModerationHistory.LastOrDefault()
|
PlanType = p.PlanLimitations.PlanType.ToString(),
|
||||||
}).ToList(),
|
ApprovedAt = p.ApprovedAt,
|
||||||
CurrentPage = page,
|
LastModerationEntry = p.ModerationHistory.LastOrDefault()
|
||||||
PageSize = size,
|
}).ToList(),
|
||||||
HasNextPage = historyPages.Count == size
|
CurrentPage = page,
|
||||||
};
|
PageSize = size,
|
||||||
|
HasNextPage = historyPages.Count == size
|
||||||
return View(viewModel);
|
};
|
||||||
}
|
|
||||||
|
return View(viewModel);
|
||||||
private List<ModerationCriterion> GetModerationCriteria()
|
}
|
||||||
{
|
|
||||||
return new List<ModerationCriterion>
|
private List<ModerationCriterion> GetModerationCriteria()
|
||||||
{
|
{
|
||||||
new() { Category = "Conteúdo Proibido", Items = new List<string>
|
return new List<ModerationCriterion>
|
||||||
{
|
{
|
||||||
"Pornografia e conteúdo sexual explícito",
|
new() { Category = "Conteúdo Proibido", Items = new List<string>
|
||||||
"Drogas ilegais e substâncias controladas",
|
{
|
||||||
"Armas e explosivos",
|
"Pornografia e conteúdo sexual explícito",
|
||||||
"Atividades ilegais (fraudes, pirataria)",
|
"Drogas ilegais e substâncias controladas",
|
||||||
"Apostas e jogos de azar",
|
"Armas e explosivos",
|
||||||
"Criptomoedas e esquemas de pirâmide",
|
"Atividades ilegais (fraudes, pirataria)",
|
||||||
"Conteúdo que promove violência ou ódio",
|
"Apostas e jogos de azar",
|
||||||
"Spam e links suspeitos/maliciosos"
|
"Criptomoedas e esquemas de pirâmide",
|
||||||
}},
|
"Conteúdo que promove violência ou ódio",
|
||||||
new() { Category = "Conteúdo Suspeito", Items = new List<string>
|
"Spam e links suspeitos/maliciosos"
|
||||||
{
|
}},
|
||||||
"Excesso de anúncios (>30% dos links)",
|
new() { Category = "Conteúdo Suspeito", Items = new List<string>
|
||||||
"Sites com pop-ups excessivos",
|
{
|
||||||
"Links encurtados suspeitos",
|
"Excesso de anúncios (>30% dos links)",
|
||||||
"Conteúdo que imita marcas conhecidas",
|
"Sites com pop-ups excessivos",
|
||||||
"Produtos \"milagrosos\""
|
"Links encurtados suspeitos",
|
||||||
}},
|
"Conteúdo que imita marcas conhecidas",
|
||||||
new() { Category = "Verificações Técnicas", Items = new List<string>
|
"Produtos \"milagrosos\""
|
||||||
{
|
}},
|
||||||
"Links funcionais (não quebrados)",
|
new() { Category = "Verificações Técnicas", Items = new List<string>
|
||||||
"Sites com SSL válido",
|
{
|
||||||
"Não redirecionamentos maliciosos"
|
"Links funcionais (não quebrados)",
|
||||||
}}
|
"Sites com SSL válido",
|
||||||
};
|
"Não redirecionamentos maliciosos"
|
||||||
}
|
}}
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@ -1,273 +1,279 @@
|
|||||||
using BCards.Web.Models;
|
using BCards.Web.Models;
|
||||||
using BCards.Web.Repositories;
|
using BCards.Web.Repositories;
|
||||||
using BCards.Web.Services;
|
using BCards.Web.Services;
|
||||||
using BCards.Web.ViewModels;
|
using BCards.Web.ViewModels;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using BCards.Web.Utils;
|
||||||
using Microsoft.AspNetCore.Identity;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Identity;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
namespace BCards.Web.Controllers;
|
using System.Globalization;
|
||||||
|
|
||||||
[Authorize]
|
namespace BCards.Web.Controllers;
|
||||||
public class PaymentController : Controller
|
|
||||||
{
|
[Authorize]
|
||||||
private readonly IPaymentService _paymentService;
|
public class PaymentController : Controller
|
||||||
private readonly IAuthService _authService;
|
{
|
||||||
private readonly IUserRepository _userService;
|
private readonly IPaymentService _paymentService;
|
||||||
private readonly ISubscriptionRepository _subscriptionRepository;
|
private readonly IAuthService _authService;
|
||||||
|
private readonly IUserRepository _userService;
|
||||||
public PaymentController(IPaymentService paymentService, IAuthService authService, IUserRepository userService, ISubscriptionRepository subscriptionRepository)
|
private readonly ISubscriptionRepository _subscriptionRepository;
|
||||||
{
|
private readonly IConfiguration _configuration;
|
||||||
_paymentService = paymentService;
|
|
||||||
_authService = authService;
|
public PaymentController(IPaymentService paymentService, IAuthService authService, IUserRepository userService, ISubscriptionRepository subscriptionRepository, IConfiguration configuration)
|
||||||
_userService = userService;
|
{
|
||||||
_subscriptionRepository = subscriptionRepository;
|
_paymentService = paymentService;
|
||||||
}
|
_authService = authService;
|
||||||
|
_userService = userService;
|
||||||
[HttpPost]
|
_subscriptionRepository = subscriptionRepository;
|
||||||
public async Task<IActionResult> CreateCheckoutSession(string planType)
|
_configuration = configuration;
|
||||||
{
|
}
|
||||||
var user = await _authService.GetCurrentUserAsync(User);
|
|
||||||
if (user == null)
|
[HttpPost]
|
||||||
return RedirectToAction("Login", "Auth");
|
public async Task<IActionResult> CreateCheckoutSession(string planType)
|
||||||
|
{
|
||||||
var successUrl = Url.Action("Success", "Payment", null, Request.Scheme);
|
var user = await _authService.GetCurrentUserAsync(User);
|
||||||
var cancelUrl = Url.Action("Cancel", "Payment", null, Request.Scheme);
|
if (user == null)
|
||||||
|
return RedirectToAction("Login", "Auth");
|
||||||
TempData[$"PlanType|{user.Id}"] = planType;
|
|
||||||
|
var successUrl = Url.Action("Success", "Payment", null, Request.Scheme);
|
||||||
try
|
var cancelUrl = Url.Action("Cancel", "Payment", null, Request.Scheme);
|
||||||
{
|
|
||||||
var checkoutUrl = await _paymentService.CreateCheckoutSessionAsync(
|
TempData[$"PlanType|{user.Id}"] = planType;
|
||||||
user.Id,
|
|
||||||
planType,
|
try
|
||||||
successUrl!,
|
{
|
||||||
cancelUrl!);
|
var checkoutUrl = await _paymentService.CreateCheckoutSessionAsync(
|
||||||
|
user.Id,
|
||||||
return Redirect(checkoutUrl);
|
planType,
|
||||||
}
|
successUrl!,
|
||||||
catch (Exception ex)
|
cancelUrl!);
|
||||||
{
|
|
||||||
TempData["Error"] = $"Erro ao processar pagamento: {ex.Message}";
|
return Redirect(checkoutUrl);
|
||||||
return RedirectToAction("Pricing", "Home");
|
}
|
||||||
}
|
catch (Exception ex)
|
||||||
}
|
{
|
||||||
|
TempData["Error"] = $"Erro ao processar pagamento: {ex.Message}";
|
||||||
public async Task<IActionResult> Success()
|
return RedirectToAction("Pricing", "Home");
|
||||||
{
|
}
|
||||||
var user = await _authService.GetCurrentUserAsync(User);
|
}
|
||||||
var planType = TempData[$"PlanType|{user.Id}"].ToString();
|
|
||||||
|
public async Task<IActionResult> Success()
|
||||||
try
|
{
|
||||||
{
|
try
|
||||||
if (!string.IsNullOrEmpty(planType) && Enum.TryParse<PlanType>(planType, out var plan))
|
{
|
||||||
{
|
var user = await _authService.GetCurrentUserAsync(User);
|
||||||
user.CurrentPlan = plan.ToString();
|
var planType = TempData[$"PlanType|{user.Id}"].ToString();
|
||||||
user.SubscriptionStatus = "active";
|
|
||||||
await _userService.UpdateAsync(user); // ou o método equivalente
|
TempData["Success"] = $"Assinatura {planType} ativada com sucesso!";
|
||||||
|
return RedirectToAction("Dashboard", "Admin");
|
||||||
TempData["Success"] = $"Assinatura {planType} ativada com sucesso!";
|
}
|
||||||
}
|
catch (Exception ex)
|
||||||
|
{
|
||||||
return RedirectToAction("Dashboard", "Admin");
|
TempData["Error"] = $"Erro ao processar pagamento: {ex.Message}";
|
||||||
}
|
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");
|
||||||
public IActionResult Cancel()
|
}
|
||||||
{
|
|
||||||
TempData["Info"] = "Pagamento cancelado. Você pode tentar novamente quando quiser.";
|
[HttpPost]
|
||||||
return RedirectToAction("Pricing", "Home");
|
[Route("webhook/stripe")]
|
||||||
}
|
[AllowAnonymous]
|
||||||
|
public async Task<IActionResult> StripeWebhook()
|
||||||
[HttpPost]
|
{
|
||||||
[Route("webhook/stripe")]
|
var signature = Request.Headers["Stripe-Signature"].FirstOrDefault();
|
||||||
[AllowAnonymous]
|
if (string.IsNullOrEmpty(signature))
|
||||||
public async Task<IActionResult> StripeWebhook()
|
return BadRequest();
|
||||||
{
|
|
||||||
var signature = Request.Headers["Stripe-Signature"].FirstOrDefault();
|
string requestBody;
|
||||||
if (string.IsNullOrEmpty(signature))
|
using (var reader = new StreamReader(Request.Body))
|
||||||
return BadRequest();
|
{
|
||||||
|
requestBody = await reader.ReadToEndAsync();
|
||||||
string requestBody;
|
}
|
||||||
using (var reader = new StreamReader(Request.Body))
|
|
||||||
{
|
try
|
||||||
requestBody = await reader.ReadToEndAsync();
|
{
|
||||||
}
|
await _paymentService.HandleWebhookAsync(requestBody, signature);
|
||||||
|
return Ok();
|
||||||
try
|
}
|
||||||
{
|
catch (Exception ex)
|
||||||
await _paymentService.HandleWebhookAsync(requestBody, signature);
|
{
|
||||||
return Ok();
|
return BadRequest($"Webhook error: {ex.Message}");
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
}
|
||||||
{
|
|
||||||
return BadRequest($"Webhook error: {ex.Message}");
|
public async Task<IActionResult> ManageSubscription()
|
||||||
}
|
{
|
||||||
}
|
var user = await _authService.GetCurrentUserAsync(User);
|
||||||
|
if (user == null)
|
||||||
public async Task<IActionResult> ManageSubscription()
|
return RedirectToAction("Login", "Auth");
|
||||||
{
|
|
||||||
var user = await _authService.GetCurrentUserAsync(User);
|
try
|
||||||
if (user == null)
|
{
|
||||||
return RedirectToAction("Login", "Auth");
|
// Parse do plano atual (mesmo que o Dashboard)
|
||||||
|
var userPlanType = Enum.TryParse<PlanType>(user.CurrentPlan, true, out var planType) ? planType : PlanType.Trial;
|
||||||
try
|
var currentPlanString = userPlanType.ToString().ToLower();
|
||||||
{
|
var subscription = await _subscriptionRepository.GetByUserIdAsync(user.Id);
|
||||||
var viewModel = new ManageSubscriptionViewModel
|
var viewModel = new ManageSubscriptionViewModel
|
||||||
{
|
{
|
||||||
User = user,
|
User = user,
|
||||||
StripeSubscription = await _paymentService.GetSubscriptionDetailsAsync(user.Id),
|
StripeSubscription = await _paymentService.GetSubscriptionDetailsAsync(user.Id),
|
||||||
PaymentHistory = await _paymentService.GetPaymentHistoryAsync(user.Id),
|
PaymentHistory = await _paymentService.GetPaymentHistoryAsync(user.Id),
|
||||||
AvailablePlans = GetAvailablePlans(user.CurrentPlan)
|
AvailablePlans = GetAvailablePlans(currentPlanString),
|
||||||
};
|
CurrentPeriodEnd = (DateTime?) subscription.CurrentPeriodEnd
|
||||||
|
};
|
||||||
// Pegar assinatura local se existir
|
|
||||||
if (!string.IsNullOrEmpty(user.StripeCustomerId))
|
// 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);
|
// Aqui você poderia buscar a subscription local se necessário
|
||||||
}
|
// viewModel.LocalSubscription = await _subscriptionRepository.GetByUserIdAsync(user.Id);
|
||||||
|
}
|
||||||
return View(viewModel);
|
|
||||||
}
|
return View(viewModel);
|
||||||
catch (Exception ex)
|
}
|
||||||
{
|
catch (Exception ex)
|
||||||
var errorViewModel = new ManageSubscriptionViewModel
|
{
|
||||||
{
|
// Parse do plano atual também no catch
|
||||||
User = user,
|
var userPlanType = Enum.TryParse<PlanType>(user.CurrentPlan, true, out var planType) ? planType : PlanType.Trial;
|
||||||
ErrorMessage = $"Erro ao carregar dados da assinatura: {ex.Message}",
|
var currentPlanString = userPlanType.ToString().ToLower();
|
||||||
AvailablePlans = GetAvailablePlans(user.CurrentPlan)
|
|
||||||
};
|
var errorViewModel = new ManageSubscriptionViewModel
|
||||||
|
{
|
||||||
return View(errorViewModel);
|
User = user,
|
||||||
}
|
ErrorMessage = $"Erro ao carregar dados da assinatura: {ex.Message}",
|
||||||
}
|
AvailablePlans = GetAvailablePlans(currentPlanString)
|
||||||
|
};
|
||||||
[HttpPost]
|
|
||||||
public async Task<IActionResult> CancelSubscription(string subscriptionId)
|
return View(errorViewModel);
|
||||||
{
|
}
|
||||||
try
|
}
|
||||||
{
|
|
||||||
await _paymentService.CancelSubscriptionAsync(subscriptionId);
|
[HttpPost]
|
||||||
TempData["Success"] = "Sua assinatura será cancelada no final do período atual.";
|
public async Task<IActionResult> CancelSubscription(string subscriptionId)
|
||||||
}
|
{
|
||||||
catch (Exception ex)
|
try
|
||||||
{
|
{
|
||||||
TempData["Error"] = $"Erro ao cancelar assinatura: {ex.Message}";
|
await _paymentService.CancelSubscriptionAsync(subscriptionId);
|
||||||
}
|
TempData["Success"] = "Sua assinatura será cancelada no final do período atual.";
|
||||||
|
}
|
||||||
return RedirectToAction("ManageSubscription");
|
catch (Exception ex)
|
||||||
}
|
{
|
||||||
|
TempData["Error"] = $"Erro ao cancelar assinatura: {ex.Message}";
|
||||||
[HttpPost]
|
}
|
||||||
public async Task<IActionResult> ChangePlan(string newPlanType)
|
|
||||||
{
|
return RedirectToAction("ManageSubscription");
|
||||||
var user = await _authService.GetCurrentUserAsync(User);
|
}
|
||||||
if (user == null)
|
|
||||||
return RedirectToAction("Login", "Auth");
|
[HttpPost]
|
||||||
|
public async Task<IActionResult> ChangePlan(string newPlanType)
|
||||||
try
|
{
|
||||||
{
|
var user = await _authService.GetCurrentUserAsync(User);
|
||||||
// Para mudanças de plano, vamos usar o Stripe Checkout
|
if (user == null)
|
||||||
var returnUrl = Url.Action("ManageSubscription", "Payment", null, Request.Scheme);
|
return RedirectToAction("Login", "Auth");
|
||||||
var cancelUrl = Url.Action("ManageSubscription", "Payment", null, Request.Scheme);
|
|
||||||
|
try
|
||||||
var checkoutUrl = await _paymentService.CreateCheckoutSessionAsync(
|
{
|
||||||
user.Id,
|
// Para mudanças de plano, vamos usar o Stripe Checkout
|
||||||
newPlanType,
|
var returnUrl = Url.Action("ManageSubscription", "Payment", null, Request.Scheme);
|
||||||
returnUrl!,
|
var cancelUrl = Url.Action("ManageSubscription", "Payment", null, Request.Scheme);
|
||||||
cancelUrl!);
|
|
||||||
|
var checkoutUrl = await _paymentService.CreateCheckoutSessionAsync(
|
||||||
return Redirect(checkoutUrl);
|
user.Id,
|
||||||
}
|
newPlanType,
|
||||||
catch (Exception ex)
|
returnUrl!,
|
||||||
{
|
cancelUrl!);
|
||||||
TempData["Error"] = $"Erro ao alterar plano: {ex.Message}";
|
|
||||||
return RedirectToAction("ManageSubscription");
|
return Redirect(checkoutUrl);
|
||||||
}
|
}
|
||||||
}
|
catch (Exception ex)
|
||||||
|
{
|
||||||
[HttpPost]
|
TempData["Error"] = $"Erro ao alterar plano: {ex.Message}";
|
||||||
public async Task<IActionResult> OpenStripePortal()
|
return RedirectToAction("ManageSubscription");
|
||||||
{
|
}
|
||||||
var user = await _authService.GetCurrentUserAsync(User);
|
}
|
||||||
if (user == null || string.IsNullOrEmpty(user.StripeCustomerId))
|
|
||||||
{
|
[HttpPost]
|
||||||
TempData["Error"] = "Erro: dados de assinatura não encontrados.";
|
public async Task<IActionResult> OpenStripePortal()
|
||||||
return RedirectToAction("ManageSubscription");
|
{
|
||||||
}
|
var user = await _authService.GetCurrentUserAsync(User);
|
||||||
|
if (user == null || string.IsNullOrEmpty(user.StripeCustomerId))
|
||||||
try
|
{
|
||||||
{
|
TempData["Error"] = "Erro: dados de assinatura não encontrados.";
|
||||||
var returnUrl = Url.Action("ManageSubscription", "Payment", null, Request.Scheme);
|
return RedirectToAction("ManageSubscription");
|
||||||
var portalUrl = await _paymentService.CreatePortalSessionAsync(user.StripeCustomerId, returnUrl!);
|
}
|
||||||
|
|
||||||
return Redirect(portalUrl);
|
try
|
||||||
}
|
{
|
||||||
catch (Exception ex)
|
var returnUrl = Url.Action("ManageSubscription", "Payment", null, Request.Scheme);
|
||||||
{
|
var portalUrl = await _paymentService.CreatePortalSessionAsync(user.StripeCustomerId, returnUrl!);
|
||||||
TempData["Error"] = $"Erro ao abrir portal de pagamento: {ex.Message}";
|
|
||||||
return RedirectToAction("ManageSubscription");
|
return Redirect(portalUrl);
|
||||||
}
|
}
|
||||||
}
|
catch (Exception ex)
|
||||||
|
{
|
||||||
private List<AvailablePlanViewModel> GetAvailablePlans(string currentPlan)
|
TempData["Error"] = $"Erro ao abrir portal de pagamento: {ex.Message}";
|
||||||
{
|
return RedirectToAction("ManageSubscription");
|
||||||
var plans = new List<AvailablePlanViewModel>
|
}
|
||||||
{
|
}
|
||||||
new()
|
|
||||||
{
|
private List<AvailablePlanViewModel> GetAvailablePlans(string currentPlan)
|
||||||
PlanType = "basic",
|
{
|
||||||
DisplayName = "Básico",
|
var plansConfig = _configuration.GetSection("Plans");
|
||||||
Price = 9.90m,
|
var plans = new List<AvailablePlanViewModel>();
|
||||||
PriceId = "price_basic", // Substitua pelos IDs reais do Stripe
|
|
||||||
MaxLinks = 5,
|
// Adicionar planos mensais apenas (excluir Trial e planos anuais)
|
||||||
AllowAnalytics = true,
|
var monthlyPlans = new[] { "Basic", "Professional", "Premium", "PremiumAffiliate" };
|
||||||
Features = new List<string> { "5 links", "Temas básicos", "Análises básicas" },
|
|
||||||
IsCurrentPlan = currentPlan == "basic"
|
foreach (var planKey in monthlyPlans)
|
||||||
},
|
{
|
||||||
new()
|
var planSection = plansConfig.GetSection(planKey);
|
||||||
{
|
if (planSection.Exists())
|
||||||
PlanType = "professional",
|
{
|
||||||
DisplayName = "Profissional",
|
plans.Add(new AvailablePlanViewModel
|
||||||
Price = 24.90m,
|
{
|
||||||
PriceId = "price_professional", // Substitua pelos IDs reais do Stripe
|
PlanType = planKey.ToLower(),
|
||||||
MaxLinks = 15,
|
DisplayName = planSection["Name"] ?? planKey,
|
||||||
AllowAnalytics = true,
|
Price = decimal.Parse(planSection["Price"] ?? "0", new CultureInfo("en-US")),
|
||||||
AllowCustomDomain = true,
|
PriceId = planSection["PriceId"] ?? "",
|
||||||
Features = new List<string> { "15 links", "Todos os temas", "Domínio personalizado", "Análises avançadas" },
|
MaxLinks = int.Parse(planSection["MaxLinks"] ?? "0"),
|
||||||
IsCurrentPlan = currentPlan == "professional"
|
AllowAnalytics = bool.Parse(planSection["AllowAnalytics"] ?? "false"),
|
||||||
},
|
AllowCustomDomain = true, // URL personalizada em todos os planos pagos
|
||||||
new()
|
AllowCustomThemes = bool.Parse(planSection["AllowPremiumThemes"] ?? "false"),
|
||||||
{
|
AllowProductLinks = bool.Parse(planSection["AllowProductLinks"] ?? "false"),
|
||||||
PlanType = "premium",
|
Features = planSection.GetSection("Features").Get<List<string>>() ?? new List<string>(),
|
||||||
DisplayName = "Premium",
|
IsCurrentPlan = currentPlan.Equals(planKey, StringComparison.OrdinalIgnoreCase)
|
||||||
Price = 29.90m,
|
});
|
||||||
PriceId = "price_premium", // Substitua pelos IDs reais do Stripe
|
}
|
||||||
MaxLinks = -1, // Ilimitado
|
}
|
||||||
AllowCustomThemes = true,
|
|
||||||
AllowAnalytics = true,
|
// Marcar upgrades e filtrar downgrades
|
||||||
AllowCustomDomain = true,
|
var currentPlanIndex = plans.FindIndex(p => p.IsCurrentPlan);
|
||||||
Features = new List<string> { "Links ilimitados", "Temas personalizados", "Múltiplos domínios", "Suporte prioritário" },
|
|
||||||
IsCurrentPlan = currentPlan == "premium"
|
// 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)
|
||||||
// Marcar upgrades e downgrades
|
{
|
||||||
var currentPlanIndex = plans.FindIndex(p => p.IsCurrentPlan);
|
plan.IsUpgrade = true;
|
||||||
for (int i = 0; i < plans.Count; i++)
|
}
|
||||||
{
|
return plans; // Mostrar todos os planos pagos como upgrade
|
||||||
if (i > currentPlanIndex)
|
}
|
||||||
plans[i].IsUpgrade = true;
|
|
||||||
else if (i < currentPlanIndex)
|
// Para planos pagos, marcar apenas upgrades superiores
|
||||||
plans[i].IsDowngrade = true;
|
for (int i = 0; i < plans.Count; i++)
|
||||||
}
|
{
|
||||||
|
if (i > currentPlanIndex)
|
||||||
return plans;
|
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.Models;
|
||||||
using BCards.Web.Services;
|
using BCards.Web.Services;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using System.Security.Claims;
|
using System.Security.Claims;
|
||||||
|
|
||||||
namespace BCards.Web.Controllers;
|
namespace BCards.Web.Controllers;
|
||||||
|
|
||||||
[Authorize]
|
[Authorize]
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Route("api/[controller]")]
|
[Route("api/[controller]")]
|
||||||
public class ProductController : ControllerBase
|
public class ProductController : ControllerBase
|
||||||
{
|
{
|
||||||
private readonly IOpenGraphService _openGraphService;
|
private readonly IOpenGraphService _openGraphService;
|
||||||
private readonly ILogger<ProductController> _logger;
|
private readonly ILogger<ProductController> _logger;
|
||||||
|
|
||||||
public ProductController(
|
public ProductController(
|
||||||
IOpenGraphService openGraphService,
|
IOpenGraphService openGraphService,
|
||||||
ILogger<ProductController> logger)
|
ILogger<ProductController> logger)
|
||||||
{
|
{
|
||||||
_openGraphService = openGraphService;
|
_openGraphService = openGraphService;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("extract")]
|
[HttpPost("extract")]
|
||||||
public async Task<IActionResult> ExtractProduct([FromBody] ExtractProductRequest request)
|
public async Task<IActionResult> ExtractProduct([FromBody] ExtractProductRequest request)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(request.Url))
|
if (string.IsNullOrWhiteSpace(request.Url))
|
||||||
{
|
{
|
||||||
return BadRequest(new { success = false, message = "URL é obrigatória." });
|
return BadRequest(new { success = false, message = "URL é obrigatória." });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!Uri.TryCreate(request.Url, UriKind.Absolute, out var uri))
|
if (!Uri.TryCreate(request.Url, UriKind.Absolute, out var uri))
|
||||||
{
|
{
|
||||||
return BadRequest(new { success = false, message = "URL inválida." });
|
return BadRequest(new { success = false, message = "URL inválida." });
|
||||||
}
|
}
|
||||||
|
|
||||||
var userId = User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
|
var userId = User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
|
||||||
if (string.IsNullOrEmpty(userId))
|
if (string.IsNullOrEmpty(userId))
|
||||||
{
|
{
|
||||||
return Unauthorized(new { success = false, message = "Usuário não autenticado." });
|
return Unauthorized(new { success = false, message = "Usuário não autenticado." });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verificar rate limiting antes de tentar extrair
|
// Verificar rate limiting antes de tentar extrair
|
||||||
var isRateLimited = await _openGraphService.IsRateLimitedAsync(userId);
|
var isRateLimited = await _openGraphService.IsRateLimitedAsync(userId);
|
||||||
if (isRateLimited)
|
if (isRateLimited)
|
||||||
{
|
{
|
||||||
return this.TooManyRequests(new {
|
return this.TooManyRequests(new {
|
||||||
success = false,
|
success = false,
|
||||||
message = "Aguarde 1 minuto antes de extrair dados de outro produto."
|
message = "Aguarde 1 minuto antes de extrair dados de outro produto."
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
var ogData = await _openGraphService.ExtractDataAsync(request.Url, userId);
|
var ogData = await _openGraphService.ExtractDataAsync(request.Url, userId);
|
||||||
|
|
||||||
if (!ogData.IsValid)
|
if (!ogData.IsValid)
|
||||||
{
|
{
|
||||||
return BadRequest(new {
|
return BadRequest(new {
|
||||||
success = false,
|
success = false,
|
||||||
message = string.IsNullOrEmpty(ogData.ErrorMessage)
|
message = string.IsNullOrEmpty(ogData.ErrorMessage)
|
||||||
? "Não foi possível extrair dados desta página."
|
? "Não foi possível extrair dados desta página."
|
||||||
: ogData.ErrorMessage
|
: ogData.ErrorMessage
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return Ok(new {
|
return Ok(new {
|
||||||
success = true,
|
success = true,
|
||||||
title = ogData.Title,
|
title = ogData.Title,
|
||||||
description = ogData.Description,
|
description = ogData.Description,
|
||||||
image = ogData.Image,
|
image = ogData.Image,
|
||||||
price = ogData.Price,
|
price = ogData.Price,
|
||||||
currency = ogData.Currency
|
currency = ogData.Currency
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
catch (InvalidOperationException ex)
|
catch (InvalidOperationException ex)
|
||||||
{
|
{
|
||||||
_logger.LogWarning(ex, "Operação inválida na extração de produto para usuário {UserId}",
|
_logger.LogWarning(ex, "Operação inválida na extração de produto para usuário {UserId}",
|
||||||
User.FindFirst(ClaimTypes.NameIdentifier)?.Value);
|
User.FindFirst(ClaimTypes.NameIdentifier)?.Value);
|
||||||
return BadRequest(new { success = false, message = ex.Message });
|
return BadRequest(new { success = false, message = ex.Message });
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger.LogError(ex, "Erro interno na extração de produto para usuário {UserId}",
|
_logger.LogError(ex, "Erro interno na extração de produto para usuário {UserId}",
|
||||||
User.FindFirst(ClaimTypes.NameIdentifier)?.Value);
|
User.FindFirst(ClaimTypes.NameIdentifier)?.Value);
|
||||||
return StatusCode(500, new {
|
return StatusCode(500, new {
|
||||||
success = false,
|
success = false,
|
||||||
message = "Erro interno do servidor. Tente novamente em alguns instantes."
|
message = "Erro interno do servidor. Tente novamente em alguns instantes."
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("cache/{urlHash}")]
|
[HttpGet("cache/{urlHash}")]
|
||||||
public Task<IActionResult> GetCachedData(string urlHash)
|
public Task<IActionResult> GetCachedData(string urlHash)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
// Por segurança, vamos reconstruir a URL a partir do hash (se necessário)
|
// Por segurança, vamos reconstruir a URL a partir do hash (se necessário)
|
||||||
// Por agora, apenas retornamos erro se não encontrado
|
// Por agora, apenas retornamos erro se não encontrado
|
||||||
return Task.FromResult<IActionResult>(NotFound(new { success = false, message = "Cache não encontrado." }));
|
return Task.FromResult<IActionResult>(NotFound(new { success = false, message = "Cache não encontrado." }));
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger.LogError(ex, "Erro ao buscar cache para hash {UrlHash}", urlHash);
|
_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." }));
|
return Task.FromResult<IActionResult>(StatusCode(500, new { success = false, message = "Erro interno do servidor." }));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public class ExtractProductRequest
|
public class ExtractProductRequest
|
||||||
{
|
{
|
||||||
public string Url { get; set; } = string.Empty;
|
public string Url { get; set; } = string.Empty;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Custom result for 429 Too Many Requests
|
// Custom result for 429 Too Many Requests
|
||||||
public class TooManyRequestsResult : ObjectResult
|
public class TooManyRequestsResult : ObjectResult
|
||||||
{
|
{
|
||||||
public TooManyRequestsResult(object value) : base(value)
|
public TooManyRequestsResult(object value) : base(value)
|
||||||
{
|
{
|
||||||
StatusCode = 429;
|
StatusCode = 429;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public static class ControllerBaseExtensions
|
public static class ControllerBaseExtensions
|
||||||
{
|
{
|
||||||
public static TooManyRequestsResult TooManyRequests(this ControllerBase controller, object value)
|
public static TooManyRequestsResult TooManyRequests(this ControllerBase controller, object value)
|
||||||
{
|
{
|
||||||
return new TooManyRequestsResult(value);
|
return new TooManyRequestsResult(value);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -1,92 +1,96 @@
|
|||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
using System.Xml.Linq;
|
using System.Xml.Linq;
|
||||||
using BCards.Web.Services;
|
using BCards.Web.Services;
|
||||||
|
|
||||||
namespace BCards.Web.Controllers;
|
namespace BCards.Web.Controllers;
|
||||||
|
|
||||||
public class SitemapController : Controller
|
public class SitemapController : Controller
|
||||||
{
|
{
|
||||||
private readonly IUserPageService _userPageService;
|
private readonly IUserPageService _userPageService;
|
||||||
private readonly ILivePageService _livePageService;
|
private readonly ILivePageService _livePageService;
|
||||||
private readonly ILogger<SitemapController> _logger;
|
private readonly ILogger<SitemapController> _logger;
|
||||||
|
|
||||||
public SitemapController(
|
public SitemapController(
|
||||||
IUserPageService userPageService,
|
IUserPageService userPageService,
|
||||||
ILivePageService livePageService,
|
ILivePageService livePageService,
|
||||||
ILogger<SitemapController> logger)
|
ILogger<SitemapController> logger)
|
||||||
{
|
{
|
||||||
_userPageService = userPageService;
|
_userPageService = userPageService;
|
||||||
_livePageService = livePageService;
|
_livePageService = livePageService;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
}
|
}
|
||||||
|
|
||||||
[Route("sitemap.xml")]
|
[Route("sitemap.xml")]
|
||||||
[ResponseCache(Duration = 86400)] // Cache for 24 hours
|
[ResponseCache(Duration = 86400)] // Cache for 24 hours
|
||||||
public async Task<IActionResult> Index()
|
public async Task<IActionResult> Index()
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
// 🔥 NOVA FUNCIONALIDADE: Usar LivePages em vez de UserPages
|
// 🔥 NOVA FUNCIONALIDADE: Usar LivePages em vez de UserPages
|
||||||
var livePages = await _livePageService.GetAllActiveAsync();
|
var livePages = await _livePageService.GetAllActiveAsync();
|
||||||
|
|
||||||
var sitemap = new XDocument(
|
// Define namespace corretamente para evitar conflitos
|
||||||
new XDeclaration("1.0", "utf-8", "yes"),
|
XNamespace ns = "http://www.sitemaps.org/schemas/sitemap/0.9";
|
||||||
new XElement("urlset",
|
|
||||||
new XAttribute("xmlns", "http://www.sitemaps.org/schemas/sitemap/0.9"),
|
// Construir URLs das páginas dinâmicas separadamente para evitar problemas
|
||||||
|
var dynamicUrls = livePages.Select(page =>
|
||||||
// Add static pages
|
new XElement(ns + "url",
|
||||||
new XElement("url",
|
new XElement(ns + "loc", $"{Request.Scheme}://{Request.Host}/page/{page.Category?.Replace(" ", "-")?.ToLower()}/{page.Slug}"),
|
||||||
new XElement("loc", $"{Request.Scheme}://{Request.Host}/"),
|
new XElement(ns + "lastmod", page.LastSyncAt.ToString("yyyy-MM-dd")),
|
||||||
new XElement("lastmod", DateTime.UtcNow.ToString("yyyy-MM-dd")),
|
new XElement(ns + "changefreq", "weekly"),
|
||||||
new XElement("changefreq", "daily"),
|
new XElement(ns + "priority", "0.8")
|
||||||
new XElement("priority", "1.0")
|
)
|
||||||
),
|
).ToList();
|
||||||
new XElement("url",
|
|
||||||
new XElement("loc", $"{Request.Scheme}://{Request.Host}/Home/Pricing"),
|
var sitemap = new XDocument(
|
||||||
new XElement("lastmod", DateTime.UtcNow.ToString("yyyy-MM-dd")),
|
new XDeclaration("1.0", "utf-8", "yes"),
|
||||||
new XElement("changefreq", "weekly"),
|
new XElement(ns + "urlset",
|
||||||
new XElement("priority", "0.9")
|
// Add static pages
|
||||||
),
|
new XElement(ns + "url",
|
||||||
|
new XElement(ns + "loc", $"{Request.Scheme}://{Request.Host}/"),
|
||||||
// Add live pages (SEO-optimized URLs only)
|
new XElement(ns + "lastmod", DateTime.UtcNow.ToString("yyyy-MM-dd")),
|
||||||
livePages.Select(page =>
|
new XElement(ns + "changefreq", "daily"),
|
||||||
new XElement("url",
|
new XElement(ns + "priority", "1.0")
|
||||||
new XElement("loc", $"{Request.Scheme}://{Request.Host}/page/{page.Category}/{page.Slug}"),
|
),
|
||||||
new XElement("lastmod", page.LastSyncAt.ToString("yyyy-MM-dd")),
|
new XElement(ns + "url",
|
||||||
new XElement("changefreq", "weekly"),
|
new XElement(ns + "loc", $"{Request.Scheme}://{Request.Host}/Home/Pricing"),
|
||||||
new XElement("priority", "0.8")
|
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)
|
||||||
_logger.LogInformation($"Generated sitemap with {livePages.Count} live pages");
|
dynamicUrls
|
||||||
|
)
|
||||||
return Content(sitemap.ToString(), "application/xml", Encoding.UTF8);
|
);
|
||||||
}
|
|
||||||
catch (Exception ex)
|
_logger.LogInformation($"Generated sitemap with {livePages.Count} live pages");
|
||||||
{
|
|
||||||
_logger.LogError(ex, "Error generating sitemap");
|
return Content(sitemap.ToString(SaveOptions.DisableFormatting), "application/xml", Encoding.UTF8);
|
||||||
return StatusCode(500, "Error generating sitemap");
|
}
|
||||||
}
|
catch (Exception ex)
|
||||||
}
|
{
|
||||||
|
_logger.LogError(ex, "Error generating sitemap");
|
||||||
[Route("robots.txt")]
|
return StatusCode(500, "Error generating sitemap");
|
||||||
[ResponseCache(Duration = 86400)] // Cache for 24 hours
|
}
|
||||||
public IActionResult RobotsTxt()
|
}
|
||||||
{
|
|
||||||
var robotsTxt = $@"User-agent: *
|
[Route("robots.txt")]
|
||||||
Allow: /
|
[ResponseCache(Duration = 86400)] // Cache for 24 hours
|
||||||
Allow: /page/
|
public IActionResult RobotsTxt()
|
||||||
|
{
|
||||||
Disallow: /Admin/
|
var robotsTxt = $@"User-agent: *
|
||||||
Disallow: /Auth/
|
Allow: /
|
||||||
Disallow: /Payment/
|
Allow: /page/
|
||||||
Disallow: /api/
|
|
||||||
|
Disallow: /Admin/
|
||||||
Sitemap: {Request.Scheme}://{Request.Host}/sitemap.xml";
|
Disallow: /Auth/
|
||||||
|
Disallow: /Payment/
|
||||||
return Content(robotsTxt, "text/plain", Encoding.UTF8);
|
Disallow: /api/
|
||||||
}
|
|
||||||
|
Sitemap: {Request.Scheme}://{Request.Host}/sitemap.xml";
|
||||||
|
|
||||||
|
return Content(robotsTxt, "text/plain", Encoding.UTF8);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@ -1,219 +1,467 @@
|
|||||||
using Microsoft.AspNetCore.Mvc;
|
using BCards.Web.Configuration;
|
||||||
using Stripe;
|
using BCards.Web.Repositories;
|
||||||
using BCards.Web.Services;
|
using BCards.Web.Services;
|
||||||
using BCards.Web.Repositories;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using BCards.Web.Configuration;
|
using Microsoft.Extensions.Options;
|
||||||
using Microsoft.Extensions.Options;
|
using MongoDB.Driver;
|
||||||
|
using Stripe;
|
||||||
namespace BCards.Web.Controllers;
|
using System.Diagnostics;
|
||||||
|
|
||||||
[ApiController]
|
namespace BCards.Web.Controllers;
|
||||||
[Route("api/stripe")]
|
|
||||||
public class StripeWebhookController : ControllerBase
|
[ApiController]
|
||||||
{
|
[Route("api/stripe")]
|
||||||
private readonly ILogger<StripeWebhookController> _logger;
|
public class StripeWebhookController : ControllerBase
|
||||||
private readonly ISubscriptionRepository _subscriptionRepository;
|
{
|
||||||
private readonly IUserPageService _userPageService;
|
private readonly ILogger<StripeWebhookController> _logger;
|
||||||
private readonly string _webhookSecret;
|
private readonly ISubscriptionRepository _subscriptionRepository;
|
||||||
|
private readonly IUserPageService _userPageService;
|
||||||
public StripeWebhookController(
|
private readonly IUserRepository _userRepository;
|
||||||
ILogger<StripeWebhookController> logger,
|
private readonly IPlanConfigurationService _planConfigurationService;
|
||||||
ISubscriptionRepository subscriptionRepository,
|
private readonly string _webhookSecret;
|
||||||
IUserPageService userPageService,
|
|
||||||
IOptions<StripeSettings> stripeSettings)
|
public StripeWebhookController(
|
||||||
{
|
ILogger<StripeWebhookController> logger,
|
||||||
_logger = logger;
|
ISubscriptionRepository subscriptionRepository,
|
||||||
_subscriptionRepository = subscriptionRepository;
|
IUserPageService userPageService,
|
||||||
_userPageService = userPageService;
|
IUserRepository userRepository,
|
||||||
_webhookSecret = stripeSettings.Value.WebhookSecret ?? "";
|
IPlanConfigurationService planConfigurationService,
|
||||||
}
|
IOptions<StripeSettings> stripeSettings)
|
||||||
|
{
|
||||||
[HttpPost("webhook")]
|
_logger = logger;
|
||||||
public async Task<IActionResult> HandleWebhook()
|
_subscriptionRepository = subscriptionRepository;
|
||||||
{
|
_userPageService = userPageService;
|
||||||
try
|
_userRepository = userRepository;
|
||||||
{
|
_planConfigurationService = planConfigurationService;
|
||||||
var json = await new StreamReader(HttpContext.Request.Body).ReadToEndAsync();
|
_webhookSecret = stripeSettings.Value.WebhookSecret ?? "";
|
||||||
|
}
|
||||||
if (string.IsNullOrEmpty(_webhookSecret))
|
|
||||||
{
|
[HttpPost("webhook")]
|
||||||
_logger.LogWarning("Webhook secret not configured");
|
public async Task<IActionResult> HandleWebhook()
|
||||||
return BadRequest("Webhook secret not configured");
|
{
|
||||||
}
|
try
|
||||||
|
{
|
||||||
var stripeSignature = Request.Headers["Stripe-Signature"].FirstOrDefault();
|
var json = await new StreamReader(HttpContext.Request.Body).ReadToEndAsync();
|
||||||
if (string.IsNullOrEmpty(stripeSignature))
|
_logger.LogInformation($"Recebido:{json}");
|
||||||
{
|
|
||||||
_logger.LogWarning("Missing Stripe signature");
|
if (string.IsNullOrEmpty(_webhookSecret))
|
||||||
return BadRequest("Missing Stripe signature");
|
{
|
||||||
}
|
_logger.LogWarning("Webhook secret not configured");
|
||||||
|
return BadRequest("Webhook secret not configured");
|
||||||
var stripeEvent = EventUtility.ConstructEvent(
|
}
|
||||||
json,
|
_logger.LogWarning($"Recebido:{json}");
|
||||||
stripeSignature,
|
|
||||||
_webhookSecret,
|
var stripeSignature = Request.Headers["Stripe-Signature"].FirstOrDefault();
|
||||||
throwOnApiVersionMismatch: false
|
if (string.IsNullOrEmpty(stripeSignature))
|
||||||
);
|
{
|
||||||
|
_logger.LogWarning("Missing Stripe signature");
|
||||||
_logger.LogInformation($"Processing Stripe webhook: {stripeEvent.Type}");
|
return BadRequest("Missing Stripe signature");
|
||||||
|
}
|
||||||
switch (stripeEvent.Type)
|
|
||||||
{
|
_logger.LogInformation($"Contruir evento Stripe: {json}");
|
||||||
case Events.InvoicePaymentSucceeded:
|
var stripeEvent = EventUtility.ConstructEvent(
|
||||||
await HandlePaymentSucceeded(stripeEvent);
|
json,
|
||||||
break;
|
stripeSignature,
|
||||||
|
_webhookSecret,
|
||||||
case Events.InvoicePaymentFailed:
|
throwOnApiVersionMismatch: false
|
||||||
await HandlePaymentFailed(stripeEvent);
|
);
|
||||||
break;
|
|
||||||
|
_logger.LogInformation($"[DEBUG] Processing Stripe webhook: {stripeEvent.Type}");
|
||||||
case Events.CustomerSubscriptionDeleted:
|
|
||||||
await HandleSubscriptionDeleted(stripeEvent);
|
switch (stripeEvent.Type)
|
||||||
break;
|
{
|
||||||
|
case "invoice.payment_succeeded":
|
||||||
case Events.CustomerSubscriptionUpdated:
|
await HandlePaymentSucceeded(stripeEvent);
|
||||||
await HandleSubscriptionUpdated(stripeEvent);
|
break;
|
||||||
break;
|
|
||||||
|
case "invoice.payment_failed":
|
||||||
default:
|
await HandlePaymentFailed(stripeEvent);
|
||||||
_logger.LogInformation($"Unhandled webhook event type: {stripeEvent.Type}");
|
break;
|
||||||
break;
|
|
||||||
}
|
case "customer.subscription.deleted":
|
||||||
|
await HandleSubscriptionDeleted(stripeEvent);
|
||||||
return Ok();
|
break;
|
||||||
}
|
|
||||||
catch (StripeException ex)
|
case "customer.subscription.updated":
|
||||||
{
|
await HandleSubscriptionUpdated(stripeEvent);
|
||||||
_logger.LogError(ex, "Stripe webhook error");
|
break;
|
||||||
return BadRequest($"Stripe error: {ex.Message}");
|
|
||||||
}
|
case "customer.subscription.created":
|
||||||
catch (Exception ex)
|
await HandleSubscriptionCreated(stripeEvent);
|
||||||
{
|
break;
|
||||||
_logger.LogError(ex, "Webhook processing error");
|
|
||||||
return StatusCode(500, "Internal server error");
|
default:
|
||||||
}
|
_logger.LogInformation($"Unhandled webhook event type: {stripeEvent.Type}");
|
||||||
}
|
break;
|
||||||
|
}
|
||||||
private async Task HandlePaymentSucceeded(Event stripeEvent)
|
|
||||||
{
|
return Ok();
|
||||||
if (stripeEvent.Data.Object is Invoice invoice)
|
}
|
||||||
{
|
catch (StripeException ex)
|
||||||
_logger.LogInformation($"Payment succeeded for customer: {invoice.CustomerId}");
|
{
|
||||||
|
_logger.LogError(ex, "Stripe webhook error");
|
||||||
var subscription = await _subscriptionRepository.GetByStripeSubscriptionIdAsync(invoice.SubscriptionId);
|
return BadRequest($"Stripe error: {ex.Message}");
|
||||||
if (subscription != null)
|
}
|
||||||
{
|
catch (Exception ex)
|
||||||
subscription.Status = "active";
|
{
|
||||||
subscription.UpdatedAt = DateTime.UtcNow;
|
_logger.LogError(ex, "Webhook processing error");
|
||||||
await _subscriptionRepository.UpdateAsync(subscription);
|
return StatusCode(500, "Internal server error");
|
||||||
|
}
|
||||||
// Reactivate user pages
|
}
|
||||||
var userPages = await _userPageService.GetUserPagesAsync(subscription.UserId);
|
|
||||||
foreach (var page in userPages.Where(p => p.Status == ViewModels.PageStatus.PendingPayment))
|
private async Task HandlePaymentSucceeded(Event stripeEvent)
|
||||||
{
|
{
|
||||||
page.Status = ViewModels.PageStatus.Active;
|
var traceId = Guid.NewGuid().ToString();
|
||||||
page.UpdatedAt = DateTime.UtcNow;
|
try
|
||||||
await _userPageService.UpdatePageAsync(page);
|
{
|
||||||
}
|
_logger.LogInformation($"[TID: {traceId}] - Staring HandlePaymentSucceeded");
|
||||||
|
if (stripeEvent.Data.Object is Invoice invoice)
|
||||||
_logger.LogInformation($"Reactivated {userPages.Count} pages for user {subscription.UserId}");
|
{
|
||||||
}
|
_logger.LogInformation($"[TID: {traceId}] - Payment succeeded for customer: {invoice.CustomerId}");
|
||||||
}
|
|
||||||
}
|
var subscriptionId = GetSubscriptionId(stripeEvent);
|
||||||
|
var subscription = await _subscriptionRepository.GetByStripeSubscriptionIdAsync(subscriptionId);
|
||||||
private async Task HandlePaymentFailed(Event stripeEvent)
|
if (subscription != null)
|
||||||
{
|
{
|
||||||
if (stripeEvent.Data.Object is Invoice invoice)
|
subscription.Status = "active";
|
||||||
{
|
subscription.UpdatedAt = DateTime.UtcNow;
|
||||||
_logger.LogInformation($"Payment failed for customer: {invoice.CustomerId}");
|
await _subscriptionRepository.UpdateAsync(subscription);
|
||||||
|
|
||||||
var subscription = await _subscriptionRepository.GetByStripeSubscriptionIdAsync(invoice.SubscriptionId);
|
// Reactivate user pages
|
||||||
if (subscription != null)
|
var userPages = await _userPageService.GetUserPagesAsync(subscription.UserId);
|
||||||
{
|
foreach (var page in userPages.Where(p => p.Status == ViewModels.PageStatus.PendingPayment))
|
||||||
subscription.Status = "past_due";
|
{
|
||||||
subscription.UpdatedAt = DateTime.UtcNow;
|
page.Status = ViewModels.PageStatus.Active;
|
||||||
await _subscriptionRepository.UpdateAsync(subscription);
|
page.UpdatedAt = DateTime.UtcNow;
|
||||||
|
await _userPageService.UpdatePageAsync(page);
|
||||||
// Set pages to pending payment
|
}
|
||||||
var userPages = await _userPageService.GetUserPagesAsync(subscription.UserId);
|
|
||||||
foreach (var page in userPages.Where(p => p.Status == ViewModels.PageStatus.Active))
|
_logger.LogInformation($"[TID: {traceId}] - Reactivated {userPages.Count} pages for user {subscription.UserId}");
|
||||||
{
|
}
|
||||||
page.Status = ViewModels.PageStatus.PendingPayment;
|
}
|
||||||
page.UpdatedAt = DateTime.UtcNow;
|
else
|
||||||
await _userPageService.UpdatePageAsync(page);
|
{
|
||||||
}
|
_logger.LogWarning($"[TID: {traceId}] - Unexpected event type on HandlePaymentSucceeded: {stripeEvent.Type}");
|
||||||
|
}
|
||||||
_logger.LogInformation($"Set {userPages.Count} pages to pending payment for user {subscription.UserId}");
|
await Task.Delay(4000);
|
||||||
}
|
}
|
||||||
}
|
catch (Exception ex)
|
||||||
}
|
{
|
||||||
|
_logger.LogError(ex, $"[TID: {traceId}] - Error handling payment succeeded event");
|
||||||
private async Task HandleSubscriptionDeleted(Event stripeEvent)
|
await Task.Delay(4000);
|
||||||
{
|
throw new Exception($"[TID: {traceId}] - Error handling payment succeeded event", ex);
|
||||||
if (stripeEvent.Data.Object is Subscription stripeSubscription)
|
}
|
||||||
{
|
}
|
||||||
_logger.LogInformation($"Subscription cancelled: {stripeSubscription.Id}");
|
|
||||||
|
private async Task HandlePaymentFailed(Event stripeEvent)
|
||||||
var subscription = await _subscriptionRepository.GetByStripeSubscriptionIdAsync(stripeSubscription.Id);
|
{
|
||||||
if (subscription != null)
|
try
|
||||||
{
|
{
|
||||||
subscription.Status = "cancelled";
|
if (stripeEvent.Data.Object is Invoice invoice)
|
||||||
subscription.UpdatedAt = DateTime.UtcNow;
|
{
|
||||||
await _subscriptionRepository.UpdateAsync(subscription);
|
_logger.LogInformation($"Payment failed for customer: {invoice.CustomerId}");
|
||||||
|
|
||||||
// Downgrade to trial or deactivate pages
|
var subscriptionId = GetSubscriptionId(stripeEvent);
|
||||||
var userPages = await _userPageService.GetUserPagesAsync(subscription.UserId);
|
var subscription = await _subscriptionRepository.GetByStripeSubscriptionIdAsync(subscriptionId);
|
||||||
foreach (var page in userPages.Where(p => p.Status == ViewModels.PageStatus.Active))
|
if (subscription != null)
|
||||||
{
|
{
|
||||||
page.Status = ViewModels.PageStatus.Expired;
|
subscription.Status = "past_due";
|
||||||
page.UpdatedAt = DateTime.UtcNow;
|
subscription.UpdatedAt = DateTime.UtcNow;
|
||||||
await _userPageService.UpdatePageAsync(page);
|
await _subscriptionRepository.UpdateAsync(subscription);
|
||||||
}
|
|
||||||
|
// Set pages to pending payment
|
||||||
_logger.LogInformation($"Deactivated {userPages.Count} pages for cancelled subscription {subscription.UserId}");
|
var userPages = await _userPageService.GetUserPagesAsync(subscription.UserId);
|
||||||
}
|
foreach (var page in userPages.Where(p => p.Status == ViewModels.PageStatus.Active))
|
||||||
}
|
{
|
||||||
}
|
page.Status = ViewModels.PageStatus.PendingPayment;
|
||||||
|
page.UpdatedAt = DateTime.UtcNow;
|
||||||
private async Task HandleSubscriptionUpdated(Event stripeEvent)
|
await _userPageService.UpdatePageAsync(page);
|
||||||
{
|
}
|
||||||
if (stripeEvent.Data.Object is Subscription stripeSubscription)
|
|
||||||
{
|
_logger.LogInformation($"Set {userPages.Count} pages to pending payment for user {subscription.UserId}");
|
||||||
_logger.LogInformation($"Subscription updated: {stripeSubscription.Id}");
|
}
|
||||||
|
}
|
||||||
var subscription = await _subscriptionRepository.GetByStripeSubscriptionIdAsync(stripeSubscription.Id);
|
else
|
||||||
if (subscription != null)
|
{
|
||||||
{
|
_logger.LogWarning($"Unexpected event type on HandlePaymentFailed: {stripeEvent.Type}");
|
||||||
subscription.Status = stripeSubscription.Status;
|
}
|
||||||
subscription.CurrentPeriodStart = stripeSubscription.CurrentPeriodStart;
|
}
|
||||||
subscription.CurrentPeriodEnd = stripeSubscription.CurrentPeriodEnd;
|
catch (Exception ex)
|
||||||
subscription.CancelAtPeriodEnd = stripeSubscription.CancelAtPeriodEnd;
|
{
|
||||||
subscription.UpdatedAt = DateTime.UtcNow;
|
_logger.LogError(ex, "Error handling payment failed event");
|
||||||
|
throw new Exception("Error handling payment failed event", ex);
|
||||||
// Update plan type based on Stripe price ID
|
}
|
||||||
var priceId = stripeSubscription.Items.Data.FirstOrDefault()?.Price.Id;
|
}
|
||||||
if (!string.IsNullOrEmpty(priceId))
|
|
||||||
{
|
private async Task HandleSubscriptionDeleted(Event stripeEvent)
|
||||||
subscription.PlanType = MapPriceIdToPlanType(priceId);
|
{
|
||||||
}
|
try
|
||||||
|
{
|
||||||
await _subscriptionRepository.UpdateAsync(subscription);
|
if (stripeEvent.Data.Object is Subscription stripeSubscription)
|
||||||
|
{
|
||||||
_logger.LogInformation($"Updated subscription for user {subscription.UserId}");
|
_logger.LogInformation($"Subscription cancelled: {stripeSubscription.Id}");
|
||||||
}
|
|
||||||
}
|
var subscription = await _subscriptionRepository.GetByStripeSubscriptionIdAsync(stripeSubscription.Id);
|
||||||
}
|
if (subscription != null)
|
||||||
|
{
|
||||||
private string MapPriceIdToPlanType(string priceId)
|
subscription.Status = "cancelled";
|
||||||
{
|
subscription.UpdatedAt = DateTime.UtcNow;
|
||||||
// Map Stripe price IDs to plan types
|
await _subscriptionRepository.UpdateAsync(subscription);
|
||||||
// This would be configured based on your actual Stripe price IDs
|
|
||||||
return priceId switch
|
// Downgrade to trial or deactivate pages
|
||||||
{
|
var userPages = await _userPageService.GetUserPagesAsync(subscription.UserId);
|
||||||
var id when id.Contains("basic") => "basic",
|
foreach (var page in userPages.Where(p => p.Status == ViewModels.PageStatus.Active))
|
||||||
var id when id.Contains("professional") => "professional",
|
{
|
||||||
var id when id.Contains("premium") => "premium",
|
page.Status = ViewModels.PageStatus.Expired;
|
||||||
_ => "trial"
|
page.UpdatedAt = DateTime.UtcNow;
|
||||||
};
|
await _userPageService.UpdatePageAsync(page);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_logger.LogInformation($"Deactivated {userPages.Count} pages for cancelled subscription {subscription.UserId}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_logger.LogWarning($"Unexpected event type on HandlePaymentFailed: {stripeEvent.Type}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Error handling subscription deleted failed event");
|
||||||
|
throw new Exception("Error handling subscription deleted failed event", ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task HandleSubscriptionUpdated(Event stripeEvent)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (stripeEvent.Data.Object is Subscription stripeSubscription)
|
||||||
|
{
|
||||||
|
_logger.LogInformation($"Subscription updated: {stripeSubscription.Id}");
|
||||||
|
|
||||||
|
var subscription = await _subscriptionRepository.GetByStripeSubscriptionIdAsync(stripeSubscription.Id);
|
||||||
|
if (subscription != null)
|
||||||
|
{
|
||||||
|
var service = new SubscriptionItemService();
|
||||||
|
var subItem = service.Get(stripeSubscription.Items.Data[0].Id);
|
||||||
|
|
||||||
|
subscription.Status = stripeSubscription.Status;
|
||||||
|
subscription.CurrentPeriodStart = subItem.CurrentPeriodStart;
|
||||||
|
subscription.CurrentPeriodEnd = subItem.CurrentPeriodEnd;
|
||||||
|
subscription.CancelAtPeriodEnd = stripeSubscription.CancelAtPeriodEnd;
|
||||||
|
subscription.UpdatedAt = DateTime.UtcNow;
|
||||||
|
|
||||||
|
// Update plan type based on Stripe price ID
|
||||||
|
var priceId = stripeSubscription.Items.Data.FirstOrDefault()?.Price.Id;
|
||||||
|
if (!string.IsNullOrEmpty(priceId))
|
||||||
|
{
|
||||||
|
subscription.PlanType = _planConfigurationService.GetPlanNameFromPriceId(priceId);
|
||||||
|
}
|
||||||
|
|
||||||
|
await _subscriptionRepository.UpdateAsync(subscription);
|
||||||
|
|
||||||
|
_logger.LogInformation($"Updated subscription for user {subscription.UserId}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_logger.LogWarning($"Unexpected event type on HandlePaymentFailed: {stripeEvent.Type}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Error handling subscription updated failed event");
|
||||||
|
throw new Exception("Error handling subscription updated failed event", ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task HandleSubscriptionCreated(Event stripeEvent)
|
||||||
|
{
|
||||||
|
var traceId = Guid.NewGuid().ToString();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_logger.LogInformation($"[DEBUG] [TID: {traceId}] - HandleSubscriptionCreated started");
|
||||||
|
|
||||||
|
if (stripeEvent.Data.Object is Subscription stripeSubscription)
|
||||||
|
{
|
||||||
|
_logger.LogInformation($"[DEBUG] [TID: {traceId}] - Subscription created: {stripeSubscription.Id} for customer: {stripeSubscription.CustomerId}");
|
||||||
|
|
||||||
|
// Get subscription record from our database
|
||||||
|
_logger.LogInformation($"[DEBUG] [TID: {traceId}] - Looking for existing subscription with ID: {stripeSubscription.Id}");
|
||||||
|
var subscription = await _subscriptionRepository.GetByStripeSubscriptionIdAsync(stripeSubscription.Id);
|
||||||
|
|
||||||
|
if (subscription != null)
|
||||||
|
{
|
||||||
|
_logger.LogInformation($"[DEBUG] [TID: {traceId}] - Found existing subscription: {subscription.Id}");
|
||||||
|
var service = new SubscriptionItemService();
|
||||||
|
var subItem = service.Get(stripeSubscription.Items.Data[0].Id);
|
||||||
|
|
||||||
|
// Update subscription status to active
|
||||||
|
subscription.Status = "active";
|
||||||
|
subscription.UpdatedAt = DateTime.UtcNow;
|
||||||
|
subscription.CurrentPeriodStart = subItem.CurrentPeriodStart;
|
||||||
|
subscription.CurrentPeriodEnd = subItem.CurrentPeriodEnd;
|
||||||
|
|
||||||
|
// Update plan type based on Stripe price ID
|
||||||
|
var priceId = stripeSubscription.Items.Data.FirstOrDefault()?.Price.Id;
|
||||||
|
if (!string.IsNullOrEmpty(priceId))
|
||||||
|
{
|
||||||
|
subscription.PlanType = _planConfigurationService.GetPlanNameFromPriceId(priceId);
|
||||||
|
}
|
||||||
|
|
||||||
|
await _subscriptionRepository.UpdateAsync(subscription);
|
||||||
|
|
||||||
|
// Activate user pages that were pending payment or trial
|
||||||
|
var userPages = await _userPageService.GetUserPagesAsync(subscription.UserId);
|
||||||
|
foreach (var page in userPages.Where(p =>
|
||||||
|
p.Status == ViewModels.PageStatus.PendingPayment ||
|
||||||
|
p.Status == ViewModels.PageStatus.Expired))
|
||||||
|
{
|
||||||
|
page.Status = ViewModels.PageStatus.Active;
|
||||||
|
page.UpdatedAt = DateTime.UtcNow;
|
||||||
|
await _userPageService.UpdatePageAsync(page);
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.LogInformation($"[TID: {traceId}] - Activated subscription and {userPages.Count()} pages for user {subscription.UserId}");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_logger.LogInformation($"[DEBUG] [TID: {traceId}] - Subscription not found in database: {stripeSubscription.Id}");
|
||||||
|
_logger.LogInformation($"[DEBUG] [TID: {traceId}] - Calling HandleSubscriptionCreatedForNewSubscription");
|
||||||
|
|
||||||
|
// Try to find user by Stripe Customer ID and create/update subscription
|
||||||
|
await HandleSubscriptionCreatedForNewSubscription(stripeSubscription);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_logger.LogWarning($"[TID: {traceId}] - Unexpected event type on HandleSubscriptionCreated: {stripeEvent.Type}");
|
||||||
|
}
|
||||||
|
await Task.Delay(4000);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "[TID: {traceId}] - Error handling subscription created event");
|
||||||
|
await Task.Delay(4000);
|
||||||
|
throw new Exception("[TID: {traceId}] - Error handling subscription created event", ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
private string GetSubscriptionId(Event stripeEvent)
|
||||||
|
{
|
||||||
|
if (stripeEvent.Data.Object is Invoice invoice)
|
||||||
|
{
|
||||||
|
var subscriptionLineItem = invoice.Lines?.Data
|
||||||
|
.FirstOrDefault(line =>
|
||||||
|
!string.IsNullOrEmpty(line.SubscriptionId) ||
|
||||||
|
line.Subscription != null
|
||||||
|
);
|
||||||
|
|
||||||
|
string subscriptionId = null;
|
||||||
|
|
||||||
|
if (subscriptionLineItem != null)
|
||||||
|
{
|
||||||
|
// Tenta obter o ID da assinatura de duas formas diferentes
|
||||||
|
subscriptionId = subscriptionLineItem.SubscriptionId
|
||||||
|
?? subscriptionLineItem.Subscription?.Id;
|
||||||
|
}
|
||||||
|
|
||||||
|
return subscriptionId;
|
||||||
|
}
|
||||||
|
else if (stripeEvent.Data.Object is Subscription stripeSubscription)
|
||||||
|
{
|
||||||
|
return stripeSubscription.Id;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task HandleSubscriptionCreatedForNewSubscription(Subscription stripeSubscription)
|
||||||
|
{
|
||||||
|
var traceId = Guid.NewGuid().ToString();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_logger.LogInformation($"[DEBUG] [TID: {traceId}] - HandleSubscriptionCreatedForNewSubscription started for customer: {stripeSubscription.CustomerId}");
|
||||||
|
_logger.LogInformation($"[DEBUG] [TID: {traceId}] - Getting MongoDB database");
|
||||||
|
|
||||||
|
var mongoDatabase = HttpContext.RequestServices.GetRequiredService<IMongoDatabase>();
|
||||||
|
var usersCollection = mongoDatabase.GetCollection<Models.User>("users");
|
||||||
|
|
||||||
|
_logger.LogInformation($"[DEBUG] [TID: {traceId}] - Searching for user with StripeCustomerId: {stripeSubscription.CustomerId}");
|
||||||
|
var user = await usersCollection.Find(u => u.StripeCustomerId == stripeSubscription.CustomerId).FirstOrDefaultAsync();
|
||||||
|
|
||||||
|
if (user != null)
|
||||||
|
{
|
||||||
|
_logger.LogInformation($"[DEBUG] [TID: {traceId}] - Found user: {user.Id} ({user.Email})");
|
||||||
|
_logger.LogInformation($"[TID: {traceId}] - Found user {user.Id} for customer {stripeSubscription.CustomerId}");
|
||||||
|
|
||||||
|
var service = new SubscriptionItemService();
|
||||||
|
var subItem = service.Get(stripeSubscription.Items.Data[0].Id);
|
||||||
|
|
||||||
|
// Get plan type from price ID
|
||||||
|
var priceId = stripeSubscription.Items.Data.FirstOrDefault()?.Price.Id;
|
||||||
|
var planType = !string.IsNullOrEmpty(priceId) ? _planConfigurationService.GetPlanNameFromPriceId(priceId) : "Trial";
|
||||||
|
|
||||||
|
_logger.LogInformation($"[TID: {traceId}] - PriceId: {priceId}, PlanType: {planType}, User: {user.Id}");
|
||||||
|
|
||||||
|
// Create new subscription in our database
|
||||||
|
var newSubscription = new Models.Subscription
|
||||||
|
{
|
||||||
|
Id = MongoDB.Bson.ObjectId.GenerateNewId().ToString(),
|
||||||
|
UserId = user.Id,
|
||||||
|
StripeSubscriptionId = stripeSubscription.Id,
|
||||||
|
Status = "active",
|
||||||
|
PlanType = planType,
|
||||||
|
CurrentPeriodStart = subItem.CurrentPeriodStart,
|
||||||
|
CurrentPeriodEnd = subItem.CurrentPeriodEnd,
|
||||||
|
CancelAtPeriodEnd = stripeSubscription.CancelAtPeriodEnd,
|
||||||
|
CreatedAt = DateTime.UtcNow,
|
||||||
|
UpdatedAt = DateTime.UtcNow
|
||||||
|
};
|
||||||
|
|
||||||
|
_logger.LogInformation($"[TID: {traceId}] - try to save into database (subscription) - User: {user.Id}");
|
||||||
|
await _subscriptionRepository.CreateAsync(newSubscription);
|
||||||
|
_logger.LogInformation($"[TID: {traceId}] - Created new subscription {newSubscription.Id} for user {user.Id}");
|
||||||
|
|
||||||
|
_logger.LogInformation($"[TID: {traceId}] - Updating user {user.Id} CurrentPlan from '{user.CurrentPlan}' to '{planType}'");
|
||||||
|
user.CurrentPlan = planType;
|
||||||
|
user.UpdatedAt = DateTime.UtcNow;
|
||||||
|
|
||||||
|
var usersCollection2 = mongoDatabase.GetCollection<Models.User>("users");
|
||||||
|
await usersCollection2.ReplaceOneAsync(u => u.Id == user.Id, user);
|
||||||
|
_logger.LogInformation($"[TID: {traceId}] - User {user.Id} CurrentPlan updated to '{planType}'");
|
||||||
|
|
||||||
|
var userPages = await _userPageService.GetUserPagesAsync(user.Id);
|
||||||
|
foreach (var page in userPages.Where(p =>
|
||||||
|
p.Status == ViewModels.PageStatus.PendingPayment ||
|
||||||
|
p.Status == ViewModels.PageStatus.Expired))
|
||||||
|
{
|
||||||
|
page.Status = ViewModels.PageStatus.Active;
|
||||||
|
page.UpdatedAt = DateTime.UtcNow;
|
||||||
|
await _userPageService.UpdatePageAsync(page);
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.LogInformation($"[TID: {traceId}] - Activated {userPages.Count()} pages for user {user.Id} after subscription creation");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_logger.LogError($"[DEBUG] [TID: {traceId}] - User not found for Stripe customer ID: {stripeSubscription.CustomerId}");
|
||||||
|
_logger.LogInformation($"[DEBUG] [TID: {traceId}] - Will try to list some users to debug");
|
||||||
|
|
||||||
|
var allUsers = await usersCollection.Find(_ => true).Limit(5).ToListAsync();
|
||||||
|
foreach (var u in allUsers)
|
||||||
|
{
|
||||||
|
_logger.LogInformation($"[DEBUG] [TID: {traceId}] - User in DB: {u.Id} - {u.Email} - StripeCustomerId: '{u.StripeCustomerId}'");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, $"[TID: {traceId}] - Error handling new subscription creation for {stripeSubscription.Id}");
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
155
src/BCards.Web/Controllers/SubscriptionController.cs
Normal file
155
src/BCards.Web/Controllers/SubscriptionController.cs
Normal file
@ -0,0 +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 com reembolso total. O valor será retornado 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 com reembolso parcial de R$ {refundAmount:F2}. O valor será retornado 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");
|
||||||
|
}
|
||||||
|
}
|
||||||
172
src/BCards.Web/Controllers/TestToolsController.cs
Normal file
172
src/BCards.Web/Controllers/TestToolsController.cs
Normal file
@ -0,0 +1,172 @@
|
|||||||
|
#if TESTING
|
||||||
|
using BCards.Web.Models;
|
||||||
|
using BCards.Web.Repositories;
|
||||||
|
using BCards.Web.Services;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
|
namespace BCards.Web.Controllers;
|
||||||
|
|
||||||
|
[Authorize]
|
||||||
|
[ApiController]
|
||||||
|
[Route("testing/tools")]
|
||||||
|
public class TestToolsController : ControllerBase
|
||||||
|
{
|
||||||
|
private readonly IUserRepository _userRepository;
|
||||||
|
private readonly ISubscriptionRepository _subscriptionRepository;
|
||||||
|
private readonly IUserPageService _userPageService;
|
||||||
|
private readonly ILivePageService _livePageService;
|
||||||
|
private readonly IPlanConfigurationService _planConfigurationService;
|
||||||
|
private readonly IWebHostEnvironment _environment;
|
||||||
|
|
||||||
|
public TestToolsController(
|
||||||
|
IUserRepository userRepository,
|
||||||
|
ISubscriptionRepository subscriptionRepository,
|
||||||
|
IUserPageService userPageService,
|
||||||
|
ILivePageService livePageService,
|
||||||
|
IPlanConfigurationService planConfigurationService,
|
||||||
|
IWebHostEnvironment environment)
|
||||||
|
{
|
||||||
|
_userRepository = userRepository;
|
||||||
|
_subscriptionRepository = subscriptionRepository;
|
||||||
|
_userPageService = userPageService;
|
||||||
|
_livePageService = livePageService;
|
||||||
|
_planConfigurationService = planConfigurationService;
|
||||||
|
_environment = environment;
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("plan")]
|
||||||
|
public async Task<IActionResult> SetPlan([FromBody] SetPlanRequest request)
|
||||||
|
{
|
||||||
|
if (!_environment.IsEnvironment("Testing"))
|
||||||
|
{
|
||||||
|
return NotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Enum.TryParse<PlanType>(request.Plan, true, out var planType))
|
||||||
|
{
|
||||||
|
return BadRequest(new { error = $"Plano desconhecido: {request.Plan}" });
|
||||||
|
}
|
||||||
|
|
||||||
|
var email = string.IsNullOrWhiteSpace(request.Email)
|
||||||
|
? TestUserDefaults.Email
|
||||||
|
: request.Email!.Trim();
|
||||||
|
|
||||||
|
var user = await _userRepository.GetByEmailAsync(email);
|
||||||
|
if (user == null)
|
||||||
|
{
|
||||||
|
return NotFound(new { error = $"Usuário de teste não encontrado para o e-mail {email}" });
|
||||||
|
}
|
||||||
|
|
||||||
|
var planLimits = _planConfigurationService.GetPlanLimitations(planType);
|
||||||
|
var normalizedPlan = planLimits.PlanType ?? planType.ToString().ToLowerInvariant();
|
||||||
|
|
||||||
|
user.CurrentPlan = normalizedPlan;
|
||||||
|
user.SubscriptionStatus = "active";
|
||||||
|
user.UpdatedAt = DateTime.UtcNow;
|
||||||
|
await _userRepository.UpdateAsync(user);
|
||||||
|
|
||||||
|
var subscription = await _subscriptionRepository.GetByUserIdAsync(user.Id);
|
||||||
|
if (subscription == null)
|
||||||
|
{
|
||||||
|
subscription = new Subscription
|
||||||
|
{
|
||||||
|
UserId = user.Id,
|
||||||
|
StripeSubscriptionId = $"test-{Guid.NewGuid():N}",
|
||||||
|
CreatedAt = DateTime.UtcNow,
|
||||||
|
UpdatedAt = DateTime.UtcNow
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
subscription.PlanType = normalizedPlan;
|
||||||
|
subscription.Status = "active";
|
||||||
|
subscription.MaxLinks = planLimits.MaxLinks;
|
||||||
|
subscription.AllowAnalytics = planLimits.AllowAnalytics;
|
||||||
|
subscription.AllowCustomThemes = planLimits.AllowCustomThemes;
|
||||||
|
subscription.AllowCustomDomain = planLimits.AllowCustomDomain;
|
||||||
|
subscription.AllowMultipleDomains = planLimits.AllowMultipleDomains;
|
||||||
|
subscription.PrioritySupport = planLimits.PrioritySupport;
|
||||||
|
subscription.CurrentPeriodStart = DateTime.UtcNow.Date;
|
||||||
|
subscription.CurrentPeriodEnd = DateTime.UtcNow.Date.AddMonths(1);
|
||||||
|
subscription.CancelAtPeriodEnd = false;
|
||||||
|
subscription.UpdatedAt = DateTime.UtcNow;
|
||||||
|
|
||||||
|
if (string.IsNullOrEmpty(subscription.Id))
|
||||||
|
{
|
||||||
|
await _subscriptionRepository.CreateAsync(subscription);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
await _subscriptionRepository.UpdateAsync(subscription);
|
||||||
|
}
|
||||||
|
|
||||||
|
var pages = await _userPageService.GetUserPagesAsync(user.Id);
|
||||||
|
var originalPagesCount = pages.Count;
|
||||||
|
var removed = 0;
|
||||||
|
if (request.ResetPages)
|
||||||
|
{
|
||||||
|
foreach (var page in pages)
|
||||||
|
{
|
||||||
|
await _livePageService.DeleteByOriginalPageIdAsync(page.Id);
|
||||||
|
await _userPageService.DeletePageAsync(page.Id);
|
||||||
|
removed++;
|
||||||
|
}
|
||||||
|
|
||||||
|
pages = new List<UserPage>();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
foreach (var page in pages)
|
||||||
|
{
|
||||||
|
page.PlanLimitations = ClonePlanLimitations(planLimits);
|
||||||
|
page.UpdatedAt = DateTime.UtcNow;
|
||||||
|
await _userPageService.UpdatePageAsync(page);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Ok(new
|
||||||
|
{
|
||||||
|
email,
|
||||||
|
plan = normalizedPlan,
|
||||||
|
pagesAffected = request.ResetPages ? originalPagesCount : pages.Count,
|
||||||
|
pagesRemoved = removed
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private static PlanLimitations ClonePlanLimitations(PlanLimitations source)
|
||||||
|
{
|
||||||
|
return new PlanLimitations
|
||||||
|
{
|
||||||
|
MaxLinks = source.MaxLinks,
|
||||||
|
AllowCustomThemes = source.AllowCustomThemes,
|
||||||
|
AllowAnalytics = source.AllowAnalytics,
|
||||||
|
AllowCustomDomain = source.AllowCustomDomain,
|
||||||
|
AllowMultipleDomains = source.AllowMultipleDomains,
|
||||||
|
PrioritySupport = source.PrioritySupport,
|
||||||
|
PlanType = source.PlanType,
|
||||||
|
MaxProductLinks = source.MaxProductLinks,
|
||||||
|
MaxOGExtractionsPerDay = source.MaxOGExtractionsPerDay,
|
||||||
|
AllowProductLinks = source.AllowProductLinks,
|
||||||
|
SpecialModeration = source.SpecialModeration,
|
||||||
|
OGExtractionsUsedToday = 0,
|
||||||
|
LastExtractionDate = null,
|
||||||
|
AllowDocumentUpload = source.AllowDocumentUpload,
|
||||||
|
MaxDocuments = source.MaxDocuments
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static class TestUserDefaults
|
||||||
|
{
|
||||||
|
public const string Email = "test.user@example.com";
|
||||||
|
}
|
||||||
|
|
||||||
|
public class SetPlanRequest
|
||||||
|
{
|
||||||
|
public string? Email { get; set; }
|
||||||
|
|
||||||
|
public string Plan { get; set; } = "trial";
|
||||||
|
|
||||||
|
public bool ResetPages { get; set; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
@ -1,134 +1,144 @@
|
|||||||
using BCards.Web.Models;
|
using BCards.Web.Models;
|
||||||
using BCards.Web.Services;
|
using BCards.Web.Services;
|
||||||
using BCards.Web.Utils;
|
using BCards.Web.Utils;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
namespace BCards.Web.Controllers;
|
namespace BCards.Web.Controllers;
|
||||||
|
|
||||||
//[Route("[controller]")]
|
public class UserPageController : Controller
|
||||||
public class UserPageController : Controller
|
{
|
||||||
{
|
private readonly IUserPageService _userPageService;
|
||||||
private readonly IUserPageService _userPageService;
|
private readonly ICategoryService _categoryService;
|
||||||
private readonly ICategoryService _categoryService;
|
private readonly ISeoService _seoService;
|
||||||
private readonly ISeoService _seoService;
|
private readonly IThemeService _themeService;
|
||||||
private readonly IThemeService _themeService;
|
private readonly IModerationService _moderationService;
|
||||||
private readonly IModerationService _moderationService;
|
private readonly ILogger<UserPageController> _logger;
|
||||||
|
|
||||||
public UserPageController(
|
public UserPageController(
|
||||||
IUserPageService userPageService,
|
IUserPageService userPageService,
|
||||||
ICategoryService categoryService,
|
ICategoryService categoryService,
|
||||||
ISeoService seoService,
|
ISeoService seoService,
|
||||||
IThemeService themeService,
|
IThemeService themeService,
|
||||||
IModerationService moderationService)
|
IModerationService moderationService,
|
||||||
{
|
ILogger<UserPageController> logger)
|
||||||
_userPageService = userPageService;
|
{
|
||||||
_categoryService = categoryService;
|
_userPageService = userPageService;
|
||||||
_seoService = seoService;
|
_categoryService = categoryService;
|
||||||
_themeService = themeService;
|
_seoService = seoService;
|
||||||
_moderationService = moderationService;
|
_themeService = themeService;
|
||||||
}
|
_moderationService = moderationService;
|
||||||
|
_logger = logger;
|
||||||
//[Route("{category}/{slug}")]
|
}
|
||||||
//VOltar a linha abaixo em prod
|
|
||||||
//[ResponseCache(Duration = 300, VaryByQueryKeys = new[] { "category", "slug" })]
|
[ResponseCache(Duration = 300, Location = ResponseCacheLocation.Any, VaryByQueryKeys = new[] { "preview" })]
|
||||||
public async Task<IActionResult> Display(string category, string slug)
|
public async Task<IActionResult> Display(string category, string slug)
|
||||||
{
|
{
|
||||||
var userPage = await _userPageService.GetPageAsync(category, slug);
|
var userPage = await _userPageService.GetPageAsync(category, slug);
|
||||||
if (userPage == null)
|
if (userPage == null)
|
||||||
return NotFound();
|
return NotFound();
|
||||||
|
|
||||||
var categoryObj = await _categoryService.GetCategoryBySlugAsync(category);
|
var categoryObj = await _categoryService.GetCategoryBySlugAsync(category);
|
||||||
if (categoryObj == null)
|
if (categoryObj == null)
|
||||||
return NotFound();
|
return NotFound();
|
||||||
|
|
||||||
// Check if it's a preview request
|
// Check if it's a preview request
|
||||||
var isPreview = HttpContext.Items.ContainsKey("IsPreview") && (bool)HttpContext.Items["IsPreview"];
|
var isPreview = HttpContext.Items.ContainsKey("IsPreview") && (bool)HttpContext.Items["IsPreview"];
|
||||||
var previewToken = Request.Query["preview"].FirstOrDefault();
|
var previewToken = Request.Query["preview"].FirstOrDefault();
|
||||||
|
|
||||||
if (!string.IsNullOrEmpty(previewToken))
|
_logger.LogDebug("Request - Page: {Slug}, Status: {Status}, IsPreview: {IsPreview}, PreviewToken: {PreviewToken}, UserId: {UserId}",
|
||||||
{
|
userPage.Slug, userPage.Status, isPreview, !string.IsNullOrEmpty(previewToken), userPage.UserId);
|
||||||
// Handle preview request
|
|
||||||
var isValidPreview = await _moderationService.ValidatePreviewTokenAsync(userPage.Id, previewToken);
|
if (!string.IsNullOrEmpty(previewToken))
|
||||||
if (!isValidPreview)
|
{
|
||||||
{
|
// Handle preview request
|
||||||
return View("PreviewExpired");
|
var isValidPreview = await _moderationService.ValidatePreviewTokenAsync(userPage.Id, previewToken);
|
||||||
}
|
if (!isValidPreview)
|
||||||
|
{
|
||||||
// Set preview flag
|
return View("PreviewExpired");
|
||||||
ViewBag.IsPreview = true;
|
}
|
||||||
ViewBag.PreviewToken = previewToken;
|
|
||||||
}
|
// Set preview flag
|
||||||
else
|
ViewBag.IsPreview = true;
|
||||||
{
|
ViewBag.PreviewToken = previewToken;
|
||||||
// Regular request - check if page is active
|
}
|
||||||
if (userPage.Status == ViewModels.PageStatus.PendingModeration)
|
else
|
||||||
{
|
{
|
||||||
return View("PendingModeration");
|
// Regular request - check if page is active
|
||||||
}
|
if (userPage.Status == ViewModels.PageStatus.PendingModeration)
|
||||||
|
{
|
||||||
if (userPage.Status == ViewModels.PageStatus.Rejected)
|
return View("PendingModeration");
|
||||||
{
|
}
|
||||||
return View("PageRejected");
|
|
||||||
}
|
if (userPage.Status == ViewModels.PageStatus.Rejected)
|
||||||
|
{
|
||||||
if (userPage.Status == ViewModels.PageStatus.Inactive || !userPage.IsActive)
|
return View("PageRejected");
|
||||||
{
|
}
|
||||||
return NotFound();
|
|
||||||
}
|
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();
|
// Ensure theme is loaded - critical fix for theme display issue
|
||||||
}
|
if (userPage.Theme?.Id == null || string.IsNullOrEmpty(userPage.Theme.PrimaryColor))
|
||||||
|
{
|
||||||
// Generate SEO settings
|
userPage.Theme = _themeService.GetDefaultTheme();
|
||||||
var seoSettings = _seoService.GenerateSeoSettings(userPage, categoryObj);
|
}
|
||||||
|
|
||||||
// Record page view (async, don't wait) - only for non-preview requests
|
// Generate SEO settings
|
||||||
if (!isPreview)
|
var seoSettings = _seoService.GenerateSeoSettings(userPage, categoryObj);
|
||||||
{
|
|
||||||
var referrer = Request.Headers["Referer"].FirstOrDefault();
|
// Record page view (async, don't wait) - only for non-preview requests
|
||||||
var userAgent = Request.Headers["User-Agent"].FirstOrDefault();
|
_logger.LogDebug("View Count - Page: {Slug}, Status: {Status}, IsPreview: {IsPreview}, PreviewToken: {PreviewToken}",
|
||||||
_ = Task.Run(() => _userPageService.RecordPageViewAsync(userPage.Id, referrer, userAgent));
|
userPage.Slug, userPage.Status, isPreview, !string.IsNullOrEmpty(previewToken));
|
||||||
}
|
if (!isPreview)
|
||||||
|
{
|
||||||
ViewBag.SeoSettings = seoSettings;
|
_logger.LogDebug("Recording view for page {Slug}", userPage.Slug);
|
||||||
ViewBag.Category = categoryObj;
|
var referrer = Request.Headers["Referer"].FirstOrDefault();
|
||||||
|
var userAgent = Request.Headers["User-Agent"].FirstOrDefault();
|
||||||
return View(userPage);
|
_ = _userPageService.RecordPageViewAsync(userPage.Id, referrer, userAgent);
|
||||||
}
|
}
|
||||||
|
else
|
||||||
[HttpPost]
|
{
|
||||||
[Route("click/{pageId}")]
|
_logger.LogDebug("NOT recording view - isPreview = true for page {Slug}", userPage.Slug);
|
||||||
public async Task<IActionResult> RecordClick(string pageId, int linkIndex)
|
}
|
||||||
{
|
|
||||||
await _userPageService.RecordLinkClickAsync(pageId, linkIndex);
|
ViewBag.SeoSettings = seoSettings;
|
||||||
return Ok();
|
ViewBag.Category = categoryObj;
|
||||||
}
|
|
||||||
|
return View(userPage);
|
||||||
[Route("preview/{category}/{slug}")]
|
}
|
||||||
public async Task<IActionResult> Preview(string category, string slug)
|
|
||||||
{
|
[HttpPost]
|
||||||
var userPage = await _userPageService.GetPageAsync(category, slug);
|
[Route("click/{pageId}")]
|
||||||
if (userPage == null)
|
public async Task<IActionResult> RecordClick(string pageId, int linkIndex)
|
||||||
return NotFound();
|
{
|
||||||
|
await _userPageService.RecordLinkClickAsync(pageId, linkIndex);
|
||||||
var categoryObj = await _categoryService.GetCategoryBySlugAsync(category);
|
return Ok();
|
||||||
if (categoryObj == null)
|
}
|
||||||
return NotFound();
|
|
||||||
|
[Route("preview/{category}/{slug}")]
|
||||||
// Ensure theme is loaded for preview too
|
public async Task<IActionResult> Preview(string category, string slug)
|
||||||
if (userPage.Theme?.Id == null || string.IsNullOrEmpty(userPage.Theme.PrimaryColor))
|
{
|
||||||
{
|
var userPage = await _userPageService.GetPageAsync(category, slug);
|
||||||
userPage.Theme = _themeService.GetDefaultTheme();
|
if (userPage == null)
|
||||||
}
|
return NotFound();
|
||||||
|
|
||||||
ViewBag.Category = categoryObj;
|
var categoryObj = await _categoryService.GetCategoryBySlugAsync(category);
|
||||||
ViewBag.IsPreview = true;
|
if (categoryObj == null)
|
||||||
|
return NotFound();
|
||||||
return View("Display", userPage);
|
|
||||||
}
|
// 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);
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
110
src/BCards.Web/HealthChecks/CriticalServicesHealthCheck.cs
Normal file
110
src/BCards.Web/HealthChecks/CriticalServicesHealthCheck.cs
Normal file
@ -0,0 +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);
|
||||||
|
}
|
||||||
|
}
|
||||||
134
src/BCards.Web/HealthChecks/ExternalServicesHealthCheck.cs
Normal file
134
src/BCards.Web/HealthChecks/ExternalServicesHealthCheck.cs
Normal file
@ -0,0 +1,134 @@
|
|||||||
|
using Microsoft.Extensions.Diagnostics.HealthChecks;
|
||||||
|
using System.Diagnostics;
|
||||||
|
|
||||||
|
namespace BCards.Web.HealthChecks;
|
||||||
|
|
||||||
|
public class ExternalServicesHealthCheck : IHealthCheck
|
||||||
|
{
|
||||||
|
private readonly HttpClient _httpClient;
|
||||||
|
private readonly ILogger<ExternalServicesHealthCheck> _logger;
|
||||||
|
|
||||||
|
public ExternalServicesHealthCheck(HttpClient httpClient, ILogger<ExternalServicesHealthCheck> logger)
|
||||||
|
{
|
||||||
|
_httpClient = httpClient;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<HealthCheckResult> CheckHealthAsync(
|
||||||
|
HealthCheckContext context,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var stopwatch = Stopwatch.StartNew();
|
||||||
|
var results = new Dictionary<string, object>();
|
||||||
|
var allHealthy = true;
|
||||||
|
var hasUnhealthy = false;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Lista de serviços externos para verificar
|
||||||
|
var services = new Dictionary<string, string>
|
||||||
|
{
|
||||||
|
{ "google_oauth", "https://accounts.google.com" },
|
||||||
|
{ "microsoft_oauth", "https://login.microsoftonline.com" }
|
||||||
|
};
|
||||||
|
|
||||||
|
foreach (var service in services)
|
||||||
|
{
|
||||||
|
var serviceStopwatch = Stopwatch.StartNew();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var response = await _httpClient.GetAsync(service.Value, cancellationToken);
|
||||||
|
serviceStopwatch.Stop();
|
||||||
|
|
||||||
|
var serviceResult = new Dictionary<string, object>
|
||||||
|
{
|
||||||
|
{ "status", response.IsSuccessStatusCode ? "healthy" : "unhealthy" },
|
||||||
|
{ "duration", $"{serviceStopwatch.ElapsedMilliseconds}ms" },
|
||||||
|
{ "status_code", (int)response.StatusCode },
|
||||||
|
{ "url", service.Value }
|
||||||
|
};
|
||||||
|
|
||||||
|
results[service.Key] = serviceResult;
|
||||||
|
|
||||||
|
if (!response.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
allHealthy = false;
|
||||||
|
if (response.StatusCode == System.Net.HttpStatusCode.ServiceUnavailable ||
|
||||||
|
response.StatusCode == System.Net.HttpStatusCode.InternalServerError)
|
||||||
|
{
|
||||||
|
hasUnhealthy = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.LogInformation("External service {Service} health check: {Status} in {Duration}ms",
|
||||||
|
service.Key, response.StatusCode, serviceStopwatch.ElapsedMilliseconds);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
serviceStopwatch.Stop();
|
||||||
|
allHealthy = false;
|
||||||
|
hasUnhealthy = true;
|
||||||
|
|
||||||
|
results[service.Key] = new Dictionary<string, object>
|
||||||
|
{
|
||||||
|
{ "status", "unhealthy" },
|
||||||
|
{ "duration", $"{serviceStopwatch.ElapsedMilliseconds}ms" },
|
||||||
|
{ "error", ex.Message },
|
||||||
|
{ "url", service.Value }
|
||||||
|
};
|
||||||
|
|
||||||
|
// Usar Warning para OAuth providers (alerta amarelo)
|
||||||
|
if (service.Key.Contains("oauth"))
|
||||||
|
{
|
||||||
|
_logger.LogWarning("🟡 OAuth service {Service} offline - usuários não conseguem fazer login: {Error}",
|
||||||
|
service.Key, ex.Message);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "🔴 Critical service {Service} failed", service.Key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
stopwatch.Stop();
|
||||||
|
var totalDuration = stopwatch.ElapsedMilliseconds;
|
||||||
|
|
||||||
|
var data = new Dictionary<string, object>
|
||||||
|
{
|
||||||
|
{ "status", hasUnhealthy ? "unhealthy" : (allHealthy ? "healthy" : "degraded") },
|
||||||
|
{ "duration", $"{totalDuration}ms" },
|
||||||
|
{ "services", results },
|
||||||
|
{ "total_services", services.Count },
|
||||||
|
{ "healthy_services", results.Values.Count(r => ((Dictionary<string, object>)r)["status"].ToString() == "healthy") }
|
||||||
|
};
|
||||||
|
|
||||||
|
if (hasUnhealthy)
|
||||||
|
{
|
||||||
|
return HealthCheckResult.Unhealthy("One or more external services are unhealthy", data: data);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!allHealthy)
|
||||||
|
{
|
||||||
|
return HealthCheckResult.Degraded("Some external services have issues", data: data);
|
||||||
|
}
|
||||||
|
|
||||||
|
return HealthCheckResult.Healthy($"All external services are responsive ({totalDuration}ms)", data: data);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
stopwatch.Stop();
|
||||||
|
var duration = stopwatch.ElapsedMilliseconds;
|
||||||
|
|
||||||
|
_logger.LogError(ex, "External services health check failed after {Duration}ms", duration);
|
||||||
|
|
||||||
|
var data = new Dictionary<string, object>
|
||||||
|
{
|
||||||
|
{ "status", "unhealthy" },
|
||||||
|
{ "duration", $"{duration}ms" },
|
||||||
|
{ "error", ex.Message }
|
||||||
|
};
|
||||||
|
|
||||||
|
return HealthCheckResult.Unhealthy($"External services check failed: {ex.Message}", ex, data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
73
src/BCards.Web/HealthChecks/MongoDbHealthCheck.cs
Normal file
73
src/BCards.Web/HealthChecks/MongoDbHealthCheck.cs
Normal file
@ -0,0 +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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
61
src/BCards.Web/HealthChecks/OAuthProvidersHealthCheck.cs
Normal file
61
src/BCards.Web/HealthChecks/OAuthProvidersHealthCheck.cs
Normal file
@ -0,0 +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}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
95
src/BCards.Web/HealthChecks/SendGridHealthCheck.cs
Normal file
95
src/BCards.Web/HealthChecks/SendGridHealthCheck.cs
Normal file
@ -0,0 +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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user