Neuroredes
Módulos do Produto

Resposta Pública

A rota /responder-pesquisa/[token] é o único caminho do produto sem login. O respondente abre o magic link recebido por email ou WhatsApp, o servidor valida o token e renderiza um formulário em que cada participante ativo da pesquisa é avaliado em cinco perguntas numa escala de quatro pontos. Esta página descreve a rota, o formulário, a persistência e os estados terminais.

Capítulo 01

Onde mora o módulo

A rota fica em app/(public-access)/responder-pesquisa/[token]/page.tsx, dentro do route group (public-access). É Server Component assíncrono, sem header autenticado e sem cookie de sessão. O bypass que permite o acesso anônimo está em lib/supabase/middleware.ts — ver Cap. 05 — Sessão & middleware.

Arquivo Responsabilidade
app/(public-access)/responder-pesquisa/[token]/page.tsx Server Component. Chama validateToken(token) e renderiza <RespondentSurveyClient> em caso de sucesso ou um card de erro em caso de falha.
components/RespondentSurvey/RespondentSurveyClient Monta RespondentSurveyProvider e o container do toaster.
components/RespondentSurvey/SurveyExperience Layout principal: header público, header da pesquisa, sidebar de filtros e tabuleiro de participantes. Inclui o ConsentModal.
components/RespondentSurvey/ConsentModal Overlay inicial de consentimento. Bloqueia interação até o aceite.
components/RespondentSurvey/PublicHeader Topo público com logotipo e identificação do respondente (nome + email).
components/RespondentSurvey/SurveyHeader Título e descrição da pesquisa.
components/RespondentSurvey/FiltersSidebar Busca por nome, toggle “só não respondidos” e grupos de atributos como filtros colapsáveis.
components/RespondentSurvey/ParticipantsBoard + ParticipantsList Tabuleiro paginado (10 por página) com busca e estado vazio.
components/RespondentSurvey/ParticipantCard Card por avaliado, expansível, com badge “Respondido”/“Pendente”.
components/RespondentSurvey/QuestionMatrix Matriz das perguntas com 4 radios por linha (escala 0–3). Tabela no desktop, lista empilhada no mobile.
contexts/RespondentSurveyProvider.tsx Estado do formulário, auto-save debounced, persistência em localStorage, hidratação no mount, hook useRespondentSurvey().
services/respondent-survey.service.ts Chamadas a magic-link-validate (servidor) e save-responses (cliente).
lib/supabase/middleware.ts Bypass de autenticação para /responder-pesquisa.
types/respondent.type.ts Tipos RespondentProfile, RespondentSurveyPayload, RespondentAnswer.
Capítulo 02

Anatomia da rota pública

O fluxo é linear, do clique até a marca de finalização. O middleware libera o prefixo sem checar cookie, o Server Component faz a validação síncrona do token e, em caso de sucesso, entrega ao client o payload já materializado pela edge.

  1. O respondente abre /responder-pesquisa/{respondentId}.{rawToken} (formato do magic link em Cap. 03 — Anatomia do magic link).
  2. O middleware vê o prefixo /responder-pesquisa na lista de bypass e não redireciona para /login.
  3. O Server Component chama validateToken(token), que faz POST em magic-link-validate com a chave anon no header.
  4. Em sucesso, a edge retorna o payload { profile, survey, participants, answers } e a página renderiza <RespondentSurveyClient token initialData />.
  5. O ConsentModal bloqueia a UI até o aceite explícito.
  6. Cada alteração em QuestionMatrix chama answerQuestion no contexto; o provider atualiza o estado, escreve em localStorage e agenda um flush em 5 s.
  7. O flush envia o delta para save-responses. Quando o respondente completa todos os avaliados, a edge marca mydb.survey_respondents.finished_filling = now().
Atenção

Há um bypass de desenvolvimento. Se DEV_MODE_ENABLED for verdadeiro e o token bater com DEV_MODE_TOKEN, o Server Component salta validateToken e usa mockRespondentSurveyData direto de config/respondent-survey-mock. Os flags estão em config/dev-mode.config e devem ficar desligados em produção.

Capítulo 03

Validação do token

A anatomia do token (formato id.raw, hash SHA-256 base64 em mydb.survey_respondent_tokens.token_hash, expires_at, revoked_at) e o passo a passo da edge estão em Cap. 03 — Anatomia do magic link e Cap. 07 — Validação do token do módulo de envio. Esta página descreve apenas o que o respondente experimenta a partir daí.

A chamada parte do servidor com a chave anon; o Server Component aguarda a resposta antes de renderizar qualquer árvore.

services/respondent-survey.service.ts typescript
const response = await fetch(
  `${SUPABASE_URL}/functions/v1/magic-link-validate`,
  {
    method: 'POST',
    headers: { 'Content-Type': 'application/json',
      Authorization: `Bearer ${SUPABASE_ANON_KEY}`, apikey: SUPABASE_ANON_KEY },
    body: JSON.stringify({ token }),
  },
);

Em sucesso, a edge devolve { success: true, data: RespondentSurveyPayload } com profile, survey (título, descrição, perguntas, rótulos e grupos de atributos), participants (todos os respondentes ativos da pesquisa) e answers (respostas já gravadas, vindas de magic_get_survey_payload). Em falha, devolve { success: false, error: TokenValidationErrorCode, message } e o Server Component renderiza um card de erro.

Código HTTP Título exibido / origem
INVALID_TOKEN_FORMAT 400 Link Inválido — token não bate com o formato id.raw.
TOKEN_NOT_FOUND 401 Link Não Encontrado — RPC magic_get_token_record não achou hash correspondente.
TOKEN_EXPIRED 401 Link Expiradoexpires_at < now().
TOKEN_REVOKED 401 Link Revogadorevoked_at IS NOT NULL. Tipicamente disparado por edição destrutiva da pesquisa.
RESPONDENT_INACTIVE 400 Acesso Desativadosurvey_respondents.active != 1.
SURVEY_NOT_FOUND 400 Pesquisa Não Encontrada — registro de surveys ausente para o respondente.
SURVEY_INACTIVE 400 Pesquisa Encerradastatus fora de {1, 2} ou end_date < now().
INTERNAL_ERROR 500 Erro Interno — fallback genérico da edge.
NETWORK_ERROR Erro de Conexão — atribuído pelo service quando o fetch falha antes de receber resposta.
Nota

As mensagens exibidas na UI não vêm da edge: a edge devolve error + message, mas o Server Component descarta message e usa o mapa errorMessages em page.tsx para títulos e descrições padronizados.

Capítulo 04

Formulário de avaliação

Layout e UX

Após o aceite no ConsentModal, a tela mostra três regiões: header público com nome/email do respondente, FiltersSidebar à esquerda (busca por nome, toggle “só não respondidos” e grupos de atributos colapsáveis), e ParticipantsBoard à direita com a lista paginada em 10 por página. Cada ParticipantCard expande para revelar o QuestionMatrix daquele avaliado, com badge Respondido ou Pendente calculado por hasAnswered(evaluateeId).

Escala de avaliação

QuestionMatrix renderiza quatro radio buttons por pergunta — não é slider, não é escala 1–5. Os valores válidos são 0 (Nulo), 1 (Baixo), 2 (Médio) e 3 (Alto). A constante ANSWER_OPTIONS no provider trava o range, e hasAnyNonNullAnswer só considera respondido quando existe pelo menos um question_N > 0 — isto é, “Nulo” conta como ausência de resposta no badge da UI, mas é persistido normalmente no banco.

No desktop o matrix é uma tabela. Abaixo de 768 px ele vira lista empilhada com uma pergunta por bloco, mantendo o mesmo conjunto de radios.

Auto-save e pausa/retomada

Toda alteração passa por answerQuestion no provider, que atualiza answers, escreve o mapa inteiro em localStorage (efeito reativo em answers) e chama scheduleSave com um delta apenas daquele avaliado. scheduleSave agenda um setTimeout de 5000 ms que dispara o flush.

contexts/RespondentSurveyProvider.tsx typescript
const getStorageKey = (surveyId: number, respondentId: number) =>
  `survey_${surveyId}_respondent_${respondentId}_answers`;

if (saveTimeoutRef.current) clearTimeout(saveTimeoutRef.current);
saveTimeoutRef.current = setTimeout(flushPendingSaves, 5000);

O flush envia o delta para save-responses. Em sucesso, remove os evaluatee_id enviados de pendingSavesRef e, se o map ficou vazio, apaga a chave em localStorage. Se ainda restarem pendências (ou se o envio falhou), reagenda em 5000 ms em caso de sucesso parcial ou em 8000 ms em caso de erro. Um setInterval de 60 s serve como sentinela: se ainda há pendentes e nenhum flush em andamento, dispara um novo.

Um listener de beforeunload mostra o aviso nativo do browser se houver itens em pendingSavesRef ou um flush ainda em andamento.

Na hidratação inicial, o provider monta answers a partir de initialData.answers (vindo do servidor, ou seja, do banco). Logo após o primeiro render, um useEffect tenta ler a chave do localStorage e faz merge não-destrutivo: só adiciona avaliados que ainda não existem no map. O servidor sempre tem prioridade.

Nota

Não há schema Zod para o payload do respondente em lib/zod/. O tipo RespondentAnswer é TypeScript only; a validação real (range 0–3, presença de evaluatee_id, vínculo respondente/avaliado/pesquisa) acontece dentro de save-responses.

Capítulo 05

Persistência das respostas

O cliente envia o delta para a edge save-responses com a chave anon no header. O payload é um array; o cliente sempre coleta vários avaliados num único request, mas a edge aceita tanto um único item quanto múltiplos.

respondent_id
Identificador numérico do respondente que está preenchendo. Vem de profile.id no contexto.
responses
Array de RespondentAnswer. Cada item precisa ter evaluatee_id; question_1..5 são opcionais (envia só o que mudou).
question_1..5
smallint no banco, range válido [0, 1, 2, 3]. Qualquer outro valor é rejeitado pela edge.

A gravação em mydb.survey_responses é um upsert com chave de conflito composta. O cliente envia apenas as colunas alteradas; campos ausentes preservam o que já está no banco (não sobrescrevem com NULL).

supabase/functions/save-responses/index.ts typescript
await supabase
  .schema('mydb')
  .from('survey_responses')
  .upsert(rows, { onConflict: 'respondent_id,evaluatee_id' });

Quando finished_filling é setado

Após o upsert, a edge conta quantas linhas o respondente já tem em survey_responses dentro da pesquisa. Se o total cobre todos os outros respondentes ativos (count(responses) >= total_respondentes_ativos − 1) e survey_respondents.finished_filling ainda é NULL, faz UPDATE setando o timestamp atual. Quem lê esse campo é Cap. 04 — Estados do respondente.

HTTP Significado no service
200 { ok: true, saved }. Todas as linhas do batch foram persistidas.
207 { ok: false, partial: true, saved, details }. Parte do batch falhou (ex.: evaluatee_id inválido). O provider exibe toast de erro mas mantém os pendentes para reenvio.
outros { ok: false, error }. O provider reagenda flush em 8 s e mantém o estado local intacto.
Capítulo 06

Estados terminais & mensagens

Quando validateToken falha, a página não renderiza o formulário — devolve um card estilizado com título, mensagem e nota de contato. A tabela abaixo cruza o cenário com a detecção da edge e o título exibido na UI.

Cenário Código retornado Título na UI
Token expirado (mais de 30 dias) TOKEN_EXPIRED Link Expirado
Token revogado por edit destrutivo TOKEN_REVOKED Link Revogado
Hash não encontrado no banco TOKEN_NOT_FOUND Link Não Encontrado
Token não bate com o formato id.raw INVALID_TOKEN_FORMAT Link Inválido
Pesquisa arquivada (status = 3) ou vencida (end_date < now()) SURVEY_INACTIVE Pesquisa Encerrada
Pesquisa apagada após o envio SURVEY_NOT_FOUND Pesquisa Não Encontrada
Respondente desativado (active = 0) RESPONDENT_INACTIVE Acesso Desativado
Falha genérica da edge INTERNAL_ERROR Erro Interno
fetch não chegou a responder NETWORK_ERROR Erro de Conexão

Já respondeu

Não há código de erro para “já respondeu”. Se o respondente reabre o link após ter completado tudo, o token segue válido enquanto não expira ou é revogado: a edge devolve o payload com answers[] populado e a UI renderiza o formulário com todos os cards no estado Respondido. O respondente pode revisitar e alterar notas livremente — o upsert por (respondent_id, evaluatee_id) aceita sobrescrita silenciosa. A única marca de “finalizado” é survey_respondents.finished_filling, lida fora da rota pública.

Capítulo 07

Integração com outros módulos

Esta rota é o único ponto do produto que escreve em mydb.survey_responses e em mydb.survey_respondents.finished_filling. Tudo o que depende dessas escritas é invalidado por trigger ou recalcula sob demanda.

Triggers de invalidação de cache

Confirmado via MCP que existem dois triggers ligados a mydb.invalidate_survey_cache():

pg_trigger (MCP) sql
CREATE TRIGGER trigger_invalidate_cache
AFTER INSERT OR DELETE OR UPDATE ON mydb.survey_responses
FOR EACH ROW EXECUTE FUNCTION mydb.invalidate_survey_cache();

CREATE TRIGGER trigger_invalidate_cache_respondents
AFTER INSERT OR DELETE OR UPDATE ON mydb.survey_respondents
FOR EACH ROW EXECUTE FUNCTION mydb.invalidate_survey_cache();

Cada upsert disparado por save-responses aciona o primeiro trigger; cada atualização de finished_filling aciona o segundo. A função apaga as entradas relevantes em public.survey_cache. A próxima chamada ao dashboard-processor ou ao graph-processor recalcula do zero — ver Cap. 06 — pg_cron, triggers & funções e Cap. 07 — Cache & invalidação.

Quem lê o que esta rota grava

Efeito desta rota Consumidor
mydb.survey_responses (linha nova ou atualizada) Lambdas dashboard-processor, graph-processor e excel-processor — ver Cap. 02 — Lambdas HTTP.
mydb.survey_respondents.finished_filling Painel de status na listagem de pesquisas; cálculo da pill de progresso descrito em Cap. 04 — Estados do respondente.
mydb.survey_respondent_tokens.used_at (setado por magic-link-validate) Cap. 08 — Modelo de dados do módulo de envio.
Invalidação de public.survey_cache Próximas leituras de dashboard, gráfico e relatórios disparam recálculo nas Lambdas.
Capítulo 08

Pegadinhas & alertas

  • Edit destrutivo apaga respostas e revoga tokens. Quando o admin altera atributos, perguntas ou respondentes depois do envio, edit-survey apaga linhas de mydb.survey_responses, seta revoked_at = now() nos tokens e zera os flags email_sent, whatsapp_sent e finished_filling. O respondente que clicar no link antigo cai em TOKEN_REVOKED sem aviso prévio — ver Cap. 05 — Fluxos.
  • O raw do token transita na URL. O banco guarda apenas o hash SHA-256 base64, mas a URL completa do magic link contém o segredo até a expiração. Logs de proxy, histórico de browser e capturas de tela vazam o acesso.
  • save-responses não re-valida o magic link nem o status da pesquisa. O gate é apenas a chave pública anon mais a checagem de respondent.active = 1 e do vínculo evaluatee → survey. Nem status nem end_date são checados na escrita. Um cliente com a chave pública que conheça um respondent_id grava respostas sem precisar do token. (save-responses e magic-link-validate são fluxos públicos no browser e por isso permanecem em verify_jwt: false — duas das 7 isenções mantidas no endurecimento de 2026-06-10; ver backend.html Cap. 03.)
  • Janela entre end_date e o cron de auto-close. close-expired-surveys roda a cada 15 minutos. Durante essa janela, magic-link-validate rejeita pelo end_date (impedindo abrir o formulário), mas save-responses aceita o upsert de quem já estava com a aba aberta — ver Cap. 03 — Status e ciclo de vida.
  • PK lógica composta. (respondent_id, evaluatee_id) é o onConflict do upsert. Reenvio é sobrescrita silenciosa — o respondente muda de ideia e a versão nova substitui a antiga sem histórico.
  • mydb.survey_respondent_tokens está sem RLS. O gate de leitura/escrita fica todo nas edges com service_role. Citado em Cap. 02 — Schema mydb.
  • localStorage não cruza dispositivos. Pause/resume só funciona no mesmo browser. Se o respondente abrir o link em outra máquina, vê apenas o que foi efetivamente persistido em survey_responses; o que estava no buffer local do outro device fica órfão.
Alerta de segurança

A RPC mydb.magic_get_survey_payload(p_respondent_id) é SECURITY DEFINER e está executável pelo role anon via /rest/v1/rpc/magic_get_survey_payload. Qualquer cliente com a chave pública chama a RPC passando um respondent_id arbitrário e recebe profile, survey, participants e answers sem nenhum token. Isso bypassa todo o caminho de magic-link-validate. Mitigação: revogar EXECUTE para anon/authenticated ou converter para SECURITY INVOKER. Identificado via advisor do MCP em 2026-05-21.