2

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 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:

  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]/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,
});
  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)?
Carregando publicação patrocinada...