diff --git a/.env.example b/.env.example index 6fc8f0d..f357709 100644 --- a/.env.example +++ b/.env.example @@ -1,58 +1,34 @@ -# =========================================== -# SmartZap - Next.js + Upstash Workflow v2.0 -# =========================================== - -# ----- SECURITY (Required for Production) ----- -# Generate with: openssl rand -base64 32 -# API Key for general API access -SMARTZAP_API_KEY=szap_your_generated_api_key_here - -# Admin Key for sensitive operations (database, redeploy) -SMARTZAP_ADMIN_KEY=szap_admin_your_admin_key_here - -# Allowed frontend origin for CORS (defaults to Vercel URL) -FRONTEND_URL=https://your-app.vercel.app - -# ----- UPSTASH (Required for Production) ----- -# Get these from: https://console.upstash.com -UPSTASH_REDIS_REST_URL=https://xxx.upstash.io -UPSTASH_REDIS_REST_TOKEN=xxx - -# QStash is required for Upstash Workflow -# Get from: https://console.upstash.com/qstash -QSTASH_TOKEN=xxx -QSTASH_CURRENT_SIGNING_KEY=xxx -QSTASH_NEXT_SIGNING_KEY=xxx - -# ----- WHATSAPP API ----- -# From Meta Business Settings: https://business.facebook.com -WHATSAPP_TOKEN=your_access_token -WHATSAPP_PHONE_ID=your_phone_number_id -WHATSAPP_BUSINESS_ACCOUNT_ID=your_business_account_id -WHATSAPP_VERIFY_TOKEN=my_verify_token - -# ----- AI (Optional) ----- -# For AI-generated templates -GEMINI_API_KEY=your_gemini_api_key - -# ----- DATABASE ----- -# Choose your database provider: 'turso' or 'supabase' +# ------------------------------------------------------------ +# 🔐 SEGURANÇA / ADMIN (Recomendado) +# ------------------------------------------------------------ + +# Senha do painel (login). É a "senha mestra" do SmartZap. +# - Local: defina aqui no .env.local +# - Vercel: o wizard salva como variável de ambiente + +MASTER_PASSWORD=Vectra@179mr$$$HUB + +AUTH_SECRET=jaVBVCWDNboM0ctLGYlYpGkp3sFD/MukA3ytSaTe3E4= + +SMARTZAP_ADMIN_KEY=Vectra@179mr + +# (opcional mas recomendado) +SMARTZAP_API_KEY=Vectra@179mr$$ + +# ===== Banco (Supabase) ===== DATABASE_PROVIDER=supabase +NEXT_PUBLIC_SUPABASE_URL=https://bfxovdgjgoijwkekbftl.supabase.co +NEXT_PUBLIC_SUPABASE_ANON_KEY==eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImJmeG92ZGdqZ29pandrZWtiZnRsIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NjU3NTg5MDQsImV4cCI6MjA4MTMzNDkwNH0.aPbtNP_pAkmBGdSrdi4VqqvkxrVTTPivu2bo-F4ckZ8 +NEXT_PUBLIC_SUPABASE_PUBLISHABLE_DEFAULT_KEY=sb_secret_nFBQ7_EGAtnsSJF_x2jFXA_yCXbJQHU + +# ===== Upstash Redis (obrigatório p/ workflows) ===== +UPSTASH_REDIS_REST_URL=https://ready-sunbird-10099.upstash.io +UPSTASH_REDIS_REST_TOKEN=ASdzAAIncDI3YWZjZTM0MmE1OWQ0ODJhYjgyNDZlYzU1Y2I5NWNlMXAyMTAwOTk + +# ===== QStash (obrigatório p/ workflows) ===== +QSTASH_URL="https://qstash.upstash.io" +QSTASH_TOKEN=eyJVc2VySUQiOiIwZjNlNTY5Ny01NWYxLTQ0ZjItOWJiYS05OWNjOWU5OWM1MDMiLCJQYXNzd29yZCI6IjNiNmZlMDNmMDJhZDQ0ZGI5YzA2Y2VkYTU2Y2NjYjk1In0= +QSTASH_CURRENT_SIGNING_KEY=sig_6F8RbNs8QXBjqzuN4h7SpXKgHMfX +QSTASH_NEXT_SIGNING_KEY=sig_5rhRMUtxzFDeqdV1YkZhCLzayuP7 -# Turso (SQLite - legacy) -TURSO_DATABASE_URL=libsql://your-database.turso.io -TURSO_AUTH_TOKEN=your_turso_auth_token - -# Supabase (PostgreSQL - recommended) -NEXT_PUBLIC_SUPABASE_URL=https://your-project.supabase.co -NEXT_PUBLIC_SUPABASE_ANON_KEY=your_anon_key -SUPABASE_SERVICE_ROLE_KEY=your_service_role_key - -# ----- Vercel / App URL ----- -# NEXT_PUBLIC_APP_URL is optional. If not set, the system will auto-detect: -# 1. VERCEL_PROJECT_PRODUCTION_URL (stable production domain, auto-set by Vercel) -# 2. VERCEL_URL (deployment-specific URL, auto-set by Vercel) -# -# Only set this manually if you have a custom domain: -# NEXT_PUBLIC_APP_URL=https://your-custom-domain.com diff --git a/.eslintignore b/.eslintignore new file mode 100644 index 0000000..04999ab --- /dev/null +++ b/.eslintignore @@ -0,0 +1,6 @@ +node_modules +.next +dist +coverage +.next/standalone +.next/cache diff --git a/.eslintrc.cjs b/.eslintrc.cjs new file mode 100644 index 0000000..c655dad --- /dev/null +++ b/.eslintrc.cjs @@ -0,0 +1,16 @@ +/** @type {import("eslint").Linter.Config} */ +module.exports = { + extends: ["next/core-web-vitals"], + parser: "@typescript-eslint/parser", + plugins: ["@typescript-eslint"], + parserOptions: { + ecmaVersion: "latest", + sourceType: "module", + project: ["./tsconfig.json"], + tsconfigRootDir: __dirname, + }, + rules: { + "react/no-unescaped-entities": "off", + "@typescript-eslint/no-explicit-any": "off", + }, +}; diff --git a/.eslintrc.json b/.eslintrc.json new file mode 100644 index 0000000..fc1dba1 --- /dev/null +++ b/.eslintrc.json @@ -0,0 +1,9 @@ +{ + "extends": ["next/core-web-vitals"], + "plugins": ["@typescript-eslint"], + "parser": "@typescript-eslint/parser", + "rules": { + "react/no-unescaped-entities": "off", + "@typescript-eslint/no-explicit-any": "off" + } +} diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 0a986a1..8b18798 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -8,34 +8,41 @@ on: jobs: test: - name: Tests & Type Check + name: Tests, Lint & Type Check runs-on: ubuntu-latest - + steps: - name: Checkout uses: actions/checkout@v4 - + - name: Setup Node.js uses: actions/setup-node@v4 with: node-version: '20' cache: 'npm' - + - name: Install dependencies run: npm ci - + - name: Run type check run: npx tsc --noEmit - + - name: Run unit tests - run: npm test -- --run - + run: npm test -- --run --passWithNoTests + + - name: Run lint + run: npm run lint + + - name: Build + run: npm run build + - name: Upload test results uses: actions/upload-artifact@v4 if: always() with: name: test-results path: test-results/ + if-no-files-found: ignore retention-days: 7 # E2E tests are run locally before deploy diff --git a/.gitignore b/.gitignore index 4d8a23b..0bae916 100644 --- a/.gitignore +++ b/.gitignore @@ -51,3 +51,4 @@ coverage/ # Backups / Utility tmp/ +.env*.local diff --git a/SmartZap_API.postman_collection.json b/SmartZap_API.postman_collection.json new file mode 100644 index 0000000..03115dd --- /dev/null +++ b/SmartZap_API.postman_collection.json @@ -0,0 +1,486 @@ +{ + "info": { + "name": "SmartZap API", + "description": "Collection completa para testar todos os endpoints da API SmartZap", + "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json" + }, + "variable": [ + { + "key": "baseUrl", + "value": "https://implacable-destiny-unflamboyantly.ngrok-free.dev", + "type": "string" + }, + { + "key": "localUrl", + "value": "http://localhost:3000", + "type": "string" + } + ], + "item": [ + { + "name": "Campanhas", + "item": [ + { + "name": "Listar Todas as Campanhas", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/api/campaigns", + "host": ["{{baseUrl}}"], + "path": ["api", "campaigns"] + } + }, + "response": [] + }, + { + "name": "Criar Nova Campanha", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"name\": \"Promoção de Natal 2025\",\n \"templateName\": \"promo_natal\",\n \"recipients\": 5,\n \"scheduledAt\": \"2025-12-25T10:00:00Z\",\n \"contacts\": [\n {\n \"name\": \"João Silva\",\n \"phone\": \"+5511999999999\"\n },\n {\n \"name\": \"Maria Santos\",\n \"phone\": \"+5511988888888\"\n }\n ],\n \"templateVariables\": [\"João\", \"25/12\"]\n}" + }, + "url": { + "raw": "{{baseUrl}}/api/campaigns", + "host": ["{{baseUrl}}"], + "path": ["api", "campaigns"] + } + }, + "response": [] + }, + { + "name": "Buscar Campanha por ID", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/api/campaigns/:id", + "host": ["{{baseUrl}}"], + "path": ["api", "campaigns", ":id"], + "variable": [ + { + "key": "id", + "value": "seu-campaign-id-aqui" + } + ] + } + }, + "response": [] + }, + { + "name": "Atualizar Campanha", + "request": { + "method": "PUT", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"name\": \"Promoção Atualizada\",\n \"status\": \"PAUSED\"\n}" + }, + "url": { + "raw": "{{baseUrl}}/api/campaigns/:id", + "host": ["{{baseUrl}}"], + "path": ["api", "campaigns", ":id"], + "variable": [ + { + "key": "id", + "value": "seu-campaign-id-aqui" + } + ] + } + }, + "response": [] + }, + { + "name": "Deletar Campanha", + "request": { + "method": "DELETE", + "header": [], + "url": { + "raw": "{{baseUrl}}/api/campaigns/:id", + "host": ["{{baseUrl}}"], + "path": ["api", "campaigns", ":id"], + "variable": [ + { + "key": "id", + "value": "seu-campaign-id-aqui" + } + ] + } + }, + "response": [] + } + ] + }, + { + "name": "Contatos", + "item": [ + { + "name": "Listar Todos os Contatos", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/api/contacts", + "host": ["{{baseUrl}}"], + "path": ["api", "contacts"] + } + }, + "response": [] + }, + { + "name": "Adicionar Contato", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"name\": \"Maria Santos\",\n \"phone\": \"+5511988887777\",\n \"tags\": [\"cliente\", \"vip\"]\n}" + }, + "url": { + "raw": "{{baseUrl}}/api/contacts", + "host": ["{{baseUrl}}"], + "path": ["api", "contacts"] + } + }, + "response": [] + }, + { + "name": "Importar Contatos CSV", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"contacts\": [\n { \"name\": \"João Silva\", \"phone\": \"+5511999999999\" },\n { \"name\": \"Maria Santos\", \"phone\": \"+5511988888888\" },\n { \"name\": \"Pedro Oliveira\", \"phone\": \"+5511977777777\" }\n ]\n}" + }, + "url": { + "raw": "{{baseUrl}}/api/contacts/import", + "host": ["{{baseUrl}}"], + "path": ["api", "contacts", "import"] + } + }, + "response": [] + }, + { + "name": "Deletar Múltiplos Contatos", + "request": { + "method": "DELETE", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"contactIds\": [\"id1\", \"id2\", \"id3\"]\n}" + }, + "url": { + "raw": "{{baseUrl}}/api/contacts", + "host": ["{{baseUrl}}"], + "path": ["api", "contacts"] + } + }, + "response": [] + } + ] + }, + { + "name": "Webhook WhatsApp", + "item": [ + { + "name": "Verificação Webhook (Meta)", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/api/webhook?hub.mode=subscribe&hub.verify_token=seu-verify-token&hub.challenge=12345", + "host": ["{{baseUrl}}"], + "path": ["api", "webhook"], + "query": [ + { + "key": "hub.mode", + "value": "subscribe" + }, + { + "key": "hub.verify_token", + "value": "seu-verify-token", + "description": "Token configurado no Meta App" + }, + { + "key": "hub.challenge", + "value": "12345", + "description": "Código enviado pela Meta" + } + ] + } + }, + "response": [] + }, + { + "name": "Receber Mensagem WhatsApp", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"object\": \"whatsapp_business_account\",\n \"entry\": [\n {\n \"id\": \"WHATSAPP_BUSINESS_ACCOUNT_ID\",\n \"changes\": [\n {\n \"value\": {\n \"messaging_product\": \"whatsapp\",\n \"metadata\": {\n \"display_phone_number\": \"5511999999999\",\n \"phone_number_id\": \"PHONE_NUMBER_ID\"\n },\n \"contacts\": [\n {\n \"profile\": {\n \"name\": \"João Silva\"\n },\n \"wa_id\": \"5511988888888\"\n }\n ],\n \"messages\": [\n {\n \"from\": \"5511988888888\",\n \"id\": \"wamid.HBgNNTUxMTk4ODg4ODg4OA==\",\n \"timestamp\": \"1702910400\",\n \"type\": \"text\",\n \"text\": {\n \"body\": \"Olá, gostaria de saber mais sobre os produtos\"\n }\n }\n ]\n },\n \"field\": \"messages\"\n }\n ]\n }\n ]\n}" + }, + "url": { + "raw": "{{baseUrl}}/api/webhook", + "host": ["{{baseUrl}}"], + "path": ["api", "webhook"] + } + }, + "response": [] + }, + { + "name": "Status de Entrega (Webhook)", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"object\": \"whatsapp_business_account\",\n \"entry\": [\n {\n \"id\": \"WHATSAPP_BUSINESS_ACCOUNT_ID\",\n \"changes\": [\n {\n \"value\": {\n \"messaging_product\": \"whatsapp\",\n \"metadata\": {\n \"display_phone_number\": \"5511999999999\",\n \"phone_number_id\": \"PHONE_NUMBER_ID\"\n },\n \"statuses\": [\n {\n \"id\": \"wamid.HBgNNTUxMTk4ODg4ODg4OA==\",\n \"status\": \"delivered\",\n \"timestamp\": \"1702910400\",\n \"recipient_id\": \"5511988888888\"\n }\n ]\n },\n \"field\": \"messages\"\n }\n ]\n }\n ]\n}" + }, + "url": { + "raw": "{{baseUrl}}/api/webhook", + "host": ["{{baseUrl}}"], + "path": ["api", "webhook"] + } + }, + "response": [] + } + ] + }, + { + "name": "Dashboard", + "item": [ + { + "name": "Estatísticas do Dashboard", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/api/dashboard", + "host": ["{{baseUrl}}"], + "path": ["api", "dashboard"] + } + }, + "response": [] + } + ] + }, + { + "name": "Templates", + "item": [ + { + "name": "Listar Templates", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/api/templates", + "host": ["{{baseUrl}}"], + "path": ["api", "templates"] + } + }, + "response": [] + }, + { + "name": "Buscar Templates da Meta", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/api/templates/fetch", + "host": ["{{baseUrl}}"], + "path": ["api", "templates", "fetch"] + } + }, + "response": [] + } + ] + }, + { + "name": "Agentes de IA", + "item": [ + { + "name": "Listar Agentes", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/api/ai-agents", + "host": ["{{baseUrl}}"], + "path": ["api", "ai-agents"] + } + }, + "response": [] + }, + { + "name": "Criar Agente de IA", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"name\": \"Assistente de Vendas\",\n \"systemPrompt\": \"Você é um assistente de vendas que ajuda clientes a escolher produtos.\",\n \"model\": \"gemini-pro\",\n \"temperature\": 0.7\n}" + }, + "url": { + "raw": "{{baseUrl}}/api/ai-agents", + "host": ["{{baseUrl}}"], + "path": ["api", "ai-agents"] + } + }, + "response": [] + } + ] + }, + { + "name": "Conversas", + "item": [ + { + "name": "Listar Conversas", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/api/conversations", + "host": ["{{baseUrl}}"], + "path": ["api", "conversations"] + } + }, + "response": [] + }, + { + "name": "Buscar Conversa por ID", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/api/conversations/:id", + "host": ["{{baseUrl}}"], + "path": ["api", "conversations", ":id"], + "variable": [ + { + "key": "id", + "value": "seu-conversation-id-aqui" + } + ] + } + }, + "response": [] + } + ] + }, + { + "name": "Configurações", + "item": [ + { + "name": "Obter Configurações WhatsApp", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/api/settings/whatsapp", + "host": ["{{baseUrl}}"], + "path": ["api", "settings", "whatsapp"] + } + }, + "response": [] + }, + { + "name": "Atualizar Configurações WhatsApp", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"phoneNumberId\": \"seu-phone-number-id\",\n \"businessAccountId\": \"seu-business-account-id\",\n \"accessToken\": \"seu-access-token\",\n \"displayPhoneNumber\": \"+5511999999999\",\n \"verifiedName\": \"Sua Empresa\"\n}" + }, + "url": { + "raw": "{{baseUrl}}/api/settings/whatsapp", + "host": ["{{baseUrl}}"], + "path": ["api", "settings", "whatsapp"] + } + }, + "response": [] + } + ] + }, + { + "name": "System Health", + "item": [ + { + "name": "Health Check", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/api/health", + "host": ["{{baseUrl}}"], + "path": ["api", "health"] + } + }, + "response": [] + }, + { + "name": "Status do Sistema", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/api/system/status", + "host": ["{{baseUrl}}"], + "path": ["api", "system", "status"] + } + }, + "response": [] + } + ] + } + ] +} diff --git a/app/(auth)/setup/wizard/page.tsx b/app/(auth)/setup/wizard/page.tsx index 49cd6bb..c70345d 100644 --- a/app/(auth)/setup/wizard/page.tsx +++ b/app/(auth)/setup/wizard/page.tsx @@ -859,7 +859,7 @@ function WizardContent() { className="w-full bg-zinc-800 border border-zinc-700 rounded-xl px-4 py-3 text-white placeholder:text-zinc-500 focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:border-transparent font-mono text-sm" />
- Use o "Connection Pooler" (porta 6543) para melhor compatibilidade + Use o "Connection Pooler" (porta 6543) para melhor compatibilidade
diff --git a/app/(dashboard)/DashboardShell.tsx b/app/(dashboard)/DashboardShell.tsx index b8b059a..d63f9a5 100644 --- a/app/(dashboard)/DashboardShell.tsx +++ b/app/(dashboard)/DashboardShell.tsx @@ -385,7 +385,7 @@ const OnboardingOverlay = ({ Complete os passos acima na ordem para liberar o acesso ao sistema.

- Após configurar cada serviço no Vercel, clique em "Verificar novamente". + Após configurar cada serviço no Vercel, clique em "Verificar novamente".

) diff --git a/app/(dashboard)/loading.tsx b/app/(dashboard)/loading.tsx index 8bf5a08..5293864 100644 --- a/app/(dashboard)/loading.tsx +++ b/app/(dashboard)/loading.tsx @@ -1,5 +1,7 @@ import { Skeleton } from "@/components/ui/skeleton" +const CHART_BAR_HEIGHTS = [20, 55, 38, 72, 45, 60, 30, 80, 50, 65, 40, 70] + export default function DashboardLoading() { return (
@@ -35,7 +37,11 @@ export default function DashboardLoading() {
{Array.from({ length: 12 }).map((_, i) => ( - + ))}
diff --git a/app/api/account/limits/route.ts b/app/api/account/limits/route.ts index 2f3449f..bd8e5be 100644 --- a/app/api/account/limits/route.ts +++ b/app/api/account/limits/route.ts @@ -12,15 +12,19 @@ const TIER_LIMITS: Record = { } // Shared logic to fetch limits from Meta API -async function fetchLimitsFromMeta(phoneNumberId: string, accessToken: string) { +async function fetchLimitsFromMeta( + phoneNumberId: string, + businessAccountId: string, + accessToken: string +) { // Parallel fetch for throughput/quality and messaging tier const [throughputResponse, tierResponse] = await Promise.all([ fetch( - `https://graph.facebook.com/v24.0/${phoneNumberId}?fields=throughput,quality_score`, + `https://graph.facebook.com/v24.0/${phoneNumberId}?fields=throughput,quality_rating,quality_score`, { headers: { 'Authorization': `Bearer ${accessToken}` } } ), fetch( - `https://graph.facebook.com/v24.0/${phoneNumberId}?fields=whatsapp_business_manager_messaging_limit`, + `https://graph.facebook.com/v24.0/${businessAccountId}?fields=whatsapp_business_manager_messaging_limit`, { headers: { 'Authorization': `Bearer ${accessToken}` } } ), ]) @@ -41,7 +45,9 @@ async function fetchLimitsFromMeta(phoneNumberId: string, accessToken: string) { const throughputLevel = throughputData.throughput?.level === 'high' ? 'HIGH' : 'STANDARD' // Parse quality score - const rawQuality = throughputData.quality_score?.score?.toUpperCase() + const rawQuality = + throughputData.quality_rating?.toUpperCase?.() || + throughputData.quality_score?.score?.toUpperCase?.() const qualityScore = ['GREEN', 'YELLOW', 'RED'].includes(rawQuality) ? rawQuality : 'UNKNOWN' // Parse messaging tier @@ -71,7 +77,7 @@ async function fetchLimitsFromMeta(phoneNumberId: string, accessToken: string) { export async function GET() { const credentials = await getWhatsAppCredentials() - if (!credentials?.phoneNumberId || !credentials?.accessToken) { + if (!credentials?.phoneNumberId || !credentials?.businessAccountId || !credentials?.accessToken) { return NextResponse.json({ error: 'NO_CREDENTIALS', message: 'Credenciais do WhatsApp não configuradas. Configure em Ajustes.' @@ -79,7 +85,11 @@ export async function GET() { } try { - const limits = await fetchLimitsFromMeta(credentials.phoneNumberId, credentials.accessToken) + const limits = await fetchLimitsFromMeta( + credentials.phoneNumberId, + credentials.businessAccountId, + credentials.accessToken + ) return NextResponse.json(limits) } catch (error) { console.error('❌ Error fetching account limits:', error) @@ -94,6 +104,7 @@ export async function GET() { // POST /api/account/limits - Fetch limits (with optional body credentials, fallback to Redis) export async function POST(request: NextRequest) { let phoneNumberId: string | undefined + let businessAccountId: string | undefined let accessToken: string | undefined // Try to get from request body first @@ -102,6 +113,7 @@ export async function POST(request: NextRequest) { // Only use if they look like real credentials (not masked) if (body.phoneNumberId && body.accessToken && !body.accessToken.includes('***')) { phoneNumberId = body.phoneNumberId + businessAccountId = body.businessAccountId accessToken = body.accessToken } } catch { @@ -109,15 +121,16 @@ export async function POST(request: NextRequest) { } // Fallback to Redis credentials if not provided - if (!phoneNumberId || !accessToken) { + if (!phoneNumberId || !businessAccountId || !accessToken) { const credentials = await getWhatsAppCredentials() if (credentials) { phoneNumberId = credentials.phoneNumberId + businessAccountId = credentials.businessAccountId accessToken = credentials.accessToken } } - if (!phoneNumberId || !accessToken) { + if (!phoneNumberId || !businessAccountId || !accessToken) { return NextResponse.json({ error: 'NO_CREDENTIALS', message: 'Credenciais do WhatsApp não configuradas. Configure em Ajustes.' @@ -125,7 +138,7 @@ export async function POST(request: NextRequest) { } try { - const limits = await fetchLimitsFromMeta(phoneNumberId, accessToken) + const limits = await fetchLimitsFromMeta(phoneNumberId, businessAccountId, accessToken) return NextResponse.json(limits) } catch (error) { console.error('❌ Error fetching account limits:', error) diff --git a/app/api/phone-numbers/[phoneNumberId]/webhook/override/route.ts b/app/api/phone-numbers/[phoneNumberId]/webhook/override/route.ts index 05a73c4..301c254 100644 --- a/app/api/phone-numbers/[phoneNumberId]/webhook/override/route.ts +++ b/app/api/phone-numbers/[phoneNumberId]/webhook/override/route.ts @@ -1,6 +1,6 @@ import { NextRequest, NextResponse } from 'next/server'; import { getWhatsAppCredentials } from '@/lib/whatsapp-credentials'; -import { redis, isRedisAvailable } from '@/lib/redis'; +import { settingsDb } from '@/lib/supabase-db'; const META_API_VERSION = 'v21.0'; const META_API_BASE = `https://graph.facebook.com/${META_API_VERSION}`; @@ -9,19 +9,26 @@ interface RouteContext { params: Promise<{ phoneNumberId: string }>; } -// Get verify token from Redis (same logic as webhook endpoint) +// Get or generate webhook verify token (Supabase settings preferred, env var fallback) async function getVerifyToken(): Promise { - if (isRedisAvailable() && redis) { - const storedToken = await redis.get('webhook:verify_token'); + try { + const storedToken = await settingsDb.get('webhook_verify_token'); if (storedToken) { - return storedToken as string; + return storedToken; } - // Generate new UUID token and store in Redis + const newToken = crypto.randomUUID(); - await redis.set('webhook:verify_token', newToken); + await settingsDb.set('webhook_verify_token', newToken); return newToken; + } catch { + if (process.env.WEBHOOK_VERIFY_TOKEN) { + return process.env.WEBHOOK_VERIFY_TOKEN.trim(); + } + if (process.env.WHATSAPP_VERIFY_TOKEN) { + return process.env.WHATSAPP_VERIFY_TOKEN.trim(); + } + return 'not-configured'; } - return process.env.WEBHOOK_VERIFY_TOKEN || 'smartzap_verify_token'; } /** @@ -69,8 +76,8 @@ export async function POST(request: NextRequest, context: RouteContext) { ); } - // Get verify token from Redis (ensures consistency with webhook endpoint) - const verifyTokenFromRedis = await getVerifyToken(); + // Use the same verify token as the main webhook endpoint + const verifyToken = await getVerifyToken(); // Call Meta API to set webhook override on phone number // Reference: https://developers.facebook.com/docs/whatsapp/cloud-api/webhooks/override @@ -85,7 +92,7 @@ export async function POST(request: NextRequest, context: RouteContext) { body: JSON.stringify({ webhook_configuration: { override_callback_uri: callbackUrl, - verify_token: verifyTokenFromRedis, + verify_token: verifyToken, }, }), } diff --git a/app/api/settings/ai/route.ts b/app/api/settings/ai/route.ts index 291293f..635cd82 100644 --- a/app/api/settings/ai/route.ts +++ b/app/api/settings/ai/route.ts @@ -36,7 +36,9 @@ async function validateApiKey(provider: string, apiKey: string): Promise<{ valid await generateText({ model, prompt: 'Hi', - maxOutputTokens: 5, + // Some providers enforce a minimum output token limit (e.g. >= 16) + // Keep this low but valid to avoid false "invalid key" errors. + maxOutputTokens: 16, }) return { valid: true } diff --git a/app/api/settings/credentials/route.ts b/app/api/settings/credentials/route.ts index a27d49e..9f0aa4b 100644 --- a/app/api/settings/credentials/route.ts +++ b/app/api/settings/credentials/route.ts @@ -14,9 +14,9 @@ interface WhatsAppCredentials { // GET - Fetch credentials from env (without exposing full token) export async function GET() { try { - const phoneNumberId = process.env.WHATSAPP_PHONE_ID - const businessAccountId = process.env.WHATSAPP_BUSINESS_ACCOUNT_ID - const accessToken = process.env.WHATSAPP_TOKEN + const phoneNumberId = process.env.WHATSAPP_PHONE_ID?.trim() + const businessAccountId = process.env.WHATSAPP_BUSINESS_ACCOUNT_ID?.trim() + const accessToken = process.env.WHATSAPP_TOKEN?.trim() if (phoneNumberId && businessAccountId && accessToken) { // Fetch display phone number from Meta API @@ -67,7 +67,9 @@ export async function GET() { export async function POST(request: NextRequest) { try { const body = await request.json() - const { phoneNumberId, businessAccountId, accessToken } = body + const phoneNumberId = typeof body?.phoneNumberId === 'string' ? body.phoneNumberId.trim() : '' + const businessAccountId = typeof body?.businessAccountId === 'string' ? body.businessAccountId.trim() : '' + const accessToken = typeof body?.accessToken === 'string' ? body.accessToken.trim() : '' if (!phoneNumberId || !businessAccountId || !accessToken) { return NextResponse.json( @@ -76,28 +78,55 @@ export async function POST(request: NextRequest) { ) } - // Validate token by making a test call to Meta API - const testResponse = await fetch( + // Validate PHONE_NUMBER_ID (must be a WhatsApp phone number node) + const phoneResponse = await fetch( `https://graph.facebook.com/v24.0/${phoneNumberId}?fields=display_phone_number,verified_name,quality_rating`, - { - headers: { - 'Authorization': `Bearer ${accessToken}`, - }, - } + { headers: { 'Authorization': `Bearer ${accessToken}` } } ) - if (!testResponse.ok) { - const error = await testResponse.json() + if (!phoneResponse.ok) { + const error = await phoneResponse.json().catch(() => ({})) + const message = error?.error?.message || 'Unknown error' + + // Common pitfall: user pasted Business Account ID into Phone Number ID + if (typeof message === 'string' && message.includes('Tried accessing nonexisting field') && message.includes('WhatsAppBusinessAccount')) { + return NextResponse.json( + { + error: 'IDs do WhatsApp parecem estar trocados', + details: 'Você colocou o WhatsApp Business Account ID no campo Phone Number ID. O Phone Number ID é o ID do número (phone_number_id), não o WABA.', + }, + { status: 400 } + ) + } + return NextResponse.json( { - error: 'Invalid credentials - Meta API rejected the token', - details: error.error?.message || 'Unknown error' + error: 'Credenciais inválidas - Meta API rejeitou a validação do Phone Number ID', + details: message, }, { status: 401 } ) } - const phoneData = await testResponse.json() + const phoneData = await phoneResponse.json() + + // Validate BUSINESS_ACCOUNT_ID (must be a WhatsApp Business Account node) + const businessResponse = await fetch( + `https://graph.facebook.com/v24.0/${businessAccountId}/phone_numbers?fields=id&limit=1`, + { headers: { 'Authorization': `Bearer ${accessToken}` } } + ) + + if (!businessResponse.ok) { + const error = await businessResponse.json().catch(() => ({})) + const message = error?.error?.message || 'Unknown error' + return NextResponse.json( + { + error: 'Credenciais inválidas - Meta API rejeitou a validação do Business Account ID', + details: message, + }, + { status: 401 } + ) + } // Note: Credentials are stored in Vercel env vars via the setup wizard // This endpoint only validates them diff --git a/app/api/setup/validate/route.ts b/app/api/setup/validate/route.ts index 27f0f47..9d47eb2 100644 --- a/app/api/setup/validate/route.ts +++ b/app/api/setup/validate/route.ts @@ -206,29 +206,44 @@ async function validateWhatsApp(credentials: Record) { } try { - // Test WhatsApp by getting phone number info - const response = await fetch( - `https://graph.facebook.com/v21.0/${phoneId}`, - { - headers: { - 'Authorization': `Bearer ${token}`, - }, - } + // 1) Validate phone number id (PHONE_NUMBER_ID) + const phoneRes = await fetch( + `https://graph.facebook.com/v24.0/${phoneId}?fields=display_phone_number,verified_name,quality_rating`, + { headers: { 'Authorization': `Bearer ${token}` } } ) - if (!response.ok) { - const error = await response.json() - return NextResponse.json({ - valid: false, - error: error.error?.message || 'Token ou Phone ID inválido' - }) + if (!phoneRes.ok) { + const error = await phoneRes.json().catch(() => ({})) + const message = error?.error?.message || 'Token ou Phone ID inválido' + + // Friendly hint when user swapped IDs + if (typeof message === 'string' && message.includes('Tried accessing nonexisting field') && message.includes('WhatsAppBusinessAccount')) { + return NextResponse.json({ + valid: false, + error: 'IDs parecem estar trocados: no campo Phone Number ID você colocou o Business Account ID (WABA).' + }) + } + + return NextResponse.json({ valid: false, error: message }) } - const data = await response.json() + const phoneData = await phoneRes.json() + + // 2) Validate business account id (WABA) can list phone numbers + const businessRes = await fetch( + `https://graph.facebook.com/v24.0/${businessId}/phone_numbers?fields=id&limit=1`, + { headers: { 'Authorization': `Bearer ${token}` } } + ) + + if (!businessRes.ok) { + const error = await businessRes.json().catch(() => ({})) + const message = error?.error?.message || 'Business Account ID inválido' + return NextResponse.json({ valid: false, error: message }) + } return NextResponse.json({ valid: true, - message: `WhatsApp OK! (${data.verified_name || data.display_phone_number || 'Conectado'})` + message: `WhatsApp OK! (${phoneData.verified_name || phoneData.display_phone_number || 'Conectado'})` }) } catch (error) { console.error('WhatsApp validation error:', error) diff --git a/app/api/webhook/info/route.ts b/app/api/webhook/info/route.ts index eaec265..c158c53 100644 --- a/app/api/webhook/info/route.ts +++ b/app/api/webhook/info/route.ts @@ -20,6 +20,9 @@ async function getVerifyToken(): Promise { if (process.env.WEBHOOK_VERIFY_TOKEN) { return process.env.WEBHOOK_VERIFY_TOKEN.trim() } + if (process.env.WHATSAPP_VERIFY_TOKEN) { + return process.env.WHATSAPP_VERIFY_TOKEN.trim() + } return 'not-configured' } } diff --git a/app/api/webhook/route.ts b/app/api/webhook/route.ts index 5693f07..fa05025 100644 --- a/app/api/webhook/route.ts +++ b/app/api/webhook/route.ts @@ -48,6 +48,9 @@ async function getVerifyToken(): Promise { if (process.env.WEBHOOK_VERIFY_TOKEN) { return process.env.WEBHOOK_VERIFY_TOKEN.trim() } + if (process.env.WHATSAPP_VERIFY_TOKEN) { + return process.env.WHATSAPP_VERIFY_TOKEN.trim() + } return 'not-configured' } } diff --git a/app/auth/callback/route.ts b/app/auth/callback/route.ts new file mode 100644 index 0000000..4d8876a --- /dev/null +++ b/app/auth/callback/route.ts @@ -0,0 +1,76 @@ +import { createClient } from '@/utils/supabase/server' +import { NextResponse } from 'next/server' + +/** + * Auth Callback Route + * + * Handles Supabase Auth callbacks for: + * - Email confirmation + * - Password reset + * - Magic link login + * - OAuth callbacks + * + * Supabase sends tokens in different ways: + * 1. PKCE flow: ?code=xxx + * 2. Email links: ?token_hash=xxx&type=recovery + * 3. After verification: redirects with session in hash fragment + */ +export async function GET(request: Request) { + const { searchParams, origin } = new URL(request.url) + const code = searchParams.get('code') + const token_hash = searchParams.get('token_hash') + const type = searchParams.get('type') + const next = searchParams.get('next') ?? '/' + const error = searchParams.get('error') + const error_description = searchParams.get('error_description') + + // If there's an error from Supabase + if (error) { + console.error('[auth/callback] Error:', error, error_description) + return NextResponse.redirect(`${origin}/login?error=${encodeURIComponent(error_description || error)}`) + } + + // Handle PKCE code exchange + if (code) { + const supabase = await createClient() + const { error: exchangeError } = await supabase.auth.exchangeCodeForSession(code) + + if (!exchangeError) { + // Redirect based on auth type + if (type === 'recovery') { + return NextResponse.redirect(`${origin}/auth/reset-password`) + } + return NextResponse.redirect(`${origin}${next}`) + } + console.error('[auth/callback] Code exchange error:', exchangeError) + } + + // Handle token hash (email links) + if (token_hash && type) { + const supabase = await createClient() + const { error: verifyError } = await supabase.auth.verifyOtp({ + token_hash, + type: type as 'recovery' | 'signup' | 'invite' | 'email', + }) + + if (!verifyError) { + if (type === 'recovery' || type === 'invite') { + return NextResponse.redirect(`${origin}/auth/reset-password`) + } + return NextResponse.redirect(`${origin}${next}`) + } + console.error('[auth/callback] OTP verify error:', verifyError) + } + + // If we get here with no code/token, Supabase already processed the token + // and is redirecting with session info in hash fragment. + // The session should already be set, redirect to reset password page. + // Check if this is a recovery flow by looking at the redirect path + if (type === 'recovery' || type === 'invite') { + return NextResponse.redirect(`${origin}/auth/reset-password`) + } + + // Default: redirect to reset-password since this callback is typically + // used after email verification for password reset + return NextResponse.redirect(`${origin}/auth/reset-password`) +} diff --git a/app/auth/reset-password/page.tsx b/app/auth/reset-password/page.tsx new file mode 100644 index 0000000..3228ab7 --- /dev/null +++ b/app/auth/reset-password/page.tsx @@ -0,0 +1,179 @@ +'use client' + +/** + * Reset Password Page + * + * Allows users to set a new password after clicking reset link + */ + +import { useState } from 'react' +import { useRouter } from 'next/navigation' +import { Lock, Eye, EyeOff, Check } from 'lucide-react' +import { createClient } from '@/utils/supabase/client' + +export default function ResetPasswordPage() { + const router = useRouter() + const [password, setPassword] = useState('') + const [confirmPassword, setConfirmPassword] = useState('') + const [showPassword, setShowPassword] = useState(false) + const [showConfirmPassword, setShowConfirmPassword] = useState(false) + const [error, setError] = useState('') + const [success, setSuccess] = useState(false) + const [isLoading, setIsLoading] = useState(false) + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + setError('') + + if (!password) { + setError('Digite a nova senha') + return + } + + if (password.length < 6) { + setError('A senha deve ter pelo menos 6 caracteres') + return + } + + if (password !== confirmPassword) { + setError('As senhas não coincidem') + return + } + + setIsLoading(true) + + try { + const supabase = createClient() + const { error } = await supabase.auth.updateUser({ + password: password, + }) + + if (error) { + throw error + } + + setSuccess(true) + + // Redirect to login after 2 seconds + setTimeout(() => { + router.push('/login') + }, 2000) + } catch (err) { + setError(err instanceof Error ? err.message : 'Erro ao atualizar senha') + } finally { + setIsLoading(false) + } + } + + if (success) { + return ( +
+
+
+
+ +
+

Senha alterada!

+

Redirecionando para o login...

+
+
+
+ ) + } + + return ( +
+
+ {/* Header */} +
+
+ S +
+

SmartZap

+

Defina sua nova senha

+
+ + {/* Card */} +
+
+ {/* New Password */} +
+ +
+ + setPassword(e.target.value)} + placeholder="Mínimo 6 caracteres" + className="w-full bg-zinc-800 border border-zinc-700 rounded-xl pl-11 pr-11 py-3 text-white placeholder:text-zinc-500 focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:border-transparent" + autoFocus + /> + +
+
+ + {/* Confirm Password */} +
+ +
+ + setConfirmPassword(e.target.value)} + placeholder="Digite novamente" + className="w-full bg-zinc-800 border border-zinc-700 rounded-xl pl-11 pr-11 py-3 text-white placeholder:text-zinc-500 focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:border-transparent" + /> + +
+
+ + {error &&

{error}

} + + +
+
+ + {/* Footer */} +

+ SmartZap © {new Date().getFullYear()} +

+
+
+ ) +} diff --git a/app/layout.tsx b/app/layout.tsx index a268b65..cbef18b 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -3,6 +3,7 @@ import { Inter } from 'next/font/google' import './globals.css' import { Providers } from './providers' import { Toaster } from 'sonner' +import { Analytics } from '@vercel/analytics/next' const inter = Inter({ subsets: ['latin'], @@ -26,6 +27,7 @@ export default function RootLayout({ {children} + ) diff --git a/components/features/dashboard/DashboardView.tsx b/components/features/dashboard/DashboardView.tsx index bbd585a..9e18e87 100644 --- a/components/features/dashboard/DashboardView.tsx +++ b/components/features/dashboard/DashboardView.tsx @@ -76,32 +76,30 @@ const StatusBadge = ({ status }: { status: CampaignStatus }) => { ); }; -export const DashboardView: React.FC = ({ stats, recentCampaigns, isLoading }) => { - // Skeleton loader for stats cards - const StatSkeleton = () => ( -
-
-
-
-
-
-
-
-
+const StatSkeleton = () => ( +
+
+
+
- ); +
+
+
+
+
+); - // Skeleton loader for campaign rows - const CampaignSkeleton = () => ( -
-
-
-
-
-
+const CampaignSkeleton = () => ( +
+
+
+
- ); +
+
+); +export const DashboardView: React.FC = ({ stats, recentCampaigns, isLoading }) => { return (
diff --git a/components/features/settings/AISettings.tsx b/components/features/settings/AISettings.tsx index 8ddac5d..fe2f3ab 100644 --- a/components/features/settings/AISettings.tsx +++ b/components/features/settings/AISettings.tsx @@ -88,7 +88,7 @@ export const AISettings: React.FC = ({ // Clear any error message when switching providers setErrorMessage(null); } - }, [selectedProvider, hasInitialized]); + }, [selectedProvider, hasInitialized, selectedModel]); const handleSave = async () => { if (isEditing && !apiKey.trim()) { diff --git a/components/features/settings/SetupWizardView.tsx b/components/features/settings/SetupWizardView.tsx index cd2fe7a..db6a8db 100644 --- a/components/features/settings/SetupWizardView.tsx +++ b/components/features/settings/SetupWizardView.tsx @@ -255,7 +255,7 @@ export const SetupWizardView: React.FC = ({ Complete os passos acima para liberar as configurações do WhatsApp.

- Após adicionar Upstash no Vercel, clique em "Verificar novamente" para atualizar. + Após adicionar Upstash no Vercel, clique em "Verificar novamente" para atualizar.

)} diff --git a/components/features/templates/BulkGenerationModal.tsx b/components/features/templates/BulkGenerationModal.tsx index c2ff520..ee50585 100644 --- a/components/features/templates/BulkGenerationModal.tsx +++ b/components/features/templates/BulkGenerationModal.tsx @@ -64,12 +64,10 @@ export const BulkGenerationModal: React.FC = ({ submitLabel = 'Submeter Lote', submitIcon = }) => { - if (!isOpen) return null; - const hasTemplates = generatedTemplates.length > 0; // Detect if selected templates have URL or PHONE buttons - const buttonAnalysis = useMemo(() => { + const buttonAnalysis = (() => { const selectedList = generatedTemplates.filter(t => selectedTemplates.has(t.id)); let hasUrlButtons = false; let hasPhoneButtons = false; @@ -92,7 +90,9 @@ export const BulkGenerationModal: React.FC = ({ } return { hasUrlButtons, hasPhoneButtons, urlButtonCount, phoneButtonCount }; - }, [generatedTemplates, selectedTemplates]); + })(); + + if (!isOpen) return null; // Validation: can submit only if required fields are filled const isUrlValid = !buttonAnalysis.hasUrlButtons || (universalUrl.startsWith('http://') || universalUrl.startsWith('https://')); @@ -357,7 +357,7 @@ export const BulkGenerationModal: React.FC = ({ {!hasTemplates ? ( <>
- Ex: "Promoção de relâmpago" (min. 10 caracteres) + Ex: "Promoção de relâmpago" (min. 10 caracteres)