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.
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. |
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.
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 }.surveyIdobrigatório;forceRefresh: trueignora cache.- Resposta
- JSON Neurocalc completo:
question_1…question_5,survey_info,overall_rating,attribute_names, e_cache_infoindicando origem (database_cacheoulambda_neurocalc). - Service que chama
services/dashboard.service.ts(Server Component). Também é chamado porservices/report.service.tscomo 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 chamacalc-new-frontvia HTTP (2 workers). Emdashboard.service.ts, se a URL não está definida o método retornanulldireto, sem invocar o fallback — quem ativa o fallback é o controle de presença da variável no deploy.
Fluxo interno:
- Lê
mydb.survey_cache.neurocalc_result. Se houver hit eforceRefreshfor falso, retorna direto com_cache_info.cached = true. - Busca
mydb.surveys(cabeçalho e perguntas),mydb.survey_respondents(lista de pessoas) emydb.survey_responses(paginação de 500 em 500 até esgotar). - Monta um
datasetcom perguntas renomeadas (question_1→pergunta_1), respondentes mapeados para chavesatributo_1…6, e respostas concatenadas emNumberpor parsource_target. - Sobe o
datasetbruto no bucketrelatorioscomodataset-<surveyId>.json. Falha aqui é não-fatal (logada). - Resolve nomes de atributos via RPC
get_survey_attributes(com fallback para "Atributo 1"…"Atributo 6"). - Executa o Neurocalc inline (
createJSON(dataset)emutils/neurocalc.ts), anexaattribute_namese grava emmydb.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 }. SemforceRefresh— o cache só é invalidado por gatilhos do banco.- Resposta
- JSON com
survey_name,company_name,depts,attributes,questions,nodes,edges,legend,respondentStatse_debug. - Service que chama
services/graph.service.ts. Concatena a rotagraphao fim deGRAPH_PROCESSOR_URL(motivo do trailing/).- Variável de ambiente (frontend)
NEXT_PUBLIC_GRAPH_PROCESSOR_URL.- Fallback quando ausente
- Edge Function
graph-handler→graph-completevia HTTP.
Fluxo interno:
- Consulta
mydb.survey_cache.graph_result. Hit → retorna direto. - Sem respondentes, devolve um shell vazio já com a
legend.synchrony_stylepadronizada (7 categorias de sincronia, valores hexadecimais fixos). - 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. - Chama
buildGraphResponse(dataset, include, attributeNames)emutils/graph-calc.tse grava emmydb.survey_cache.graph_resultcomonConflict: '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' }.reportTypedefault'full'.surveyIdé aceito mas só serve para logging.- Resposta
{ preUploaded: false, signedUrl: string | null, fileName: string }. OsignedUrlaponta para o bucketrelatoriose 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:
- Sanitiza
nome_arquivo(remove acentos, espaços viram_, lowercase) e acrescenta.xlsx. - Roda
generateExcel(json, reportType)emutils/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 entrefulleexecutiveestá no nível de detalhe das abas de pergunta. - Sobe o buffer em
storage/relatorios/<arquivo>.xlsxcomupsert: true. - Cria signed URL via
storage.createSignedUrl(fileName, 3600)e devolve.
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.
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:
- Promove a pesquisa para
status = 2(Em Andamento) se ainda estava em 1. - Cria um batch via RPC
bulk_whatsapp_create_batch(linha emmydb.bulk_whatsapp_batches). - Gera tokens via
magic_create_tokene monta a mensagem personalizada (substitui{{name}}e{{link}}). - Enfileira em SQS via
SendMessageBatchCommandem lotes de 10. URL da fila vem deSQS_WHATSAPP_QUEUE_URL.
{
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.
{
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 retryable — Throttling,
ServiceUnavailable, InternalError,
RequestTimeout. Match volta para a fila; o resto é
permanente.
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).
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.
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. |
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.
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).
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.
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.
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.