Envio (Magic Links)
Disparo de magic links por email e WhatsApp, rastreamento por respondente, status consolidado por canal e retry in-place das falhas. Cobre desde o token criptográfico até a UI de detalhe da mensagem.
Arquitetura — dois caminhos, um modelo de dados
Email e WhatsApp compartilham o mesmo schema de rastreamento
(survey_messages + survey_message_respondents) e o mesmo
formato de magic link, mas seguem caminhos de execução diferentes.
| Canal | Caminho | Modelo |
|---|---|---|
Frontend → Edge Function magic-link-send-bulk →
SMTP Brevo direto → atualiza Supabase no mesmo request
|
Síncrono | |
Frontend → Edge Function whatsapp-send-bulk → SQS →
Lambda bulk-whatsapp-consumer → Meta WhatsApp API → atualiza Supabase via RPC
|
Assíncrono |
Existe um Lambda bulk-email-consumer com handler SQS pronto, mas
nada o aciona hoje — o fluxo de email é síncrono dentro da edge
function. O Lambda é vestígio de uma arquitetura anterior (ou preparação para
uma futura). Não remova sem decisão explícita: o caminho síncrono tem limite de
30s da Edge Function, então migrar para SQS é uma evolução natural quando o
volume crescer.
Edge functions envolvidas
| Função | Quando dispara |
|---|---|
magic-link-send |
Envio individual de email para um único respondente (reenvio manual, depuração). |
magic-link-send-bulk |
Disparo em massa por email. Síncrono via SMTP. Até 100 respondentes por chamada. |
whatsapp-send-bulk |
Disparo em massa por WhatsApp. Enfileira em SQS, retorna imediatamente. |
magic-link-validate |
Valida o token quando o respondente abre o link (rota pública). |
magic-link-revoke |
Invalida todos os tokens ativos de um respondente (admin/surveyor action). |
Anatomia do magic link
Cada respondente recebe um token único de uso "primeiro acesso" com validade de 30 dias. O token bruto nunca é persistido — guardamos apenas seu hash SHA-256.
// 32 bytes aleatórios, base64-url-safe
const bytes = new Uint8Array(32);
crypto.getRandomValues(bytes);
const rawToken = btoa(String.fromCharCode(...bytes))
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=/g, '');
// Hash SHA-256 — só o hash vai pro banco
const hashBuffer = await crypto.subtle.digest(
'SHA-256',
new TextEncoder().encode(rawToken)
);
const tokenHash = [...new Uint8Array(hashBuffer)]
.map((b) => b.toString(16).padStart(2, '0'))
.join('');
// RPC com hash + TTL de 30 dias
await supabase.rpc('magic_create_token', {
p_respondent_id: respondentId,
p_token_hash: tokenHash,
p_expires_at: new Date(Date.now() + 30 * 86400_000).toISOString(),
});
Formato do payload no link
O token vai pro link no formato {respondentId}.{rawToken}. O prefixo
com o ID é puramente otimização: permite que magic-link-validate faça
SELECT por respondent_id antes de comparar o hash, em vez de varrer a
tabela inteira.
https://app.neuroredes.com.br/responder-pesquisa/4287.dQw4w9WgXcQ-aBcDeFgHiJk...
Ciclo de vida do token
| Estado | Coluna em survey_respondent_tokens |
O que dispara |
|---|---|---|
| Ativo | tudo NULL exceto created_at | Insert via RPC magic_create_token |
| Usado | used_at preenchido | Primeira validação bem-sucedida |
| Expirado | expires_at < now() | Tempo (30 dias) |
| Revogado | revoked_at preenchido | Manual via magic-link-revoke, ou implicitamente quando um novo token é criado para o mesmo respondente |
O token pode ser usado múltiplas vezes após o primeiro acesso —
used_at é só um marcador. A barreira real é
expires_at + revoked_at. Isso é intencional: o
respondente precisa poder voltar e completar a pesquisa em sessões diferentes.
Envio em massa por email
magic-link-send-bulk é síncrono: a edge function fica de pé durante
todo o disparo, envia por SMTP, atualiza status no Supabase a cada respondente, e
responde com o resumo no fim.
Payload de entrada
- survey_id
- Bigint. Pesquisa que receberá o disparo.
- assunto_email
- String. Vai pro campo Subject.
- cabecalho
- String. Texto introdutório do corpo do email.
- link_video
- String. URL do vídeo de orientação — obrigatório.
- rodape
- String opcional. Texto adicional no rodapé do email.
- message_id
- UUID opcional. Aponta para
survey_messages.idpreviamente criado — sem ele, o disparo não fica rastreável. - respondent_ids
- Array opcional. Se presente, limita o envio aos IDs informados. Usado pelo fluxo de retry de falhas.
Constantes operacionais
| Constante | Valor | Por quê |
|---|---|---|
MAX_RESPONDENTS | 100 | Caber dentro dos 30s de timeout da edge function (com folga para o SMTP). |
DELAY_BETWEEN_EMAILS_MS | 200 | Respeita rate limit da Brevo (~300 emails/dia no free, ~50/hora estável). |
TOKEN_EXPIRATION_DAYS | 30 | Janela de resposta da pesquisa — ajustável por constante. |
Loop principal
- Promove a pesquisa de status
1 (Aguardando)para2 (Em Andamento)se for o primeiro disparo. Idempotente: oUPDATEsó toca linhas em status 1. - Busca respondentes ativos com email. Filtra por
respondent_idsse passado. - Para cada respondente: gera token, monta HTML, envia via SMTP, e chama RPC
survey_message_respondents_update_statuscomsentoufailed. - Se a Brevo devolver "Quota Exceeded" ou "rate limit", marca os respondentes restantes como
failede encerra o loop — não bate cabeça insistindo. - No fim, chama RPC
survey_messages_update_statspara denormalizaremail_total/enviados/falhasemsurvey_messages.
Configuração SMTP
MAIL_HOST=smtp-relay.brevo.com
MAIL_PORT=587
MAIL_USERNAME=<brevo-smtp-user>
MAIL_PASSWORD=<brevo-smtp-key>
MAIL_FROM_ADDRESS=noreply@neuroredes.com.br
SITE_URL=https://app.neuroredes.com.br
Template de email
Template inline em buildEmailHtml() dentro da própria edge function.
Não é Handlebars nem MJML — é uma template string crua que interpola variáveis
diretamente no HTML.
Variáveis interpoladas
- ${name}
- Nome do respondente.
- ${cabecalho}
- Cabeçalho custom passado no payload.
- ${linkVideo}
- URL do vídeo de orientação.
- ${magicLinkUrl}
- Link personalizado para responder (com token).
- ${endDate}
- Data/hora limite formatada via
formatDate()emAmerica/Sao_Paulo. - ${rodape}
- Rodapé opcional.
Branding
- Logo branca (
neuroredes-logo-branco.png) sobre fundo azul-escuro#0b3a72. - Corpo em
#f5f7fb, Arial/Helvetica, sem custom fonts (clientes de email são imprevisíveis). - Botão CTA azul-escuro, mesmo tom do header.
O commit d42de2e feat: add deadline time and white logo to survey email
trocou a logo colorida pela branca e passou a mostrar hora além da data
limite. Se for refazer o template, mantenha esses dois ajustes.
Envio em massa por WhatsApp
Fluxo assíncrono: a edge function gera os tokens, monta as mensagens, enfileira em SQS e responde imediatamente. Um Lambda consumer drena a fila e chama a Meta Graph API.
Etapa 1 — Edge function enfileira
// Para cada respondente: gera token + monta corpo da mensagem
const personalizedMessage = message_template
.replace('{{name}}', respondent.name)
.replace('{{link}}', magicLinkUrl);
// Enfileira em lotes de 10 (limite SendMessageBatch do SQS)
await sqsClient.send(new SendMessageBatchCommand({
QueueUrl: Deno.env.get('SQS_WHATSAPP_QUEUE_URL')!,
Entries: batch.map((r, i) => ({
Id: String(i),
MessageBody: JSON.stringify({
phone: r.phone,
name: r.name,
message: personalizedMessage,
magicLink: magicLinkUrl,
batchId,
respondentId: r.id,
surveyId: survey_id,
messageId: message_id,
}),
})),
}));
Etapa 2 — Lambda consumer drena a fila
bulk-whatsapp-consumer recebe lotes de até 10 mensagens por
invocação (configuração do SQS event source). Para cada mensagem:
- Normaliza o telefone: remove tudo que não é dígito, prepende
+55se faltar país. - Busca o token da Meta no Secrets Manager (cacheado por 5min).
- Chama
POST graph.facebook.com/v17.0/{phoneNumberId}/messagescomtype: 'text'epreview_url: true. - Em sucesso: RPC
bulk_whatsapp_increment_sent. - Em falha permanente: RPC
bulk_whatsapp_increment_failedcom mensagem de erro. - Em falha transitória: adiciona o ID ao
batchItemFailuresda resposta — o SQS reenfileira automaticamente.
Classificação de falhas (Meta API)
| Tipo | Sinais | Ação |
|---|---|---|
| Permanente | "invalid phone number", "not a valid whatsapp account", "recipient not found", "blocked" | Marca failed e segue. |
| Transitória | 5xx, timeout, rate limit | Retorna no batchItemFailures. SQS retenta até max receive count → DLQ. |
Validação do token (rota pública)
Quando o respondente abre o magic link, o frontend chama
magic-link-validate com o payload. A função encadeia checagens
antes de devolver o estado completo da pesquisa.
- Parsing: separa
respondentIderawTokenno ponto. - Hash: SHA-256 do
rawToken. - RPC
magic_get_token_record: busca o registro pelo parrespondent_id + token_hash. - Checagens: existe, não revogado, não expirado.
- RPC
magic_mark_token_used: idempotente, só atualiza seused_atestava NULL. - RPC
magic_get_survey_payload: traz survey + participantes + respostas já dadas. - Checagem final: status da pesquisa.
Códigos de erro padronizados
| Código | HTTP | Quando ocorre |
|---|---|---|
INVALID_TOKEN_FORMAT | 400 | Payload sem ponto, respondent_id não numérico |
TOKEN_NOT_FOUND | 401 | Hash não bate com nenhum registro |
TOKEN_EXPIRED | 401 | expires_at < now() |
TOKEN_REVOKED | 401 | revoked_at IS NOT NULL |
SURVEY_INACTIVE | 400 | Status fora de {1, 2} ou end_date < now() |
RESPONDENT_INACTIVE | 400 | survey_respondents.active = false |
O commit 85e1d9b fix: fix magic link validate blocking all links
corrigiu um bug em que a checagem de status só aceitava 1 (Aguardando).
Como o próprio magic-link-send-bulk promove a pesquisa para
2 (Em Andamento) no primeiro disparo, todos os links que
chegavam ao validate caíam em SURVEY_INACTIVE. A correção
permite os dois status (1 e 2). Se for reescrever a regra, lembre desse caso.
Modelo de dados — rastreamento
survey_messages
Uma linha por disparo. Carrega metadados da mensagem (assunto, cabeçalho, vídeo) e contagens denormalizadas por canal para o card da UI consultar sem agregação.
CREATE TABLE mydb.survey_messages (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
survey_id bigint REFERENCES mydb.surveys(id),
titulo text,
link_video text,
assunto_email text,
parte_personalizada text,
meio_email boolean DEFAULT false,
meio_whatsapp boolean DEFAULT false,
email_total integer DEFAULT 0,
email_enviados integer DEFAULT 0,
email_falhas integer DEFAULT 0,
whatsapp_total integer DEFAULT 0,
whatsapp_enviados integer DEFAULT 0,
whatsapp_falhas integer DEFAULT 0,
email_batch_id uuid,
whatsapp_batch_id uuid,
created_at timestamptz DEFAULT now(),
updated_at timestamptz DEFAULT now()
);
survey_message_respondents
Uma linha por respondente × canal. É a tabela fonte da verdade —
as contagens em survey_messages são recalculadas a partir daqui via
RPC survey_messages_update_stats.
CREATE TABLE mydb.survey_message_respondents (
id bigserial PRIMARY KEY,
message_id uuid REFERENCES mydb.survey_messages(id) ON DELETE CASCADE,
respondent_id bigint REFERENCES mydb.survey_respondents(id),
canal varchar(10) CHECK (canal IN ('email','whatsapp')),
status varchar(10) CHECK (status IN ('pending','sent','failed')),
error_detail text,
sent_at timestamptz,
created_at timestamptz DEFAULT now(),
UNIQUE (message_id, respondent_id, canal)
);
survey_respondent_tokens
Tabela dos magic links. Note que token_hash é o SHA-256 — o token bruto
nunca toca o banco.
| Coluna | Descrição |
|---|---|
id | UUID, PK. |
respondent_id | FK para survey_respondents. |
token_hash | SHA-256 do raw token. Indexed. |
expires_at | 30 dias após criação. |
used_at | NULL até a primeira validação bem-sucedida. |
revoked_at | NULL até revogação manual ou re-emissão. |
bulk_email_batches / bulk_whatsapp_batches
Estruturas idênticas. Carregam contadores por batch (não por message) — utilitário para auditoria do consumer Lambda do WhatsApp.
Status consolidado (pill)
Toda mensagem mostra um pill resumindo o resultado. O cálculo vive em
utils/survey-message-status.ts e é compartilhado entre o card e a
tela de detalhe (commit 5f18627).
export function getSurveyMessageStatus(message: SurveyMessage): MessageStatus {
const channels = [];
if (message.meio_email) channels.push({
total: message.email_total, sent: message.email_enviados, failed: message.email_falhas,
});
if (message.meio_whatsapp) channels.push({
total: message.whatsapp_total, sent: message.whatsapp_enviados, failed: message.whatsapp_falhas,
});
const totalProcessed = channels.reduce((a, c) => a + c.sent + c.failed, 0);
if (totalProcessed === 0) return 'pending';
const totalFailed = channels.reduce((a, c) => a + c.failed, 0);
const totalSent = channels.reduce((a, c) => a + c.sent, 0);
if (totalFailed === 0) return 'success';
if (totalSent === 0) return 'error';
return 'partial';
}
| Status | Label UI | Significado |
|---|---|---|
success | Sucesso | Zero falhas em todos os canais usados. |
partial | Parcial | Alguns sucessos + algumas falhas. |
error | Falha | Tudo falhou. |
pending | Em andamento | Ainda não houve registro processado. |
Retry de falhas — in-place
A versão anterior criava uma nova survey_messages a cada retry,
poluindo o histórico. O commit
8e36aaa refactor(send): retry failed recipients in-place mudou a
regra: o retry mantém a mesma mensagem e apenas reseta para pending
os respondentes que falharam.
RPC survey_message_respondents_reset_failed
CREATE OR REPLACE FUNCTION mydb.survey_message_respondents_reset_failed(
p_message_id uuid,
p_canal text
) RETURNS integer
LANGUAGE plpgsql SECURITY DEFINER AS $$
DECLARE
v_count integer;
BEGIN
UPDATE mydb.survey_message_respondents
SET status = 'pending',
error_detail = NULL,
sent_at = NULL
WHERE message_id = p_message_id
AND canal = p_canal
AND status = 'failed';
GET DIAGNOSTICS v_count = ROW_COUNT;
RETURN v_count;
END;
$$;
Fluxo de UI
- Usuário clica em Reenviar Falhas em
MessageDetailClient. ConfirmDialogabre com texto claro do que vai acontecer.- Em confirmação:
survey-messages.client.service.tschama o RPC acima. - Service re-invoca
magic-link-send-bulk(ouwhatsapp-send-bulk) passando o array derespondent_idsque foram resetados. - A edge function executa o disparo normal — mas só para essa lista filtrada.
- Stats são recalculadas. A pill no card é atualizada automaticamente.
UI — onde o usuário toca
NewMessageClient
Formulário para criar uma nova mensagem. Permite escolher canais (email e/ou
WhatsApp), título, link do vídeo, assunto, rodapé custom. Em
handleDisparar(): cria o registro em survey_messages via
RPC, dispara as duas funções em paralelo (uma por canal), redireciona para a tela
de detalhe.
MessageCard
Item da listagem. Mostra título, data de envio, breakdown por canal (enviados/falhas), e o pill consolidado. Linka para o detalhe.
MessageDetailClient
Tela do detalhe da mensagem. Lista respondentes por canal, com seu status
individual e error_detail se houver. Permite:
- Baixar CSV das falhas (colunas: Nome, Email, Telefone, Atributos 1–3, Canal, Erro).
- Reenviar falhas por email.
- Reenviar falhas por WhatsApp.
- Atualizar a view (refresh after retry, commit
279fd8b).
Mapa de RPCs
Todas no schema mydb, SECURITY DEFINER. As edge functions
chamam diretamente; o frontend acessa via supabase.rpc() (padrão
descrito em docs/rpc-approach.md).
Mensagem & rastreamento
survey_messages_create()— Insere o registro inicial antes do disparo.survey_messages_list()— Lista todas as mensagens de uma pesquisa.survey_messages_get()— Detalha uma mensagem específica.survey_messages_update_stats()— Recalcula contagens a partir desurvey_message_respondents.survey_messages_update_batch_ids()— Linka batches do email/WhatsApp na mensagem.
Por respondente
survey_message_respondents_bulk_insert()— Cria N linhaspendingantes do disparo.survey_message_respondents_update_status()—sentoufailed+error_detail.survey_message_respondents_get_failed()— Lista falhas com detalhes para CSV.survey_message_respondents_reset_failed()— Reseta para retry (ver Cap. 10).
Batches (lambda consumer)
bulk_email_create_batch()/bulk_whatsapp_create_batch()bulk_email_increment_sent()/bulk_whatsapp_increment_sent()bulk_email_increment_failed()/bulk_whatsapp_increment_failed()bulk_email_get_status()/bulk_whatsapp_get_status()
Magic links
magic_create_token()— Insere hash + TTL.magic_revoke_tokens()— Revoga todos os ativos de um respondente.magic_get_token_record()— Buscar porrespondent_id + token_hash.magic_mark_token_used()— Idempotente, marcaused_at.magic_get_survey_payload()— Payload completo para o respondente abrir.magic_update_email_sent()— Atualiza flag de "email já enviado" no respondente.
Edge cases & armadilhas
- Respondente sem email
- É filtrado antes do loop. Não aparece em
survey_message_respondentscom canalemail— não conta como falha. - Respondente sem telefone
- Mesmo tratamento para WhatsApp.
- Duplicação no insert
- Constraint
UNIQUE(message_id, respondent_id, canal)+ON CONFLICT DO NOTHING. Re-tentar o RPC é seguro. - Telefone com país errado
- O normalizador prepende
+55se não houver código de país. Se vier com país errado (ex.:+1), passa direto para a Meta e provavelmente falha como "not a valid whatsapp account". - Rate limit Brevo
- Detectado por substring na mensagem de erro. Sintomas: a partir de um certo respondente, todos passam a falhar com a mesma mensagem. Loop é interrompido — o restante fica
failedpara retry posterior. - Pesquisa em status Arquivada
- Validate bloqueia com
SURVEY_INACTIVE. Respondentes que abrirem o link recebem 400. Não há reabertura via UI — só via SQL direto. - SQS DLQ (WhatsApp)
- Mensagens que falham mais que
maxReceiveCountvezes (padrão: 3) vão para a DLQ. Não há monitoramento automatizado hoje — auditar manualmente quando houver suspeita. - Reuso de token
- Tokens não são "consumidos" no primeiro uso. O mesmo link funciona até
expires_atourevoked_at. Isso é intencional para o respondente poder voltar.