Pitch: Como construí o ViraPropo: arquitetura, Structured Outputs com Gemini, persistência em Postgres e assinatura com hash criptográfico

1. Contexto e Problema

Quem atua como desenvolvedor freelancer ou presta serviços de tecnologia conhece bem o gargalo da etapa comercial: gastar horas formatando propostas no Docs/Canva, enviando PDFs estáticos por WhatsApp ou redigindo mensagens sem padronização de escopo, cronograma e garantias.

O problema não é apenas o tempo gasto na redação, mas a falta de rastreabilidade: propostas que viram "telefone sem fio", alterações de escopo sem histórico formal e a ausência de um aceite eletrônico com validade jurídica mínima. Para resolver essa fricção do meu próprio fluxo, desenvolvi o ViraPropo, uma aplicação para gerar propostas comerciais consultivas estruturadas, disponibilizar visualização pública via web e registrar assinaturas eletrônicas auditáveis.

2. Stack e Arquitetura

A aplicação foi construída com foco em simplicidade operacional, baixo custo de infraestrutura e performance em ambiente serverless:

  • Framework Full-Stack: Next.js 16 (App Router) + React 19 + TypeScript.
  • Estilização: Tailwind CSS v4.
  • Provedor de LLM: Google Generative AI SDK (@google/generative-ai) consumindo Gemini Flash.
  • Banco de Dados: PostgreSQL hospedado remotamente, consumido via driver nativo pg com pool de conexões e transações manuais.
  • Validação de Schemas: Zod 4 para validação de payloads nas rotas de API e formulários.
  • Autenticação e Criptografia: JWT (jsonwebtoken) para sessões stateless, bcryptjs para hashing de credenciais e módulo nativo crypto do Node.js para hashes de integridade SHA-256.
  • Gateways de Pagamento / Webhooks: Integração com Asaas e Abacate Pay com tabela dedicada de idempotência (webhook_eventos) para evitar duplicidade no processamento de eventos.
  • Hospedagem & Deploy: Vercel (Edge Middleware + Serverless Route Handlers).

[ Cliente / Browser ] │ ├──> [ Next.js App Router (Vercel Serverless) ] │ │ │ ├──> [ Gemini API ] (Structured JSON + Timeout Race) │ │ │ ├──> [ PostgreSQL ] (pg Pool + Transactions) │ │ │ └──> [ Node.js crypto ] (SHA-256 Integrity & Signatures) │ └──> [ Visualizador Público /p/[id] ] (Sandbox Iframe + Print Engine)

3. Decisões Técnicas e Desafios de Implementação

3.1. Integração com LLM: Structured Outputs vs. Geração Direta de HTML

Inicialmente, testei pedir para a IA gerar o documento HTML completo com CSS inline. O resultado foi péssimo: inconsistências no design responsivo, quebra de tabelas em telas menores e alucinações nas tags de fechamento.

A solução foi migrar para Structured Outputs. O modelo atua exclusivamente como um sintetizador de inteligência de negócios (diagnóstico de oportunidade, divisão de entregáveis em fases executivas, diferenciais competitivos e reversão de riscos), retornando um JSON estrito validado:

// Chamada tipada com enforcement de JSON e controle de timeoutconst model = genAI.getGenerativeModel({ model: "gemini-3.6-flash", generationConfig: { temperature: 0.7, maxOutputTokens: 2048, responseMimeType: "application/json", },});// Hard timeout de 8.5s para garantir resposta dentro da janela serverless da Vercelconst timeoutPromise = new Promise<never>((_, reject) => setTimeout(() => reject(new Error("Timeout de IA")), 8500));const rawText = await Promise.race([ model.generateContent(prompt).then((res) => res.response.text()), timeoutPromise,]);

Os dados retornados pelo JSON são injetados em templates HTML modulares (gerarTemplatePro / gerarTemplateFree), garantindo conformidade visual, sanitização e responsividade.

Estratégia de Resiliência (Zero Downtime): Se a chamada à API do Gemini atingir o timeout de 8.5s ou sofrer rate limit, a aplicação dispara instantaneamente um gerador determinístico de fallback (gerarCopywritingFallback). O usuário nunca recebe erro 504 na tela; a proposta é montada imediatamente com copywriting baseado no escopo e itens enviados.

3.2. Persistência Atômica e Visualização Pública

A persistência do documento e seus itens filhos ocorre em uma única transação atômica no PostgreSQL. O identificador único da proposta é um UUIDv4 gerado pelo banco (gen_random_uuid()), acompanhado de um código legível (PROP-YYYY-XXXX):

export async function salvarProposta(dados: SalvarPropostaDTO): Promise<PropostaRow> { await garantirColunasDualSignature(); // 1. Hash de integridade do documento const docHashPayload = `${dados.numero}:${dados.clienteNome}:${dados.total}:${dados.subtotal}:${dados.usuarioId}`; const documentoHash = crypto.createHash("sha256").update(docHashPayload).digest("hex"); // 2. Hash da assinatura de emissão const emissorSigPayload = `${dados.numero}:${dados.emissorNome}:${dados.emissorEmail}:${dados.emissorIp}:${new Date().toISOString()}:${documentoHash}`; const emissorAssinaturaHash = crypto.createHash("sha256").update(emissorSigPayload).digest("hex"); return transaction(async (client) => { const propResult = await client.query( `INSERT INTO propostas ( usuario_id, numero, cliente_nome, cliente_empresa, cliente_email, descricao, conteudo_html, subtotal, total, documento_hash, emissor_nome, emissor_email, emissor_assinatura_ip, emissor_assinatura_hash ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14) RETURNING *`, [...params] ); const proposta = propResult.rows[0]; for (let i = 0; i < dados.itens.length; i++) { const item = dados.itens[i]; await client.query( `INSERT INTO itens_proposta (proposta_id, descricao, quantidade, valor_unitario, subtotal, ordem) VALUES ($1, $2, $3, $4, $5, $6)`, [proposta.id, item.descricao, item.quantidade, item.valorUnitario, item.quantidade * item.valorUnitario, i] ); } return proposta; });}

Na rota pública /p/[id], o HTML da proposta é renderizado dentro de um <iframe> isolado via srcDoc. Isso impede conflitos de CSS entre o design system da aplicação (Tailwind CSS) e os estilos inline do documento comercial.

3.3. Assinatura Eletrônica e Manifesto Jurídico (Dual Signature + SHA-256)

Para que o aceite não seja apenas um botão que altera uma flag no banco, implementei um fluxo de Dupla Assinatura Criptográfica:

  1. Assinatura do Emissor (Criação): No momento em que o dev gera a proposta, o sistema calcula o hash do conteúdo do documento somado ao IP, timestamp e dados do emissor.
  2. Assinatura do Cliente (Aceite): Quando o cliente clica em "Aceitar & Assinar", um modal coleta o nome completo e CPF/CNPJ. A rota /api/propostas/[id]/assinar valida os dados via Zod, extrai o IP real do cliente via headers (x-forwarded-for) e gera o hash final de aceite:

// Handler da rota POST /api/propostas/[id]/assinarconst forwarded = request.headers.get("x-forwarded-for");const ip = forwarded ? forwarded.split(",")[0].trim() : request.headers.get("x-real-ip") || "127.0.0.1";const timestamp = new Date().toISOString();// Encadeamento do hash do documento original ao hash do signatárioconst hashData = `${proposta.id}:${nome}:${documento}:${ip}:${timestamp}:${proposta.documento_hash || ""}`;const assinaturaHash = crypto.createHash("sha256").update(hashData).digest("hex");const propostaAssinada = await assinarProposta({ propostaId: id, assinanteNome: nome, assinanteDocumento: documento, assinaturaIp: ip, assinaturaHash,});

  1. Manifesto de Assinaturas (PDF / Print): Ao utilizar a funcionalidade de impressão ou exportação para PDF, o componente injeta automaticamente uma página final de Manifesto de Assinaturas com quebra de página forçada (break-before: page). O manifesto lista o ID do documento, hash SHA-256, dados e IPs de ambos os signatários, com respaldo legal amparado pela MP nº 2.200-2/2001 e Lei Federal nº 14.063/2020.

4. Métricas e Lições Aprendidas

  1. LLM em Serverless exige arquitetura de fallback: Confiar 100% no tempo de resposta de APIs de LLM em lambdas serverless sem timeout explícito resulta em falhas intermitentes de cold start e status 504. O uso de Promise.race com fallback determinístico reduziu a taxa de erro percebida para 0%.
  2. Postgres Connection Pooling em Serverless: O uso do driver pg padrão exige controle rigoroso de DATABASE_POOL_SIZE e liberação correta dos clientes (client.release()) em blocos finally, especialmente durante transações, para evitar exaustão de conexões no Postgres.
  3. Isolamento de estilos de documentos: Injetar HTML dinâmico diretamente na árvore DOM de uma SPA causa vazamento de estilos. Renderizar o documento gerado em um <iframe> com srcDoc e aplicar estilos de @media print no runtime resolveu a fidelidade de exportação para PDF.

5. Acesso e Discussão

A aplicação está no ar em produção: https://virapropo-ai.vercel.app

Gostaria de abrir o debate com a comunidade sobre os seguintes pontos técnicos:

  1. Geração de Documentos via LLM: Vocês preferem a abordagem de Structured Outputs (JSON) injetados em templates HTML fixos ou já tiveram experiências confiáveis com geração direta de Markdown/HTML pelo modelo?
  2. Connection Pooling em Serverless: Quais estratégias vocês têm adotado para gerenciar pools de PostgreSQL no Next.js (Prisma Accelerate, Drizzle com Neon/Serverless Driver, PgBouncer nativo ou instâncias dedicadas)?
  3. Validade de Assinaturas Eletrônicas Simples: Como vocês enxergam a evolução do uso de hashes SHA-256 + logs de IP/timestamp frente a soluções SaaS terceirizadas de assinatura digital (DocuSign, ClickSign)?

Source

1 share