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.
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. |
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.
- O respondente abre
/responder-pesquisa/{respondentId}.{rawToken}(formato do magic link em Cap. 03 — Anatomia do magic link). - O middleware vê o prefixo
/responder-pesquisana lista de bypass e não redireciona para/login. - O Server Component chama
validateToken(token), que fazPOSTemmagic-link-validatecom a chaveanonno header. - Em sucesso, a edge retorna o payload
{ profile, survey, participants, answers }e a página renderiza<RespondentSurveyClient token initialData />. - O
ConsentModalbloqueia a UI até o aceite explícito. - Cada alteração em
QuestionMatrixchamaanswerQuestionno contexto; o provider atualiza o estado, escreve emlocalStoragee agenda um flush em 5 s. - O flush envia o delta para
save-responses. Quando o respondente completa todos os avaliados, a edge marcamydb.survey_respondents.finished_filling = now().
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.
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.
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 Expirado — expires_at < now(). |
TOKEN_REVOKED |
401 | Link Revogado — revoked_at IS NOT NULL. Tipicamente disparado por edição destrutiva da pesquisa. |
RESPONDENT_INACTIVE |
400 | Acesso Desativado — survey_respondents.active != 1. |
SURVEY_NOT_FOUND |
400 | Pesquisa Não Encontrada — registro de surveys ausente para o respondente. |
SURVEY_INACTIVE |
400 | Pesquisa Encerrada — status 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. |
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.
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.
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.
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.
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.idno contexto. - responses
- Array de
RespondentAnswer. Cada item precisa terevaluatee_id;question_1..5são opcionais (envia só o que mudou). - question_1..5
smallintno 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).
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. |
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.
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():
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. |
Pegadinhas & alertas
-
Edit destrutivo apaga respostas e revoga tokens. Quando o admin altera
atributos, perguntas ou respondentes depois do envio,
edit-surveyapaga linhas demydb.survey_responses, setarevoked_at = now()nos tokens e zera os flagsemail_sent,whatsapp_sentefinished_filling. O respondente que clicar no link antigo cai emTOKEN_REVOKEDsem 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-responsesnão re-valida o magic link nem o status da pesquisa. O gate é apenas a chave públicaanonmais a checagem derespondent.active = 1e do vínculoevaluatee → survey. Nemstatusnemend_datesão checados na escrita. Um cliente com a chave pública que conheça umrespondent_idgrava respostas sem precisar do token. (save-responsesemagic-link-validatesão fluxos públicos no browser e por isso permanecem emverify_jwt: false— duas das 7 isenções mantidas no endurecimento de 2026-06-10; ver backend.html Cap. 03.) -
Janela entre
end_datee o cron de auto-close.close-expired-surveysroda a cada 15 minutos. Durante essa janela,magic-link-validaterejeita peloend_date(impedindo abrir o formulário), massave-responsesaceita 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)é oonConflictdo upsert. Reenvio é sobrescrita silenciosa — o respondente muda de ideia e a versão nova substitui a antiga sem histórico. -
mydb.survey_respondent_tokensestá sem RLS. O gate de leitura/escrita fica todo nas edges comservice_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.
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.