diff --git a/.env.example b/.env.example index 7c94b31..2b5faaa 100644 --- a/.env.example +++ b/.env.example @@ -1 +1,80 @@ -OPENAI_API_KEY=your_key_here +# Escolha um provedor (opcional): openai | anthropic | google +# Se não definir, usa o primeiro com chave configurada. +# DARSHAN_AI_PROVIDER=openai + +# ========== USO GRATUITO (recomendado para dev) ========== +# Google Gemini tem tier gratuito com limite de requisições/dia. +# 1. Acesse https://aistudio.google.com/apikey +# 2. Crie uma API key (conta Google) +# 3. Defina só a chave abaixo; o app usará Gemini automaticamente. +# GOOGLE_AI_API_KEY=sua_chave_gemini_aqui +# DARSHAN_AI_PROVIDER=google + +# ========== OpenAI (GPT) – pago após créditos iniciais ========== +# OPENAI_API_KEY=your_openai_key_here +# OPENAI_MODEL=gpt-4o-mini + +# ========== Anthropic (Claude) – pago ========== +# ANTHROPIC_API_KEY=your_anthropic_key_here +# ANTHROPIC_MODEL=claude-sonnet-4-20250514 + +# ========== Google (Gemini) – tier gratuito disponível ========== +# GOOGLE_AI_API_KEY=your_google_key_here +# GOOGLE_MODEL=gemini-2.5-flash + +# ========== Configuração editável (sementes da IA) ========== +# Chave para acessar /config e GET|PUT /api/config. Defina para usar a página de configuração. +# CONFIG_SECRET=your_secret_key_here +# Pasta onde será criado data/config.json (opcional; padrão: ./data) +# DATA_DIR=./data + +# ========== reCAPTCHA v2 (página /config) ========== +# Chaves em https://www.google.com/recaptcha/admin (tipo reCAPTCHA v2 "Não sou um robô"). +# Se não definir, a página /config aceita apenas o código secreto (sem captcha). +# NEXT_PUBLIC_RECAPTCHA_SITE_KEY=your_site_key_here +# RECAPTCHA_SECRET_KEY=your_secret_key_here + +# ========== Log e auditoria ========== +# Pasta base para logs (app.log e audit.log em /logs/). Se não definir, usa DATA_DIR ou ./data. +# LOG_DIR=./data +# Nível mínimo de log: debug | info | warn | error (em produção o padrão é info). +# LOG_LEVEL=info + +# ========== Login com Google (SSO) ========== +# Crie credenciais em https://console.cloud.google.com/apis/credentials (tipo "Aplicativo da Web"). +# URIs de redirecionamento autorizados: https://seu-dominio.com/api/auth/callback/google e http://localhost:3000/api/auth/callback/google +# GOOGLE_CLIENT_ID=your_google_oauth_client_id +# GOOGLE_CLIENT_SECRET=your_google_oauth_client_secret + +# ========== E-mail (Resend) – código de login ========== +# Em produção, send-code envia o código por e-mail. Crie API key em https://resend.com +# RESEND_API_KEY=re_xxxxxxxxxxxx +# E-mail remetente (domínio verificado no Resend) +# RESEND_FROM=Darshan + +# ========== Pagamento (Stripe Checkout – cartão, Google Pay, Link) ========== +# Chave secreta em https://dashboard.stripe.com/apikeys. Em checkout/create, defina success/cancel URLs. +# STRIPE_SECRET_KEY=sk_test_xxxxxxxxxxxx +# Webhook: em https://dashboard.stripe.com/webhooks adicione o endpoint (ex.: https://seu-dominio.com/api/webhooks/stripe) +# e selecione o evento checkout.session.completed. Use o signing secret (whsec_...) em STRIPE_WEBHOOK_SECRET. +# STRIPE_WEBHOOK_SECRET=whsec_xxxxxxxxxxxx + +# ========== Pagamento (Mercado Pago – PIX, cartão, etc.) ========== +# Access Token em https://www.mercadopago.com.br/developers/panel/app (credenciais da aplicação). +# O modal de créditos tenta Mercado Pago primeiro; se não configurado, usa Stripe. +# MERCADOPAGO_ACCESS_TOKEN=APP_USR-xxxxxxxxxxxx + +# ========== Taxa da plataforma (margem) ========== +# Percentual 0–100 usado em descrições e meta de custo IA vs faturamento. Padrão: 30. +# PLATFORM_FEE_PERCENT=30 + +# ========== Limites de consumo de IA (docs/PRECIFICACAO_CREDITOS.md) ========== +# Requisições por minuto por usuário. 0 = desativado. Padrão: 5. +# RATE_LIMIT_PER_MINUTE=5 +# Respostas de IA por dia por usuário. 0 = desativado. Padrão: 30. +# DAILY_AI_LIMIT=30 + +# ========== Módulo financeiro (Supabase) ========== +# Para log de uso de IA, ledger de créditos e exportação CSV. Sem isso, créditos continuam só em cookie. +# SUPABASE_URL=https://seu-projeto.supabase.co +# SUPABASE_SERVICE_KEY=eyJhbG... (service role key no Dashboard > Settings > API) diff --git a/.eslintrc.json b/.eslintrc.json new file mode 100644 index 0000000..957cd15 --- /dev/null +++ b/.eslintrc.json @@ -0,0 +1,3 @@ +{ + "extends": ["next/core-web-vitals"] +} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..ac4a56c --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,78 @@ +# Esteira CI: lint, test e build em todo push e PR +name: CI + +on: + push: + branches: [main, master] + pull_request: + branches: [main, master] + +defaults: + run: + working-directory: . + +jobs: + lint: + name: Lint + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: "20" + cache: "npm" + + - name: Install + run: npm ci + + - name: Lint + run: npm run lint + + test: + name: Test + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: "20" + cache: "npm" + + - name: Install + run: npm ci + + - name: Test + run: npm run test + + build: + name: Build + runs-on: ubuntu-latest + needs: [lint, test] + steps: + - uses: actions/checkout@v4 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: "20" + cache: "npm" + + - name: Install + run: npm ci + + - name: Build + run: npm run build + env: + # Build sem env sensíveis; variáveis vazias para não quebrar + NEXT_PUBLIC_VERCEL_URL: "" + + - name: Upload build + uses: actions/upload-artifact@v4 + with: + name: build + path: build/ + retention-days: 1 diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 0000000..2912525 --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,86 @@ +# Esteira Deploy: push em main → build + deploy Vercel (produção) +name: Deploy + +on: + push: + branches: [main, master] + workflow_dispatch: + +defaults: + run: + working-directory: . + +env: + VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }} + VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }} + +jobs: + deploy: + name: Build & Deploy (Vercel) + runs-on: ubuntu-latest + steps: + - name: Check secrets + id: secrets + run: | + if [ -z "${{ secrets.VERCEL_TOKEN }}" ]; then + echo "::error title=Secret ausente::VERCEL_TOKEN não configurado. Em Settings > Secrets and variables > Actions, adicione VERCEL_TOKEN (token em https://vercel.com/account/tokens)." + echo "ok=false" >> $GITHUB_OUTPUT + elif [ -z "${{ secrets.VERCEL_ORG_ID }}" ]; then + echo "::error title=Secret ausente::VERCEL_ORG_ID não configurado. Obtenha em Vercel Dashboard > Settings do projeto (ou vercel link)." + echo "ok=false" >> $GITHUB_OUTPUT + elif [ -z "${{ secrets.VERCEL_PROJECT_ID }}" ]; then + echo "::error title=Secret ausente::VERCEL_PROJECT_ID não configurado. Obtenha em Vercel Dashboard > Settings do projeto." + echo "ok=false" >> $GITHUB_OUTPUT + else + echo "ok=true" >> $GITHUB_OUTPUT + fi + + - name: Checkout + if: steps.secrets.outputs.ok == 'true' + uses: actions/checkout@v4 + + - name: Setup Node + if: steps.secrets.outputs.ok == 'true' + uses: actions/setup-node@v4 + with: + node-version: "20" + cache: "npm" + + - name: Restore Next.js cache + if: steps.secrets.outputs.ok == 'true' + uses: actions/cache@v4 + with: + path: .next/cache + key: next-${{ runner.os }}-${{ hashFiles('**/package-lock.json') }}-${{ hashFiles('**/*.ts', '**/*.tsx', '!node_modules/**') }} + restore-keys: | + next-${{ runner.os }}-${{ hashFiles('**/package-lock.json') }}- + + - name: Install + if: steps.secrets.outputs.ok == 'true' + run: npm ci + + - name: Build + if: steps.secrets.outputs.ok == 'true' + run: npm run build + env: + NEXT_PUBLIC_VERCEL_URL: "" + + - name: Deploy to Vercel (production) + if: steps.secrets.outputs.ok == 'true' + uses: amondnet/vercel-action@v25 + with: + vercel-token: ${{ secrets.VERCEL_TOKEN }} + vercel-org-id: ${{ secrets.VERCEL_ORG_ID }} + vercel-project-id: ${{ secrets.VERCEL_PROJECT_ID }} + working-directory: ./ + vercel-args: "--prod" + + - name: Fail (secrets ausentes) + if: steps.secrets.outputs.ok != 'true' + run: | + echo "Configure os secrets do repositório (Settings > Secrets and variables > Actions):" + echo " - VERCEL_TOKEN" + echo " - VERCEL_ORG_ID" + echo " - VERCEL_PROJECT_ID" + echo "Ver docs/DEPLOY_WORKFLOWS.md para obter os valores." + exit 1 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..0bade9f --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,103 @@ +# Esteira Release: build + artefato + deploy (falha se config ausente) +name: Release + +on: + release: + types: [published] + push: + tags: + - "v*" + +jobs: + build-and-bundle: + name: Build & Bundle + runs-on: ubuntu-latest + outputs: + version: ${{ steps.version.outputs.version }} + steps: + - uses: actions/checkout@v4 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: "20" + cache: "npm" + + - name: Install + run: npm ci + + - name: Get version + id: version + run: echo "version=$(node -p "require('./package.json').version")" >> $GITHUB_OUTPUT + + - name: Build + run: npm run build + env: + NEXT_PUBLIC_VERCEL_URL: "" + + - name: Bundle artifact + run: node scripts/bundle-artifact.js --no-archive + + - name: Create archive + run: | + VERSION=${{ steps.version.outputs.version }} + FOLDER=$(ls -d dist/darshan-* 2>/dev/null | head -1) + if [ -z "$FOLDER" ]; then echo "Pasta do artefato não encontrada"; exit 1; fi + BASENAME=$(basename "$FOLDER") + tar czf "dist/darshan-${VERSION}.tar.gz" -C dist "$BASENAME" + echo "ARTIFACT_PATH=dist/darshan-${VERSION}.tar.gz" >> $GITHUB_ENV + + - name: Upload artifact + uses: actions/upload-artifact@v4 + with: + name: release-artifact + path: dist/darshan-*.tar.gz + retention-days: 30 + + # Deploy opcional: só roda se VERCEL_* estiverem configurados (release/tag) + deploy: + name: Deploy (Vercel) + runs-on: ubuntu-latest + needs: build-and-bundle + if: success() + steps: + - name: Check deploy config + id: check + run: | + if [ -n "${{ secrets.VERCEL_TOKEN }}" ] && [ -n "${{ secrets.VERCEL_ORG_ID }}" ] && [ -n "${{ secrets.VERCEL_PROJECT_ID }}" ]; then + echo "skip=false" >> $GITHUB_OUTPUT + else + echo "skip=true" >> $GITHUB_OUTPUT + fi + + - name: Checkout + if: steps.check.outputs.skip != 'true' + uses: actions/checkout@v4 + + - name: Setup Node + if: steps.check.outputs.skip != 'true' + uses: actions/setup-node@v4 + with: + node-version: "20" + cache: "npm" + + - name: Install & Build + if: steps.check.outputs.skip != 'true' + run: npm ci && npm run build + env: + NEXT_PUBLIC_VERCEL_URL: "" + + - name: Deploy to Vercel (production) + if: steps.check.outputs.skip != 'true' + uses: amondnet/vercel-action@v25 + with: + vercel-token: ${{ secrets.VERCEL_TOKEN }} + vercel-org-id: ${{ secrets.VERCEL_ORG_ID }} + vercel-project-id: ${{ secrets.VERCEL_PROJECT_ID }} + working-directory: ./ + vercel-args: "--prod" + + - name: Deploy skipped (secrets ausentes) + if: steps.check.outputs.skip == 'true' + run: | + echo "::notice title=Deploy não executado::Para deploy em release/tag, configure VERCEL_TOKEN, VERCEL_ORG_ID e VERCEL_PROJECT_ID. Ver docs/DEPLOY_WORKFLOWS.md" diff --git a/.gitignore b/.gitignore index 04887d0..3631b00 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,13 @@ node_modules .next +build +dist .env .env.local .env.production .DS_Store coverage +data +tsconfig.tsbuildinfo +build.log +*.log diff --git a/__tests__/lib/adminAuth.test.ts b/__tests__/lib/adminAuth.test.ts new file mode 100644 index 0000000..b6d18b0 --- /dev/null +++ b/__tests__/lib/adminAuth.test.ts @@ -0,0 +1,64 @@ +import { getSecretFromRequest, checkAdminAuth } from "@/lib/adminAuth"; + +const originalEnv = process.env; + +describe("lib/adminAuth", () => { + beforeEach(() => { + process.env = { ...originalEnv }; + }); + afterEach(() => { + process.env = originalEnv; + }); + + describe("getSecretFromRequest", () => { + it("lê do header x-config-key", () => { + const req = new Request("https://x.com", { + headers: { "x-config-key": "secret123" }, + }); + expect(getSecretFromRequest(req)).toBe("secret123"); + }); + it("lê do Authorization Bearer", () => { + const req = new Request("https://x.com", { + headers: { authorization: "Bearer token456" }, + }); + expect(getSecretFromRequest(req)).toBe("token456"); + }); + it("lê do query key", () => { + const req = new Request("https://x.com/admin?key=q789"); + expect(getSecretFromRequest(req)).toBe("q789"); + }); + it("retorna null quando nenhum presente", () => { + const req = new Request("https://x.com"); + expect(getSecretFromRequest(req)).toBeNull(); + }); + }); + + describe("checkAdminAuth", () => { + it("retorna 503 quando CONFIG_SECRET não está definido", () => { + delete process.env.CONFIG_SECRET; + const req = new Request("https://x.com"); + const result = checkAdminAuth(req); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.status).toBe(503); + } + }); + it("retorna 401 quando secret não confere", () => { + process.env.CONFIG_SECRET = "correct"; + const req = new Request("https://x.com", { + headers: { "x-config-key": "wrong" }, + }); + const result = checkAdminAuth(req); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.status).toBe(401); + }); + it("retorna ok quando secret confere", () => { + process.env.CONFIG_SECRET = "correct"; + const req = new Request("https://x.com", { + headers: { "x-config-key": "correct" }, + }); + const result = checkAdminAuth(req); + expect(result.ok).toBe(true); + }); + }); +}); diff --git a/__tests__/lib/audit.test.ts b/__tests__/lib/audit.test.ts new file mode 100644 index 0000000..f813523 --- /dev/null +++ b/__tests__/lib/audit.test.ts @@ -0,0 +1,43 @@ +import { audit } from "@/lib/audit"; + +const fs = require("fs"); +jest.mock("fs", () => ({ + existsSync: jest.fn(() => false), + mkdirSync: jest.fn(), + appendFileSync: jest.fn(), +})); + +describe("lib/audit", () => { + beforeEach(() => { + jest.clearAllMocks(); + (fs.existsSync as jest.Mock).mockReturnValue(false); + }); + + it("grava linha no audit.log com event e subject", () => { + audit("login_email", "a@b.com"); + expect(fs.mkdirSync).toHaveBeenCalled(); + expect(fs.appendFileSync).toHaveBeenCalled(); + const [path, line] = (fs.appendFileSync as jest.Mock).mock.calls[0]; + expect(path).toContain("audit.log"); + expect(line).toContain("login_email"); + expect(line).toContain("a@b.com"); + }); + + it("inclui details quando passado", () => { + audit("credits_add", "u@x.com", { amount: 50, balanceAfter: 150 }); + const [, line] = (fs.appendFileSync as jest.Mock).mock.calls[0]; + expect(line).toContain("credits_add"); + expect(line).toContain("50"); + expect(line).toContain("150"); + }); + + it("não quebra quando appendFileSync lança", () => { + (fs.appendFileSync as jest.Mock).mockImplementationOnce(() => { + throw new Error("disk full"); + }); + const spy = jest.spyOn(console, "error").mockImplementation(); + expect(() => audit("logout", "a@b.com")).not.toThrow(); + expect(spy).toHaveBeenCalledWith("[audit] write failed:", expect.any(Error)); + spy.mockRestore(); + }); +}); diff --git a/__tests__/lib/auth.test.ts b/__tests__/lib/auth.test.ts new file mode 100644 index 0000000..3fd3c47 --- /dev/null +++ b/__tests__/lib/auth.test.ts @@ -0,0 +1,49 @@ +import { + getSessionFromCookie, + sessionCookieHeader, + clearSessionCookieHeader, + type Session, +} from "@/lib/auth"; + +describe("lib/auth", () => { + describe("sessionCookieHeader e getSessionFromCookie", () => { + it("round-trip: header gerado é lido de volta", () => { + const session: Session = { email: "a@b.com" }; + const header = sessionCookieHeader(session); + const parsed = getSessionFromCookie(header); + expect(parsed?.email).toBe("a@b.com"); + }); + + it("getSessionFromCookie retorna null para header null", () => { + expect(getSessionFromCookie(null)).toBeNull(); + }); + + it("getSessionFromCookie retorna null quando cookie não existe", () => { + expect(getSessionFromCookie("other=val")).toBeNull(); + }); + + it("sessionCookieHeader contém Path e HttpOnly", () => { + const h = sessionCookieHeader({ email: "x@y.com" }); + expect(h).toContain("Path=/"); + expect(h).toContain("HttpOnly"); + }); + + it("getSessionFromCookie retorna null para sessão com email vazio", () => { + const header = sessionCookieHeader({ email: "" }); + expect(getSessionFromCookie(header)).toBeNull(); + }); + + it("getSessionFromCookie retorna null para valor malformado no cookie", () => { + expect(getSessionFromCookie("darshan_session=not-valid-base64!!!")).toBeNull(); + expect(getSessionFromCookie("darshan_session=")).toBeNull(); + }); + }); + + describe("clearSessionCookieHeader", () => { + it("retorna header que limpa a sessão", () => { + const h = clearSessionCookieHeader(); + expect(h).toContain("darshan_session=;"); + expect(h).toContain("Max-Age=0"); + }); + }); +}); diff --git a/__tests__/lib/credits.test.ts b/__tests__/lib/credits.test.ts new file mode 100644 index 0000000..95fb86e --- /dev/null +++ b/__tests__/lib/credits.test.ts @@ -0,0 +1,82 @@ +import { + CREDITS_PER_AI_REQUEST, + CREDIT_PACKAGES, + formatPriceBRL, + getCreditsFromCookie, + creditsCookieHeader, + clearCreditsCookieHeader, + getCreditsForRevelation, +} from "@/lib/credits"; + +describe("lib/credits", () => { + describe("CREDITS_PER_AI_REQUEST", () => { + it("deve ser 1 (modo ritual)", () => { + expect(CREDITS_PER_AI_REQUEST).toBe(1); + }); + }); + + describe("CREDIT_PACKAGES", () => { + it("deve ter 5 pacotes Fibonacci com id, amount, priceCents, label", () => { + expect(CREDIT_PACKAGES).toHaveLength(5); + expect(CREDIT_PACKAGES[0]).toEqual({ id: "13", amount: 13, priceCents: 890, label: "13 créditos" }); + expect(CREDIT_PACKAGES[1].id).toBe("21"); + expect(CREDIT_PACKAGES[2].amount).toBe(34); + expect(CREDIT_PACKAGES[4].priceCents).toBe(5590); + }); + }); + + describe("formatPriceBRL", () => { + it("formata centavos em BRL", () => { + expect(formatPriceBRL(1990)).toMatch(/19,90/); + expect(formatPriceBRL(0)).toMatch(/0,00/); + }); + }); + + describe("getCreditsFromCookie", () => { + it("retorna 0 quando header é null", () => { + expect(getCreditsFromCookie(null)).toBe(0); + }); + it("retorna 0 quando cookie não existe", () => { + expect(getCreditsFromCookie("other=1")).toBe(0); + }); + it("retorna o valor do cookie darshan_credits", () => { + expect(getCreditsFromCookie("darshan_credits=100")).toBe(100); + expect(getCreditsFromCookie("darshan_credits=0")).toBe(0); + expect(getCreditsFromCookie("foo=1; darshan_credits=50; bar=2")).toBe(50); + }); + it("retorna 0 para valor inválido ou negativo", () => { + expect(getCreditsFromCookie("darshan_credits=abc")).toBe(0); + expect(getCreditsFromCookie("darshan_credits=-1")).toBe(0); + }); + it("aceita valor com decodeURIComponent", () => { + expect(getCreditsFromCookie("darshan_credits=" + encodeURIComponent("42"))).toBe(42); + }); + }); + + describe("creditsCookieHeader", () => { + it("retorna header com valor não negativo e inteiro", () => { + const h = creditsCookieHeader(100); + expect(h).toContain("darshan_credits=100"); + expect(h).toContain("Path=/"); + expect(creditsCookieHeader(0)).toContain("darshan_credits=0"); + expect(creditsCookieHeader(99.7)).toContain("darshan_credits=99"); + expect(creditsCookieHeader(-5)).toContain("darshan_credits=0"); + }); + }); + + describe("clearCreditsCookieHeader", () => { + it("retorna header que limpa o cookie", () => { + const h = clearCreditsCookieHeader(); + expect(h).toContain("darshan_credits=;"); + expect(h).toContain("Max-Age=0"); + }); + }); + + describe("getCreditsForRevelation", () => { + it("ritual = 1, long = 2, long_image = 3", () => { + expect(getCreditsForRevelation("ritual")).toBe(1); + expect(getCreditsForRevelation("long")).toBe(2); + expect(getCreditsForRevelation("long_image")).toBe(3); + }); + }); +}); diff --git a/__tests__/lib/darshan.test.ts b/__tests__/lib/darshan.test.ts new file mode 100644 index 0000000..8d68f58 --- /dev/null +++ b/__tests__/lib/darshan.test.ts @@ -0,0 +1,11 @@ +import { PHASE_NAMES } from "@/lib/darshan"; + +describe("lib/darshan", () => { + describe("PHASE_NAMES", () => { + it("tem nomes para fases 1 a 7", () => { + expect(PHASE_NAMES[1]).toBe("Luz — frase-oráculo"); + expect(PHASE_NAMES[2]).toContain("Jyotish"); + expect(PHASE_NAMES[7]).toContain("presença"); + }); + }); +}); diff --git a/__tests__/lib/dateFormatBr.test.ts b/__tests__/lib/dateFormatBr.test.ts new file mode 100644 index 0000000..1237cef --- /dev/null +++ b/__tests__/lib/dateFormatBr.test.ts @@ -0,0 +1,77 @@ +import { + toBrDate, + fromBrDate, + maskBrDate, + maskBrTime, + fromBrTime, +} from "@/lib/dateFormatBr"; + +describe("lib/dateFormatBr", () => { + describe("toBrDate", () => { + it("converte YYYY-MM-DD para DD/MM/AAAA", () => { + expect(toBrDate("2025-01-15")).toBe("15/01/2025"); + }); + it("retorna vazio para undefined ou formato inválido", () => { + expect(toBrDate(undefined)).toBe(""); + expect(toBrDate("")).toBe(""); + expect(toBrDate("15/01/2025")).toBe(""); + expect(toBrDate("2025-1-5")).toBe(""); + }); + }); + + describe("fromBrDate", () => { + it("converte DD/MM/AAAA para YYYY-MM-DD", () => { + expect(fromBrDate("15/01/2025")).toBe("2025-01-15"); + expect(fromBrDate("15012025")).toBe("2025-01-15"); + }); + it("retorna vazio para string com menos de 8 dígitos", () => { + expect(fromBrDate("150125")).toBe(""); + }); + it("retorna vazio para datas inválidas", () => { + expect(fromBrDate("00/01/2025")).toBe(""); + expect(fromBrDate("32/01/2025")).toBe(""); + expect(fromBrDate("15/00/2025")).toBe(""); + expect(fromBrDate("15/13/2025")).toBe(""); + }); + it("retorna vazio para ano fora do intervalo 1900-2100", () => { + expect(fromBrDate("15/01/1899")).toBe(""); + expect(fromBrDate("15/01/2101")).toBe(""); + }); + }); + + describe("maskBrDate", () => { + it("aplica máscara DD/MM/AAAA", () => { + expect(maskBrDate("1")).toBe("1"); + expect(maskBrDate("15")).toBe("15"); + expect(maskBrDate("151")).toBe("15/1"); + expect(maskBrDate("1501")).toBe("15/01"); + expect(maskBrDate("15012025")).toBe("15/01/2025"); + expect(maskBrDate("15/01/2025")).toBe("15/01/2025"); + }); + }); + + describe("maskBrTime", () => { + it("aplica máscara HH:mm", () => { + expect(maskBrTime("1")).toBe("1"); + expect(maskBrTime("12")).toBe("12"); + expect(maskBrTime("123")).toBe("12:3"); + expect(maskBrTime("1234")).toBe("12:34"); + }); + }); + + describe("fromBrTime", () => { + it("normaliza HH:mm", () => { + expect(fromBrTime("12:34")).toBe("12:34"); + expect(fromBrTime("1234")).toBe("12:34"); + expect(fromBrTime("123")).toBe("12:3"); + }); + it("limita hora e minuto", () => { + expect(fromBrTime("25:00")).toBe("23:00"); + expect(fromBrTime("12:99")).toBe("12:59"); + }); + it("retorna br quando menos de 3 dígitos", () => { + expect(fromBrTime("1")).toBe("1"); + expect(fromBrTime("12")).toBe("12"); + }); + }); +}); diff --git a/__tests__/lib/finance/costEstimator.test.ts b/__tests__/lib/finance/costEstimator.test.ts new file mode 100644 index 0000000..0087232 --- /dev/null +++ b/__tests__/lib/finance/costEstimator.test.ts @@ -0,0 +1,101 @@ +import { + estimateCost, + setUsdToBrl, + getUsdToBrl, + getRates, + refreshUsdToBrlCache, + type CostProvider, +} from "@/lib/finance/costEstimator"; + +describe("lib/finance/costEstimator", () => { + beforeEach(() => { + setUsdToBrl(5); + }); + + describe("getUsdToBrl e setUsdToBrl", () => { + it("valor padrão e set", () => { + setUsdToBrl(5.5); + expect(getUsdToBrl()).toBe(5.5); + }); + it("não aceita valor menor que 0.01", () => { + setUsdToBrl(5); + setUsdToBrl(0); + expect(getUsdToBrl()).toBeGreaterThanOrEqual(0.01); + }); + }); + + describe("estimateCost", () => { + it("calcula custo USD e BRL para openai", () => { + setUsdToBrl(5); + const r = estimateCost("openai", 1000, 500); + expect(r.costUsd).toBeGreaterThan(0); + expect(r.costBrl).toBe(r.costUsd * 5); + }); + it("calcula custo para gemini", () => { + const r = estimateCost("gemini", 1_000_000, 500_000); + expect(r.costUsd).toBeGreaterThan(0); + expect(r.costBrl).toBeGreaterThan(0); + }); + it("provider desconhecido usa rates openai", () => { + const r = estimateCost("openai" as CostProvider, 0, 0); + expect(r.costUsd).toBe(0); + expect(r.costBrl).toBe(0); + }); + }); + + describe("getRates", () => { + it("retorna cópia das taxas para openai, anthropic, gemini", () => { + const rates = getRates(); + expect(rates.openai.inputPer1M).toBe(0.15); + expect(rates.openai.outputPer1M).toBe(0.6); + expect(rates.gemini.inputPer1M).toBe(0.35); + expect(rates.anthropic.outputPer1M).toBe(15); + }); + }); + + describe("refreshUsdToBrlCache", () => { + it("retorna valor atual quando fetch falha", async () => { + setUsdToBrl(5.2); + const globalFetch = global.fetch; + global.fetch = jest.fn().mockResolvedValue({ ok: false }); + const rate = await refreshUsdToBrlCache(); + expect(rate).toBe(5.2); + global.fetch = globalFetch; + }); + it("atualiza usdToBrl quando API retorna dados válidos", async () => { + setUsdToBrl(5); + const globalFetch = global.fetch; + global.fetch = jest.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ compra: 5.1, venda: 5.2 }), + }); + const rate = await refreshUsdToBrlCache(); + expect(rate).toBeCloseTo(5.15); + global.fetch = globalFetch; + }); + it("mantém valor atual quando fetch lança erro", async () => { + setUsdToBrl(5.5); + const globalFetch = global.fetch; + global.fetch = jest.fn().mockRejectedValue(new Error("network")); + const rate = await refreshUsdToBrlCache(); + expect(rate).toBe(5.5); + global.fetch = globalFetch; + }); + it("mantém valor atual quando JSON não tem compra/venda numéricos", async () => { + setUsdToBrl(5); + const globalFetch = global.fetch; + global.fetch = jest.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve({}), + }); + const rate = await refreshUsdToBrlCache(); + expect(rate).toBe(5); + global.fetch = globalFetch; + }); + it("anthropic usa taxas corretas", () => { + const r = estimateCost("anthropic", 1000, 500); + expect(r.costUsd).toBeGreaterThan(0); + expect(r.costBrl).toBeCloseTo(r.costUsd * 5); + }); + }); +}); diff --git a/__tests__/lib/finance/creditsManager.test.ts b/__tests__/lib/finance/creditsManager.test.ts new file mode 100644 index 0000000..97301ee --- /dev/null +++ b/__tests__/lib/finance/creditsManager.test.ts @@ -0,0 +1,326 @@ +import { + getCreditsBalance, + debitCredits, + addCredits, + addCreditsForPurchase, + recordPayment, +} from "@/lib/finance/creditsManager"; + +const mockGetSupabase = jest.fn(); +const mockIsSupabaseConfigured = jest.fn(); + +jest.mock("@/lib/supabase", () => ({ + getSupabase: (...args: unknown[]) => mockGetSupabase(...args), + isSupabaseConfigured: () => mockIsSupabaseConfigured(), +})); + +function createMockSupabaseClient(overrides: { + selectUser?: { data: { id: string; credits_balance: number } | null }; + insertUser?: { data: { id: string; credits_balance?: number } | null }; + updateUser?: { error: Error | null }; + insertLedger?: { data: { id: string } | null }; + insertPayment?: { data: { id: string } | null }; +} = {}) { + const { + selectUser = { data: { id: "u1", credits_balance: 50 } }, + insertUser = { data: { id: "u1", credits_balance: 50 } }, + updateUser = { error: null }, + insertLedger = { data: { id: "ledger1" } }, + insertPayment = { data: { id: "pay1" } }, + } = overrides; + + const from = jest.fn((table: string) => { + if (table === "users") { + return { + select: jest.fn(() => ({ + eq: jest.fn(() => ({ + single: jest.fn().mockResolvedValue(selectUser), + })), + })), + insert: jest.fn(() => ({ + select: jest.fn(() => ({ + single: jest.fn().mockResolvedValue(insertUser), + })), + })), + update: jest.fn(() => ({ + eq: jest.fn().mockResolvedValue(updateUser), + })), + }; + } + if (table === "credit_ledger") { + return { + insert: jest.fn(() => ({ + select: jest.fn(() => ({ + single: jest.fn().mockResolvedValue(insertLedger), + })), + })), + }; + } + if (table === "payments") { + return { + insert: jest.fn(() => ({ + select: jest.fn(() => ({ + single: jest.fn().mockResolvedValue(insertPayment), + })), + })), + }; + } + return {}; + }); + return { from }; +} + +describe("lib/finance/creditsManager (sem Supabase)", () => { + beforeEach(() => { + mockGetSupabase.mockReturnValue(null); + mockIsSupabaseConfigured.mockReturnValue(false); + }); + + describe("getCreditsBalance", () => { + it("retorna balanceFromCookie quando Supabase não configurado", async () => { + const balance = await getCreditsBalance("a@b.com", 100); + expect(balance).toBe(100); + }); + }); + + describe("debitCredits", () => { + it("retorna newBalance = current - amount quando sem Supabase", async () => { + const r = await debitCredits("a@b.com", 13, "darshan_call", { + currentBalanceFromCookie: 50, + }); + expect(r.newBalance).toBe(37); + }); + it("não fica negativo", async () => { + const r = await debitCredits("a@b.com", 100, "darshan_call", { + currentBalanceFromCookie: 20, + }); + expect(r.newBalance).toBe(0); + }); + }); + + describe("addCredits", () => { + it("retorna newBalance = current + amount quando sem Supabase", async () => { + const r = await addCredits("a@b.com", 50, "purchase", { + currentBalanceFromCookie: 10, + }); + expect(r.newBalance).toBe(60); + }); + }); + + describe("recordPayment", () => { + it("retorna null quando Supabase não configurado", async () => { + const id = await recordPayment( + "a@b.com", + "stripe", + 39.9, + 100, + "completed", + "cs_abc" + ); + expect(id).toBeNull(); + }); + }); + + describe("addCreditsForPurchase", () => { + it("soma créditos e retorna newBalance quando sem Supabase", async () => { + const r = await addCreditsForPurchase( + "a@b.com", + 100, + 39.9, + "stripe", + "cs_xyz", + 0 + ); + expect(r.newBalance).toBe(100); + }); + }); +}); + +describe("lib/finance/creditsManager (com Supabase)", () => { + beforeEach(() => { + mockIsSupabaseConfigured.mockReturnValue(true); + }); + + describe("getCreditsBalance", () => { + it("retorna credits_balance do user quando existe", async () => { + mockGetSupabase.mockReturnValue( + createMockSupabaseClient({ + selectUser: { data: { id: "u1", credits_balance: 80 } }, + }) + ); + const balance = await getCreditsBalance("a@b.com", 0); + expect(balance).toBe(80); + }); + it("insere user e retorna balanceFromCookie quando user não existe", async () => { + mockGetSupabase.mockReturnValue( + createMockSupabaseClient({ + selectUser: { data: null }, + insertUser: { data: { id: "u2", credits_balance: 100 } }, + }) + ); + const balance = await getCreditsBalance("a@b.com", 100); + expect(balance).toBe(100); + }); + }); + + describe("debitCredits", () => { + it("debita e retorna newBalance quando user existe", async () => { + mockGetSupabase.mockReturnValue( + createMockSupabaseClient({ + selectUser: { data: { id: "u1", credits_balance: 50 } }, + }) + ); + const r = await debitCredits("a@b.com", 13, "darshan_call", { + relatedUsageId: "usage-1", + }); + expect(r.newBalance).toBe(37); + expect(r.ledgerId).toBe("ledger1"); + }); + it("cria user e debita quando user não existe", async () => { + mockGetSupabase.mockReturnValue( + createMockSupabaseClient({ + selectUser: { data: null }, + insertUser: { data: { id: "u-new", credits_balance: 0 } }, + }) + ); + const r = await debitCredits("a@b.com", 10, "darshan_call", { + currentBalanceFromCookie: 20, + }); + expect(r.newBalance).toBe(0); + }); + it("retorna fallback quando insert user falha (inserted null)", async () => { + mockGetSupabase.mockReturnValue( + createMockSupabaseClient({ + selectUser: { data: null }, + insertUser: { data: null }, + }) + ); + const r = await debitCredits("a@b.com", 13, "darshan_call", { + currentBalanceFromCookie: 50, + }); + expect(r.newBalance).toBe(37); + }); + it("retorna fallback quando update user retorna erro", async () => { + mockGetSupabase.mockReturnValue( + createMockSupabaseClient({ + selectUser: { data: { id: "u1", credits_balance: 50 } }, + updateUser: { error: new Error("db error") }, + }) + ); + const r = await debitCredits("a@b.com", 13, "darshan_call", { + currentBalanceFromCookie: 50, + }); + expect(r.newBalance).toBe(37); + }); + }); + + describe("addCredits", () => { + it("adiciona créditos quando user existe", async () => { + mockGetSupabase.mockReturnValue( + createMockSupabaseClient({ + selectUser: { data: { id: "u1", credits_balance: 10 } }, + }) + ); + const r = await addCredits("a@b.com", 50, "purchase", {}); + expect(r.newBalance).toBe(60); + }); + it("cria user e adiciona quando user não existe", async () => { + mockGetSupabase.mockReturnValue( + createMockSupabaseClient({ + selectUser: { data: null }, + insertUser: { data: { id: "u-new", credits_balance: 100 } }, + }) + ); + const r = await addCredits("a@b.com", 100, "purchase", { + currentBalanceFromCookie: 0, + }); + expect(r.newBalance).toBe(200); + }); + it("retorna fallback quando insert user falha", async () => { + mockGetSupabase.mockReturnValue( + createMockSupabaseClient({ + selectUser: { data: null }, + insertUser: { data: null }, + }) + ); + const r = await addCredits("a@b.com", 50, "purchase", { + currentBalanceFromCookie: 10, + }); + expect(r.newBalance).toBe(60); + }); + }); + + describe("recordPayment", () => { + it("retorna id do payment quando user existe", async () => { + mockGetSupabase.mockReturnValue( + createMockSupabaseClient({ + selectUser: { data: { id: "u1", credits_balance: 0 } }, + insertPayment: { data: { id: "pay-123" } }, + }) + ); + const id = await recordPayment( + "a@b.com", + "stripe", + 39.9, + 100, + "completed", + "cs_abc" + ); + expect(id).toBe("pay-123"); + }); + it("cria user e retorna id quando user não existe", async () => { + mockGetSupabase.mockReturnValue( + createMockSupabaseClient({ + selectUser: { data: null }, + insertUser: { data: { id: "u-new" } }, + insertPayment: { data: { id: "pay-456" } }, + }) + ); + const id = await recordPayment( + "a@b.com", + "stripe", + 19.9, + 50, + "completed" + ); + expect(id).toBe("pay-456"); + }); + it("retorna null quando insert payment falha", async () => { + mockGetSupabase.mockReturnValue( + createMockSupabaseClient({ + selectUser: { data: { id: "u1" } }, + insertPayment: { data: null }, + }) + ); + const id = await recordPayment( + "a@b.com", + "stripe", + 39.9, + 100, + "completed" + ); + expect(id).toBeNull(); + }); + }); + + describe("addCreditsForPurchase", () => { + it("registra payment e adiciona créditos", async () => { + mockGetSupabase.mockReturnValue( + createMockSupabaseClient({ + selectUser: { data: { id: "u1", credits_balance: 0 } }, + insertPayment: { data: { id: "pay-1" } }, + insertUser: { data: { id: "u1", credits_balance: 100 } }, + }) + ); + const r = await addCreditsForPurchase( + "a@b.com", + 100, + 39.9, + "stripe", + "cs_xyz", + 0 + ); + expect(r.newBalance).toBe(100); + }); + }); +}); diff --git a/__tests__/lib/finance/exportCsv.test.ts b/__tests__/lib/finance/exportCsv.test.ts new file mode 100644 index 0000000..b8a74a4 --- /dev/null +++ b/__tests__/lib/finance/exportCsv.test.ts @@ -0,0 +1,91 @@ +import { usageToCsv, paymentsToCsv } from "@/lib/finance/exportCsv"; + +describe("lib/finance/exportCsv", () => { + describe("usageToCsv", () => { + it("retorna header e linhas", () => { + const rows = [ + { + user_id: "a@b.com", + provider: "gemini", + total_calls: 2, + total_tokens: 1000, + total_cost_brl: 1.5, + credits_spent: 26, + revenue_estimate: 10.4, + }, + ]; + const csv = usageToCsv(rows); + expect(csv).toContain("user_id,provider,total_calls"); + expect(csv).toContain("a@b.com"); + expect(csv).toContain("gemini"); + expect(csv).toContain("1.50"); + }); + it("retorna só header para array vazio", () => { + const csv = usageToCsv([]); + expect(csv).toBe( + "user_id,provider,total_calls,total_tokens,total_cost_brl,credits_spent,revenue_estimate" + ); + }); + it("escapa vírgulas e aspas no user_id", () => { + const csv = usageToCsv([ + { + user_id: 'a"b,c@d.com', + provider: "openai", + total_calls: 1, + total_tokens: 100, + total_cost_brl: 0.5, + credits_spent: 13, + revenue_estimate: 5.2, + }, + ]); + expect(csv).toContain('"a""b,c@d.com"'); + }); + }); + + describe("paymentsToCsv", () => { + it("retorna header e linhas", () => { + const rows = [ + { + user_id: "u@x.com", + amount_brl: 39.9, + credits_added: 100, + status: "completed", + created_at: "2025-01-15T12:00:00.000Z", + }, + ]; + const csv = paymentsToCsv(rows); + expect(csv).toContain("user_id,amount_brl,credits_added,status,created_at"); + expect(csv).toContain("u@x.com"); + expect(csv).toContain("39.90"); + expect(csv).toContain("completed"); + }); + it("retorna só header para array vazio", () => { + const csv = paymentsToCsv([]); + expect(csv).toBe("user_id,amount_brl,credits_added,status,created_at"); + }); + it("escapa status com vírgula", () => { + const csv = paymentsToCsv([ + { + user_id: "u@x.com", + amount_brl: 0, + credits_added: 0, + status: "pending, review", + created_at: "2025-01-01T00:00:00.000Z", + }, + ]); + expect(csv).toContain('"pending, review"'); + }); + it("escapa created_at com newline", () => { + const csv = paymentsToCsv([ + { + user_id: "u@x.com", + amount_brl: 10, + credits_added: 50, + status: "completed", + created_at: "2025-01-01\nT00:00:00Z", + }, + ]); + expect(csv).toContain('"'); + }); + }); +}); diff --git a/__tests__/lib/finance/platformFee.test.ts b/__tests__/lib/finance/platformFee.test.ts new file mode 100644 index 0000000..393560a --- /dev/null +++ b/__tests__/lib/finance/platformFee.test.ts @@ -0,0 +1,67 @@ +import { getPlatformFeePercent, getPlatformFeeDecimal } from "@/lib/finance/platformFee"; + +const ENV_KEY = "PLATFORM_FEE_PERCENT"; + +describe("lib/finance/platformFee", () => { + const originalEnv = process.env[ENV_KEY]; + + afterEach(() => { + if (originalEnv !== undefined) { + process.env[ENV_KEY] = originalEnv; + } else { + delete process.env[ENV_KEY]; + } + }); + + describe("getPlatformFeePercent", () => { + it("retorna 30 quando PLATFORM_FEE_PERCENT não está definido", () => { + delete process.env[ENV_KEY]; + expect(getPlatformFeePercent()).toBe(30); + }); + + it("retorna 30 quando PLATFORM_FEE_PERCENT está vazio", () => { + process.env[ENV_KEY] = ""; + expect(getPlatformFeePercent()).toBe(30); + }); + + it("retorna o valor numérico válido da env", () => { + process.env[ENV_KEY] = "25"; + expect(getPlatformFeePercent()).toBe(25); + process.env[ENV_KEY] = "0"; + expect(getPlatformFeePercent()).toBe(0); + process.env[ENV_KEY] = "100"; + expect(getPlatformFeePercent()).toBe(100); + }); + + it("arredonda e limita entre 0 e 100", () => { + process.env[ENV_KEY] = "35.7"; + expect(getPlatformFeePercent()).toBe(36); + process.env[ENV_KEY] = "-10"; + expect(getPlatformFeePercent()).toBe(0); + process.env[ENV_KEY] = "150"; + expect(getPlatformFeePercent()).toBe(100); + }); + + it("retorna 30 quando valor não é numérico", () => { + process.env[ENV_KEY] = "abc"; + expect(getPlatformFeePercent()).toBe(30); + }); + }); + + describe("getPlatformFeeDecimal", () => { + it("retorna 0.30 quando percentual é 30", () => { + process.env[ENV_KEY] = "30"; + expect(getPlatformFeeDecimal()).toBe(0.3); + }); + + it("retorna 0 quando percentual é 0", () => { + process.env[ENV_KEY] = "0"; + expect(getPlatformFeeDecimal()).toBe(0); + }); + + it("retorna 1 quando percentual é 100", () => { + process.env[ENV_KEY] = "100"; + expect(getPlatformFeeDecimal()).toBe(1); + }); + }); +}); diff --git a/__tests__/lib/finance/usageLogger.test.ts b/__tests__/lib/finance/usageLogger.test.ts new file mode 100644 index 0000000..5866558 --- /dev/null +++ b/__tests__/lib/finance/usageLogger.test.ts @@ -0,0 +1,155 @@ +import { logAiUsage } from "@/lib/finance/usageLogger"; + +const mockGetSupabase = jest.fn(); +const mockIsSupabaseConfigured = jest.fn(); + +jest.mock("@/lib/supabase", () => ({ + getSupabase: (...args: unknown[]) => mockGetSupabase(...args), + isSupabaseConfigured: () => mockIsSupabaseConfigured(), +})); + +const baseEntry = { + provider: "gemini" as const, + model: "gemini-2.5-flash", + inputTokens: 100, + outputTokens: 50, + totalTokens: 150, + costUsd: 0.001, + costBrl: 0.005, + creditsSpent: 13, + mode: "now" as const, + questionLength: 10, + responseLength: 100, + success: true, +}; + +describe("lib/finance/usageLogger (sem Supabase)", () => { + beforeEach(() => { + mockGetSupabase.mockReturnValue(null); + mockIsSupabaseConfigured.mockReturnValue(false); + }); + + it("retorna null quando Supabase não configurado", async () => { + const id = await logAiUsage(baseEntry, { userEmail: "a@b.com" }); + expect(id).toBeNull(); + }); +}); + +describe("lib/finance/usageLogger (com Supabase)", () => { + beforeEach(() => { + mockIsSupabaseConfigured.mockReturnValue(true); + }); + + it("retorna id do log quando user existe e insert ok", async () => { + mockGetSupabase.mockReturnValue({ + from: jest.fn((table: string) => { + if (table === "users") { + return { + select: jest.fn(() => ({ + eq: jest.fn(() => ({ + single: jest.fn().mockResolvedValue({ data: { id: "u1" } }), + })), + })), + }; + } + if (table === "ai_usage_log") { + return { + insert: jest.fn(() => ({ + select: jest.fn(() => ({ + single: jest.fn().mockResolvedValue({ + data: { id: "log-123" }, + error: null, + }), + })), + })), + }; + } + return {}; + }), + }); + const id = await logAiUsage(baseEntry, { userEmail: "a@b.com" }); + expect(id).toBe("log-123"); + }); + + it("cria user e insere log quando user não existe", async () => { + mockGetSupabase.mockReturnValue({ + from: jest.fn((table: string) => { + if (table === "users") { + return { + select: jest.fn(() => ({ + eq: jest.fn(() => ({ + single: jest.fn().mockResolvedValue({ data: null }), + })), + })), + insert: jest.fn(() => ({ + select: jest.fn(() => ({ + single: jest.fn().mockResolvedValue({ + data: { id: "u-new" }, + }), + })), + })), + }; + } + if (table === "ai_usage_log") { + return { + insert: jest.fn(() => ({ + select: jest.fn(() => ({ + single: jest.fn().mockResolvedValue({ + data: { id: "log-456" }, + error: null, + }), + })), + })), + }; + } + return {}; + }), + }); + const id = await logAiUsage(baseEntry, { userEmail: "new@b.com" }); + expect(id).toBe("log-456"); + }); + + it("retorna null quando userTableId passado mas insert ai_usage_log falha", async () => { + mockGetSupabase.mockReturnValue({ + from: jest.fn((table: string) => { + if (table === "ai_usage_log") { + return { + insert: jest.fn(() => ({ + select: jest.fn(() => ({ + single: jest.fn().mockResolvedValue({ + data: null, + error: new Error("db error"), + }), + })), + })), + }; + } + return {}; + }), + }); + const id = await logAiUsage(baseEntry, { + userEmail: "a@b.com", + userTableId: "u1", + }); + expect(id).toBeNull(); + }); + + it("retorna null quando userEmail vazio e userTableId não passado", async () => { + mockGetSupabase.mockReturnValue({ + from: jest.fn(() => ({ + select: jest.fn(() => ({ + eq: jest.fn(() => ({ + single: jest.fn().mockResolvedValue({ data: null }), + })), + })), + insert: jest.fn(() => ({ + select: jest.fn(() => ({ + single: jest.fn().mockResolvedValue({ data: null }), + })), + })), + })), + }); + const id = await logAiUsage(baseEntry, { userEmail: "" }); + expect(id).toBeNull(); + }); +}); diff --git a/__tests__/lib/logger.test.ts b/__tests__/lib/logger.test.ts new file mode 100644 index 0000000..07dae0b --- /dev/null +++ b/__tests__/lib/logger.test.ts @@ -0,0 +1,67 @@ +import { logger } from "@/lib/logger"; + +const fs = require("fs"); +jest.mock("fs", () => ({ + existsSync: jest.fn(() => false), + mkdirSync: jest.fn(), + appendFileSync: jest.fn(), +})); + +describe("lib/logger", () => { + beforeEach(() => { + jest.clearAllMocks(); + (fs.existsSync as jest.Mock).mockReturnValue(false); + }); + + it("chama appendFileSync ao logar error", () => { + logger.error("test error"); + expect(fs.mkdirSync).toHaveBeenCalled(); + expect(fs.appendFileSync).toHaveBeenCalled(); + const call = (fs.appendFileSync as jest.Mock).mock.calls[0]; + expect(call[1]).toContain("test error"); + expect(call[1]).toContain("ERROR"); + }); + + it("chama appendFileSync ao logar info", () => { + logger.info("test info"); + expect(fs.appendFileSync).toHaveBeenCalled(); + const call = (fs.appendFileSync as jest.Mock).mock.calls[0]; + expect(call[1]).toContain("test info"); + }); + + it("aceita meta no segundo argumento", () => { + logger.warn("warn msg", { code: 429 }); + const call = (fs.appendFileSync as jest.Mock).mock.calls[0]; + expect(call[1]).toContain("429"); + }); + + it("debug chama write", () => { + const orig = process.env.LOG_LEVEL; + process.env.LOG_LEVEL = "debug"; + logger.debug("debug msg"); + expect(fs.appendFileSync).toHaveBeenCalled(); + process.env.LOG_LEVEL = orig; + }); + + it("não quebra quando appendFileSync lança", () => { + (fs.appendFileSync as jest.Mock).mockImplementationOnce(() => { + throw new Error("disk full"); + }); + const spy = jest.spyOn(console, "error").mockImplementation(); + expect(() => logger.error("fail")).not.toThrow(); + expect(spy).toHaveBeenCalledWith("[logger] write failed:", expect.any(Error)); + spy.mockRestore(); + }); + + it("chama mkdirSync quando diretório não existe", () => { + (fs.existsSync as jest.Mock).mockReturnValue(false); + logger.info("test"); + expect(fs.mkdirSync).toHaveBeenCalledWith(expect.any(String), { recursive: true }); + }); + + it("não chama mkdirSync quando diretório já existe", () => { + (fs.existsSync as jest.Mock).mockReturnValue(true); + logger.warn("test"); + expect(fs.mkdirSync).not.toHaveBeenCalled(); + }); +}); diff --git a/__tests__/lib/stripe.test.ts b/__tests__/lib/stripe.test.ts new file mode 100644 index 0000000..664a189 --- /dev/null +++ b/__tests__/lib/stripe.test.ts @@ -0,0 +1,45 @@ +const originalEnv = process.env; + +jest.mock("stripe", () => { + return jest.fn().mockImplementation(() => ({ _mock: "stripe" })); +}); + +describe("lib/stripe", () => { + afterEach(() => { + process.env = { ...originalEnv }; + jest.resetModules(); + }); + + describe("isStripeConfigured", () => { + it("retorna false quando STRIPE_SECRET_KEY não está definida", () => { + delete process.env.STRIPE_SECRET_KEY; + const { isStripeConfigured } = require("@/lib/stripe"); + expect(isStripeConfigured()).toBe(false); + }); + it("retorna false quando STRIPE_SECRET_KEY é string vazia", () => { + process.env.STRIPE_SECRET_KEY = " "; + const { isStripeConfigured } = require("@/lib/stripe"); + expect(isStripeConfigured()).toBe(false); + }); + it("retorna true quando STRIPE_SECRET_KEY tem valor", () => { + process.env.STRIPE_SECRET_KEY = "sk_test_abc"; + const { isStripeConfigured } = require("@/lib/stripe"); + expect(isStripeConfigured()).toBe(true); + }); + }); + + describe("getStripe", () => { + it("retorna null quando não configurado", () => { + delete process.env.STRIPE_SECRET_KEY; + jest.resetModules(); + const { getStripe } = require("@/lib/stripe"); + expect(getStripe()).toBeNull(); + }); + it("retorna instância quando configurado", () => { + process.env.STRIPE_SECRET_KEY = "sk_test_abc123"; + jest.resetModules(); + const { getStripe } = require("@/lib/stripe"); + expect(getStripe()).not.toBeNull(); + }); + }); +}); diff --git a/__tests__/lib/supabase.test.ts b/__tests__/lib/supabase.test.ts new file mode 100644 index 0000000..359ee74 --- /dev/null +++ b/__tests__/lib/supabase.test.ts @@ -0,0 +1,46 @@ +jest.mock("@supabase/supabase-js", () => ({ + createClient: jest.fn(() => ({ mock: true })), +})); + +const originalEnv = process.env; + +describe("lib/supabase", () => { + beforeEach(() => { + jest.resetModules(); + process.env = { ...originalEnv }; + }); + + afterEach(() => { + process.env = originalEnv; + }); + + describe("isSupabaseConfigured", () => { + it("retorna false quando faltam variáveis", () => { + delete process.env.SUPABASE_URL; + delete process.env.SUPABASE_SERVICE_KEY; + const { isSupabaseConfigured } = require("@/lib/supabase"); + expect(isSupabaseConfigured()).toBe(false); + }); + it("retorna true quando ambas estão definidas", () => { + process.env.SUPABASE_URL = "https://x.supabase.co"; + process.env.SUPABASE_SERVICE_KEY = "key"; + const { isSupabaseConfigured } = require("@/lib/supabase"); + expect(isSupabaseConfigured()).toBe(true); + }); + }); + + describe("getSupabase", () => { + it("retorna null quando não configurado", () => { + delete process.env.SUPABASE_URL; + delete process.env.SUPABASE_SERVICE_KEY; + const { getSupabase } = require("@/lib/supabase"); + expect(getSupabase()).toBeNull(); + }); + it("retorna cliente quando configurado", () => { + process.env.SUPABASE_URL = "https://x.supabase.co"; + process.env.SUPABASE_SERVICE_KEY = "key"; + const { getSupabase } = require("@/lib/supabase"); + expect(getSupabase()).toEqual({ mock: true }); + }); + }); +}); diff --git a/__tests__/lib/usageLimits.test.ts b/__tests__/lib/usageLimits.test.ts new file mode 100644 index 0000000..862dece --- /dev/null +++ b/__tests__/lib/usageLimits.test.ts @@ -0,0 +1,84 @@ +import { + checkAndRecordRateLimit, + checkDailyLimit, + recordDailyRequest, + getRateLimitConfig, + getDailyLimitConfig, +} from "@/lib/usageLimits"; + +const RATE_KEY = "RATE_LIMIT_PER_MINUTE"; +const DAILY_KEY = "DAILY_AI_LIMIT"; + +describe("lib/usageLimits", () => { + const originalRate = process.env[RATE_KEY]; + const originalDaily = process.env[DAILY_KEY]; + + afterEach(() => { + if (originalRate !== undefined) process.env[RATE_KEY] = originalRate; + else delete process.env[RATE_KEY]; + if (originalDaily !== undefined) process.env[DAILY_KEY] = originalDaily; + else delete process.env[DAILY_KEY]; + }); + + describe("checkAndRecordRateLimit", () => { + it("retorna true quando RATE_LIMIT_PER_MINUTE=0 (desativado)", () => { + process.env[RATE_KEY] = "0"; + const key = "rate-test-off@test.com"; + expect(checkAndRecordRateLimit(key)).toBe(true); + expect(checkAndRecordRateLimit(key)).toBe(true); + }); + + it("permite até N requisições por minuto e bloqueia a N+1", () => { + process.env[RATE_KEY] = "2"; + const key = "rate-test-2@test.com"; + expect(checkAndRecordRateLimit(key)).toBe(true); + expect(checkAndRecordRateLimit(key)).toBe(true); + expect(checkAndRecordRateLimit(key)).toBe(false); + }); + + it("usa chaves diferentes por usuário", () => { + process.env[RATE_KEY] = "1"; + expect(checkAndRecordRateLimit("user-a@test.com")).toBe(true); + expect(checkAndRecordRateLimit("user-b@test.com")).toBe(true); + expect(checkAndRecordRateLimit("user-a@test.com")).toBe(false); + }); + }); + + describe("getRateLimitConfig / getDailyLimitConfig", () => { + it("retorna padrão quando env não definido", () => { + delete process.env[RATE_KEY]; + delete process.env[DAILY_KEY]; + expect(getRateLimitConfig().perMinute).toBe(5); + expect(getDailyLimitConfig()).toBe(30); + }); + it("retorna valor da env quando definido", () => { + process.env[RATE_KEY] = "10"; + process.env[DAILY_KEY] = "50"; + expect(getRateLimitConfig().perMinute).toBe(10); + expect(getDailyLimitConfig()).toBe(50); + }); + }); + + describe("checkDailyLimit", () => { + it("retorna allowed true e limit 0 quando DAILY_AI_LIMIT=0", async () => { + process.env[DAILY_KEY] = "0"; + const r = await checkDailyLimit("daily-off@test.com"); + expect(r.allowed).toBe(true); + expect(r.limit).toBe(0); + }); + it("retorna allowed true quando count < limit (sem Supabase usa in-memory 0)", async () => { + process.env[DAILY_KEY] = "30"; + const r = await checkDailyLimit("daily-new@test.com"); + expect(r.allowed).toBe(true); + expect(r.count).toBe(0); + expect(r.limit).toBe(30); + }); + }); + + describe("recordDailyRequest", () => { + it("não lança quando chamado (in-memory quando sem Supabase)", () => { + recordDailyRequest("record@test.com"); + recordDailyRequest("record@test.com"); + }); + }); +}); diff --git a/app/api/admin/export-payments/route.ts b/app/api/admin/export-payments/route.ts new file mode 100644 index 0000000..6887afd --- /dev/null +++ b/app/api/admin/export-payments/route.ts @@ -0,0 +1,57 @@ +import { NextResponse } from "next/server"; +import { getSupabase, isSupabaseConfigured } from "@/lib/supabase"; +import { checkAdminAuth } from "@/lib/adminAuth"; +import { paymentsToCsv, type PaymentExportRow } from "@/lib/finance"; + +export const dynamic = "force-dynamic"; + +/** + * GET /api/admin/export-payments?key=CONFIG_SECRET + * Retorna CSV: user_id, amount_brl, credits_added, status, created_at + */ +export async function GET(req: Request) { + const auth = checkAdminAuth(req); + if (!auth.ok) { + return NextResponse.json({ error: auth.error }, { status: auth.status }); + } + if (!isSupabaseConfigured()) { + return NextResponse.json( + { error: "Exportação requer SUPABASE_URL e SUPABASE_SERVICE_KEY." }, + { status: 503 } + ); + } + + const supabase = getSupabase(); + if (!supabase) { + return NextResponse.json({ error: "Supabase não disponível." }, { status: 503 }); + } + + const { data: payments, error } = await supabase + .from("payments") + .select("user_id, amount_brl, credits_added, status, created_at") + .order("created_at", { ascending: false }); + + if (error) { + return NextResponse.json({ error: "Falha ao buscar pagamentos." }, { status: 500 }); + } + + const { data: users } = await supabase.from("users").select("id, email"); + const idToEmail = new Map((users ?? []).map((u) => [u.id, u.email])); + + const rows: PaymentExportRow[] = (payments ?? []).map((p) => ({ + user_id: idToEmail.get(p.user_id) ?? p.user_id, + amount_brl: Number(p.amount_brl ?? 0), + credits_added: Number(p.credits_added ?? 0), + status: String(p.status ?? "pending"), + created_at: new Date(p.created_at).toISOString(), + })); + + const csv = paymentsToCsv(rows); + return new NextResponse(csv, { + status: 200, + headers: { + "Content-Type": "text/csv; charset=utf-8", + "Content-Disposition": `attachment; filename="darshan-payments-${Date.now()}.csv"`, + }, + }); +} diff --git a/app/api/admin/export-usage/route.ts b/app/api/admin/export-usage/route.ts new file mode 100644 index 0000000..c47d7c2 --- /dev/null +++ b/app/api/admin/export-usage/route.ts @@ -0,0 +1,108 @@ +import { NextResponse } from "next/server"; +import { getSupabase, isSupabaseConfigured } from "@/lib/supabase"; +import { checkAdminAuth } from "@/lib/adminAuth"; +import { usageToCsv, type UsageExportRow } from "@/lib/finance"; + +export const dynamic = "force-dynamic"; + +/** Preço médio por crédito em BRL (para revenue_estimate). */ +const AVG_REVENUE_PER_CREDIT_BRL = 0.40; + +/** + * GET /api/admin/export-usage?range=month&key=CONFIG_SECRET + * Query: range = day | week | month (default month) + * Retorna CSV: user_id, provider, total_calls, total_tokens, total_cost_brl, credits_spent, revenue_estimate + */ +export async function GET(req: Request) { + const auth = checkAdminAuth(req); + if (!auth.ok) { + return NextResponse.json({ error: auth.error }, { status: auth.status }); + } + if (!isSupabaseConfigured()) { + return NextResponse.json( + { error: "Exportação requer SUPABASE_URL e SUPABASE_SERVICE_KEY." }, + { status: 503 } + ); + } + + const url = new URL(req.url); + const range = url.searchParams.get("range") ?? "month"; + const since = getSinceDate(range); + + const supabase = getSupabase(); + if (!supabase) { + return NextResponse.json({ error: "Supabase não disponível." }, { status: 503 }); + } + + const { data: logs, error: logError } = await supabase + .from("ai_usage_log") + .select("user_id, provider, input_tokens, output_tokens, total_tokens, cost_brl, credits_spent") + .gte("created_at", since.toISOString()); + + if (logError) { + return NextResponse.json({ error: "Falha ao buscar uso." }, { status: 500 }); + } + + const rows = (logs ?? []).reduce((acc, row) => { + const key = `${row.user_id}:${row.provider}`; + const existing = acc.get(key); + const costBrl = Number(row.cost_brl ?? 0); + const creditsSpent = Number(row.credits_spent ?? 0); + const totalTokens = Number(row.total_tokens ?? 0) || Number(row.input_tokens ?? 0) + Number(row.output_tokens ?? 0); + if (existing) { + existing.total_calls += 1; + existing.total_tokens += totalTokens; + existing.total_cost_brl += costBrl; + existing.credits_spent += creditsSpent; + } else { + acc.set(key, { + user_id: row.user_id, + provider: row.provider, + total_calls: 1, + total_tokens: totalTokens, + total_cost_brl: costBrl, + credits_spent: creditsSpent, + revenue_estimate: 0, + }); + } + return acc; + }, new Map()); + + const { data: users } = await supabase.from("users").select("id, email"); + const idToEmail = new Map((users ?? []).map((u) => [u.id, u.email])); + + const exportRows: UsageExportRow[] = Array.from(rows.values()).map((r) => ({ + user_id: idToEmail.get(r.user_id) ?? r.user_id, + provider: r.provider, + total_calls: r.total_calls, + total_tokens: r.total_tokens, + total_cost_brl: Math.round(r.total_cost_brl * 100) / 100, + credits_spent: r.credits_spent, + revenue_estimate: Math.round(r.credits_spent * AVG_REVENUE_PER_CREDIT_BRL * 100) / 100, + })); + + const csv = usageToCsv(exportRows); + return new NextResponse(csv, { + status: 200, + headers: { + "Content-Type": "text/csv; charset=utf-8", + "Content-Disposition": `attachment; filename="darshan-usage-${range}-${Date.now()}.csv"`, + }, + }); +} + +function getSinceDate(range: string): Date { + const now = new Date(); + switch (range) { + case "day": + now.setDate(now.getDate() - 1); + break; + case "week": + now.setDate(now.getDate() - 7); + break; + default: + now.setMonth(now.getMonth() - 1); + break; + } + return now; +} diff --git a/app/api/admin/logs/route.ts b/app/api/admin/logs/route.ts new file mode 100644 index 0000000..e4ba87b --- /dev/null +++ b/app/api/admin/logs/route.ts @@ -0,0 +1,58 @@ +import { NextResponse } from "next/server"; +import fs from "fs"; +import { checkAdminAuth } from "@/lib/adminAuth"; +import { getAppLogPath, getAuditLogPath } from "@/lib/logPaths"; + +export const dynamic = "force-dynamic"; + +const MAX_LINES = 500; +const MAX_FILE_BYTES = 1024 * 512; // 512 KB por arquivo + +function readLastLines(filePath: string, maxLines: number): string[] { + try { + if (!fs.existsSync(filePath)) return []; + const stat = fs.statSync(filePath); + const size = Math.min(stat.size, MAX_FILE_BYTES); + const fd = fs.openSync(filePath, "r"); + const buffer = Buffer.alloc(size); + const start = Math.max(0, stat.size - size); + fs.readSync(fd, buffer, 0, size, start); + fs.closeSync(fd); + const text = buffer.toString("utf8", 0, size); + const lines = text.split(/\n/).filter((l) => l.length > 0); + return lines.slice(-maxLines); + } catch { + return []; + } +} + +/** + * GET /api/admin/logs?source=app|audit|both&lines=200 + * Header: X-Config-Key ou Authorization: Bearer CONFIG_SECRET ou ?key=CONFIG_SECRET + * Retorna últimas N linhas de app.log e/ou audit.log. + */ +export async function GET(req: Request) { + const auth = checkAdminAuth(req); + if (!auth.ok) { + return NextResponse.json({ error: auth.error }, { status: auth.status }); + } + + const url = new URL(req.url); + const source = url.searchParams.get("source") ?? "both"; + const linesParam = url.searchParams.get("lines"); + const lines = Math.min(MAX_LINES, Math.max(1, parseInt(linesParam ?? "200", 10) || 200)); + + const result: { app: string[]; audit: string[]; error?: string } = { + app: [], + audit: [], + }; + + if (source === "app" || source === "both") { + result.app = readLastLines(getAppLogPath(), lines); + } + if (source === "audit" || source === "both") { + result.audit = readLastLines(getAuditLogPath(), lines); + } + + return NextResponse.json(result); +} diff --git a/app/api/admin/status/route.ts b/app/api/admin/status/route.ts new file mode 100644 index 0000000..b6fcd00 --- /dev/null +++ b/app/api/admin/status/route.ts @@ -0,0 +1,34 @@ +import { NextResponse } from "next/server"; +import { checkAdminAuth } from "@/lib/adminAuth"; +import { isStripeConfigured } from "@/lib/stripe"; +import { isSupabaseConfigured } from "@/lib/supabase"; +import { getPlatformFeePercent } from "@/lib/finance"; +import { getRateLimitConfig, getDailyLimitConfig } from "@/lib/usageLimits"; + +export const dynamic = "force-dynamic"; + +/** + * GET /api/admin/status + * Header: X-Config-Key ou Authorization: Bearer CONFIG_SECRET ou ?key=CONFIG_SECRET + * Retorna estado da plataforma (sem segredos): Stripe, Supabase, limites, taxa. + */ +export async function GET(req: Request) { + const auth = checkAdminAuth(req); + if (!auth.ok) { + return NextResponse.json({ error: auth.error }, { status: auth.status }); + } + + const rateLimit = getRateLimitConfig(); + const dailyLimit = getDailyLimitConfig(); + + const status = { + stripe: isStripeConfigured(), + supabase: isSupabaseConfigured(), + rateLimitPerMinute: rateLimit.perMinute, + dailyAiLimit: dailyLimit, + platformFeePercent: getPlatformFeePercent(), + nodeEnv: process.env.NODE_ENV ?? "development", + }; + + return NextResponse.json(status); +} diff --git a/app/api/auth/callback/google/route.ts b/app/api/auth/callback/google/route.ts new file mode 100644 index 0000000..38895b7 --- /dev/null +++ b/app/api/auth/callback/google/route.ts @@ -0,0 +1,99 @@ +import { NextRequest, NextResponse } from "next/server"; +import { headers } from "next/headers"; +import { sessionCookieHeader } from "@/lib/auth"; +import { audit } from "@/lib/audit"; + +export const dynamic = "force-dynamic"; + +const GOOGLE_TOKEN_URL = "https://oauth2.googleapis.com/token"; +const GOOGLE_USERINFO_URL = "https://www.googleapis.com/oauth2/v2/userinfo"; + +function getRedirectUri(headersList: Headers): string { + const host = headersList.get("x-forwarded-host") || headersList.get("host") || "localhost:3000"; + const proto = headersList.get("x-forwarded-proto") || (host.includes("localhost") ? "http" : "https"); + return `${proto}://${host}/api/auth/callback/google`; +} + +export async function GET(req: NextRequest) { + const clientId = process.env.GOOGLE_CLIENT_ID; + const clientSecret = process.env.GOOGLE_CLIENT_SECRET; + if (!clientId || !clientSecret) { + return NextResponse.redirect(new URL("/?error=google_not_configured", req.url)); + } + const { searchParams } = new URL(req.url); + const code = searchParams.get("code"); + const error = searchParams.get("error"); + if (error) { + return NextResponse.redirect(new URL(`/?error=${encodeURIComponent(error)}`, req.url)); + } + if (!code) { + return NextResponse.redirect(new URL("/?error=missing_code", req.url)); + } + const headersList = await headers(); + const redirectUri = getRedirectUri(headersList); + + let tokenRes: Response; + try { + tokenRes = await fetch(GOOGLE_TOKEN_URL, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + code, + client_id: clientId, + client_secret: clientSecret, + redirect_uri: redirectUri, + grant_type: "authorization_code", + }), + }); + } catch (e) { + console.error("[auth/callback/google] token exchange failed:", e); + return NextResponse.redirect(new URL("/?error=token_exchange_failed", req.url)); + } + + if (!tokenRes.ok) { + const text = await tokenRes.text(); + console.error("[auth/callback/google] token error:", tokenRes.status, text); + return NextResponse.redirect(new URL("/?error=token_failed", req.url)); + } + + let tokenData: { access_token?: string }; + try { + tokenData = await tokenRes.json(); + } catch { + return NextResponse.redirect(new URL("/?error=token_parse", req.url)); + } + const accessToken = tokenData.access_token; + if (!accessToken) { + return NextResponse.redirect(new URL("/?error=no_access_token", req.url)); + } + + let userRes: Response; + try { + userRes = await fetch(GOOGLE_USERINFO_URL, { + headers: { Authorization: `Bearer ${accessToken}` }, + }); + } catch (e) { + console.error("[auth/callback/google] userinfo failed:", e); + return NextResponse.redirect(new URL("/?error=userinfo_failed", req.url)); + } + + if (!userRes.ok) { + return NextResponse.redirect(new URL("/?error=userinfo_failed", req.url)); + } + + let userData: { email?: string }; + try { + userData = await userRes.json(); + } catch { + return NextResponse.redirect(new URL("/?error=userinfo_parse", req.url)); + } + const email = typeof userData.email === "string" ? userData.email.trim() : ""; + if (!email) { + return NextResponse.redirect(new URL("/?error=no_email", req.url)); + } + + audit("login_google", email); + const res = NextResponse.redirect(new URL("/", req.url)); + res.headers.set("Set-Cookie", sessionCookieHeader({ email })); + return res; +} diff --git a/app/api/auth/google/route.ts b/app/api/auth/google/route.ts new file mode 100644 index 0000000..5554fdc --- /dev/null +++ b/app/api/auth/google/route.ts @@ -0,0 +1,36 @@ +import { NextResponse } from "next/server"; +import { headers } from "next/headers"; + +export const dynamic = "force-dynamic"; + +const SCOPE = "email profile"; +const GOOGLE_AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth"; + +function getRedirectUri(headersList: Headers): string { + const host = headersList.get("x-forwarded-host") || headersList.get("host") || "localhost:3000"; + const proto = headersList.get("x-forwarded-proto") || (host.includes("localhost") ? "http" : "https"); + return `${proto}://${host}/api/auth/callback/google`; +} + +export async function GET(req: Request) { + const clientId = process.env.GOOGLE_CLIENT_ID; + if (!clientId) { + const url = new URL(req.url); + const origin = url.origin || "http://localhost:3000"; + return NextResponse.redirect(`${origin}/?error=google_not_configured`); + } + const headersList = await headers(); + const redirectUri = getRedirectUri(headersList); + const state = Buffer.from(JSON.stringify({ t: Date.now() })).toString("base64url"); + const params = new URLSearchParams({ + client_id: clientId, + redirect_uri: redirectUri, + response_type: "code", + scope: SCOPE, + state, + access_type: "offline", + prompt: "consent", + }); + const url = `${GOOGLE_AUTH_URL}?${params.toString()}`; + return NextResponse.redirect(url); +} diff --git a/app/api/auth/logout/route.ts b/app/api/auth/logout/route.ts new file mode 100644 index 0000000..59b73b6 --- /dev/null +++ b/app/api/auth/logout/route.ts @@ -0,0 +1,20 @@ +import { NextResponse } from "next/server"; +import { cookies } from "next/headers"; +import { getSessionFromCookie } from "@/lib/auth"; +import { clearSessionCookieHeader } from "@/lib/auth"; +import { clearCreditsCookieHeader } from "@/lib/credits"; +import { audit } from "@/lib/audit"; + +export const dynamic = "force-dynamic"; + +export async function POST() { + const cookieStore = await cookies(); + const session = getSessionFromCookie(cookieStore.toString()); + if (session?.email) { + audit("logout", session.email); + } + const res = NextResponse.json({ ok: true }); + res.headers.append("Set-Cookie", clearSessionCookieHeader()); + res.headers.append("Set-Cookie", clearCreditsCookieHeader()); + return res; +} diff --git a/app/api/auth/send-code/route.ts b/app/api/auth/send-code/route.ts new file mode 100644 index 0000000..5161ee7 --- /dev/null +++ b/app/api/auth/send-code/route.ts @@ -0,0 +1,32 @@ +import { NextResponse } from "next/server"; +import { setOtp } from "@/lib/otpStore"; +import { sendVerificationCode } from "@/lib/email"; + +export const dynamic = "force-dynamic"; + +export async function POST(req: Request) { + const body = await req.json().catch(() => ({})); + const email = typeof body.email === "string" ? body.email.trim() : ""; + if (!email || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) { + return NextResponse.json( + { ok: false, error: "E-mail inválido." }, + { status: 400 } + ); + } + const code = setOtp(email); + + const sent = await sendVerificationCode(email, code); + if (sent.ok) { + return NextResponse.json({ ok: true, message: "Código enviado para seu e-mail." }); + } + + if (process.env.NODE_ENV !== "production") { + console.log("[darshan] OTP para", email, ":", code, "(e-mail não enviado:", sent.error, ")"); + return NextResponse.json({ ok: true, message: "Código enviado para seu e-mail." }); + } + + return NextResponse.json( + { ok: false, error: "Não foi possível enviar o e-mail. Tente novamente ou use Google para entrar." }, + { status: 503 } + ); +} diff --git a/app/api/auth/session/route.ts b/app/api/auth/session/route.ts new file mode 100644 index 0000000..72ad447 --- /dev/null +++ b/app/api/auth/session/route.ts @@ -0,0 +1,15 @@ +import { NextResponse } from "next/server"; +import { getSessionFromCookie } from "@/lib/auth"; +import { cookies } from "next/headers"; + +export const dynamic = "force-dynamic"; + +export async function GET() { + const cookieStore = await cookies(); + const cookieHeader = cookieStore.toString(); + const session = getSessionFromCookie(cookieHeader); + if (!session) { + return NextResponse.json({ ok: false, session: null }); + } + return NextResponse.json({ ok: true, session: { email: session.email } }); +} diff --git a/app/api/auth/verify/route.ts b/app/api/auth/verify/route.ts new file mode 100644 index 0000000..f4f2d01 --- /dev/null +++ b/app/api/auth/verify/route.ts @@ -0,0 +1,29 @@ +import { NextResponse } from "next/server"; +import { verifyOtp, isDevCode } from "@/lib/otpStore"; +import { sessionCookieHeader } from "@/lib/auth"; +import { audit } from "@/lib/audit"; + +export const dynamic = "force-dynamic"; + +export async function POST(req: Request) { + const body = await req.json().catch(() => ({})); + const email = typeof body.email === "string" ? body.email.trim() : ""; + const code = typeof body.code === "string" ? body.code : ""; + if (!email || !code) { + return NextResponse.json( + { ok: false, error: "E-mail e código são obrigatórios." }, + { status: 400 } + ); + } + const valid = verifyOtp(email, code) || isDevCode(email, code); + if (!valid) { + return NextResponse.json( + { ok: false, error: "Código inválido ou expirado." }, + { status: 401 } + ); + } + audit("login_email", email); + const res = NextResponse.json({ ok: true }); + res.headers.set("Set-Cookie", sessionCookieHeader({ email })); + return res; +} diff --git a/app/api/checkout/create/route.ts b/app/api/checkout/create/route.ts new file mode 100644 index 0000000..0b1a0f2 --- /dev/null +++ b/app/api/checkout/create/route.ts @@ -0,0 +1,106 @@ +import { NextResponse } from "next/server"; +import { cookies } from "next/headers"; +import { headers } from "next/headers"; +import { getSessionFromCookie } from "@/lib/auth"; +import { getStripe, isStripeConfigured } from "@/lib/stripe"; +import { CREDIT_PACKAGES } from "@/lib/credits"; + +export const dynamic = "force-dynamic"; + +/** + * POST /api/checkout/create + * Body: { packageId: "13" | "21" | "34" | "55" | "89" } + * Cria sessão Stripe Checkout (cartão, Google Pay e Stripe Link). + * Google Pay aparece automaticamente quando "Wallets" está ativo no Dashboard do Stripe. + */ +export async function POST(req: Request) { + if (!isStripeConfigured()) { + return NextResponse.json( + { error: "Pagamento não configurado. Defina STRIPE_SECRET_KEY." }, + { status: 503 } + ); + } + + const cookieStore = await cookies(); + const cookieHeader = cookieStore.toString(); + const session = getSessionFromCookie(cookieHeader); + if (!session?.email) { + return NextResponse.json( + { error: "Faça login para comprar créditos." }, + { status: 401 } + ); + } + + let body: { packageId?: string }; + try { + body = await req.json(); + } catch { + return NextResponse.json({ error: "Body inválido." }, { status: 400 }); + } + + const packageId = typeof body.packageId === "string" ? body.packageId.trim() : ""; + const pkg = CREDIT_PACKAGES.find((p) => p.id === packageId); + if (!pkg) { + return NextResponse.json( + { error: "Pacote inválido. Use 13, 21, 34, 55 ou 89." }, + { status: 400 } + ); + } + + const stripe = getStripe(); + if (!stripe) { + return NextResponse.json( + { error: "Pagamento indisponível." }, + { status: 503 } + ); + } + + const headersList = await headers(); + const host = headersList.get("x-forwarded-host") || headersList.get("host") || "localhost:3000"; + const proto = headersList.get("x-forwarded-proto") || (host.includes("localhost") ? "http" : "https"); + const origin = `${proto}://${host}`; + + try { + const checkoutSession = await stripe.checkout.sessions.create({ + mode: "payment", + payment_method_types: ["card", "link"], + line_items: [ + { + price_data: { + currency: "brl", + product_data: { + name: pkg.label, + description: "Créditos para revelações com IA no Darshan.", + }, + unit_amount: pkg.priceCents, + }, + quantity: 1, + }, + ], + success_url: `${origin}/?checkout=success&session_id={CHECKOUT_SESSION_ID}`, + cancel_url: `${origin}/?checkout=cancelled`, + client_reference_id: session.email, + metadata: { + packageId: pkg.id, + credits: String(pkg.amount), + }, + }); + + const url = checkoutSession.url; + if (!url) { + return NextResponse.json( + { error: "Não foi possível criar a sessão de pagamento." }, + { status: 500 } + ); + } + + return NextResponse.json({ url }); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + console.error("[checkout/create]", message); + return NextResponse.json( + { error: "Erro ao criar sessão de pagamento. Tente novamente." }, + { status: 500 } + ); + } +} diff --git a/app/api/checkout/fulfill-mercadopago/route.ts b/app/api/checkout/fulfill-mercadopago/route.ts new file mode 100644 index 0000000..fb89c29 --- /dev/null +++ b/app/api/checkout/fulfill-mercadopago/route.ts @@ -0,0 +1,107 @@ +import { NextResponse } from "next/server"; +import { cookies } from "next/headers"; +import { getSessionFromCookie } from "@/lib/auth"; +import { getCreditsFromCookie, creditsCookieHeader } from "@/lib/credits"; +import { getPayment, isMercadoPagoConfigured } from "@/lib/mercadopago"; +import { addCreditsForPurchase } from "@/lib/finance"; +import { audit } from "@/lib/audit"; + +export const dynamic = "force-dynamic"; + +/** + * POST /api/checkout/fulfill-mercadopago + * Body: { payment_id: string } + * Verifica o pagamento no Mercado Pago e adiciona créditos ao usuário. + */ +export async function POST(req: Request) { + if (!isMercadoPagoConfigured()) { + return NextResponse.json( + { error: "Pagamento não configurado.", balance: null }, + { status: 503 } + ); + } + + const cookieStore = await cookies(); + const cookieHeader = cookieStore.toString(); + const session = getSessionFromCookie(cookieHeader); + if (!session?.email) { + return NextResponse.json( + { error: "Faça login para receber os créditos.", balance: null }, + { status: 401 } + ); + } + + let body: { payment_id?: string }; + try { + body = await req.json(); + } catch { + return NextResponse.json({ error: "Body inválido.", balance: null }, { status: 400 }); + } + + const paymentId = typeof body.payment_id === "string" ? body.payment_id.trim() : ""; + if (!paymentId) { + return NextResponse.json( + { error: "ID do pagamento inválido.", balance: null }, + { status: 400 } + ); + } + + const payment = await getPayment(paymentId); + if (!payment) { + return NextResponse.json( + { error: "Pagamento não encontrado.", balance: null }, + { status: 400 } + ); + } + + if (payment.status !== "approved") { + return NextResponse.json( + { error: "Pagamento ainda não aprovado.", balance: null }, + { status: 400 } + ); + } + + const externalRef = payment.external_reference ?? ""; + const match = externalRef.match(/^darshan:(.+):(\d+)$/); + const emailFromRef = match?.[1] ?? ""; + const creditsFromRef = match?.[2] ? parseInt(match[2], 10) : 0; + const credits = creditsFromRef > 0 ? creditsFromRef : parseInt(String(payment.metadata?.credits ?? "0"), 10); + + if (emailFromRef !== session.email) { + return NextResponse.json( + { error: "Pagamento não corresponde ao usuário logado.", balance: null }, + { status: 403 } + ); + } + + if (!Number.isFinite(credits) || credits <= 0) { + return NextResponse.json( + { error: "Pacote de créditos inválido.", balance: null }, + { status: 400 } + ); + } + + const current = getCreditsFromCookie(cookieHeader); + const amountBrl = Number(payment.transaction_amount) ?? 0; + + const result = await addCreditsForPurchase( + session.email, + credits, + amountBrl, + "mercadopago", + String(payment.id), + current + ); + const newBalance = result.newBalance; + + audit("credits_add", session.email, { + amount: credits, + balanceBefore: current, + balanceAfter: newBalance, + source: "mercadopago_checkout", + }); + + const res = NextResponse.json({ ok: true, balance: newBalance }); + res.headers.set("Set-Cookie", creditsCookieHeader(newBalance)); + return res; +} diff --git a/app/api/checkout/fulfill/route.ts b/app/api/checkout/fulfill/route.ts new file mode 100644 index 0000000..97b54d4 --- /dev/null +++ b/app/api/checkout/fulfill/route.ts @@ -0,0 +1,117 @@ +import { NextResponse } from "next/server"; +import { cookies } from "next/headers"; +import { getSessionFromCookie } from "@/lib/auth"; +import { getCreditsFromCookie, creditsCookieHeader } from "@/lib/credits"; +import { getStripe, isStripeConfigured } from "@/lib/stripe"; +import { addCreditsForPurchase } from "@/lib/finance"; +import { audit } from "@/lib/audit"; + +export const dynamic = "force-dynamic"; + +/** + * POST /api/checkout/fulfill + * Body: { sessionId: string } + * Verifica o pagamento Stripe e adiciona créditos ao usuário (cookie e, se configurado, Supabase). + * Só credita se o e-mail da sessão atual coincidir com client_reference_id do Checkout. + */ +export async function POST(req: Request) { + if (!isStripeConfigured()) { + return NextResponse.json( + { error: "Pagamento não configurado.", balance: null }, + { status: 503 } + ); + } + + const cookieStore = await cookies(); + const cookieHeader = cookieStore.toString(); + const session = getSessionFromCookie(cookieHeader); + if (!session?.email) { + return NextResponse.json( + { error: "Faça login para receber os créditos.", balance: null }, + { status: 401 } + ); + } + + let body: { sessionId?: string }; + try { + body = await req.json(); + } catch { + return NextResponse.json({ error: "Body inválido.", balance: null }, { status: 400 }); + } + + const sessionId = typeof body.sessionId === "string" ? body.sessionId.trim() : ""; + if (!sessionId || !sessionId.startsWith("cs_")) { + return NextResponse.json( + { error: "Sessão de pagamento inválida.", balance: null }, + { status: 400 } + ); + } + + const stripe = getStripe(); + if (!stripe) { + return NextResponse.json( + { error: "Pagamento indisponível.", balance: null }, + { status: 503 } + ); + } + + try { + const checkoutSession = await stripe.checkout.sessions.retrieve(sessionId, { + expand: [], + }); + + if (checkoutSession.payment_status !== "paid") { + return NextResponse.json( + { error: "Pagamento ainda não confirmado.", balance: null }, + { status: 400 } + ); + } + + const email = checkoutSession.client_reference_id ?? ""; + if (email !== session.email) { + return NextResponse.json( + { error: "Sessão não corresponde ao usuário logado.", balance: null }, + { status: 403 } + ); + } + + const credits = parseInt(String(checkoutSession.metadata?.credits ?? "0"), 10); + if (!Number.isFinite(credits) || credits <= 0) { + return NextResponse.json( + { error: "Pacote de créditos inválido.", balance: null }, + { status: 400 } + ); + } + + const current = getCreditsFromCookie(cookieHeader); + const amountBrl = Number(checkoutSession.amount_total ?? 0) / 100; + + const result = await addCreditsForPurchase( + session.email, + credits, + amountBrl, + "stripe", + sessionId, + current + ); + const newBalance = result.newBalance; + + audit("credits_add", session.email, { + amount: credits, + balanceBefore: current, + balanceAfter: newBalance, + source: "stripe_checkout", + }); + + const res = NextResponse.json({ ok: true, balance: newBalance }); + res.headers.set("Set-Cookie", creditsCookieHeader(newBalance)); + return res; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + console.error("[checkout/fulfill]", message); + return NextResponse.json( + { error: "Não foi possível confirmar o pagamento.", balance: null }, + { status: 500 } + ); + } +} diff --git a/app/api/checkout/mercadopago/route.ts b/app/api/checkout/mercadopago/route.ts new file mode 100644 index 0000000..10c2ddd --- /dev/null +++ b/app/api/checkout/mercadopago/route.ts @@ -0,0 +1,95 @@ +import { NextResponse } from "next/server"; +import { cookies } from "next/headers"; +import { headers } from "next/headers"; +import { getSessionFromCookie } from "@/lib/auth"; +import { createPreference, isMercadoPagoConfigured } from "@/lib/mercadopago"; +import { CREDIT_PACKAGES } from "@/lib/credits"; + +export const dynamic = "force-dynamic"; + +/** + * POST /api/checkout/mercadopago + * Body: { packageId: "13" | "21" | "34" | "55" | "89" } + * Cria preferência Checkout Pro e retorna URL de redirecionamento (PIX, cartão, etc.). + */ +export async function POST(req: Request) { + if (!isMercadoPagoConfigured()) { + return NextResponse.json( + { error: "Pagamento não configurado. Defina MERCADOPAGO_ACCESS_TOKEN." }, + { status: 503 } + ); + } + + const cookieStore = await cookies(); + const cookieHeader = cookieStore.toString(); + const session = getSessionFromCookie(cookieHeader); + if (!session?.email) { + return NextResponse.json( + { error: "Faça login para comprar créditos." }, + { status: 401 } + ); + } + + let body: { packageId?: string }; + try { + body = await req.json(); + } catch { + return NextResponse.json({ error: "Body inválido." }, { status: 400 }); + } + + const packageId = typeof body.packageId === "string" ? body.packageId.trim() : ""; + const pkg = CREDIT_PACKAGES.find((p) => p.id === packageId); + if (!pkg) { + return NextResponse.json( + { error: "Pacote inválido. Use 13, 21, 34, 55 ou 89." }, + { status: 400 } + ); + } + + const headersList = await headers(); + const host = headersList.get("x-forwarded-host") || headersList.get("host") || "localhost:3000"; + const proto = headersList.get("x-forwarded-proto") || (host.includes("localhost") ? "http" : "https"); + const origin = `${proto}://${host}`; + + const preference = await createPreference({ + items: [ + { + title: pkg.label, + quantity: 1, + unit_price: pkg.priceCents / 100, + currency_id: "BRL", + }, + ], + payer: { email: session.email }, + back_urls: { + success: `${origin}/?checkout=success&provider=mercadopago`, + failure: `${origin}/?checkout=failure`, + pending: `${origin}/?checkout=pending`, + }, + auto_return: "approved", + notification_url: `${origin}/api/webhooks/mercadopago`, + external_reference: `darshan:${session.email}:${pkg.amount}`, + metadata: { + packageId: pkg.id, + credits: String(pkg.amount), + email: session.email, + }, + }); + + if (!preference?.id) { + return NextResponse.json( + { error: "Não foi possível criar a preferência de pagamento." }, + { status: 500 } + ); + } + + const url = preference.init_point ?? preference.sandbox_init_point ?? null; + if (!url) { + return NextResponse.json( + { error: "Não foi possível obter a URL de pagamento." }, + { status: 500 } + ); + } + + return NextResponse.json({ url }); +} diff --git a/app/api/config/route.ts b/app/api/config/route.ts new file mode 100644 index 0000000..0709fa2 --- /dev/null +++ b/app/api/config/route.ts @@ -0,0 +1,96 @@ +import { NextResponse } from "next/server"; +import { getConfig, setConfig, type AppConfig, type ConfigFieldMode } from "@/lib/configStore"; +import { audit } from "@/lib/audit"; + +export const dynamic = "force-dynamic"; + +function getSecretFromRequest(req: Request): string | null { + const key = req.headers.get("x-config-key"); + if (key) return key; + const auth = req.headers.get("authorization"); + if (auth?.startsWith("Bearer ")) return auth.slice(7).trim(); + return null; +} + +function checkAuth(req: Request): { ok: true } | { ok: false; status: number; error: string } { + const secret = process.env.CONFIG_SECRET; + if (!secret) { + return { + ok: false, + status: 503, + error: "Configuração desativada. Defina CONFIG_SECRET em .env.local e reinicie o servidor.", + }; + } + const provided = getSecretFromRequest(req); + if (provided !== secret) { + return { ok: false, status: 401, error: "Código inválido." }; + } + return { ok: true }; +} + +/** + * GET /api/config — retorna a configuração atual (requer CONFIG_SECRET no header). + */ +export async function GET(req: Request) { + const auth = checkAuth(req); + if (!auth.ok) { + return NextResponse.json({ error: auth.error }, { status: auth.status }); + } + const config = getConfig(); + return NextResponse.json(config); +} + +/** + * PUT /api/config — atualiza configuração (merge parcial). Requer CONFIG_SECRET. + */ +export async function PUT(req: Request) { + const auth = checkAuth(req); + if (!auth.ok) { + return NextResponse.json({ error: auth.error }, { status: auth.status }); + } + let body: Partial; + try { + body = await req.json(); + } catch { + return NextResponse.json({ error: "Body JSON inválido." }, { status: 400 }); + } + const mode = (v: unknown): ConfigFieldMode => + v === "replace" || v === "complement" ? v : "complement"; + const partial: Partial = {}; + if (body.masterPromptOverride !== undefined) { + partial.masterPromptOverride = + typeof body.masterPromptOverride === "string" ? body.masterPromptOverride : null; + } + if (body.masterPromptMode !== undefined) partial.masterPromptMode = mode(body.masterPromptMode); + if (body.revelationInstructionOverride !== undefined) { + partial.revelationInstructionOverride = + typeof body.revelationInstructionOverride === "string" ? body.revelationInstructionOverride : null; + } + if (body.revelationInstructionMode !== undefined) partial.revelationInstructionMode = mode(body.revelationInstructionMode); + if (body.mockMessagesOverride !== undefined) { + partial.mockMessagesOverride = Array.isArray(body.mockMessagesOverride) + ? body.mockMessagesOverride.filter((x): x is string => typeof x === "string") + : null; + } + if (body.mockMessagesMode !== undefined) partial.mockMessagesMode = mode(body.mockMessagesMode); + if (body.readingInstructionOverride !== undefined) { + partial.readingInstructionOverride = + typeof body.readingInstructionOverride === "string" ? body.readingInstructionOverride : null; + } + if (body.readingInstructionMode !== undefined) partial.readingInstructionMode = mode(body.readingInstructionMode); + if (body.creditsPerRevelation !== undefined) { + const n = Number(body.creditsPerRevelation); + partial.creditsPerRevelation = Number.isFinite(n) && n >= 0 ? n : null; + } + if (body.creditsPerReading !== undefined) { + const n = Number(body.creditsPerReading); + partial.creditsPerReading = Number.isFinite(n) && n >= 0 ? n : null; + } + if (body.pricePerCreditCents !== undefined) { + const n = Number(body.pricePerCreditCents); + partial.pricePerCreditCents = Number.isFinite(n) && n >= 0 ? n : null; + } + const updated = setConfig(partial); + audit("config_update", "admin", { keys: Object.keys(partial) }); + return NextResponse.json(updated); +} diff --git a/app/api/config/unlock/route.ts b/app/api/config/unlock/route.ts new file mode 100644 index 0000000..2f678c7 --- /dev/null +++ b/app/api/config/unlock/route.ts @@ -0,0 +1,97 @@ +import { NextResponse } from "next/server"; +import { getConfig } from "@/lib/configStore"; +import { audit } from "@/lib/audit"; + +export const dynamic = "force-dynamic"; + +/** + * POST /api/config/unlock + * Body: { secretCode: string, recaptchaToken: string } + * Verifica o reCAPTCHA com o Google e, se o código secreto estiver correto, retorna a config. + */ +export async function POST(req: Request) { + const secret = process.env.CONFIG_SECRET; + if (!secret) { + return NextResponse.json( + { error: "Configuração desativada. Defina CONFIG_SECRET." }, + { status: 503 } + ); + } + + const recaptchaSecret = process.env.RECAPTCHA_SECRET_KEY; + if (!recaptchaSecret) { + return NextResponse.json( + { error: "reCAPTCHA não configurado. Defina RECAPTCHA_SECRET_KEY." }, + { status: 503 } + ); + } + + let body: { secretCode?: string; recaptchaToken?: string }; + try { + body = await req.json(); + } catch { + return NextResponse.json({ error: "Body JSON inválido." }, { status: 400 }); + } + + const token = typeof body.recaptchaToken === "string" ? body.recaptchaToken.trim() : ""; + if (!token) { + return NextResponse.json( + { error: "Complete o reCAPTCHA." }, + { status: 400 } + ); + } + + const secretCode = typeof body.secretCode === "string" ? body.secretCode.trim() : ""; + if (!secretCode) { + return NextResponse.json( + { error: "Digite o código secreto." }, + { status: 400 } + ); + } + + let verifyRes: Response; + try { + verifyRes = await fetch("https://www.google.com/recaptcha/api/siteverify", { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + secret: recaptchaSecret, + response: token, + }), + }); + } catch (e) { + console.error("[config/unlock] reCAPTCHA verify failed:", e); + return NextResponse.json( + { error: "Não foi possível verificar o reCAPTCHA. Tente novamente." }, + { status: 502 } + ); + } + + let verifyData: { success?: boolean; "error-codes"?: string[] }; + try { + verifyData = await verifyRes.json(); + } catch { + return NextResponse.json( + { error: "Resposta inválida do reCAPTCHA." }, + { status: 502 } + ); + } + + if (!verifyData.success) { + return NextResponse.json( + { error: "reCAPTCHA inválido ou expirado. Tente novamente." }, + { status: 400 } + ); + } + + if (secretCode !== secret) { + return NextResponse.json( + { error: "Código inválido." }, + { status: 401 } + ); + } + + audit("config_unlock", "admin"); + const config = getConfig(); + return NextResponse.json(config); +} diff --git a/app/api/credits/cost/route.ts b/app/api/credits/cost/route.ts new file mode 100644 index 0000000..be37da2 --- /dev/null +++ b/app/api/credits/cost/route.ts @@ -0,0 +1,21 @@ +import { NextResponse } from "next/server"; +import { CREDITS_PER_AI_REQUEST, CREDITS_PER_PERSONAL_MAP } from "@/lib/credits"; +import { getConfig } from "@/lib/configStore"; +import { getPlatformFeePercent } from "@/lib/finance"; + +export const dynamic = "force-dynamic"; + +export async function GET() { + const config = getConfig(); + const creditsPerRevelation = config.creditsPerRevelation ?? CREDITS_PER_AI_REQUEST; + const creditsPerReading = config.creditsPerReading ?? CREDITS_PER_PERSONAL_MAP; + const pricePerCreditCents = config.pricePerCreditCents ?? null; + const platformFeePercent = getPlatformFeePercent(); + return NextResponse.json({ + creditsPerRevelation, + creditsPerReading, + pricePerCreditCents, + platformFeePercent, + description: `Créditos por revelação: ${creditsPerRevelation}; por leitura: ${creditsPerReading}. Margem: ${platformFeePercent}%.`, + }); +} diff --git a/app/api/credits/dev-decrease/route.ts b/app/api/credits/dev-decrease/route.ts new file mode 100644 index 0000000..358d78e --- /dev/null +++ b/app/api/credits/dev-decrease/route.ts @@ -0,0 +1,34 @@ +import { NextResponse } from "next/server"; +import { cookies } from "next/headers"; +import { getSessionFromCookie } from "@/lib/auth"; +import { + getCreditsFromCookie, + creditsCookieHeader, + CREDITS_PER_AI_REQUEST, +} from "@/lib/credits"; + +export const dynamic = "force-dynamic"; + +/** + * POST /api/credits/dev-decrease + * Apenas em NODE_ENV=development: diminui créditos em 1 revelação (para testar fluxo). + */ +export async function POST() { + if (process.env.NODE_ENV !== "development") { + return NextResponse.json( + { ok: false, error: "Disponível apenas em desenvolvimento." }, + { status: 403 } + ); + } + const cookieStore = await cookies(); + const cookieHeader = cookieStore.toString(); + const session = getSessionFromCookie(cookieHeader); + if (!session) { + return NextResponse.json({ ok: false, error: "Não autenticado.", balance: 0 }, { status: 401 }); + } + const current = getCreditsFromCookie(cookieHeader); + const newBalance = Math.max(0, current - CREDITS_PER_AI_REQUEST); + const res = NextResponse.json({ ok: true, balance: newBalance }); + res.headers.set("Set-Cookie", creditsCookieHeader(newBalance)); + return res; +} diff --git a/app/api/credits/route.ts b/app/api/credits/route.ts new file mode 100644 index 0000000..faf5c49 --- /dev/null +++ b/app/api/credits/route.ts @@ -0,0 +1,43 @@ +import { NextResponse } from "next/server"; +import { cookies } from "next/headers"; +import { getSessionFromCookie } from "@/lib/auth"; +import { + getCreditsFromCookie, + creditsCookieHeader, +} from "@/lib/credits"; +import { getCreditsBalance } from "@/lib/finance"; +import { audit } from "@/lib/audit"; + +export const dynamic = "force-dynamic"; + +export async function GET() { + const cookieStore = await cookies(); + const cookieHeader = cookieStore.toString(); + const session = getSessionFromCookie(cookieHeader); + if (!session) { + return NextResponse.json({ ok: false, error: "Não autenticado.", balance: 0 }, { status: 401 }); + } + const fromCookie = getCreditsFromCookie(cookieHeader); + const balance = await getCreditsBalance(session.email, fromCookie); + return NextResponse.json({ ok: true, balance }); +} + +export async function POST(req: Request) { + const cookieStore = await cookies(); + const cookieHeader = cookieStore.toString(); + const session = getSessionFromCookie(cookieHeader); + if (!session) { + return NextResponse.json({ ok: false, error: "Não autenticado." }, { status: 401 }); + } + const body = await req.json().catch(() => ({})); + const amount = typeof body.amount === "number" ? Math.floor(body.amount) : 0; + if (amount <= 0) { + return NextResponse.json({ ok: false, error: "Valor inválido." }, { status: 400 }); + } + const current = getCreditsFromCookie(cookieHeader); + const newBalance = current + amount; + audit("credits_add", session.email, { amount, balanceBefore: current, balanceAfter: newBalance }); + const res = NextResponse.json({ ok: true, balance: newBalance }); + res.headers.set("Set-Cookie", creditsCookieHeader(newBalance)); + return res; +} diff --git a/app/api/darshan/check/route.ts b/app/api/darshan/check/route.ts new file mode 100644 index 0000000..d39ce8d --- /dev/null +++ b/app/api/darshan/check/route.ts @@ -0,0 +1,25 @@ +import { NextResponse } from "next/server"; +import { getConnector } from "@/lib/ai"; + +export const dynamic = "force-dynamic"; + +/** + * GET /api/darshan/check + * Valida se há um conector de IA configurado (chave em .env.local). + * Aceita ?mock=1 para retornar ok: true (frontend usa mock e permite interação sem API). + */ +export async function GET(req: Request) { + const url = new URL(req.url); + const mockParam = url.searchParams.get("mock"); + if (mockParam === "1" || mockParam === "true") { + return NextResponse.json({ ok: true, provider: "mock" }); + } + const connector = getConnector(); + if (!connector) { + return NextResponse.json( + { ok: false, message: "Nenhum provedor de IA configurado. Defina OPENAI_API_KEY, GOOGLE_AI_API_KEY ou ANTHROPIC_API_KEY em .env.local. Ou use o toggle 'AI desligada' (mock) no topo da página." }, + { status: 503 } + ); + } + return NextResponse.json({ ok: true, provider: connector.id }); +} diff --git a/app/api/darshan/route.ts b/app/api/darshan/route.ts index 2ba9271..8199800 100644 --- a/app/api/darshan/route.ts +++ b/app/api/darshan/route.ts @@ -1,67 +1,302 @@ import { NextResponse } from "next/server"; -import OpenAI from "openai"; -import { loadMasterPrompt } from "../../../lib/darshanPrompt"; +import { cookies } from "next/headers"; +import { getConnector } from "@/lib/ai"; +import { loadMasterPrompt } from "@/lib/darshanPrompt"; +import { getConfig } from "@/lib/configStore"; +import { PHASE_NAMES } from "@/lib/darshan"; +import { getOfflineRevelation } from "@/lib/oracleOffline"; +import { getSessionFromCookie } from "@/lib/auth"; +import { + getCreditsFromCookie, + creditsCookieHeader, + CREDITS_PER_AI_REQUEST, +} from "@/lib/credits"; +import { + getCreditsBalance, + debitCredits, + logAiUsage, + estimateCost, + refreshUsdToBrlCache, + type AiUsageProvider, +} from "@/lib/finance"; +import { logger } from "@/lib/logger"; +import { checkAndRecordRateLimit, checkDailyLimit, recordDailyRequest } from "@/lib/usageLimits"; +import { isSupabaseConfigured } from "@/lib/supabase"; -const openai = new OpenAI({ - apiKey: process.env.OPENAI_API_KEY, -}); +export const dynamic = "force-dynamic"; + +function buildUserContext(userProfile: { + fullName?: string; + birthDate?: string; + birthPlace?: string; + birthTime?: string; +}): string { + const parts: string[] = []; + if (userProfile.fullName?.trim()) parts.push(`Nome completo: ${userProfile.fullName.trim()}`); + if (userProfile.birthDate?.trim()) parts.push(`Data de nascimento: ${userProfile.birthDate.trim()}`); + if (userProfile.birthPlace?.trim()) parts.push(`Local de nascimento: ${userProfile.birthPlace.trim()}`); + if (userProfile.birthTime?.trim()) parts.push(`Horário de nascimento: ${userProfile.birthTime.trim()}`); + if (parts.length === 0) return ""; + return `\n\nMapa do usuário (dados para interpretação, NÃO para citar literalmente):\n${parts.join("\n")}\n\nUse o mapa para derivar aspectos astrológicos, arquétipos, traços de personalidade, samskaras, karmas ou kleshas que aqueles dados sugerem — fale sobre o que esse mapa traz como resultado/theme, não repita o dado em si. Pode chamar o usuário pelo nome (primeiro nome) às vezes. Evite referências diretas aos dados (ex.: não cite cidade, data ou horário) e evite trocadilhos com nomes ou lugares.`; +} + +function buildHistoryContext(history: { userMessage?: string; darshanMessage: string }[]): string { + if (!history.length) return ""; + const lines = history.map( + (t) => `${t.userMessage ? `Usuário: ${t.userMessage}\n` : ""}Darshan: ${t.darshanMessage}` + ); + return `\n\nHistórico desta sessão (use como semente para uma NOVA resposta coerente; NUNCA repita frases ou blocos já ditos):\n${lines.join("\n\n")}`; +} + +const REVELATION_INSTRUCTION = `Elabore UMA única mensagem em português, orgânica e dinâmica, inspirada no espírito do Darshan (presença, tempo vivo, corpo, silêncio, devolução à consciência). Não siga ordem fixa nem liste tópicos; deixe o texto fluir em torno do mesmo tema (a pergunta ou intenção do usuário). + +OBRIGATÓRIO — retorno e elo de sentido: +- Dê um retorno ao usuário a partir do que o mapa e a pergunta sugerem: fale sobre aspectos astrológicos, arquétipos, traços de personalidade, samskaras, karmas ou kleshas identificados — o resultado ou o tema que aqueles dados trazem, não os dados em si. +- Pode chamar o usuário pelo nome (primeiro nome) às vezes, com naturalidade. +- Evite referências diretas aos dados (não cite cidade, data, horário) e evite trocadilhos com nome, local ou qualquer dado do mapa. Crie elo de sentido entre pergunta, histórico e a interpretação (arquétipos, karma, kleshas, tendências), não com os dados crus. + +Use quebras de linha (\\n\\n) apenas quando fizer sentido separar um bloco do outro para leitura (telas de transição). De 1 a no máximo 7 blocos; cada bloco = no máximo 1 ou 2 frases curtas. Nunca repita o que já foi dito no histórico; use o histórico e a nova pergunta como semente para uma revelação coerente e diferente.`; + +const PHASE_ROLES: Record = { + 1: "Dê UMA frase-oráculo (Luz): curta, poética, que abra o presente.", + 2: "Dê UMA mensagem sobre o Pulso Jyotish do agora: qualidade do tempo, fase lunar, momento do dia. Se tiver mapa do usuário, use para considerações astrológicas sutis.", + 3: "Dê UMA mensagem sobre o Arquétipo Chinês do ciclo anual vigente.", + 4: "Dê UMA mensagem sobre o Elemento predominante (Ayurveda: Terra/Água/Fogo/Ar/Éter) para este momento.", + 5: "Dê UMA mensagem sobre Consciência / guna (Sattva/Rajas/Tamas) em linguagem humana.", + 6: "Dê UMA prática corporal segura e mínima (30–90 segundos).", + 7: "Dê UMA pergunta final que devolva à presença (ex.: O que em você já sabe?).", +}; + +const MOCK_MESSAGES = [ + "O momento pede presença.\n\nRespire e sinta o que já está aqui.\n\nO que em você já sabe?", + "A luz do tempo não aponta para fora.\n\nEla revela o que já pulsa em você.\n\nPermita-se pausar.", + "O oráculo não adivinha — ele devolve.\n\nTraga a pergunta ao corpo.\n\nO que o silêncio responde?", + "Cada fase lunar traz um tom.\n\nEste instante pede escuta, não resposta.\n\nO que em você quer ser ouvido?", + "O mapa não define — sugere.\n\nVocê é maior que qualquer aspecto.\n\nRespire e deixe o agora falar.", +]; + +function getMockMessage(messages: string[] = MOCK_MESSAGES): string { + const list = messages.length > 0 ? messages : MOCK_MESSAGES; + const i = Math.floor(Math.random() * list.length); + return list[i] ?? list[0]; +} export async function POST(req: Request) { - const body = await req.json(); - const question = body.question || ""; - const mode = question ? "question" : "now"; - const masterPrompt = loadMasterPrompt(); - const fallbackSteps = [ - "O tempo pede suavidade.", - "Lua crescente: algo quer nascer devagar.", - "O ciclo coletivo favorece transformação.", - "Hoje há Ar: mente sensível.", - "Rajas está alto: simplifique.", - "Respire 4 vezes lentamente e sinta os pés.", - "O que em você já sabe?", - ]; - let steps = fallbackSteps; - let safetyNote = ""; - - if (!process.env.OPENAI_API_KEY) { - safetyNote = "OPENAI_API_KEY not set; returned fallback steps."; - } else { - try { - const completion = await openai.responses.create({ - model: "gpt-4o-mini", - input: [ - { - role: "system", - content: masterPrompt, - }, - { - role: "user", - content: question || "Pulse of Now", - }, - ], - }); - - const text = completion.output_text ?? ""; - const parsedSteps = text - .split("\n") - .map((line) => line.trim()) - .filter(Boolean) - .slice(0, 7); - if (parsedSteps.length === 7) { - steps = parsedSteps; - } else { - safetyNote = "OpenAI response incomplete; returned fallback steps."; + const body = await req.json().catch(() => ({})); + const useMock = body.mock === true; + const revelation = body.revelation === true; + const phase = revelation ? 1 : Math.min(7, Math.max(1, Number(body.phase) || 1)); + const userMessage = typeof body.userMessage === "string" ? body.userMessage.trim() : ""; + const history: { userMessage?: string; darshanMessage: string }[] = Array.isArray(body.history) + ? body.history + .filter( + (t: unknown) => + t && typeof t === "object" && "darshanMessage" in t && typeof (t as { darshanMessage: unknown }).darshanMessage === "string" + ) + .map((t: { userMessage?: string; darshanMessage: string }) => ({ + userMessage: typeof (t as { userMessage?: string }).userMessage === "string" ? (t as { userMessage: string }).userMessage : undefined, + darshanMessage: String((t as { darshanMessage: string }).darshanMessage), + })) + : []; + + const userProfile = body.userProfile && typeof body.userProfile === "object" + ? { + fullName: typeof body.userProfile.fullName === "string" ? body.userProfile.fullName : undefined, + birthDate: typeof body.userProfile.birthDate === "string" ? body.userProfile.birthDate : undefined, + birthPlace: typeof body.userProfile.birthPlace === "string" ? body.userProfile.birthPlace : undefined, + birthTime: typeof body.userProfile.birthTime === "string" ? body.userProfile.birthTime : undefined, } - } catch (error) { - console.warn("OpenAI request failed; returning fallback steps.", error); - safetyNote = "OpenAI request failed; returned fallback steps."; - } + : {}; + + const config = getConfig(); + const mockMessages = + config.mockMessagesOverride?.length + ? config.mockMessagesMode === "replace" + ? config.mockMessagesOverride + : [...MOCK_MESSAGES, ...config.mockMessagesOverride] + : MOCK_MESSAGES; + + // IA desativada (mock): 100% offline — NÃO chama getConnector() nem APIs externas. + if (useMock) { + const lastRevelations = history.slice(-5).map((t) => t.darshanMessage); + const phrases = lastRevelations.flatMap((msg) => + msg.split(/\n\n/).map((s) => s.trim()).filter(Boolean) + ); + const recentlyUsedPhrases = phrases.flatMap((p) => + /o que em você já sabe/i.test(p) ? [p, "O que em você já sabe?"] : [p] + ); + const message = getOfflineRevelation(userProfile, userMessage, recentlyUsedPhrases); + return NextResponse.json({ message: message || getMockMessage(mockMessages), phase: 1 } satisfies { message: string; phase: number }); + } + + const cookieStore = await cookies(); + const cookieHeader = cookieStore.toString(); + const session = getSessionFromCookie(cookieHeader); + if (!session) { + return NextResponse.json( + { message: "Faça login para usar a IA.", phase: 1, needsLogin: true }, + { status: 401 } + ); + } + if (!checkAndRecordRateLimit(session.email)) { + return NextResponse.json( + { message: "Muitas requisições. Aguarde um minuto e tente novamente.", phase: 1 }, + { status: 429 } + ); } + const dailyLimit = await checkDailyLimit(session.email); + if (!dailyLimit.allowed) { + return NextResponse.json( + { + message: `Limite diário de revelações atingido (${dailyLimit.count}/${dailyLimit.limit}). Volte amanhã.`, + phase: 1, + }, + { status: 429 } + ); + } + const creditsPerRevelation = config.creditsPerRevelation ?? CREDITS_PER_AI_REQUEST; + const balanceFromCookie = getCreditsFromCookie(cookieHeader); + const balance = await getCreditsBalance(session.email, balanceFromCookie); + if (balance < creditsPerRevelation) { + return NextResponse.json( + { message: "Créditos insuficientes. Adicione créditos para usar a IA.", phase: 1, needsCredits: true }, + { status: 402 } + ); + } + // Só chamamos a IA quando há créditos suficientes; o débito ocorre apenas após retorno com sucesso (mais abaixo). + + const defaultMaster = loadMasterPrompt(); + const masterOverride = config.masterPromptOverride?.trim(); + const masterPrompt = + !masterOverride + ? defaultMaster + : config.masterPromptMode === "replace" + ? masterOverride + : `${defaultMaster}\n\n${masterOverride}`; + const defaultRevelation = REVELATION_INSTRUCTION; + const revelationOverride = config.revelationInstructionOverride?.trim(); + const revelationInstruction = + !revelationOverride + ? defaultRevelation + : config.revelationInstructionMode === "replace" + ? revelationOverride + : `${defaultRevelation}\n\n${revelationOverride}`; + const userContext = buildUserContext(userProfile); + const historyContext = buildHistoryContext(history); + + const connector = getConnector(); + if (!connector) { + const fallback = "O tempo pede presença. Respire e sinta o agora."; + return NextResponse.json({ message: fallback, phase: 1 } satisfies { message: string; phase: number }); + } + + try { + let userContent: string; + if (revelation) { + userContent = `${revelationInstruction}\n\n`; + if (userMessage) userContent += `Pergunta ou intenção do usuário: ${userMessage}\n\n`; + if (userContext) userContent += userContext; + if (historyContext) userContent += historyContext; + userContent += `\nRetorne APENAS um JSON válido, sem markdown: {"message": "sua mensagem em português (use \\n\\n entre blocos; 1 a 7 blocos; cada bloco = 1 ou 2 frases curtas)"}. Fale sobre aspectos/arquétipos/samskaras/karmas/kleshas que o mapa e a pergunta sugerem; pode usar o nome do usuário às vezes. Não cite dados do mapa literalmente nem faça trocadilhos. Mensagem orgânica, sempre nova e diferente do histórico.`; + } else { + const phaseRole = PHASE_ROLES[phase] ?? PHASE_NAMES[phase] ?? `Fase ${phase}.`; + userContent = `Diretriz para esta etapa: ${phaseRole}\n\n`; + if (userMessage) userContent += (phase === 1 ? "Pergunta ou intenção do usuário: " : "O que o usuário disse agora: ") + `${userMessage}\n\n`; + if (userContext) userContent += userContext; + if (historyContext) userContent += historyContext; + userContent += `\nRetorne APENAS um JSON válido, sem markdown: {"message": "sua resposta em português (use \\n\\n entre blocos; 1 a 7 blocos; cada bloco = 1 ou 2 frases curtas)", "phase": ${phase}}. Fale sobre aspectos/arquétipos/samskaras/karmas/kleshas que o mapa sugere; pode usar o nome do usuário às vezes. Não cite dados do mapa literalmente nem faça trocadilhos. Mensagem orgânica e SEMPRE nova; nunca repita o histórico.`; + } + + const result = await connector.complete(masterPrompt, userContent); + const raw = result.text; + + if (!raw) { + // IA não retornou conteúdo — não debitar créditos. + return NextResponse.json({ + message: "O momento pede silêncio. Deixe a resposta nascer em você.", + phase: 1, + } satisfies { message: string; phase: number }); + } - return NextResponse.json({ - mode, - steps, - image_prompt: null, - safety: { flags: [], note: safetyNote }, - }); + let parsed: { message?: string; phase?: number }; + try { + const cleaned = raw.replace(/^```(?:json)?\s*/i, "").replace(/\s*```$/i, "").trim(); + parsed = JSON.parse(cleaned) as { message?: string; phase?: number }; + } catch { + // Resposta da IA inválida (JSON quebrado) — não debitar créditos. + return NextResponse.json({ + message: raw.slice(0, 2000) || "Respire. O que em você já sabe?", + phase: 1, + } satisfies { message: string; phase: number }); + } + + const message = typeof parsed.message === "string" ? parsed.message.trim() : ""; + + // Provedor para log/custo: google -> gemini + const provider: AiUsageProvider = connector.id === "google" ? "gemini" : (connector.id as AiUsageProvider); + const modelName = + connector.id === "openai" + ? (process.env.OPENAI_MODEL ?? "gpt-4o-mini") + : connector.id === "anthropic" + ? (process.env.ANTHROPIC_MODEL ?? "claude-sonnet-4-20250514") + : (process.env.GOOGLE_MODEL ?? "gemini-2.5-flash"); + const inputTokens = result.usage?.input_tokens ?? Math.ceil(userContent.length / 4); + const outputTokens = result.usage?.output_tokens ?? Math.ceil((message || raw).length / 4); + const totalTokens = inputTokens + outputTokens; + await refreshUsdToBrlCache(); + const { costUsd, costBrl } = estimateCost(provider, inputTokens, outputTokens); + const mode = revelation ? "question" : "now"; + + const usageLogId = await logAiUsage( + { + provider, + model: modelName, + inputTokens, + outputTokens, + totalTokens, + costUsd, + costBrl, + creditsSpent: creditsPerRevelation, + mode, + questionLength: userMessage.length, + responseLength: (message || raw).length, + success: true, + }, + { userEmail: session.email } + ); + + const debitResult = await debitCredits(session.email, creditsPerRevelation, "darshan_call", { + relatedUsageId: usageLogId ?? undefined, + currentBalanceFromCookie: balance, + }); + + if (!isSupabaseConfigured()) { + recordDailyRequest(session.email); + } + const res = NextResponse.json({ + message: message || "Respire. O que em você já sabe?", + phase: revelation ? 1 : (parsed.phase ?? phase), + creditsUsed: creditsPerRevelation, + balance: debitResult.newBalance, + } satisfies { message: string; phase: number; creditsUsed?: number; balance?: number }); + res.headers.set("Set-Cookie", creditsCookieHeader(debitResult.newBalance)); + return res; + } catch (err) { + // Erro de rede, timeout ou exceção da IA — não debitar créditos. + const errMessage = err instanceof Error ? err.message : String(err); + const errCode = err && typeof err === "object" && "status" in err ? (err as { status?: number }).status : null; + logger.error("darshan IA request failed", { + connector: connector.id, + message: errMessage, + status: errCode ?? undefined, + }); + const is429 = errCode === 429 || /quota|exceeded|limit/i.test(errMessage); + const message = is429 + ? "Limite de uso da API atingido. Adicione créditos em platform.openai.com (Billing) ou use outro provedor no .env.local." + : "O tempo pede pausa. Respire e tente novamente quando se sentir pronto."; + return NextResponse.json({ + message, + phase: 1, + } satisfies { message: string; phase: number }); + } } diff --git a/app/api/map/personal/route.ts b/app/api/map/personal/route.ts new file mode 100644 index 0000000..6193adb --- /dev/null +++ b/app/api/map/personal/route.ts @@ -0,0 +1,299 @@ +/** + * Mapa pessoal — resumo completo por IA (Jyotish, numerologia, arquétipos). + * Custa 9 créditos; pode ser refeito quantas vezes o usuário quiser. + */ + +import { NextResponse } from "next/server"; +import { cookies } from "next/headers"; +import { getConnector } from "@/lib/ai"; +import { getSessionFromCookie } from "@/lib/auth"; +import { + getCreditsFromCookie, + creditsCookieHeader, + CREDITS_PER_PERSONAL_MAP, +} from "@/lib/credits"; +import { getCreditsBalance, debitCredits, logAiUsage, estimateCost, refreshUsdToBrlCache } from "@/lib/finance"; +import type { AiUsageProvider } from "@/lib/finance"; +import { logger } from "@/lib/logger"; +import { getConfig } from "@/lib/configStore"; +import { getOfflineReading } from "@/lib/readingOffline"; +import { computeVedicChartSimplified } from "@/lib/knowledge/vedic"; +import { getRulingNumberFromName, getNumberTraits } from "@/lib/knowledge/numerology"; +import { RASHI_NAMES } from "@/lib/knowledge/archetypes"; +import type { RashiKey, NakshatraKey } from "@/lib/knowledge/types"; + +export const dynamic = "force-dynamic"; + +const ARCHETYPE_NAMES: Record = { + pioneiro: "Pioneiro", + raiz: "Raiz", + mensageiro: "Mensageiro", + cuidador: "Cuidador", + soberano: "Soberano", + servidor: "Servidor", + alquimista: "Alquimista", + guerreiro: "Guerreiro", + sábio: "Sábio", + realizador: "Realizador", + humanitário: "Humanitário", + dissolvente: "Dissolvente", +}; + +const NAKSHATRA_NAMES: Record = { + ashwini: "Ashwini", + bharani: "Bharani", + krittika: "Krittika", + rohini: "Rohini", + mrigashira: "Mrigashira", + ardra: "Ardra", + punarvasu: "Punarvasu", + pushya: "Pushya", + ashlesha: "Ashlesha", + magha: "Magha", + "purva-phalguni": "Purva Phalguni", + "uttara-phalguni": "Uttara Phalguni", + hasta: "Hasta", + chitra: "Chitra", + swati: "Swati", + vishakha: "Vishakha", + anuradha: "Anuradha", + jyestha: "Jyestha", + mula: "Mula", + "purva-ashadha": "Purva Ashadha", + "uttara-ashadha": "Uttara Ashadha", + shravana: "Shravana", + dhanishta: "Dhanishta", + shatabhisha: "Shatabhisha", + "purva-bhadra": "Purva Bhadra", + "uttara-bhadra": "Uttara Bhadra", + revati: "Revati", +}; + +function buildMapContext(profile: { + fullName?: string; + birthDate?: string; + birthPlace?: string; + birthTime?: string; +}): string { + const chart = computeVedicChartSimplified({ + birthDate: profile.birthDate, + birthTime: profile.birthTime, + }); + const rulingNumber = getRulingNumberFromName(profile.fullName ?? ""); + const numberTraits = getNumberTraits(rulingNumber); + + const parts: string[] = []; + parts.push(`Nome: ${profile.fullName?.trim() || "(não informado)"}`); + parts.push(`Data de nascimento: ${profile.birthDate?.trim() || "(não informado)"}`); + if (profile.birthPlace?.trim()) parts.push(`Local: ${profile.birthPlace.trim()}`); + if (profile.birthTime?.trim()) parts.push(`Horário: ${profile.birthTime.trim()}`); + + parts.push(""); + parts.push("--- Jyotish (mapa védico simplificado) ---"); + if (chart.moonRashi) { + const rashiName = RASHI_NAMES[chart.moonRashi as RashiKey] ?? chart.moonRashi; + parts.push(`Lua no signo (Rashi): ${rashiName}`); + } + if (chart.moonNakshatra) { + const naksName = NAKSHATRA_NAMES[chart.moonNakshatra as NakshatraKey] ?? chart.moonNakshatra; + parts.push(`Lua na estação lunar (Nakshatra): ${naksName}`); + } + if (chart.archetypeKeys?.length) { + const archetypeNames = chart.archetypeKeys.map((k) => ARCHETYPE_NAMES[k] ?? k).join(", "); + parts.push(`Arquétipos sugeridos pelo mapa: ${archetypeNames}`); + } + + parts.push(""); + parts.push("--- Numerologia (Pitágoras) ---"); + parts.push(`Número regente: ${rulingNumber} — ${numberTraits.name}`); + parts.push(`Traço curto: ${numberTraits.shortTrait}`); + parts.push(`Tendências: ${numberTraits.tendencies.join("; ")}`); + parts.push(`Desafios: ${numberTraits.challenges.join("; ")}`); + + return parts.join("\n"); +} + +const SYSTEM_PROMPT = `Você é um intérprete do Darshan. Sua tarefa é gerar uma leitura pessoal em português, harmoniosa e não repetitiva. + +INÍCIO OBRIGATÓRIO (uma ou duas frases): Explique de forma breve, simples e agradável o que foi feito — que a leitura integra Sol regente, Lua, planetas, estações lunares (Nakshatras) e numerologia, em uma síntese única. + +CORPO DA LEITURA (fluido, sem listas longas): +- Percorra o Sol regente, a Lua, cada planeta relevante e a convergência com Nakshatras e numerologia, sem repetir a mesma ideia. +- Use poucos termos em sânscrito; quando usar, explique em uma palavra ou deixe o contexto claro. Prefira português. +- Parágrafos curtos, quebras de linha (\\n\\n) quando fizer sentido. Nada de bullet points ou listas numeradas longas. +- Não cite dados crus (cidade, data ou horário); use só como base para interpretação. +- Linguagem clara, acolhedora e útil. Cada leitura deve ser única. + +Retorne APENAS um JSON válido, sem markdown nem texto antes ou depois: {"message": "seu texto completo aqui"}.`; + +export async function POST(req: Request) { + const cookieStore = await cookies(); + const cookieHeader = cookieStore.toString(); + const session = getSessionFromCookie(cookieHeader); + if (!session) { + return NextResponse.json( + { error: "Faça login para adquirir sua leitura.", needsLogin: true }, + { status: 401 } + ); + } + + let body: { profile?: { fullName?: string; birthDate?: string; birthPlace?: string; birthTime?: string }; offline?: boolean } = {}; + try { + body = await req.json(); + } catch { + return NextResponse.json({ error: "Corpo inválido." }, { status: 400 }); + } + + const profile = body.profile ?? {}; + const fullName = typeof profile.fullName === "string" ? profile.fullName.trim() : ""; + const birthDate = typeof profile.birthDate === "string" ? profile.birthDate.trim() : ""; + if (!fullName && !birthDate) { + return NextResponse.json( + { error: "Adicione nome e data de nascimento no seu perfil antes de gerar a leitura." }, + { status: 400 } + ); + } + + const useOffline = body.offline === true; + if (useOffline) { + const message = getOfflineReading(profile); + const balanceFromCookie = getCreditsFromCookie(cookieHeader); + const balance = await getCreditsBalance(session.email, balanceFromCookie); + return NextResponse.json({ + message, + balance, + creditsUsed: 0, + offline: true, + }); + } + + const readingConfig = getConfig(); + const creditsPerReading = readingConfig.creditsPerReading ?? CREDITS_PER_PERSONAL_MAP; + const balanceFromCookie = getCreditsFromCookie(cookieHeader); + const balance = await getCreditsBalance(session.email, balanceFromCookie); + if (balance < creditsPerReading) { + return NextResponse.json( + { + error: `Créditos insuficientes. A leitura custa ${creditsPerReading} créditos.`, + needsCredits: true, + balance, + }, + { status: 402 } + ); + } + + const connector = getConnector(); + if (!connector) { + // Em dev: retorna mock para teste sem debitar créditos + if (process.env.NODE_ENV === "development") { + const mapContext = buildMapContext(profile); + const mockMessage = `[Mock — dev] Leitura pessoal com base nos dados do perfil.\n\n${mapContext}\n\n--- Fim do mock. Em produção a IA gera o texto completo (Sol, Lua, planetas, Nakshatras, numerologia). ---`; + return NextResponse.json({ + message: mockMessage, + balance, + creditsUsed: 0, + }); + } + return NextResponse.json( + { error: "Serviço de IA indisponível. Tente mais tarde." }, + { status: 503 } + ); + } + + const mapContext = buildMapContext(profile); + const userContent = `Gere uma leitura pessoal completa com base no contexto abaixo.\n\n${mapContext}`; + + const readingOverride = readingConfig.readingInstructionOverride?.trim(); + const systemPrompt = + !readingOverride + ? SYSTEM_PROMPT + : readingConfig.readingInstructionMode === "replace" + ? readingOverride + : `${SYSTEM_PROMPT}\n\n${readingOverride}`; + + try { + const result = await connector.complete(systemPrompt, userContent); + const raw = result.text; + + if (!raw) { + return NextResponse.json( + { error: "A IA não retornou conteúdo. Tente novamente." }, + { status: 500 } + ); + } + + function extractMessageFromRaw(raw: string): string { + let s = raw.trim().replace(/^=>\s*/i, "").replace(/^```(?:json)?\s*/i, "").replace(/\s*```$/i, "").trim(); + try { + const parsed = JSON.parse(s) as { message?: string }; + if (typeof parsed.message === "string") return parsed.message.trim(); + } catch { + const idx = s.search(/"message"\s*:\s*"/i); + if (idx !== -1) { + const start = s.indexOf('"', idx + 10) + 1; + let end = start; + for (let i = start; i < s.length; i++) { + if (s[i] === "\\" && s[i + 1] === '"') { i++; continue; } + if (s[i] === '"') { end = i; break; } + } + const extracted = s.slice(start, end).replace(/\\"/g, '"').replace(/\\n/g, "\n"); + if (extracted.length > 0) return extracted; + } + } + return s.replace(/\}\s*$/, "").trim(); + } + + const message = extractMessageFromRaw(raw).slice(0, 15000) || raw.slice(0, 15000); + + const provider: AiUsageProvider = connector.id === "google" ? "gemini" : (connector.id as AiUsageProvider); + const modelName = + connector.id === "openai" + ? (process.env.OPENAI_MODEL ?? "gpt-4o-mini") + : connector.id === "anthropic" + ? (process.env.ANTHROPIC_MODEL ?? "claude-sonnet-4-20250514") + : (process.env.GOOGLE_MODEL ?? "gemini-2.5-flash"); + const inputTokens = result.usage?.input_tokens ?? Math.ceil((systemPrompt.length + userContent.length) / 4); + const outputTokens = result.usage?.output_tokens ?? Math.ceil(message.length / 4); + const totalTokens = inputTokens + outputTokens; + await refreshUsdToBrlCache(); + const { costUsd, costBrl } = estimateCost(provider, inputTokens, outputTokens); + + const usageLogId = await logAiUsage( + { + provider, + model: modelName, + inputTokens, + outputTokens, + totalTokens, + costUsd, + costBrl, + creditsSpent: creditsPerReading, + mode: "personal_map", + questionLength: userContent.length, + responseLength: message.length, + success: true, + }, + { userEmail: session.email } + ); + + const debitResult = await debitCredits(session.email, creditsPerReading, "personal_map", { + relatedUsageId: usageLogId ?? undefined, + currentBalanceFromCookie: balance, + }); + + const res = NextResponse.json({ + message, + balance: debitResult.newBalance, + creditsUsed: creditsPerReading, + }); + res.headers.set("Set-Cookie", creditsCookieHeader(debitResult.newBalance)); + return res; + } catch (err) { + const errMessage = err instanceof Error ? err.message : String(err); + logger.error("personal map IA request failed", { message: errMessage }); + return NextResponse.json( + { error: "Não foi possível gerar a leitura. Tente novamente." }, + { status: 500 } + ); + } +} diff --git a/app/api/webhooks/mercadopago/route.ts b/app/api/webhooks/mercadopago/route.ts new file mode 100644 index 0000000..458336c --- /dev/null +++ b/app/api/webhooks/mercadopago/route.ts @@ -0,0 +1,94 @@ +import { NextResponse } from "next/server"; +import { getPayment, isMercadoPagoConfigured } from "@/lib/mercadopago"; +import { addCreditsForPurchase } from "@/lib/finance"; +import { getSupabase, isSupabaseConfigured } from "@/lib/supabase"; +import { audit } from "@/lib/audit"; + +export const dynamic = "force-dynamic"; + +type WebhookBody = { + type?: string; + data?: { id?: string }; +}; + +/** + * POST /api/webhooks/mercadopago + * Recebe notificações do Mercado Pago (payment). Busca o pagamento, verifica se está + * aprovado e se ainda não foi processado; em seguida adiciona créditos ao usuário. + * Responde 200 rapidamente para evitar retentativas desnecessárias. + */ +export async function POST(req: Request) { + if (!isMercadoPagoConfigured()) { + return NextResponse.json({ received: true }, { status: 200 }); + } + + let body: WebhookBody; + try { + body = await req.json(); + } catch { + return NextResponse.json({ error: "Invalid body" }, { status: 400 }); + } + + if (body.type !== "payment" || typeof body.data?.id !== "string") { + return NextResponse.json({ received: true }, { status: 200 }); + } + + const paymentId = String(body.data.id).trim(); + if (!paymentId) return NextResponse.json({ received: true }, { status: 200 }); + + const payment = await getPayment(paymentId); + if (!payment) { + return NextResponse.json({ received: true }, { status: 200 }); + } + + if (payment.status !== "approved") { + return NextResponse.json({ received: true }, { status: 200 }); + } + + const supabase = getSupabase(); + if (supabase && isSupabaseConfigured()) { + const { data: existing } = await supabase + .from("payments") + .select("id") + .eq("provider", "mercadopago") + .eq("external_id", paymentId) + .maybeSingle(); + if (existing) { + return NextResponse.json({ received: true }, { status: 200 }); + } + } + + const externalRef = payment.external_reference ?? ""; + const match = externalRef.match(/^darshan:(.+):(\d+)$/); + const emailFromRef = match?.[1] ?? ""; + const creditsFromRef = match?.[2] ? parseInt(match[2], 10) : 0; + const credits = + creditsFromRef > 0 ? creditsFromRef : parseInt(String(payment.metadata?.credits ?? "0"), 10); + + if (!emailFromRef || !Number.isFinite(credits) || credits <= 0) { + return NextResponse.json({ received: true }, { status: 200 }); + } + + const amountBrl = Number(payment.transaction_amount) ?? 0; + const currentBalance = 0; + + try { + await addCreditsForPurchase( + emailFromRef, + credits, + amountBrl, + "mercadopago", + String(payment.id), + currentBalance + ); + audit("credits_add", emailFromRef, { + amount: credits, + source: "mercadopago_webhook", + payment_id: paymentId, + }); + } catch (err) { + console.error("[webhooks/mercadopago]", err); + } + + return NextResponse.json({ received: true }, { status: 200 }); +} diff --git a/app/api/webhooks/stripe/route.ts b/app/api/webhooks/stripe/route.ts new file mode 100644 index 0000000..c98ea99 --- /dev/null +++ b/app/api/webhooks/stripe/route.ts @@ -0,0 +1,89 @@ +import { NextResponse } from "next/server"; +import Stripe from "stripe"; +import { getStripe, isStripeConfigured } from "@/lib/stripe"; +import { addCreditsForPurchase } from "@/lib/finance"; +import { getSupabase, isSupabaseConfigured } from "@/lib/supabase"; +import { audit } from "@/lib/audit"; + +export const dynamic = "force-dynamic"; + +/** + * POST /api/webhooks/stripe + * Recebe eventos do Stripe (ex.: checkout.session.completed). + * Credita o usuário mesmo se ele não retornar à página de sucesso (Google Pay, fechou aba, etc.). + * Configure STRIPE_WEBHOOK_SECRET no Stripe Dashboard (Webhooks > Add endpoint > signing secret). + */ +export async function POST(req: Request) { + const secret = process.env.STRIPE_WEBHOOK_SECRET; + if (!secret?.trim() || !isStripeConfigured()) { + return NextResponse.json({ received: true }, { status: 200 }); + } + + const stripe = getStripe(); + if (!stripe) return NextResponse.json({ received: true }, { status: 200 }); + + let payload: string; + try { + payload = await req.text(); + } catch { + return NextResponse.json({ error: "Invalid body" }, { status: 400 }); + } + + const sig = req.headers.get("stripe-signature"); + if (!sig) { + return NextResponse.json({ error: "Missing stripe-signature" }, { status: 400 }); + } + + let event: Stripe.Event; + try { + event = stripe.webhooks.constructEvent(payload, sig.trim(), secret.trim()); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + console.error("[webhooks/stripe] Signature verification failed:", message); + return NextResponse.json({ error: "Webhook signature verification failed" }, { status: 400 }); + } + + if (event.type !== "checkout.session.completed") { + return NextResponse.json({ received: true }, { status: 200 }); + } + + const session = event.data.object as Stripe.Checkout.Session; + if (session.payment_status !== "paid") { + return NextResponse.json({ received: true }, { status: 200 }); + } + + const sessionId = session.id ?? ""; + const email = typeof session.client_reference_id === "string" ? session.client_reference_id : ""; + const credits = parseInt(String(session.metadata?.credits ?? "0"), 10); + const amountBrl = Number(session.amount_total ?? 0) / 100; + + if (!email || !Number.isFinite(credits) || credits <= 0) { + return NextResponse.json({ received: true }, { status: 200 }); + } + + const supabase = getSupabase(); + if (supabase && isSupabaseConfigured()) { + const { data: existing } = await supabase + .from("payments") + .select("id") + .eq("provider", "stripe") + .eq("external_id", sessionId) + .maybeSingle(); + if (existing) { + return NextResponse.json({ received: true }, { status: 200 }); + } + } + + try { + await addCreditsForPurchase(email, credits, amountBrl, "stripe", sessionId, 0); + audit("credits_add", email, { + amount: credits, + source: "stripe_webhook", + session_id: sessionId, + }); + } catch (err) { + console.error("[webhooks/stripe]", err); + } + + return NextResponse.json({ received: true }, { status: 200 }); +} diff --git a/app/components/CreditsModal.tsx b/app/components/CreditsModal.tsx new file mode 100644 index 0000000..2de13a3 --- /dev/null +++ b/app/components/CreditsModal.tsx @@ -0,0 +1,139 @@ +"use client"; + +import { useState } from "react"; +import { motion, AnimatePresence } from "framer-motion"; +import { + CREDIT_PACKAGES, + formatPriceBRL, +} from "../../lib/credits"; + +type Props = { + isOpen: boolean; + onClose: () => void; + onPurchased: (newBalance: number) => void; + /** Créditos por revelação (configurável na página secreta). */ + creditsPerRevelation: number; + /** Créditos por leitura (configurável na página secreta). */ + creditsPerReading: number; +}; + +export default function CreditsModal({ isOpen, onClose, onPurchased, creditsPerRevelation, creditsPerReading }: Props) { + const [selectedId, setSelectedId] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(""); + + async function handlePurchase() { + const pkg = CREDIT_PACKAGES.find((p) => p.id === selectedId); + if (!pkg) { + setError("Escolha um pacote."); + return; + } + setError(""); + setLoading(true); + try { + const body = { packageId: pkg.id }; + const opts = { + method: "POST" as const, + headers: { "Content-Type": "application/json" }, + credentials: "include" as const, + body: JSON.stringify(body), + }; + let res = await fetch("/api/checkout/mercadopago", opts); + let data: { url?: string; error?: string } = await res.json().catch(() => ({})); + if (!res.ok || !data?.url) { + res = await fetch("/api/checkout/create", opts); + data = await res.json().catch(() => ({})); + } + if (res.ok && typeof data?.url === "string") { + window.location.href = data.url; + return; + } + setError(data?.error ?? "Nenhum pagamento configurado. Configure Stripe ou Mercado Pago."); + } catch { + setError("Erro de conexão. Tente novamente."); + } finally { + setLoading(false); + } + } + + if (!isOpen) return null; + + return ( + <> + +
+ e.stopPropagation()} + > +
+

Recarregar créditos

+ +
+

+ Revelação com IA: {creditsPerRevelation} crédito{creditsPerRevelation !== 1 ? "s" : ""}. Leitura completa: {creditsPerReading} créditos. +

+ +
+ {CREDIT_PACKAGES.map((pkg) => ( + + ))} +
+ + {error &&

{error}

} + +
+ + +
+ +

+ Pagamento seguro: Mercado Pago (PIX, cartão) ou Stripe (cartão, Google Pay). Você será redirecionado ao checkout. +

+
+
+ + ); +} diff --git a/app/components/CrystalOrb.tsx b/app/components/CrystalOrb.tsx index 19c64a0..038717a 100644 --- a/app/components/CrystalOrb.tsx +++ b/app/components/CrystalOrb.tsx @@ -1,9 +1,31 @@ -export default function CrystalOrb() { +"use client"; + +import { motion } from "framer-motion"; + +type Props = { + isRevealing?: boolean; + onClick?: () => void; + clickable?: boolean; +}; + +export default function CrystalOrb({ isRevealing = false, onClick, clickable = false }: Props) { + const Wrapper = clickable ? "button" : "div"; return ( -
-
+ -
+ ); } diff --git a/app/components/DarshanMessage.tsx b/app/components/DarshanMessage.tsx new file mode 100644 index 0000000..258cd7e --- /dev/null +++ b/app/components/DarshanMessage.tsx @@ -0,0 +1,80 @@ +"use client"; + +import { useState, useRef, useEffect } from "react"; +import { motion, AnimatePresence } from "framer-motion"; + +const FADE_DURATION_MS = 600; +const FADE_DURATION_S = FADE_DURATION_MS / 1000; +const MAX_STEPS = 7; + +type Props = { + message: string; + onComplete?: () => void; +}; + +function splitIntoSteps(message: string): string[] { + const trimmed = message.trim(); + if (!trimmed) return []; + const raw = trimmed.split(/\n\n+/).map((s) => s.trim()).filter(Boolean); + const steps = raw.length > 0 ? raw : [trimmed]; + if (steps.length <= MAX_STEPS) return steps; + return [ + ...steps.slice(0, MAX_STEPS - 1), + steps.slice(MAX_STEPS - 1).join("\n\n"), + ]; +} + +export default function DarshanMessage({ message, onComplete }: Props) { + const steps = splitIntoSteps(message); + const [currentIndex, setCurrentIndex] = useState(0); + const [exiting, setExiting] = useState(false); + const onCompleteRef = useRef(onComplete); + onCompleteRef.current = onComplete; + + const isLast = currentIndex >= steps.length - 1; + + useEffect(() => { + if (!exiting) return; + const t = setTimeout(() => { + onCompleteRef.current?.(); + }, FADE_DURATION_MS); + return () => clearTimeout(t); + }, [exiting]); + + function handleClick() { + if (isLast) { + setExiting(true); + } else { + setCurrentIndex((i) => i + 1); + } + } + + if (steps.length === 0) { + onComplete?.(); + return null; + } + + return ( +
+ + !exiting && e.key === "Enter" && handleClick()} + initial={{ opacity: 0, y: 16 }} + animate={exiting ? { opacity: 0, y: -12 } : { opacity: 1, y: 0 }} + exit={{ opacity: 0, y: -12 }} + transition={{ duration: FADE_DURATION_S, ease: [0.25, 0.1, 0.25, 1] }} + className="text-center w-full cursor-pointer rounded-2xl px-6 py-8 focus:outline-none" + aria-label={exiting ? undefined : isLast ? "Toque para voltar" : "Toque para a próxima"} + > +

+ {steps[currentIndex]} +

+
+
+
+ ); +} diff --git a/app/components/DarshanReveal.tsx b/app/components/DarshanReveal.tsx index e49f560..c740965 100644 --- a/app/components/DarshanReveal.tsx +++ b/app/components/DarshanReveal.tsx @@ -1,17 +1,42 @@ "use client"; +import { useEffect } from "react"; +import { motion } from "framer-motion"; + +const REVEAL_DELAY_MS = 900; +const DURATION_MS = 600; + type Props = { steps: string[]; + onComplete?: () => void; }; -export default function DarshanReveal({ steps }: Props) { +export default function DarshanReveal({ steps, onComplete }: Props) { + useEffect(() => { + if (!onComplete || steps.length === 0) return; + const lastIndex = steps.length - 1; + const totalMs = lastIndex * REVEAL_DELAY_MS + DURATION_MS; + const t = setTimeout(onComplete, totalMs); + return () => clearTimeout(t); + }, [steps.length, onComplete]); + return ( -
+
    {steps.map((line, index) => ( -

    + {line} -

    + ))} -
+ ); } diff --git a/app/components/IconMetatronCube.tsx b/app/components/IconMetatronCube.tsx new file mode 100644 index 0000000..ea7e48a --- /dev/null +++ b/app/components/IconMetatronCube.tsx @@ -0,0 +1,36 @@ +"use client"; + +/** Ícone vetorial simplificado do cubo de Metatron (geometria sagrada). */ +export default function IconMetatronCube({ className }: { className?: string }) { + return ( + + {/* Centro */} + + {/* 6 círculos em hexágono ao redor do centro */} + + + + + + + {/* Linhas do centro aos 6 vértices */} + + + + + + + {/* Hexágono externo (conecta os 6 círculos) */} + + + ); +} diff --git a/app/components/IconPlus.tsx b/app/components/IconPlus.tsx new file mode 100644 index 0000000..1c41317 --- /dev/null +++ b/app/components/IconPlus.tsx @@ -0,0 +1,18 @@ +"use client"; + +/** Plus minimalista — duas linhas finas cruzadas. */ +export default function IconPlus({ className }: { className?: string }) { + return ( + + + + ); +} diff --git a/app/components/LoginModal.tsx b/app/components/LoginModal.tsx new file mode 100644 index 0000000..f001d00 --- /dev/null +++ b/app/components/LoginModal.tsx @@ -0,0 +1,200 @@ +"use client"; + +import { useState } from "react"; +import { motion, AnimatePresence } from "framer-motion"; +import Tooltip from "./Tooltip"; + +type Step = "email" | "code"; + +type Props = { + isOpen: boolean; + onClose: () => void; + onSuccess: () => void; +}; + +export default function LoginModal({ isOpen, onClose, onSuccess }: Props) { + const [step, setStep] = useState("email"); + const [email, setEmail] = useState(""); + const [code, setCode] = useState(""); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(""); + + async function handleSendCode(e: React.FormEvent) { + e.preventDefault(); + setError(""); + setLoading(true); + try { + const res = await fetch("/api/auth/send-code", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ email: email.trim() }), + }); + const data = await res.json(); + if (!res.ok) { + setError(data.error ?? "Erro ao enviar código."); + return; + } + setStep("code"); + } catch { + setError("Erro de conexão."); + } finally { + setLoading(false); + } + } + + async function handleVerify(e: React.FormEvent) { + e.preventDefault(); + setError(""); + setLoading(true); + try { + const res = await fetch("/api/auth/verify", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ email: email.trim(), code: code.trim() }), + credentials: "include", + }); + const data = await res.json(); + if (!res.ok) { + setError(data.error ?? "Código inválido."); + return; + } + onSuccess(); + onClose(); + } catch { + setError("Erro de conexão."); + } finally { + setLoading(false); + } + } + + if (!isOpen) return null; + + return ( + <> + +
+ e.stopPropagation()} + > +
+

Entrar

+ +
+

+ Sem senha: informe seu e-mail e use o código que enviarmos para acessar. +

+ + + {step === "email" ? ( + + setEmail(e.target.value)} + placeholder="seu@email.com" + required + className="w-full bg-transparent border-0 border-b border-white/25 rounded-none px-0 py-2.5 text-sm text-mist placeholder:text-white/40 outline-none focus:border-white/50 transition-colors mb-4" + /> + {error &&

{error}

} + +
+ ) : ( + +

Código enviado para {email}

+ setCode(e.target.value.replace(/\D/g, ""))} + placeholder="000000" + required + className="w-full bg-transparent border-0 border-b border-white/25 rounded-none px-0 py-2.5 text-sm text-mist placeholder:text-white/40 outline-none focus:border-white/50 transition-colors mb-4 tracking-widest" + /> + {error &&

{error}

} +
+ + +
+

+ Em desenvolvimento: use código 123456 para testar. +

+
+ )} +
+ +
+

Ou entre com

+
+ + + Google + + + + + +
+
+
+
+ + ); +} diff --git a/app/components/PersonalMapIcon.tsx b/app/components/PersonalMapIcon.tsx new file mode 100644 index 0000000..2e9648c --- /dev/null +++ b/app/components/PersonalMapIcon.tsx @@ -0,0 +1,42 @@ +"use client"; + +import { motion } from "framer-motion"; +import Tooltip from "./Tooltip"; + +type Props = { + onClick: () => void; +}; + +/** Ícone minimalista de mapa (documento/folha) — posicionado abaixo do ícone de cadastro. */ +export default function PersonalMapIcon({ onClick }: Props) { + return ( + + + + + + + + + + + + ); +} diff --git a/app/components/PersonalMapModal.tsx b/app/components/PersonalMapModal.tsx new file mode 100644 index 0000000..3dedb4b --- /dev/null +++ b/app/components/PersonalMapModal.tsx @@ -0,0 +1,331 @@ +"use client"; + +import { useState } from "react"; +import { motion } from "framer-motion"; +import type { UserProfile } from "@/lib/userProfile"; + +/** Ícone minimalista de copiar (dois retângulos). */ +function IconCopy({ className }: { className?: string }) { + return ( + + + + + ); +} + +/** Ícone minimalista de check (copiado). */ +function IconCheck({ className }: { className?: string }) { + return ( + + + + ); +} + +/** Remove prefixos/sufixos de JSON que às vezes vêm na resposta da IA (ex.: => {"message": ... }). */ +function cleanReadingMessage(raw: string): string { + let s = raw.trim().replace(/^=>\s*/i, "").trim(); + try { + const parsed = JSON.parse(s) as { message?: string }; + if (typeof parsed.message === "string") return parsed.message.trim(); + } catch { + // Tentar extrair o valor de "message" manualmente + const idx = s.search(/"message"\s*:\s*"/i); + if (idx !== -1) { + const start = s.indexOf('"', idx + 10) + 1; + let end = start; + for (let i = start; i < s.length; i++) { + if (s[i] === "\\" && s[i + 1] === '"') { i++; continue; } + if (s[i] === '"') { end = i; break; } + } + const extracted = s.slice(start, end).replace(/\\"/g, '"').replace(/\\n/g, "\n"); + if (extracted.length > 0) return extracted; + } + } + return s.replace(/\}\s*$/, "").trim(); +} + +type Props = { + isOpen: boolean; + onClose: () => void; + profile: UserProfile; + credits: number; + /** Créditos por leitura (configurável na página secreta). */ + creditsPerReading: number; + /** Quando true, envia offline: true e a leitura é gerada sem IA e sem custo. */ + offlineMode?: boolean; + onBalanceUpdate: (newBalance: number) => void; + onOpenCredits: () => void; +}; + +export default function PersonalMapModal({ + isOpen, + onClose, + profile, + credits, + creditsPerReading, + offlineMode = false, + onBalanceUpdate, + onOpenCredits, +}: Props) { + const [loading, setLoading] = useState(false); + const [error, setError] = useState(""); + const [result, setResult] = useState(null); + const [copyDone, setCopyDone] = useState(false); + const [confirmNewReading, setConfirmNewReading] = useState(false); + + const hasEnoughCredits = credits >= creditsPerReading; + const canGenerate = + (offlineMode || hasEnoughCredits) && profile && (profile.fullName?.trim() || profile.birthDate?.trim()); + + async function handleGenerate() { + if (!canGenerate) { + if (!hasEnoughCredits) onOpenCredits(); + return; + } + setConfirmNewReading(false); + setError(""); + setLoading(true); + setResult(null); + try { + const res = await fetch("/api/map/personal", { + method: "POST", + headers: { "Content-Type": "application/json" }, + credentials: "include", + body: JSON.stringify({ + profile: { + fullName: profile.fullName ?? undefined, + birthDate: profile.birthDate ?? undefined, + birthPlace: profile.birthPlace ?? undefined, + birthTime: profile.birthTime ?? undefined, + }, + offline: offlineMode, + }), + }); + const data = await res.json(); + + if (res.status === 401) { + setError("Faça login para adquirir sua leitura."); + return; + } + if (res.status === 402) { + setError(`A leitura custa ${creditsPerReading} créditos. Adicione créditos para continuar.`); + if (typeof data.balance === "number") onBalanceUpdate(data.balance); + return; + } + if (!res.ok) { + setError(data?.error ?? "Não foi possível gerar a leitura. Tente novamente."); + return; + } + + const rawMessage = typeof data.message === "string" ? data.message : ""; + setResult(cleanReadingMessage(rawMessage) || rawMessage); + if (typeof data.balance === "number") onBalanceUpdate(data.balance); + } catch { + setError("Erro de conexão. Tente novamente."); + } finally { + setLoading(false); + } + } + + function handleClose() { + setError(""); + setResult(null); + setConfirmNewReading(false); + onClose(); + } + + function handleNewReadingClick() { + if (offlineMode) { + handleGenerate(); + return; + } + if (!hasEnoughCredits) { + onOpenCredits(); + return; + } + setConfirmNewReading(true); + } + + function handleConfirmNewReading() { + handleGenerate(); + } + + if (!isOpen) return null; + + return ( + <> + +
+ e.stopPropagation()} + > +
+

Leitura

+ +
+ +
+ {!result ? ( + <> +
+

+ Resumo completo considerando Sol regente, Lua, planetas, Nakshatras e numerologia — com exemplos e detalhes práticos. Você pode gerar quantas vezes quiser; cada vez será um novo resultado. +

+
+ {!confirmNewReading ? ( + <> +
+ {!offlineMode && ( + {creditsPerReading} créditos + )} + +
+ + ) : ( +
+

+ Esta operação vai consumir {creditsPerReading} créditos de seu saldo. Confirmar? +

+
+ + +
+
+ )} +
+
+ {error && ( +

+ {error} +

+ )} + + ) : ( + <> +
+
+ {result} +
+
+
+
+ +
+
+ {!offlineMode && ( + {creditsPerReading} créditos + )} + {!confirmNewReading ? ( + + ) : ( +
+

+ Esta operação vai consumir {creditsPerReading} créditos de seu saldo. Confirmar? +

+
+ + +
+
+ )} +
+
+
+ {error && ( +

+ {error} +

+ )} + + )} +
+ +
+ + ); +} diff --git a/app/components/ProfileIcon.tsx b/app/components/ProfileIcon.tsx new file mode 100644 index 0000000..ec8f30d --- /dev/null +++ b/app/components/ProfileIcon.tsx @@ -0,0 +1,34 @@ +"use client"; + +import { motion } from "framer-motion"; +import Tooltip from "./Tooltip"; +import IconPlus from "./IconPlus"; + +type Props = { + onClick: () => void; + hasData?: boolean; +}; + +export default function ProfileIcon({ onClick, hasData }: Props) { + return ( + + {hasData ? ( + + + + ) : ( + + + + )} + + ); +} diff --git a/app/components/ProfilePanel.tsx b/app/components/ProfilePanel.tsx new file mode 100644 index 0000000..fd0b26f --- /dev/null +++ b/app/components/ProfilePanel.tsx @@ -0,0 +1,171 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { motion, AnimatePresence } from "framer-motion"; +import type { UserProfile } from "@/lib/userProfile"; +import { toBrDate, maskBrDate, fromBrDate, maskBrTime, fromBrTime } from "@/lib/dateFormatBr"; + +type Props = { + isOpen: boolean; + onClose: () => void; + profile: UserProfile; + onSave: (profile: UserProfile) => void; + onDeleteAccount: () => void; +}; + +const inputClass = + "w-full bg-transparent border-0 border-b border-white/25 rounded-none px-0 py-2.5 text-sm text-mist placeholder:text-white/40 outline-none focus:border-white/50 transition-colors"; + +export default function ProfilePanel({ isOpen, onClose, profile, onSave, onDeleteAccount }: Props) { + const [fullName, setFullName] = useState(profile.fullName ?? ""); + const [birthDateBr, setBirthDateBr] = useState(""); + const [birthPlace, setBirthPlace] = useState(profile.birthPlace ?? ""); + const [birthTimeBr, setBirthTimeBr] = useState(""); + + useEffect(() => { + if (isOpen) { + setFullName(profile.fullName ?? ""); + setBirthDateBr(profile.birthDate ? toBrDate(profile.birthDate) : ""); + setBirthPlace(profile.birthPlace ?? ""); + setBirthTimeBr(profile.birthTime ?? ""); + } + }, [isOpen, profile.fullName, profile.birthDate, profile.birthPlace, profile.birthTime]); + + function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + const isoDate = birthDateBr ? fromBrDate(birthDateBr) : undefined; + const time = birthTimeBr ? fromBrTime(birthTimeBr) : undefined; + onSave({ + fullName: fullName.trim() || undefined, + birthDate: isoDate || undefined, + birthPlace: birthPlace.trim() || undefined, + birthTime: time || undefined, + }); + onClose(); + } + + function handleClear() { + setFullName(""); + setBirthDateBr(""); + setBirthPlace(""); + setBirthTimeBr(""); + onSave({}); + } + + async function handleDeleteAccount() { + if (!confirm("Excluir sua conta? Seus dados locais serão apagados e você precisará entrar novamente.")) return; + onDeleteAccount(); + onClose(); + } + + return ( + + {isOpen && ( + <> + + +
+

Seu mapa

+ +
+
+

+ Dados opcionais para respostas mais precisas (astrologia e numerologia). +

+ + + setFullName(e.target.value)} + placeholder="" + className={inputClass} + /> + + + setBirthDateBr(maskBrDate(e.target.value))} + placeholder="DD/MM/AAAA" + maxLength={10} + className={inputClass} + /> + + + setBirthPlace(e.target.value)} + placeholder="" + className={inputClass} + /> + + + setBirthTimeBr(maskBrTime(e.target.value))} + placeholder="HH:mm" + maxLength={5} + className={inputClass} + /> + +
+ + +
+ + + +
+
+ + )} +
+ ); +} diff --git a/app/components/SupportModal.tsx b/app/components/SupportModal.tsx new file mode 100644 index 0000000..f341959 --- /dev/null +++ b/app/components/SupportModal.tsx @@ -0,0 +1,80 @@ +"use client"; + +import { motion, AnimatePresence } from "framer-motion"; + +type Props = { + isOpen: boolean; + onClose: () => void; + creditsPerRevelation: number; +}; + +export default function SupportModal({ isOpen, onClose, creditsPerRevelation }: Props) { + return ( + + {isOpen && ( + <> + +
+ e.stopPropagation()} + > +
+ ? + +
+ +

+ As informações que você recebe aqui não são determinísticas. O mapa, o oráculo e a IA oferecem leituras interpretativas — sugestões de sentido, não previsões fixas. O que importa é o que ressoa em você. +

+ +
+
+

Créditos e consumo

+

+ Cada revelação com IA consome {creditsPerRevelation} créditos. O valor cobre o custo do uso da IA e uma margem para o app. O modo “AI desligada” usa o oráculo offline e não consome créditos. +

+
+
+

Como adicionar créditos

+

+ Toque no ícone de créditos no topo da tela. Escolha a quantidade de créditos. Pagamento seguro: cartão, Google Pay ou Stripe Link (Stripe); ou PIX/cartão (Mercado Pago). +

+
+
+

Login sem senha

+

+ Seu acesso é pelo e-mail (código de uso único) ou com conta Google. Não guardamos senha. Em breve: login por biometria. +

+
+
+

Contato

+

+ Dúvidas ou problemas: envie um e-mail para suporte indicado nas configurações do app ou na página de divulgação. +

+
+
+
+
+ + )} +
+ ); +} diff --git a/app/components/TimeHeader.tsx b/app/components/TimeHeader.tsx index d8d7947..3ced3a2 100644 --- a/app/components/TimeHeader.tsx +++ b/app/components/TimeHeader.tsx @@ -1,16 +1,97 @@ +"use client"; + +import { getMoonPhaseHover } from "@/lib/knowledge/moonPhases"; +import Tooltip from "./Tooltip"; + +const HOVER_SUNRISE = "Nascer do sol"; +const HOVER_SUNSET = "Pôr do sol"; + type Props = { sunrise: string; sunset: string; moonPhase: string; }; +const iconClass = "w-4 h-4 shrink-0 text-white/70"; + +function IconSun() { + return ( + + + + + ); +} + +function IconMoon() { + return ( + + + + ); +} + +function IconCrescent() { + return ( + + + + ); +} + +const itemClass = "flex items-center gap-2 text-[11px] uppercase tracking-widest text-white/60"; + export default function TimeHeader({ sunrise, sunset, moonPhase }: Props) { return ( -
-
- 🌅 Sunrise: {sunrise} · 🌇 Sunset: {sunset} -
-
🌙 Moon: {moonPhase}
+
+ + + + {sunrise} + + + + + + {sunset} + + + + + + {moonPhase} + + +
+ ); +} + +export function TimeHeaderSunrise({ sunrise }: { sunrise: string }) { + return ( + + + + {sunrise} + + + ); +} + +export function TimeHeaderSunsetMoon({ sunset, moonPhase }: { sunset: string; moonPhase: string }) { + return ( +
+ + + + {sunset} + + + + + + {moonPhase} + +
); } diff --git a/app/components/Tooltip.tsx b/app/components/Tooltip.tsx new file mode 100644 index 0000000..ea693d1 --- /dev/null +++ b/app/components/Tooltip.tsx @@ -0,0 +1,45 @@ +"use client"; + +type Props = { + text: string; + children: React.ReactNode; + /** Alinhamento do tooltip em relação ao elemento. default: left */ + align?: "left" | "right" | "center"; + /** Nome do group para hover (deve ser único no mesmo pai). */ + groupName?: string; + /** Quando definido, o wrapper usa esta classe (ex.: para botão fixo). */ + wrapperClassName?: string; +}; + +const tooltipBase = + "absolute top-full mt-1 text-[10px] text-white/70 opacity-0 pointer-events-none transition-opacity duration-150 z-50 max-w-[200px] break-words "; + +export default function Tooltip({ + text, + children, + align = "left", + groupName = "tip", + wrapperClassName, +}: Props) { + const position = + align === "right" + ? "right-0" + : align === "center" + ? "left-1/2 -translate-x-1/2" + : "left-0"; + const Wrapper = wrapperClassName ? "div" : "span"; + const wrapperClass = wrapperClassName + ? `${wrapperClassName} group` + : "relative group inline-flex"; + return ( + + {children} + + {text} + + + ); +} diff --git a/app/config/page.tsx b/app/config/page.tsx new file mode 100644 index 0000000..708b3ea --- /dev/null +++ b/app/config/page.tsx @@ -0,0 +1,477 @@ +"use client"; + +import { useState, useCallback, useRef, useEffect } from "react"; +import Script from "next/script"; +import Link from "next/link"; + +type ConfigFieldMode = "replace" | "complement"; + +type AppConfig = { + masterPromptOverride?: string | null; + masterPromptMode?: ConfigFieldMode | null; + revelationInstructionOverride?: string | null; + revelationInstructionMode?: ConfigFieldMode | null; + mockMessagesOverride?: string[] | null; + mockMessagesMode?: ConfigFieldMode | null; + readingInstructionOverride?: string | null; + readingInstructionMode?: ConfigFieldMode | null; + creditsPerRevelation?: number | null; + creditsPerReading?: number | null; + pricePerCreditCents?: number | null; + updatedAt?: string | null; +}; + +declare global { + interface Window { + grecaptcha?: { + render: (container: HTMLElement, options: { sitekey: string; callback?: (token: string) => void }) => number; + reset: (widgetId?: number) => void; + }; + onRecaptchaLoad?: () => void; + } +} + +const RECAPTCHA_SITE_KEY = typeof process.env.NEXT_PUBLIC_RECAPTCHA_SITE_KEY === "string" + ? process.env.NEXT_PUBLIC_RECAPTCHA_SITE_KEY.trim() + : ""; + +export default function ConfigPage() { + const [secretCode, setSecretCode] = useState(""); + const [recaptchaToken, setRecaptchaToken] = useState(""); + const recaptchaWidgetId = useRef(null); + const recaptchaContainerRef = useRef(null); + const [configKey, setConfigKey] = useState(""); + const [config, setConfig] = useState(null); + const [unlocked, setUnlocked] = useState(false); + const [error, setError] = useState(""); + const [saving, setSaving] = useState(false); + + const headers = () => ({ "X-Config-Key": configKey }); + + const resetRecaptcha = useCallback(() => { + setRecaptchaToken(""); + if (typeof window !== "undefined" && window.grecaptcha && recaptchaWidgetId.current !== null) { + window.grecaptcha.reset(recaptchaWidgetId.current); + } + }, []); + + /** Valida reCAPTCHA e código secreto; se corretos, desbloqueia a página e carrega a config. */ + const unlock = useCallback(async () => { + const code = secretCode.trim(); + if (!code) { + setError("Digite o código secreto."); + return; + } + if (RECAPTCHA_SITE_KEY && !recaptchaToken) { + setError("Complete o reCAPTCHA (marque \"Não sou um robô\" e, se aparecer, as imagens)."); + return; + } + setError(""); + try { + const res = RECAPTCHA_SITE_KEY + ? await fetch("/api/config/unlock", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ secretCode: code, recaptchaToken }), + }) + : await fetch("/api/config", { headers: { "X-Config-Key": code } }); + if (!res.ok) { + const data = await res.json().catch(() => ({})); + setError(data?.error || "Código inválido."); + resetRecaptcha(); + return; + } + const data = await res.json(); + setConfigKey(code); + setConfig(data); + setUnlocked(true); + } catch { + setError("Erro de conexão. Tente novamente."); + resetRecaptcha(); + } + }, [secretCode, recaptchaToken, resetRecaptcha]); + + const save = useCallback(async () => { + if (!configKey.trim() || !config) return; + setSaving(true); + setError(""); + try { + const res = await fetch("/api/config", { + method: "PUT", + headers: { "Content-Type": "application/json", ...headers() }, + body: JSON.stringify({ + masterPromptOverride: config.masterPromptOverride ?? null, + masterPromptMode: config.masterPromptMode ?? "complement", + revelationInstructionOverride: config.revelationInstructionOverride ?? null, + revelationInstructionMode: config.revelationInstructionMode ?? "complement", + mockMessagesOverride: config.mockMessagesOverride ?? null, + mockMessagesMode: config.mockMessagesMode ?? "complement", + readingInstructionOverride: config.readingInstructionOverride ?? null, + readingInstructionMode: config.readingInstructionMode ?? "complement", + creditsPerRevelation: config.creditsPerRevelation ?? null, + creditsPerReading: config.creditsPerReading ?? null, + pricePerCreditCents: config.pricePerCreditCents ?? null, + }), + }); + if (!res.ok) { + const data = await res.json().catch(() => ({})); + setError(data?.error || "Erro ao salvar."); + return; + } + const data = await res.json(); + setConfig(data); + } catch { + setError("Erro ao salvar. Verifique a conexão."); + } finally { + setSaving(false); + } + }, [configKey, config]); + + const mockMessagesText = + Array.isArray(config?.mockMessagesOverride) && config.mockMessagesOverride.length > 0 + ? config.mockMessagesOverride.join("\n\n") + : ""; + + const setMockMessagesText = (text: string) => { + const list = text + .split(/\n\n+/) + .map((s) => s.trim()) + .filter(Boolean); + setConfig((c) => (c ? { ...c, mockMessagesOverride: list.length ? list : null } : null)); + }; + + useEffect(() => { + if (!RECAPTCHA_SITE_KEY) return; + window.onRecaptchaLoad = () => { + setTimeout(() => { + if (recaptchaContainerRef.current && typeof window !== "undefined" && window.grecaptcha) { + try { + recaptchaWidgetId.current = window.grecaptcha.render(recaptchaContainerRef.current, { + sitekey: RECAPTCHA_SITE_KEY, + callback: (token: string) => setRecaptchaToken(token), + }); + } catch (e) { + console.error("[config] reCAPTCHA render error:", e); + } + } + }, 0); + }; + return () => { + window.onRecaptchaLoad = undefined; + }; + }, []); + + if (!unlocked) { + return ( +
+ {RECAPTCHA_SITE_KEY && ( +