Neuroredes
Módulos do Produto

Relatórios (Excel)

Geração de planilhas .xlsx via Lambda excel-processor. Dois modos — completo e executivo — montados a partir do JSON já calculado pelo Neurocalc, com banner azul nas seções e atributos ordenados alfabeticamente.

Capítulo 01

Fluxo em dois saltos

O excel-processor não calcula nada — ele consome o JSON já produzido pelo Neurocalc (via dashboard-processor) e materializa em planilha. Por isso o frontend faz dois requests em sequência.

# Passo Endpoint
1 Frontend busca o JSON do Neurocalc para a pesquisa. NEXT_PUBLIC_DASHBOARD_PROCESSOR_URL
2 Frontend envia esse JSON + nome do arquivo + tipo de relatório para o gerador. NEXT_PUBLIC_EXCEL_PROCESSOR_URL
3 Lambda monta o .xlsx, faz upload no bucket relatorios e devolve signed URL. Supabase Storage
4 Frontend faz fetch da signed URL, cria ObjectURL e força o download no navegador.
Nota

Se NEXT_PUBLIC_EXCEL_PROCESSOR_URL não estiver setada, report.service.ts faz fallback para a Edge Function legada. Em produção, a URL do Lambda deve estar sempre presente — o caminho antigo é só para ambientes locais sem AWS.

Capítulo 02

Handler HTTP

Lambda Function URL, POST direto. CORS resolvido pela própria configuração da Function URL (não tem header CORS no código — comentário do index.ts avisa para não duplicar).

Payload de entrada

json
O resultado do Neurocalc no formato { result: { question_1, survey_info, overall_rating, ... } }. Não é validado a fundo — o gerador espera as chaves padrão.
nome_arquivo
Nome base. Passa pelo sanitizeFileName() antes de virar nome do blob.
reportType
'full' (default) ou 'executive'.

Resposta

200 OK typescript
{
  preUploaded: false,
  signedUrl:   "https://<project>.supabase.co/storage/v1/object/sign/relatorios/<file>.xlsx?token=...",
  fileName:    "pesquisa_empresa_2026.xlsx"
}

Erros

StatusQuando
405Método ≠ POST.
400JSON inválido, faltam campos obrigatórios, reportType fora de {full, executive}.
500Falha no generateExcel ou no upload — mensagem descritiva no body.
Capítulo 03

Completo vs. Executivo

Os dois modos compartilham a aba Geral. As abas Pergunta 1..5 são reconstruídas em formato diferente conforme o tipo.

Aspecto Completo (full) Executivo (executive)
Público Admin / surveyor Leitura executiva, geralmente compartilhada externamente
Por pergunta Relações gerais + relações por grupo (todas as categorias, com percentuais e contagens) Resumo, ranking, relações internas por grupo, outliers
Colunas máximas A–T+ (depende do número de atributos) A–J
Linhas típicas 1500+ em pesquisas médias ~500

A escolha é feita em ReportTypeDialog antes do disparo — SurveysCard chama startReportGeneration() com o tipo selecionado. Modal de loading fica em pé durante toda a geração (pode chegar a ~30s em pesquisas grandes).

Capítulo 04

Aba Geral

Primeira aba do workbook em ambos os modos. Resume a pesquisa, taxa de resposta, e o breakdown por atributo.

LinhasConteúdo
3Título "INFORMAÇÕES GERAIS" (banner principal, fonte 14, branco sobre azul-marinho).
5–6Nome da pesquisa + período (data de início e fim).
9Header da tabela: Grupo · Pessoas · % do total.
11–12Responderam / Não responderam.
15Linha "TOTAL GERAL".
17+Para cada atributo: subtítulo "ANÁLISE POR GRUPOS [atributo]", lista de grupos com contagem e percentual.
fimSe houver, lista de "NÃO RESPONDENTES" com nome, ID e atributos 1–7.

Ordenação alfabética dos atributos

Commit 1a2c53d trocou a ordem dos grupos de "por aparição no JSON" para "alfabética por nome do grupo" usando localeCompare com locale pt-BR e sensitivity: 'base' — então acentos e capitalização não atrapalham.

lambda/excel-processor/src/utils/excel.ts typescript
function sortAttributeEntries(attributeData: any): [string, any][] {
  return Object.entries(attributeData).sort(([keyA, itemA], [keyB, itemB]) => {
    const labelA = String((itemA && itemA.key) || keyA);
    const labelB = String((itemB && itemB.key) || keyB);
    return labelA.localeCompare(labelB, 'pt-BR', { sensitivity: 'base' });
  });
}
Capítulo 05

Aba "Pergunta N" — modo completo

Uma aba por pergunta. Pulada se a pergunta tem 100% de conexões nulas — a planilha nunca expõe pergunta vazia.

Estrutura

  1. Título: "Pergunta N: {{texto da pergunta}}" em banner azul.
  2. RELAÇÕES GERAIS — três subseções:
    • Sincronias: alta, média, baixa.
    • Assincronias: alta/baixa, média/nulo, alta/nulo.
    • Conexões Nulas (PCR): total de nulos.
    Cada linha traz contagem + percentual + descrição.
  3. RELAÇÕES INTERNAS POR GRUPO — itera atributos 1–6 (em ordem alfabética). Para cada atributo, monta um sub-bloco com:
    • Linha de header com 4 colunas-chave (Total possíveis, Síncronas, Assíncronas, Nulas).
    • Uma linha por valor de atributo (grupo) — contagens e percentuais por categoria.
    • Linha de totais agregados ao final do bloco.

Esquema de cores por categoria

FaixaColunasFill
SíncronasE–GVerde claro
AssíncronasH–KLaranja claro
Nulas (PCR)L–QVermelho claro
Capítulo 06

Aba "Pergunta N" — modo executivo

Mais limpo, com banners azuis nas seções (commit 1a2c53d) e estrutura achatada para leitura rápida.

LinhaConteúdo
3Banner: "Pergunta N: ..." (merge B3:J3, fundo #2F5597, texto branco).
5Banner: "Relações gerais".
7–10Síncronas / Assíncronas / PCR com label e percentual.
13Banner: "Relações internas por grupos".
15+Por atributo (ordem alfabética): nome do grupo, tamanho, contagens por categoria.
getSectionBannerStyle() typescript
{
  fill:      { type: 'pattern', pattern: 'solid', fgColor: { rgb: '2F5597' } },
  font:      { bold: true, size: 10, color: { rgb: 'FFFFFF' } },
  alignment: { horizontal: 'center', vertical: 'center' },
  border:    { top: thin, bottom: thin, left: thin, right: thin, color: { rgb: '2F5597' } }
}
Capítulo 07

Catálogo de estilos

Os estilos são funções em excel.ts que retornam objetos consumidos pela xlsx. Não há tema centralizado — se precisar mudar cor, faz em todas elas.

Função Uso Cor
getMainTitleStyle()Título "INFORMAÇÕES GERAIS"Azul-marinho
getSubtitleStyle()Subseção dentro da aba GeralCinza claro
getSectionBannerStyle()Banner azul (modo executivo, commit recente)#2F5597
getHeaderStyle()Headers de colunasCinza claro
getDataCellStyle()Células de dados (linhas ímpares)Branco
getAlternateRowStyle()Células de dados (linhas pares)#FAFBFC
getSummaryStyle()Linhas de totalAzul claro
getConditionalStyle()Intensidade da conexãoVerde/laranja/vermelho

Larguras de coluna

  • A: 3px — margem decorativa (recebe bullets como , ).
  • B: 40–45px — labels longos, nomes de grupo.
  • C–G, H–I: 18px — dados numéricos.
  • H, J, L (no modo full): 55px — textos descritivos.
  • Demais: 14–25px.
Capítulo 08

Sanitização do filename & upload

Sanitização

lambda/excel-processor/src/utils/excel.ts typescript
export function sanitizeFileName(fileName: string) {
  return fileName.normalize('NFD')           // decomposição Unicode
    .replace(/[̀-ͯ]/g, '')         // remove diacríticos (acentos)
    .replace(/[^a-zA-Z0-9\s\-_]/g, '')       // só alfanum + espaço + - + _
    .replace(/\s+/g, '_')                    // espaços → underscore
    .toLowerCase();
}

"Pesquisa Empresa 2026" vira "pesquisa_empresa_2026"; o handler adiciona .xlsx ao final.

Upload no Supabase Storage

  • Bucket: relatorios (hardcoded).
  • MIME: application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.
  • Upsert: true — sobrescreve se já existir mesmo nome.
  • Signed URL TTL: 3600s (1 hora). Suficiente para o usuário baixar; expira sozinho.
Atenção

Existe um workaround histórico em fetchSurveyFile() no frontend que tenta dois nomes de arquivo: survey_123.xlsx e survey_123 .xlsx (com espaço antes da extensão). É vestígio de uma inconsistência antiga; não remova sem confirmar que nenhum bucket legado tem arquivos com espaço.

Capítulo 09

Secrets & deploy

Variáveis de ambiente do Lambda

VariávelDefault
SUPABASE_URLURL do projeto
SUPABASE_SECRET_NAMEsupabase-service-key
AWS_REGIONus-east-1

Cache do secret (5min)

SupabaseService faz GetSecretValue no Secrets Manager e cacheia em memória por CACHE_TTL_MS = 5 * 60 * 1000. Cold start paga uma chamada AWS; invocações subsequentes na mesma execução environment reaproveitam.

Build

lambda/excel-processor/package.json bash
npm run build    # esbuild --bundle --target=node20 --external:@aws-sdk/client-secrets-manager
npm run zip      # gera function.zip pronto para upload
Capítulo 10

Frontend — report.service.ts

Antes de mandar pro Lambda, o frontend remove campos de debug do JSON do dashboard e empacota no formato esperado.

pesquisa/services/report.service.ts typescript
function transformToExcelFormat(dashboardResponse: SurveyHandlerResponse, fileName: string) {
  const { _debug, ...rest } = dashboardResponse;
  return {
    json: { result: rest },
    nome_arquivo: fileName,
  };
}

export async function generateSurveyReport(surveyId: number, reportType: 'full' | 'executive') {
  const dashboard = await fetchDashboardData(surveyId);
  const payload   = { ...transformToExcelFormat(dashboard, fileNameFor(dashboard)), reportType };

  const { signedUrl, fileName } = await postToExcelLambda(payload);
  return { downloadUrl: signedUrl, fileName };
}

function triggerDownload(url: string, fileName: string) {
  const a = document.createElement('a');
  a.href = url;
  a.download = fileName;
  a.click();
}

Permissão por papel

  • ADMIN: completo + executivo.
  • SURVEYOR: completo + executivo (sua empresa).
  • VIEWER: completo + executivo (sua empresa, somente leitura).

O ReportTypeDialog só aparece após o gate de papel.

Capítulo 11

Edge cases & armadilhas

Pergunta 100% nula
Aba inteira é pulada — o relatório omite a pergunta em vez de mostrar uma planilha vazia.
Atributo com chave que parece data
Heurística filtra strings em formato de data (ex.: 2025-09-01) para evitar agrupamento espúrio. Se houver atributo legítimo nesse formato, ele será ignorado — mover o valor para outro formato resolve.
NaN / Infinity
setCellValue() converte para 0. Strings vazias são preservadas (não viram zero).
Datas ausentes
"Data não informada" no lugar. Fallback ordem: start_dateend_datecreated_at.
Nome da empresa
Heurística de extração tenta, em ordem: regex em survey_info.title por "pesquisa (.+)", depois respondents[0].atributo_6, depois primeiro valor não-palavra-chave em overall_rating.attribute_*. Default: "Empresa".
Pesquisas gigantes
Memória da Lambda: 1024MB. Timeout: 120s. Comporta ~5000 respondentes na prática. Acima disso, considerar streaming ou paginação por aba.
Caracteres especiais no nome
Os nomes no conteúdo da planilha mantêm acentos (UTF-8). Só o filename é sanitizado — necessário porque alguns navegadores quebram download com caracteres não-ASCII.
Capítulo 12

Histórico relevante

Commits que mudaram comportamento — referência rápida para entender por que algo está do jeito que está.

1a2c53d
banner azul nos títulos de seção e ordenação alfabética por atributo. Introduziu getSectionBannerStyle() e sortAttributeEntries(). Aplicou nos três sheet builders (Geral, full, executivo).
4f6579d
Renomeou "Summary" para "Executivo" e reformulou a estrutura da aba executiva.
5a743db
Gate de papel no ReportTypeDialog — permissões por role para escolher tipo.