Visão geral
Stack: API Node (app) + Postgres + MinIO + Traefik. Studio e respondente são HTML estático servidos pela mesma API.
Studio (survey.*) ──admin──► API /v1/* Respondente (r.*) ──public──► /v1/public|sessions Painel (painel.*) ──token──► /v1/public/painel/:slug/tabulation Workers ─────────── jobs (runtime_ai, classify_*, quality_run)
Hosts / navegação
survey.gmrpesquisa.com.br— Studio + /docs/*r.gmrpesquisa.com.br— coleta/{publicName}, resume/r/{responseId}painel.survey…— tabulação públicaapi.survey…— API (CORS)
Runtime respondente
response.html: SurveyJS model + save parcial + automations (Then AI/webhook) + complete → fila.
Survey / settings
survey {
id, publicName, title, status, folderId, tags,
surveyJson, // Form Library
settings: {
webhooks, // legado → migra p/ automations
runtimeAi, // legado → automations then=ai
automations[], // canônico
quotas[], // QuotaSet[]
painel: { token },
theme, quality, filters
}
}
Session / response
Dois retratos last-write e uma trilha append-only. Formato canônico da trilha: Trilha (visits).
session {
responseId, surveyId, status,
data, // respostas vigentes (SurveyJS)
surveyJson, // snapshot do form no insert
customs: {
meta, // último ip/ua (retrato)
stages, // { answered, skipped, hidden }
visits[], // hops — sessão nova não cresce webhookLogs
review, field, quality.study
}
}
Trilha da sessão (visits)
JSON persistido após a trilha única (tickets 01–09). Spec: docs/superpowers/specs/2026-08-26-jornada-review-field-design.md. Código: session-events.js, session-stages.js, sessions.js.
Dois retratos, uma trilha
data/surveyJson— valor vigente e snapshot do form aplicado (já no insert).customs.stages={ answered, skipped, hidden }— omitidas =hidden; visíveis sem resposta =skipped. Writer no POST/datae no complete (replay SurveyJS). Mora na sessão, não duplica por hop.customs.visits[]— hops append-only. Sessão nova não appendwebhookLogs(legado só se já existir).customs.review,field.audio_url,quality.study— eixos já implementados (veredito, evidência de campo, job GMR).
Visita (hop)
startedAt,actor(system|respondent|auditor),meta(ip/ua daquele hop),records[].- Visita
system: criada emcreateSessionantes deonStarted; segura oopeninstantâneo + orunde abertura. - Abrir
/r/{id}abre hoprespondent. Reopen/lock admin abre hopauditor. - A visita não grava
endedAt. Stages não vão no hop.
Record (sempre startAt + endAt)
- Instantâneo (
open,share,complete,gate,review):startAt === endAt. answer:startAt= foco/página;endAt= commit. Primeira gravação =action: new(sóto); edição =edit(from+to).run:startAt= disparo;endAt= resposta de volta;ms= delta.event: run,action: webhook | email | ai. Semsource: automation.- Share:
dest.phone/dest.email— não persistir URLwa.me. - Não persistir body/headers do request HTTP. Ausente = omitir a chave.
to do run (recorte)
{
"automation": { "id": "wh-1", "name": "Lead n8n" },
"rule": { "when": "onStarted" },
"request": { "url": "https://hooks.example/lead" },
"response": { "ok": true, "status": 200, "ms": 40, "error": null, "preview": "…≤500" }
}
settings.webhooks.onStarted usa automation.id = settings.webhooks e name = onStarted. Falha investigável: status + preview, sem reenviar o envelope.
Glossário rápido
actor respondent | expression | remote | system | auditor
event answer | share | run | gate | open | complete | review
action new | edit | copy | whatsapp | email | button | webhook | ai | hit | reopen | lock | set
target { kind: question|automation|channel|button|rule, id }
dest { phone? } | { email? } só share
stages answered | skipped | hidden last-write da sessão
review category pending|approved|flagged|rejected · publishedAt (dia)
field audio_url (canônico; alias call_url) · transcription · call_id · surveyor
quality.study pending|running|done|skipped (job GMR; ≠ callStudy 3C)
Exemplo completo (DEPOIS)
Mesma entrevista: start → open no celular → idade 25 depois 18 → WhatsApp → complete + webhook 200 → reopen auditor corrige 17. Questionário tem idade e motivo (visibleIf idade < 18). motivo omitida está em stages.hidden, não na visita. Webhook de abertura na visita system; o de conclusão no hop do respondente. Sem webhookLogs.
{
"responseId": "resp_a1",
"status": "incomplete",
"surveyJson": { "pages": [{ "elements": [
{ "name": "idade", "type": "text" },
{ "name": "motivo", "type": "comment", "visibleIf": "{idade} < 18" }
]}] },
"data": { "idade": "17" },
"customs": {
"meta": { "ip": { "address": "198.51.100.9" } },
"stages": {
"answered": ["idade"],
"skipped": [],
"hidden": ["motivo"]
},
"review": {
"category": "pending",
"source": "auto",
"publishedAt": "2026-08-26",
"publishedFrom": "completed"
},
"field": {},
"quality": {
"study": { "job": "quality.study", "state": "skipped", "reason": "no_evidence" }
},
"visits": [
{
"startedAt": "2026-08-26T12:00:00.000Z",
"actor": "system",
"records": [
{ "actor": "system", "event": "open", "startAt": "2026-08-26T12:00:00.000Z", "endAt": "2026-08-26T12:00:00.000Z" },
{
"actor": "remote",
"event": "run",
"action": "webhook",
"startAt": "2026-08-26T12:00:00.000Z",
"endAt": "2026-08-26T12:00:00.040Z",
"to": {
"automation": { "id": "settings.webhooks", "name": "onStarted" },
"rule": { "when": "onStarted" },
"request": { "url": "https://hooks.example/start" },
"response": { "ok": true, "status": 200, "ms": 40, "error": null, "preview": "ok" }
}
}
]
},
{
"startedAt": "2026-08-26T12:00:01.000Z",
"actor": "respondent",
"meta": { "ip": { "address": "203.0.113.10" }, "ua": { "raw": "Phone/1" } },
"records": [
{ "actor": "respondent", "event": "open", "startAt": "2026-08-26T12:00:01.000Z", "endAt": "2026-08-26T12:00:01.000Z" },
{
"actor": "respondent", "event": "answer", "action": "new",
"target": { "kind": "question", "id": "idade" },
"startAt": "2026-08-26T12:00:01.000Z", "endAt": "2026-08-26T12:01:10.000Z",
"to": "25"
},
{
"actor": "respondent", "event": "answer", "action": "edit",
"target": { "kind": "question", "id": "idade" },
"startAt": "2026-08-26T12:01:10.000Z", "endAt": "2026-08-26T12:02:40.000Z",
"from": "25", "to": "18"
},
{
"actor": "respondent", "event": "share", "action": "whatsapp",
"dest": { "phone": "11988887777" },
"startAt": "2026-08-26T12:03:00.000Z",
"endAt": "2026-08-26T12:03:00.000Z"
},
{ "actor": "respondent", "event": "complete", "startAt": "2026-08-26T12:05:12.000Z", "endAt": "2026-08-26T12:05:12.000Z" },
{
"actor": "remote", "event": "run", "action": "webhook",
"startAt": "2026-08-26T12:05:12.955Z",
"endAt": "2026-08-26T12:05:13.000Z",
"to": {
"automation": { "id": "wh-1", "name": "Lead n8n" },
"rule": { "when": "onCompleted" },
"request": { "url": "https://hooks.example/lead" },
"response": { "ok": true, "status": 200, "ms": 45, "error": null, "preview": "accepted" }
}
}
]
},
{
"startedAt": "2026-08-26T15:00:00.000Z",
"actor": "auditor",
"meta": { "ip": { "address": "198.51.100.9" } },
"records": [
{
"actor": "auditor", "event": "open", "action": "reopen",
"reason": "idade suspeita",
"startAt": "2026-08-26T15:00:00.000Z",
"endAt": "2026-08-26T15:00:00.000Z"
},
{
"actor": "auditor", "event": "answer", "action": "edit",
"target": { "kind": "question", "id": "idade" },
"from": "18", "to": "17",
"startAt": "2026-08-26T15:00:00.000Z",
"endAt": "2026-08-26T15:01:20.000Z"
}
]
}
]
}
}
Leitura: idade 25→18→17 está na trilha. Auditor acha “Lead n8n” em to.automation.name. Se o 400 vier, to.response.status + preview (sem body do request). stages é last-write: neste retrato motivo permanece em hidden (como no plano). Após o auditor gravar 17, um replay SurveyJS real pode mover motivo para skipped (visível sem resposta).
SurveyIndex
Autoreferência read-only: paths, choices, matrix cells, settings, quotas. Alimenta cascata, { autocomplete, validação pós-edit.
buildSurveyIndex(survey, session?) → {
questions: { [name]: { type, title, value, displayValue, isAnswered, choices|rateValues|rows×cols } },
customs, automations, quotas, filters, session?
}
VarMap
O que Then/templates podem ler/escrever (data.ai_*, customs.*, tokens de e-mail).
Automation
automation {
id, name, enabled,
expression, // when embutido (SurveyJS-compatible paths)
recommendBatch?, // opt-in onCompleted
then: "webhook"|"email"|"redirect"|"pubOff"|"ai",
webhook? | email? | redirect? | ai?: {
sysPrompt, userPrompt, responseFormat, saveTo
// tokens: {question.value} {question.title} {priorData} …
}
}
then: "ai" fixo.
Props SurveyJS na expression: q.P1.isAnswered (condição + gatilho).
QuotaSet
QuotaSet {
id, name,
active, // operacional (contagem/enforcement); espelho legado: locked = !active
schemaSaved, // schema persistido (rows definidas)
context: [vars], // variáveis de controle (perfil)
populationEnabled?, weighting?, weightMode: byTarget|byCompleted,
rows: [{
id, criteria {var:value},
amostra, // editável (UI); espelho legado: target
population?, // editável (opcional)
completed, overlimit, incomplete, filtered, // calculados
status: open|started|full
}]
}
// evento: quota.{setId}.increment | .completa | .overlimit
// when: onContextChange (não onCompleted)
Studio: aba Cota — settings em #envCotaPanel (renderCotaSettings); schema em #modalCotaSchema. Draft local: cotaDraft / cotaSelId.
Catálogo de eventos (When)
Paths canônicos. UI em PT; paths em EN. Aba Automação filtra por ambiente (Form → Cota → Dados → Dash).
Duas origens (não misturar na cabeça do autor)
Emitidos pelo runtime GMR / SurveyJS complete — existem mesmo sem automação. Cotas, filtros (settings.filters + triggers complete), ciclo de sessão. Destino natural: timeline / survey_event_log (proposta).
session.onStarted | session.onCompleted | session.onFiltered
quota.{setId}.rowIncrement | .rowCompleted | .rowOverlimit
// filtro: expression → trigger complete → status filtered
// cota full → status overlimit (não é trigger SurveyJS)
O autor assina um path When e define Then (webhook, IA, redirect, settings…). Persistido em settings.automations[]. Envios: record event: run na trilha (visits). webhookLogs só em sessão legado.
// mesmos paths podem ser When de automação:
survey.onPublished | survey.onSaved
session.onCompleted | session.onFiltered
q.{name}.isAnswered
quota.{setId}.rowIncrement | …
Regra: sistema emite; automação ouve e age. Recurso Filtro/Cota configura o motor; ⚡ na UI cria assinatura de usuário.
Survey (Formulário) — típico usuário
survey.onPublished // Pub no Studio survey.onSaved // Salvar survey (versão)
Session / pergunta (Dados)
session.onStarted // sistema (ciclo)
session.onCompleted // sistema + When comum
session.onFiltered // sistema (screen-out) + When opcional
q.{name}.isAnswered // When realTime (usuário)
q.{name}.value // condição If / expression
Quota (Cota) — sistema; When opcional
quota.{setId}.rowIncrement // alias UI: increment
quota.{setId}.rowCompleted // alias: completa
quota.{setId}.rowOverlimit // alias: overlimit
// disparo motor: onContextChange (não onCompleted)
Dashboard
// publicação: settings.dashboard.sections[leaf].enabled // ACL de abas: settings.painel.sections | tokens[].sections // omitido → ["survey.overview"]; allowedSections = sections ∩ published // tabulation: allowedSections + quotas? + campo? (se leaf permitida) // Campo: totals + bySurveyor|byChannel|byCustom · customDim · UI cards → tabelas dashboard.* / painel.*
Autoref (payload / templates)
// pergunta
{scope.question.value|name|title|displayValue}
{scope.priorData} {q.{name}.value} {field.surveyor|phone|client_id|profile_key} {meta.*} {isTest}
// legado ainda resolve: {customs.pesquisador} → field.surveyor
// cota
{scope.quota.name|status|rows|rows.completed}
// survey / sessão (Pub, Salvar, complete…)
{event} {responseId} {status} {surveyId} {publicName}
Trilha canônica: customs.visits[] (hops + records, inclusive event: run). Sessão nova não cresce webhookLogs. Formato: Trilha (visits). Tabela append-only survey_event_log permanece proposta — draft docs/archive/2026-07-24-eventos-filtros-automacoes-DRAFT.md.
Tabelas Postgres
Schema tipicamente hub.surveyjs — ver docs/product/POSTGRES.md.
- folders, surveys, survey_versions
- sessions (responses)
- jobs
- open_classifications, assets…
Fila jobs
runtime_ai— batch Then=AI no completeclassify_open/classify_applyquality_run
Studio
Pasta → criar survey → editar JSON → Salvar (versão) → Pub → Cota → Respostas/Painel.
Coleta
start (visita system + onStarted) → open /r (hop respondente) → data patches (trilha answer + stages) → complete → run no hop → jobs. Ver trilha.
Cotas
Mudança em context[] → hash rowKey → counter ±1 → automation quota.*.increment|completa.
SurveyJS Form Library
question.value— valor bruto (choices value, rating number, matrix object)question.displayValue— texto exibido (label da choice)question.isAnswered— tem value válidoquestion.title/name
Não usar alias answer nas expressions — preferir value.
Expressions & tokens IA
{q.P1.isAnswered} // condição + gatilho (valueChanged)
{q.P1.value = 'sim'} // value = bruto
{q.P1.displayValue} // label exibido
{q.M1.cell('r','c').value}
{session.onStarted} once
{quota.qs_br.completa} onContextChange
// userPrompt (Then AI) — chips / autocomplete em "{"
{priorData} // respostas já dadas (não o surveyJson)
{question.title}
{question.value}
{question.displayValue}
Mockup Automação
/studio-events-mockup.html — UI playground (não produção).
Agente Studio (LLM)
O agente do rail edita surveyJson e auto-salva draft (skipVersion). Chave OpenAI só no servidor (OPENAI_API_KEY / LLM_MODEL).
- Endpoint:
POST /v1/admin/agent/chat - Status:
GET /v1/admin/agent/status - ≠ IA Runtime (Then AI nas automações /
LLM_RUNTIME_MODEL)
Doc completa no repo: docs/product/AI_AGENTE.md · /docs/ai redireciona para esta seção.
3C Plus — consulta
Orquestrador voz/discador. Fonte: docs/product/3C_PLUS_CONSULTA.md · atalho /docs/3c. Não há POST .../next.
Etapas
- Questionário —
GET /v1/surveys/{surveyId}no início da ligação (cache por id+versão). - Sessão —
POST /v1/public/{publicName}/startcom prefill defield.*. - Estágio atual —
GET /v1/sessions/{responseId}→status+data. Não usarcallStudy.stage. - Próxima pergunta — SurveyJS (ou equivalente) em
surveyJson+data(visibleIf). - Enviar respostas —
POST /v1/sessions/{id}/data(parcialcompleted:falseou finaltrue). - Ligação —
field.surveyor,call_id,audio_url,transcription(aliasescall_url/pesquisador).
callStudy 3C é opcional (boletim de abordagem). Study GMR é camada quality.study sobre áudio/transcrição (CAPI e CATI) — não bloqueia o submit. Ainda intenção; ver o markdown.
Padronização UI — tokens & layout
Studio, Painel e respondente usam HTML/CSS vanilla (sem Tailwind). Referência viva: studio.html.
- Simplicidade funcional — um bloco, um trabalho; evitar label duplicada do botão
- Coesão — mesmos tokens e densidade em todas as telas
- Beleza estrutural — grid, micro-legendas, divisores (não cards soltos)
Tokens :root
--teal: #19b394; --teal-dark: #14967c; --ink: #1a1d21; --muted: #6b7280; --line: #e5e7eb; --bg: #f3f4f6; --card: #fff; --sidebar: #0f172a; --sidebar-text: #e2e8f0; --postit: #fef08a; --postit-ink: #713f12; font: "Segoe UI", system-ui, sans-serif;
Header editor (.flow-blocks)
- Faixa única em grid; blocos
.flow-blockcomborder-right - Micro-legenda
.blk-labelno topo do bloco (uppercase, muted) - Localização: gradiente
#f0fdfa → #fff - Breakpoint ~1100px: 2 colunas; Publicação full width
Botões & estados
.btn-primaryteal ·.btn-icocompactos no header- Salvar dirty: laranja pulsante · ok: teal com ✓
- Tabs/modais: underline teal no ativo
Cópia para agentes: .cursor/skills/layout-mockup-review/references/gmr-layout-standards.md
Mockups de aprovação
Antes de alterar layout em produção, gerar 3 opções HTML nomeadas para aprovação (skill layout-mockup-review).
Pasta: app/public/mockups/
Nome: {contexto}-mockup-opcao-{a|b|c}-{slug}.html
A — evolutiva (mínimo diff)
B — estrutural (hierarquia refinada) ← usual recomendação
C — exploratória (marca GMR, mais ousada)
- Legado: studio-mockup.html, studio-events-mockup.html
- Novos mockups:
/mockups/no mesmo host do Studio
Markdown do repo
docs/product/README.md— índicePOSTGRES.md,CATALOGO.md,COMO_FUNCIONA.md,AI_AGENTE.md,3C_PLUS_CONSULTA.md- Jornada / trilha:
docs/superpowers/specs/2026-08-26-jornada-review-field-design.md· esta página#ent-visits - Entrada única:
/docs