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

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

Visita (hop)

Record (sempre startAt + endAt)

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} …
  }
}
Session IA = mesma tela com 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)

Sistema (motor)

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)
Usuário (automação)

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}
Observabilidade

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.

Fila jobs

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

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

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

  1. QuestionárioGET /v1/surveys/{surveyId} no início da ligação (cache por id+versão).
  2. SessãoPOST /v1/public/{publicName}/start com prefill de field.*.
  3. Estágio atualGET /v1/sessions/{responseId}status + data. Não usar callStudy.stage.
  4. Próxima pergunta — SurveyJS (ou equivalente) em surveyJson + data (visibleIf).
  5. Enviar respostasPOST /v1/sessions/{id}/data (parcial completed:false ou final true).
  6. Ligaçãofield.surveyor, call_id, audio_url, transcription (aliases call_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.

Princípios
  • 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)

Botões & estados

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)

Markdown do repo