Neuroredes
Plataforma

Lambdas (AWS)

Cinco funções AWS Lambda complementam o Supabase: três HTTP atendem a computação pesada (dashboard, grafo, Excel) e duas SQS-triggered consomem filas de envio em massa (email, WhatsApp). Todas escrevem no Postgres do Supabase usando a SERVICE_ROLE_KEY guardada no AWS Secrets Manager.

Capítulo 01

Onde mora o módulo

O código vive em ../lambda/ — um diretório acima do pesquisa/. Cada função tem o mesmo esqueleto: src/index.ts (handler), src/services/supabase.service.ts (cliente Postgres com SERVICE_ROLE), src/utils/secrets.ts (busca de credenciais no Secrets Manager) e um package.json com esbuild para empacotar em um único bundle. Runtime Node.js 20 (Node 18 nas duas consumers SQS, conforme o --target do esbuild).

Função Tipo Propósito
dashboard-processor HTTP Calcula Neurocalc (rankings, relações, sincronia) e devolve o JSON consumido pelo Dashboard. Cacheia em Postgres.
graph-processor HTTP Monta nodes, edges e legenda do grafo de sincronia. Cacheia em Postgres.
excel-processor HTTP Gera arquivo .xlsx a partir do payload Neurocalc, sobe no Storage e devolve signed URL.
bulk-email-consumer SQS Consome mensagens de envio de email, dispara via SMTP Brevo e atualiza contadores do batch no Postgres.
bulk-whatsapp-consumer SQS Consome mensagens de envio de WhatsApp, dispara via Meta Graph API e atualiza contadores do batch.
Nota

A infraestrutura (CDK stacks, Function URLs, gatilhos SQS, IAM) mora em um diretório infra/ referenciado pelos READMEs das Lambdas, fora do repositório pesquisa/. Esta doc não cobre o CDK; foca no comportamento de runtime.

Capítulo 02

Lambdas HTTP

As três funções HTTP são expostas via Lambda Function URLs (sem API Gateway). O frontend lê a URL em uma variável NEXT_PUBLIC_*_PROCESSOR_URL; quando a variável está ausente, o service Next.js cai num fallback baseado em Edge Functions do Supabase. CORS é configurado no Function URL (CDK) — os handlers não emitem cabeçalhos Access-Control-* para evitar duplicação.

dashboard-processor

Entrada em ../lambda/dashboard-processor/src/index.ts. Aceita apenas POST. Substitui o par de Edge Functions survey-handler + calc-new-front rodando o Neurocalc inline numa única execução.

Payload de entrada
{ surveyId: number, forceRefresh?: boolean }. surveyId obrigatório; forceRefresh: true ignora cache.
Resposta
JSON Neurocalc completo: question_1question_5, survey_info, overall_rating, attribute_names, e _cache_info indicando origem (database_cache ou lambda_neurocalc).
Service que chama
services/dashboard.service.ts (Server Component). Também é chamado por services/report.service.ts como primeiro passo da geração de relatório.
Variável de ambiente (frontend)
NEXT_PUBLIC_DASHBOARD_PROCESSOR_URL. Precisa terminar em /.
Fallback quando ausente
Edge Function survey-handler, que por sua vez chama calc-new-front via HTTP (2 workers). Em dashboard.service.ts, se a URL não está definida o método retorna null direto, sem invocar o fallback — quem ativa o fallback é o controle de presença da variável no deploy.

Fluxo interno:

  1. mydb.survey_cache.neurocalc_result. Se houver hit e forceRefresh for falso, retorna direto com _cache_info.cached = true.
  2. Busca mydb.surveys (cabeçalho e perguntas), mydb.survey_respondents (lista de pessoas) e mydb.survey_responses (paginação de 500 em 500 até esgotar).
  3. Monta um dataset com perguntas renomeadas (question_1pergunta_1), respondentes mapeados para chaves atributo_16, e respostas concatenadas em Number por par source_target.
  4. Sobe o dataset bruto no bucket relatorios como dataset-<surveyId>.json. Falha aqui é não-fatal (logada).
  5. Resolve nomes de atributos via RPC get_survey_attributes (com fallback para "Atributo 1"…"Atributo 6").
  6. Executa o Neurocalc inline (createJSON(dataset) em utils/neurocalc.ts), anexa attribute_names e grava em mydb.survey_cache.neurocalc_result.

Configuração interna: SUPABASE_URL e SUPABASE_SECRET_NAME (default supabase-service-key) vêm do ambiente; a service role key vem do Secrets Manager. Sem respondentes a Lambda retorna { survey:{}, respondents:{}, responses:{} } (sem persistir cache).

graph-processor

Entrada em ../lambda/graph-processor/src/index.ts, roteador em src/routes/graph.ts. Aceita apenas POST na rota /graph — qualquer outra rota responde 404. Substitui o par graph-handler + graph-complete.

Payload
{ surveyId: number }. Sem forceRefresh — o cache só é invalidado por gatilhos do banco.
Resposta
JSON com survey_name, company_name, depts, attributes, questions, nodes, edges, legend, respondentStats e _debug.
Service que chama
services/graph.service.ts. Concatena a rota graph ao fim de GRAPH_PROCESSOR_URL (motivo do trailing /).
Variável de ambiente (frontend)
NEXT_PUBLIC_GRAPH_PROCESSOR_URL.
Fallback quando ausente
Edge Function graph-handlergraph-complete via HTTP.

Fluxo interno:

  1. Consulta mydb.survey_cache.graph_result. Hit → retorna direto.
  2. Sem respondentes, devolve um shell vazio já com a legend.synchrony_style padronizada (7 categorias de sincronia, valores hexadecimais fixos).
  3. Caso contrário, busca survey, respondentes e respostas (paginação igual ao dashboard) e monta o dataset. Diferença em relação ao dashboard: as respostas viram strings concatenadas (não Number) para preservar zeros à esquerda.
  4. Chama buildGraphResponse(dataset, include, attributeNames) em utils/graph-calc.ts e grava em mydb.survey_cache.graph_result com onConflict: 'survey_id'.

excel-processor

Entrada em ../lambda/excel-processor/src/index.ts. Não lê dados da pesquisa: recebe o JSON Neurocalc já calculado, monta a planilha e devolve uma URL assinada para download. Substitui a Edge Function excel.

Payload
{ json: { result: SurveyDashboard }, nome_arquivo: string, reportType?: 'full' | 'executive' }. reportType default 'full'. surveyId é aceito mas só serve para logging.
Resposta
{ preUploaded: false, signedUrl: string | null, fileName: string }. O signedUrl aponta para o bucket relatorios e expira em 3600 s.
Service que chama
services/report.service.ts (client-side). Primeiro busca dados via dashboard-processor, depois envia para excel-processor.
Variável de ambiente (frontend)
NEXT_PUBLIC_EXCEL_PROCESSOR_URL.
Fallback quando ausente
Edge Function excel (mesmo contrato de I/O).

Fluxo interno:

  1. Sanitiza nome_arquivo (remove acentos, espaços viram _, lowercase) e acrescenta .xlsx.
  2. Roda generateExcel(json, reportType) em utils/excel.ts — monta a aba Geral e uma aba por pergunta (até cinco). Perguntas com 100% de relações nulas são puladas. A diferença entre full e executive está no nível de detalhe das abas de pergunta.
  3. Sobe o buffer em storage/relatorios/<arquivo>.xlsx com upsert: true.
  4. Cria signed URL via storage.createSignedUrl(fileName, 3600) e devolve.
Nota

Os três handlers HTTP exigem POST e respondem 405 Method not allowed para qualquer outro verbo. Corpo inválido devolve 400 Invalid JSON body.

Capítulo 03

Lambdas SQS-triggered

As duas consumers são acionadas por mensagens em filas SQS, com relato parcial de falhas (SQSBatchResponse / batchItemFailures): mensagens que falham por motivo transitório voltam para a fila; falhas permanentes são marcadas no Postgres e descartadas.

bulk-whatsapp-consumer

Entrada em ../lambda/bulk-whatsapp-consumer/src/index.ts. Disparada pela Edge Function whatsapp-send-bulk (supabase/functions/whatsapp-send-bulk/index.ts), que:

  1. Promove a pesquisa para status = 2 (Em Andamento) se ainda estava em 1.
  2. Cria um batch via RPC bulk_whatsapp_create_batch (linha em mydb.bulk_whatsapp_batches).
  3. Gera tokens via magic_create_token e monta a mensagem personalizada (substitui {{name}} e {{link}}).
  4. Enfileira em SQS via SendMessageBatchCommand em lotes de 10. URL da fila vem de SQS_WHATSAPP_QUEUE_URL.
SQS message body (WhatsApp) typescript
{
  phone: string;       // como no banco; o Lambda normaliza e prefixa "55" se faltar
  name: string;
  message: string;     // já com placeholders substituídos
  magicLink: string;
  batchId: string;     // uuid de bulk_whatsapp_batches
  respondentId: number;
  surveyId: number;
  messageId: string | null;  // uuid de survey_messages
}

Ao consumir, a Lambda chama WhatsAppService.sendTextMessage(phone, message) contra https://graph.facebook.com/v17.0/{phoneNumberId}/messages com Authorization: Bearer <token>. O token vem do Secrets Manager (default whatsapp-access-token), o phoneNumberId vem de WHATSAPP_PHONE_NUMBER_ID. Cada mensagem é enviada uma a uma — não há batching no lado da Meta API.

Classificação de erro: a string da exceção (lowercase) é comparada contra "invalid phone number", "not a valid whatsapp account", "recipient not found" e "blocked". Match é falha permanente: incrementa failed_count em mydb.bulk_whatsapp_batches via RPC bulk_whatsapp_increment_failed e descarta. Demais erros viram retentativa (a mensagem volta para a fila pela SQS).

bulk-email-consumer

Entrada em ../lambda/bulk-email-consumer/src/index.ts. Estrutura simétrica ao consumer de WhatsApp: usa o SQSEmailMessage definido em src/types/message.types.ts e atualiza mydb.bulk_email_batches via RPCs bulk_email_increment_sent e bulk_email_increment_failed.

SQS message body (email) typescript
{
  messageId: string;
  batchId: string;
  surveyId: number;
  respondentId: number;
  email: string;
  name: string;
  tokenPayload: string;
  magicLinkUrl: string;
  emailSubject: string;
  emailHtml: string;
  createdAt: string;
  retryCount: number;
}

Apesar do nome do arquivo (services/ses.service.ts) e da descrição no package.json mencionarem SES, o envio efetivo é via SMTP da Brevo (smtp-relay.brevo.com:587) usando nodemailer. Credenciais vêm do Secrets Manager (BREVO_SECRET_NAME, com chaves user/pass ou variantes) ou diretamente de BREVO_SMTP_USER/BREVO_SMTP_PASS.

Classificação de erro: a Lambda comparara o erro contra uma lista de códigos retryableThrottling, ServiceUnavailable, InternalError, RequestTimeout. Match volta para a fila; o resto é permanente.

Atenção

A Edge Function magic-link-send-bulk deployada hoje (versão 20) não enfileira na SQS — ela envia emails diretamente por SMTP num loop com setTimeout de 200 ms, limitada a 100 destinatários por chamada. A Lambda bulk-email-consumer permanece no repositório com a tubulação completa, mas sem produtor ativo. Para um envio em massa real por SQS, o produtor de email precisa ser re-conectado (ou substituído por outro Edge Function que enfileire). Detalhes em Envio (Magic Links).

Capítulo 04

Tabelas de status no Postgres

Quatro tabelas servem como espelho do que está acontecendo nas Lambdas SQS. Todas vivem em mydb e não têm RLS — as Lambdas usam SERVICE_ROLE para escrever, e a UI lê via RPCs ou queries server-side.

Tabela Papel
mydb.bulk_email_batches Um registro por chamada bulk de email. Colunas-chave: survey_id, total_count, sent_count, failed_count, status (default 'pending'), completed_at, error_details (jsonb).
mydb.bulk_whatsapp_batches Mesmo formato do batch de email, mas para WhatsApp. Criado pela Edge Function whatsapp-send-bulk via RPC bulk_whatsapp_create_batch.
mydb.survey_messages Cabeçalho da campanha de envio do ponto de vista do produto: titulo, link_video, assunto_email, parte_personalizada, meio_email/meio_whatsapp, contadores email_total/email_enviados/email_falhas e equivalentes para WhatsApp, mais email_batch_id e whatsapp_batch_id que apontam para os batches acima.
mydb.survey_message_respondents Status por destinatário. Colunas: message_id, respondent_id, canal ('email'/'whatsapp'), status ('pending'/'sent'/'failed'), error_detail, sent_at. Populada pela Edge Function produtora; em tese seria atualizada pela Lambda consumer, mas hoje só bulk_email/whatsapp_batches recebe escrita do consumer (ver pegadinhas).

A separação batches × messages existe porque um survey_message pode disparar simultaneamente um batch de email e um de WhatsApp; cada canal mantém contadores próprios e ainda existem totais agregados em mydb.survey_messages.

Capítulo 05

Secrets & credenciais

Toda Lambda lê credenciais via src/utils/secrets.ts, que usa o SecretsManagerClient (região default us-east-1) e cacheia o valor em memória por 5 minutos. O nome do secret é parametrizado por variável de ambiente, com defaults que valem hoje.

Variável de ambiente Default O que guarda
SUPABASE_SECRET_NAME supabase-service-key Service role key do Supabase. Lida por todas as cinco Lambdas.
WHATSAPP_SECRET_NAME whatsapp-access-token Token de acesso da Meta WhatsApp Business API. Só na consumer de WhatsApp.
BREVO_SECRET_NAME sem default JSON com user/pass (ou login/password/key) do SMTP Brevo. Quando ausente, a consumer cai em BREVO_SMTP_USER/BREVO_SMTP_PASS diretos.
SUPABASE_URL obrigatória URL do projeto Supabase. Setada pelo CDK no deploy.
WHATSAPP_PHONE_NUMBER_ID obrigatória ID do número emissor na Meta Cloud API.
SQS_WHATSAPP_QUEUE_URL obrigatória no produtor URL da fila SQS de WhatsApp. Lida pela Edge Function whatsapp-send-bulk, não pela Lambda em si.
Nota

O cache de 5 minutos do getSecret significa que rotacionar o secret no AWS pode levar até esse tempo para propagar em containers Lambda quentes. Cold starts pegam o valor novo imediatamente.

Capítulo 06

Permissões & escopo de acesso

Todas as Lambdas usam a SERVICE_ROLE_KEY do Supabase — bypassa Row Level Security e ignora auth.uid(). Não há validação de papel ADMIN/SURVEYOR/VIEWER dentro das Lambdas; a autorização acontece antes, no Server Component que monta a chamada (ex: app/(main-noheader)/surveys/[id]/dashboard/page.tsx verifica o perfil e só então invoca fetchSurveyDashboardById).

Destaque

Quem tem a Function URL pública de qualquer uma das três Lambdas HTTP pode invocá-las informando qualquer surveyId. Não há autenticação no Function URL — o CDK as configura como AUTH_NONE (subentendido pelo comentário de CORS no handler). A confidencialidade depende da URL ser tratada como segredo operacional, não como endpoint público.

Como as Lambdas SQS-triggered são acionadas só pelo evento de fila, elas não expõem endpoint público — o controle de quem pode escrever na fila depende da política da SQS, configurada no CDK.

Capítulo 07

Observabilidade

Logs estruturados não são usados — os handlers fazem console.log e console.error em texto livre. Os destinos:

  • /aws/lambda/dashboard-processor
  • /aws/lambda/graph-processor
  • /aws/lambda/excel-processor
  • /aws/lambda/bulk-email-consumer
  • /aws/lambda/bulk-whatsapp-consumer

Padrões úteis para grep: as HTTP escrevem Cache hit for survey <id> ou Cache miss for survey <id>, processing...; as consumers SQS logam Processing N messages e Batch complete: X succeeded, Y failed (will retry) ao começar e terminar cada invocação. Falhas permanentes aparecem como Permanent failure for message <sqsMessageId> antes do update no Postgres.

O excel-processor e o frontend (services/report.service.ts) instrumentam um step trace com prefixo [report] nos logs do navegador, útil para depurar geração de relatório.

Capítulo 08

Pegadinhas

Cache fica em survey_cache, não em survey_processing_results

Existem duas tabelas de cache no schema: mydb.survey_cache (legado, com colunas neurocalc_result e graph_result) e mydb.survey_processing_results (nova, com processing_status e cache_version). Ambas as Lambdas HTTP (dashboard-processor e graph-processor) escrevem e leem em mydb.survey_cache, não na tabela nova. Migrar exige ajustar services/supabase.service.ts nas duas Lambdas e sincronizar com o que survey-handler ainda lê para o fallback.

Fallback automático ≠ comportamento dos services

O README de cada Lambda menciona "fallback automático para Edge Functions" se a URL não estiver configurada, mas os services do Next.js (dashboard.service.ts, graph.service.ts, report.service.ts) retornam null/erro quando a URL está ausente — não tentam a Edge Function de fato. O "fallback" é uma decisão de deploy (não setar a variável faz a UI ficar sem dado), não um circuito em runtime.

excel-processor lê o JSON do payload, não do banco

Diferente das outras duas Lambdas HTTP, excel-processor não chama Postgres para buscar dados da pesquisa — ele só recebe o JSON Neurocalc pronto vindo do cliente. Por isso o fluxo no frontend é sequencial: primeiro chama dashboard-processor, depois passa o resultado para excel-processor. Se o payload for muito grande, o gargalo é a transferência HTTP cliente-Lambda.

O consumer de email não tem produtor ativo

Conforme descrito no Capítulo 03, a Edge Function que deveria enfileirar na SQS de email hoje envia sincronamente via SMTP. A consumer Lambda existe, está deployada, mas só dispararia se a fila SQS_EMAIL_QUEUE_URL recebesse mensagem — coisa que nenhum produtor faz hoje.

Tracking por destinatário só é atualizado pelo produtor de email

A Edge Function magic-link-send-bulk escreve em mydb.survey_message_respondents via survey_message_respondents_update_status após cada envio. A Edge Function whatsapp-send-bulk só insere a linha pendente e não escreve status final — quem deveria fazer isso é a consumer Lambda, mas bulk-whatsapp-consumer só incrementa contadores no batch. Estado por destinatário no canal WhatsApp fica preso em 'pending'.

Tabelas de batch sem RLS

mydb.bulk_email_batches, mydb.bulk_whatsapp_batches, mydb.survey_messages e mydb.survey_message_respondents estão com Row Level Security desabilitado. As Lambdas usam SERVICE_ROLE; qualquer leitura via anon key também passa, sem filtros. Ver Autenticação & Papéis para o panorama completo.

Idempotência limitada

As consumers SQS não armazenam messageId nem deduplicam: se uma mensagem for entregue duas vezes pela SQS (cenário possível em at-least-once), o envio também acontece duas vezes e os contadores incrementam duas vezes. Não há idempotency key no payload — apenas o retryCount no envelope de email, que não é usado pela Lambda para decisões.

WhatsApp prefixa Brasil sem validar

WhatsAppService.formatPhoneNumber remove caracteres não-numéricos e antepõe "55" se o número não começa com isso. Telefones de outros países que comecem com algo diferente de 55 ganham um prefixo extra e provavelmente são rejeitados pela Meta — caem na classificação de erro permanente.