Neuroredes
Módulos do Produto

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.

Capítulo 01

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
Email Frontend → Edge Function magic-link-send-bulkSMTP Brevo direto → atualiza Supabase no mesmo request Síncrono
WhatsApp Frontend → Edge Function whatsapp-send-bulkSQS → Lambda bulk-whatsapp-consumer → Meta WhatsApp API → atualiza Supabase via RPC Assíncrono
Atenção — herança do passado

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.

Capítulo 02

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).
Capítulo 03

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.

geração — supabase/functions/magic-link-send/index.ts typescript
// 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.

URL final bash
https://app.neuroredes.com.br/responder-pesquisa/4287.dQw4w9WgXcQ-aBcDeFgHiJk...

Ciclo de vida do token

Estado Coluna em survey_respondent_tokens O que dispara
Ativotudo NULL exceto created_atInsert via RPC magic_create_token
Usadoused_at preenchidoPrimeira validação bem-sucedida
Expiradoexpires_at < now()Tempo (30 dias)
Revogadorevoked_at preenchidoManual via magic-link-revoke, ou implicitamente quando um novo token é criado para o mesmo respondente
Nota

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.

Capítulo 04

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.id previamente 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

ConstanteValorPor quê
MAX_RESPONDENTS100Caber dentro dos 30s de timeout da edge function (com folga para o SMTP).
DELAY_BETWEEN_EMAILS_MS200Respeita rate limit da Brevo (~300 emails/dia no free, ~50/hora estável).
TOKEN_EXPIRATION_DAYS30Janela de resposta da pesquisa — ajustável por constante.

Loop principal

  1. Promove a pesquisa de status 1 (Aguardando) para 2 (Em Andamento) se for o primeiro disparo. Idempotente: o UPDATE só toca linhas em status 1.
  2. Busca respondentes ativos com email. Filtra por respondent_ids se passado.
  3. Para cada respondente: gera token, monta HTML, envia via SMTP, e chama RPC survey_message_respondents_update_status com sent ou failed.
  4. Se a Brevo devolver "Quota Exceeded" ou "rate limit", marca os respondentes restantes como failed e encerra o loop — não bate cabeça insistindo.
  5. No fim, chama RPC survey_messages_update_stats para denormalizar email_total/enviados/falhas em survey_messages.

Configuração SMTP

env vars da edge function bash
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
Capítulo 05

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() em America/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.
Nota

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.

Capítulo 06

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

supabase/functions/whatsapp-send-bulk/index.ts typescript
// 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:

  1. Normaliza o telefone: remove tudo que não é dígito, prepende +55 se faltar país.
  2. Busca o token da Meta no Secrets Manager (cacheado por 5min).
  3. Chama POST graph.facebook.com/v17.0/{phoneNumberId}/messages com type: 'text' e preview_url: true.
  4. Em sucesso: RPC bulk_whatsapp_increment_sent.
  5. Em falha permanente: RPC bulk_whatsapp_increment_failed com mensagem de erro.
  6. Em falha transitória: adiciona o ID ao batchItemFailures da resposta — o SQS reenfileira automaticamente.

Classificação de falhas (Meta API)

TipoSinaisAçã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.
Capítulo 07

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.

  1. Parsing: separa respondentId e rawToken no ponto.
  2. Hash: SHA-256 do rawToken.
  3. RPC magic_get_token_record: busca o registro pelo par respondent_id + token_hash.
  4. Checagens: existe, não revogado, não expirado.
  5. RPC magic_mark_token_used: idempotente, só atualiza se used_at estava NULL.
  6. RPC magic_get_survey_payload: traz survey + participantes + respostas já dadas.
  7. Checagem final: status da pesquisa.

Códigos de erro padronizados

CódigoHTTPQuando ocorre
INVALID_TOKEN_FORMAT400Payload sem ponto, respondent_id não numérico
TOKEN_NOT_FOUND401Hash não bate com nenhum registro
TOKEN_EXPIRED401expires_at < now()
TOKEN_REVOKED401revoked_at IS NOT NULL
SURVEY_INACTIVE400Status fora de {1, 2} ou end_date < now()
RESPONDENT_INACTIVE400survey_respondents.active = false
Atenção — bug recente

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.

Capítulo 08

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.

mydb.survey_messages sql
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.

mydb.survey_message_respondents sql
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.

ColunaDescrição
idUUID, PK.
respondent_idFK para survey_respondents.
token_hashSHA-256 do raw token. Indexed.
expires_at30 dias após criação.
used_atNULL até a primeira validação bem-sucedida.
revoked_atNULL 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.

Capítulo 09

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).

utils/survey-message-status.ts typescript
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';
}
StatusLabel UISignificado
successSucessoZero falhas em todos os canais usados.
partialParcialAlguns sucessos + algumas falhas.
errorFalhaTudo falhou.
pendingEm andamentoAinda não houve registro processado.
Capítulo 10

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

migrations/20260430203459_reset_failed_message_respondents.sql sql
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

  1. Usuário clica em Reenviar Falhas em MessageDetailClient.
  2. ConfirmDialog abre com texto claro do que vai acontecer.
  3. Em confirmação: survey-messages.client.service.ts chama o RPC acima.
  4. Service re-invoca magic-link-send-bulk (ou whatsapp-send-bulk) passando o array de respondent_ids que foram resetados.
  5. A edge function executa o disparo normal — mas só para essa lista filtrada.
  6. Stats são recalculadas. A pill no card é atualizada automaticamente.
Capítulo 11

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).
Capítulo 12

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 de survey_message_respondents.
  • survey_messages_update_batch_ids() — Linka batches do email/WhatsApp na mensagem.

Por respondente

  • survey_message_respondents_bulk_insert() — Cria N linhas pending antes do disparo.
  • survey_message_respondents_update_status()sent ou failed + 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 por respondent_id + token_hash.
  • magic_mark_token_used() — Idempotente, marca used_at.
  • magic_get_survey_payload() — Payload completo para o respondente abrir.
  • magic_update_email_sent() — Atualiza flag de "email já enviado" no respondente.
Capítulo 13

Edge cases & armadilhas

Respondente sem email
É filtrado antes do loop. Não aparece em survey_message_respondents com canal email — 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 +55 se 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 failed para 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 maxReceiveCount vezes (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_at ou revoked_at. Isso é intencional para o respondente poder voltar.