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 timeout
const 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 Vercel
const 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]/assinar
const 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ário
const 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)?