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
pgcom 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,bcryptjspara hashing de credenciais e módulo nativocryptodo 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:
- 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.
- 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]/assinarvalida 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,});
- 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
- 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.racecom fallback determinístico reduziu a taxa de erro percebida para 0%. - Postgres Connection Pooling em Serverless: O uso do driver
pgpadrão exige controle rigoroso deDATABASE_POOL_SIZEe liberação correta dos clientes (client.release()) em blocosfinally, especialmente durante transações, para evitar exaustão de conexões no Postgres. - 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>comsrcDoce aplicar estilos de@media printno 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:
- 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?
- 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)?
- 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)?
