commit 06950d6e2c22056e64f5fd8267aad9eaf7d00f7a Author: Luiz Silva Date: Wed Dec 31 11:18:20 2025 -0300 primeira versão do e-li-nps construido com IA diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..efe9b42 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,11 @@ +.git +.gitignore +.env +server + +# IDE +.vscode + +# OS +.DS_Store + diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4c49bd7 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +.env diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..f33bc18 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,44 @@ +# syntax=docker/dockerfile:1 + +# Build stage +FROM golang:1.22-alpine AS build + +WORKDIR /src + +# Dependências do build +RUN apk add --no-cache git ca-certificates + +COPY go.mod go.sum ./ +RUN go mod download + +COPY . . + +# Build do binário +RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \ + go build -trimpath -ldflags="-s -w" -o /out/server ./cmd/server + + +# Runtime stage +FROM alpine:3.20 + +RUN apk add --no-cache ca-certificates tzdata + +WORKDIR /app + +# Binário +COPY --from=build /out/server /app/server + +# Entry point (exige /app/.env montado via volume) +COPY docker-entrypoint.sh /app/docker-entrypoint.sh +RUN chmod +x /app/docker-entrypoint.sh + +# Assets/templates (o servidor lê do filesystem) +COPY web/ /app/web/ +COPY README.md /app/README.md + +# Variáveis default +ENV ADDR=":8080" + +EXPOSE 8080 + +ENTRYPOINT ["/app/docker-entrypoint.sh"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..9d87b46 --- /dev/null +++ b/README.md @@ -0,0 +1,298 @@ +# e-li.nps (Go + HTMX) + +Widget NPS embutível via **1 arquivo JS** + API em Go. + +## Requisitos + +- Go 1.22+ +- PostgreSQL 14+ + +## Variáveis de ambiente + +- `DATABASE_URL` (obrigatória) + - Ex: `postgres://postgres:postgres@localhost:5432/gonps?sslmode=disable` +- `ADDR` (opcional, default `:8080`) +- `SENHA_PAINEL` (opcional) + - Se definida, habilita o painel em `/painel`. + - Se vazia, o painel fica desabilitado. + +### Cache do widget (e-li.nps.js) + +O servidor controla o cache de `/static/e-li.nps.js` via **ETag**. + +- A versão (ETag) é **gerada automaticamente a cada inicialização do servidor**. +- O browser é instruído a **revalidar** (`Cache-Control: no-cache, must-revalidate`), então: + - se o ETag não mudou: o servidor responde `304` (rápido) + - se o ETag mudou: o browser baixa o JS novo automaticamente + +Isso evita problemas de clientes com JS antigo em cache após mudanças. + +### Arquivo `.env` + +O servidor carrega automaticamente um arquivo `.env` na raiz do projeto (se existir) usando `godotenv`. +Isso facilita rodar localmente sem exportar variáveis manualmente. + +Exemplo de `.env`: + +```env +DATABASE_URL='postgres://postgres:postgres@localhost:5432/gonps?sslmode=disable' +ADDR=':8080' +``` + +## Como rodar + +1. Suba um Postgres (exemplo via Docker): + +```bash +docker run --rm -e POSTGRES_PASSWORD=postgres -e POSTGRES_DB=gonps -p 5432:5432 postgres:16 +``` + +2. Rode o server: + +```bash +go run ./cmd/server +``` + +## Rodar com Docker + +Este repositório inclui: +- `Dockerfile` (build multi-stage do binário Go) +- `docker-compose.yml` (apenas o app; Postgres é externo) + +Para subir tudo: + +```bash +docker compose up --build +``` + +Para forçar rebuild da imagem (mesmo sem mudanças detectadas): + +```bash +docker compose build --no-cache && docker compose up +``` + +Para parar a aplicação: + +```bash +docker compose down +``` + +> Importante: +> - O Postgres é **externo**. +> - O arquivo `.env` é **obrigatório** e deve ser passado como **volume** para `/app/.env`. +> - O servidor carrega esse arquivo automaticamente via `godotenv` ao iniciar. +> +> Exemplo (compose): `./.env:/app/.env:ro` + +### Postgres no host (host.docker.internal) + +Se o seu Postgres estiver rodando **no host** (fora do container) e você quiser +que o container acesse via `host.docker.internal`, use no `.env`: + +```env +DATABASE_URL='postgres://usuario:senha@host.docker.internal:5432/seu_banco?sslmode=disable' +``` + +No Linux, o `docker-compose.yml` já inclui `extra_hosts` com `host-gateway` para +esse hostname funcionar. + +Depois acesse: +- Home/README: `http://localhost:8080/` +- Teste do widget: `http://localhost:8080/teste.html` +- Painel: `http://localhost:8080/painel` (senha em `SENHA_PAINEL`) + +Painel: + +- Acesse `http://localhost:8080/painel` +- Você será redirecionado para `/painel/login` + +Healthcheck: + +```bash +curl -i http://localhost:8080/healthz +``` + +## Incluir o widget em outra aplicação + +### Tipagem TypeScript (opcional) + +Se você quiser ter autocomplete e validação de tipos no seu projeto (TS), pode +declarar a interface abaixo: + +```ts +declare global { + interface Window { + ELiNPS: { + init: (opts: ELiNPSInitOptions) => Promise | void; + }; + } +} + +export type ELiNPSInitOptions = { + // apiBase (opcional) + // Base da API do e-li.nps. + // Se o widget estiver sendo servido pelo mesmo host, pode deixar vazio. + apiBase?: string; + + // cooldownHours (opcional) + // Tempo (em horas) de cooldown visual no navegador. + cooldownHours?: number; + + // data_minima_abertura (opcional) + // Bloqueia a abertura do modal antes de uma data. + // Formato ISO (data): YYYY-MM-DD (ex.: "2026-01-01"). + data_minima_abertura?: string; + + // produto_nome (obrigatório) + produto_nome: string; + + // inquilino_codigo (obrigatório) + inquilino_codigo: string; + + // inquilino_nome (obrigatório) + inquilino_nome: string; + + // usuario_codigo (obrigatório) + usuario_codigo: string; + + // usuario_nome (obrigatório) + usuario_nome: string; + + // usuario_telefone (opcional) + usuario_telefone?: string; + + // usuario_email (opcional) + usuario_email?: string; +}; +``` + +```html + + + + +``` + +## Endpoints + +### `POST /api/e-li.nps/pedido` + +```bash +curl -sS -X POST http://localhost:8080/api/e-li.nps/pedido \ + -H 'Content-Type: application/json' \ + -d '{ + "produto_nome":"e-licencie.gov", + "inquilino_codigo":"acme", + "inquilino_nome":"ACME", + "usuario_codigo":"u-123", + "usuario_nome":"Maria", + "usuario_telefone":"+55...", + "usuario_email":"maria@acme.com" + }' +``` + +### `GET /e-li.nps/{produto}/{id}/form` + +Abre o formulário (HTML) para responder/editar. + +### `PATCH /api/e-li.nps/{produto}/{id}` + +```bash +curl -sS -X PATCH http://localhost:8080/api/e-li.nps/elicencie_gov/ \ + -H 'Content-Type: application/json' \ + -d '{"nota":10}' +``` + +Finalizar: + +```bash +curl -sS -X PATCH http://localhost:8080/api/e-li.nps/elicencie_gov/ \ + -H 'Content-Type: application/json' \ + -d '{"justificativa":"muito bom", "finalizar":true}' +``` + +## Observações importantes + +- **Fail-closed**: se a API falhar, o widget não abre o modal. +- **CORS**: liberado com `Access-Control-Allow-Origin: *`. +- **IP real do usuário**: o sistema grava `ip_real` no banco (IPv4/IPv6). + - Para funcionar corretamente atrás de proxy/Docker, garanta que o proxy repasse + `X-Forwarded-For` / `X-Real-IP`. + - O servidor usa `middleware.RealIP` (chi) para resolver o IP antes de gravar. +- **Tabelas por produto**: `nps_{produto}` é criada automaticamente ao ver um `produto_nome` novo. +- O backend **normaliza** `produto_nome` apenas para uso técnico (nome da tabela e rota): + - minúsculo + trim + - remove diacríticos + - converte caracteres fora de `[a-z0-9_]` para `_` + - valida por regex: `^[a-z_][a-z0-9_]*$` +- O nome **exibido ao usuário** é o original informado e fica salvo em `produto_nome` na tabela do produto. +- O controle de exibição (regras 45 dias / 10 dias) é baseado em: **produto + inquilino_codigo + usuario_codigo**. + +## Recomendações (para prompts / manutenção) + +Alguns cuidados: + +- Nomes de variáveis ou arquivos preferencialmente em português +- Sempre adicionar comentários em português que ajudem humanos e IAs na manutenção +- Se a mudança for importante, atualizar `README.md` + +--- + +## Créditos e suporte + +Desenvolvido por **Azteca Software (e-licencie)** para pesquisa de NPS. + +Suporte: **ti@e-licencie.com.br** ou WhatsApp **(48) 9 9948 2983**. diff --git a/cmd/server/main.go b/cmd/server/main.go new file mode 100644 index 0000000..be6ea16 --- /dev/null +++ b/cmd/server/main.go @@ -0,0 +1,159 @@ +package main + +import ( + "context" + "fmt" + "log" + "net/http" + "os" + "os/signal" + "strconv" + "syscall" + "time" + + "github.com/go-chi/chi/v5" + "github.com/go-chi/chi/v5/middleware" + "github.com/joho/godotenv" + + "e-li.nps/internal/db" + elinps "e-li.nps/internal/elinps" +) + +func main() { + // Load .env if present (convenience for local dev). Environment variables + // explicitly set in the OS take precedence. + _ = godotenv.Load() + + cfg := mustLoadConfig() + + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + + pool, err := db.NewPool(ctx, cfg.DatabaseURL) + if err != nil { + log.Fatalf("db connect: %v", err) + } + defer pool.Close() + + // Ensures required extensions exist. + if err := db.EnsurePgcrypto(ctx, pool); err != nil { + log.Fatalf("ensure pgcrypto: %v", err) + } + + r := chi.NewRouter() + r.Use(middleware.RequestID) + r.Use(middleware.RealIP) + r.Use(middleware.Recoverer) + r.Use(middleware.Timeout(15 * time.Second)) + r.Use(middleware.Compress(5)) + + // CORS wildcard + preflight + r.Use(elinps.CORSMiddleware()) + + // Basic limits + r.Use(elinps.MaxBodyBytesMiddleware(64 * 1024)) + + // Health + r.Get("/healthz", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) }) + + // Home: renderiza README.md + // Público (sem senha), para facilitar documentação do serviço. + r.Get("/", elinps.NewReadmePage("README.md").ServeHTTP) + + // Static widget + fileServer := http.FileServer(http.Dir("web/static")) + // Versão do widget para controle de cache. + // + // Regra do projeto: a versão é gerada a cada inicialização do servidor. + // Isso evita que o browser continue usando um gonps.js antigo após uma + // atualização e o usuário final veja comportamento quebrado. + versaoWidget := strconv.FormatInt(time.Now().UnixNano(), 10) + + r.Route("/static", func(r chi.Router) { + r.Get("/e-li.nps.js", func(w http.ResponseWriter, r *http.Request) { + // Estratégia: permitir cache local, mas obrigar revalidação. + // Quando a versão mudar, o ETag muda e o browser baixa o JS novo. + etag := fmt.Sprintf("\"%s\"", versaoWidget) + w.Header().Set("ETag", etag) + w.Header().Set("Cache-Control", "no-cache, must-revalidate") + + // Se o cliente já tem essa versão, evitamos enviar o arquivo novamente. + if r.Header.Get("If-None-Match") == etag { + w.WriteHeader(http.StatusNotModified) + return + } + + http.ServeFile(w, r, "web/static/e-li.nps.js") + }) + r.Handle("/*", http.StripPrefix("/static/", fileServer)) + }) + // Convenience: allow /teste.html + r.Get("/teste.html", func(w http.ResponseWriter, r *http.Request) { + http.ServeFile(w, r, "web/static/teste.html") + }) + + // NPS routes + h := elinps.NewHandlers(pool) + r.Route("/api/e-li.nps", func(r chi.Router) { + r.Post("/pedido", h.PostPedido) + r.Patch("/{produto}/{id}", h.PatchResposta) + }) + + r.Route("/e-li.nps", func(r chi.Router) { + r.Get("/{produto}/{id}/form", h.GetForm) + }) + + // Painel (dashboard) + // Protegido por SENHA_PAINEL. + // Se SENHA_PAINEL estiver vazia, o painel fica desabilitado. + painel := elinps.NewPainelHandlers(pool, cfg.SenhaPainel) + r.Mount("/painel", painel.Router()) + + srv := &http.Server{ + Addr: cfg.Addr, + Handler: r, + ReadHeaderTimeout: 5 * time.Second, + } + + go func() { + log.Printf("listening on %s", cfg.Addr) + if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { + log.Fatalf("listen: %v", err) + } + }() + + <-ctx.Done() + shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + if err := srv.Shutdown(shutdownCtx); err != nil { + log.Printf("shutdown: %v", err) + } + log.Printf("bye") +} + +type config struct { + Addr string + DatabaseURL string + SenhaPainel string +} + +func mustLoadConfig() config { + cfg := config{ + Addr: envOr("ADDR", ":8080"), + DatabaseURL: os.Getenv("DATABASE_URL"), + SenhaPainel: os.Getenv("SENHA_PAINEL"), + } + if cfg.DatabaseURL == "" { + fmt.Fprintln(os.Stderr, "missing DATABASE_URL") + os.Exit(2) + } + return cfg +} + +func envOr(k, def string) string { + v := os.Getenv(k) + if v == "" { + return def + } + return v +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..cb47c81 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,17 @@ +services: + app: + build: . + # Postgres é externo. + # Regra do projeto: o .env deve ser passado APENAS como volume. + # Importante: o compose não lê automaticamente variáveis de um arquivo montado + # dentro do container. Para funcionar, o container carrega /app/.env no startup. + ports: + - "8080:8080" + volumes: + - ./.env:/app/.env:ro + + # Permite acessar serviços no host pelo hostname "host.docker.internal". + # Em Linux, isso exige mapear para o gateway do host. + # (Docker 20.10+) + extra_hosts: + - "host.docker.internal:host-gateway" diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh new file mode 100644 index 0000000..488a272 --- /dev/null +++ b/docker-entrypoint.sh @@ -0,0 +1,24 @@ +#!/bin/sh +set -eu + +# Entrada do container. +# +# Regra do projeto: o arquivo .env deve ser montado como volume em /app/.env. +# Ele é obrigatório, pois contém DATABASE_URL e outras variáveis. + +if [ ! -f "/app/.env" ]; then + echo "ERRO: arquivo /app/.env não encontrado. Monte o .env como volume no container." >&2 + echo "Exemplo (compose): volumes: - ./.env:/app/.env:ro" >&2 + exit 2 +fi + +# Carrega variáveis do /app/.env para o ambiente do processo. +# +# Observações: +# - Isso faz o papel do "env_file" do compose. +# - Mantemos simples: lê linhas no formato KEY=VALOR (sem export explícito). +set -a +. /app/.env +set +a + +exec /app/server diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..6cfa5d6 --- /dev/null +++ b/go.mod @@ -0,0 +1,20 @@ +module e-li.nps + +go 1.22 + +require ( + github.com/go-chi/chi/v5 v5.1.0 + github.com/jackc/pgx/v5 v5.7.1 + github.com/joho/godotenv v1.5.1 +) + +require github.com/yuin/goldmark v1.7.4 // indirect + +require ( + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/puddle/v2 v2.2.2 // indirect + golang.org/x/crypto v0.27.0 // indirect + golang.org/x/sync v0.8.0 // indirect + golang.org/x/text v0.18.0 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..71a1a7d --- /dev/null +++ b/go.sum @@ -0,0 +1,34 @@ +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/go-chi/chi/v5 v5.1.0 h1:acVI1TYaD+hhedDJ3r54HyA6sExp3HfXq7QWEEY/xMw= +github.com/go-chi/chi/v5 v5.1.0/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.7.1 h1:x7SYsPBYDkHDksogeSmZZ5xzThcTgRz++I5E+ePFUcs= +github.com/jackc/pgx/v5 v5.7.1/go.mod h1:e7O26IywZZ+naJtWWos6i6fvWK+29etgITqrqHLfoZA= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/yuin/goldmark v1.7.4 h1:BDXOHExt+A7gwPCJgPIIq7ENvceR7we7rOS9TNoLZeg= +github.com/yuin/goldmark v1.7.4/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E= +golang.org/x/crypto v0.27.0 h1:GXm2NjJrPaiv/h1tb2UH8QfgC/hOf/+z0p6PT8o1w7A= +golang.org/x/crypto v0.27.0/go.mod h1:1Xngt8kV6Dvbssa53Ziq6Eqn0HqbZi5Z6R0ZpwQzt70= +golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ= +golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/text v0.18.0 h1:XvMDiNzPAl0jr17s6W9lcaIhGUfUORdGCNsuLmPG224= +golang.org/x/text v0.18.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/db/pool.go b/internal/db/pool.go new file mode 100644 index 0000000..cf3cbb7 --- /dev/null +++ b/internal/db/pool.go @@ -0,0 +1,35 @@ +package db + +import ( + "context" + "fmt" + "time" + + "github.com/jackc/pgx/v5/pgxpool" +) + +func NewPool(ctx context.Context, databaseURL string) (*pgxpool.Pool, error) { + cfg, err := pgxpool.ParseConfig(databaseURL) + if err != nil { + return nil, fmt.Errorf("parse DATABASE_URL: %w", err) + } + + // Reasonable defaults + cfg.MaxConns = 10 + cfg.MinConns = 0 + cfg.MaxConnLifetime = 60 * time.Minute + cfg.MaxConnIdleTime = 10 * time.Minute + + pool, err := pgxpool.NewWithConfig(ctx, cfg) + if err != nil { + return nil, err + } + + ctxPing, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + if err := pool.Ping(ctxPing); err != nil { + pool.Close() + return nil, err + } + return pool, nil +} diff --git a/internal/db/schema.go b/internal/db/schema.go new file mode 100644 index 0000000..1517f8a --- /dev/null +++ b/internal/db/schema.go @@ -0,0 +1,133 @@ +package db + +import ( + "context" + "fmt" + "regexp" + "strings" + "unicode" + + "github.com/jackc/pgx/v5/pgxpool" + "golang.org/x/text/unicode/norm" +) + +var produtoRe = regexp.MustCompile(`^[a-z_][a-z0-9_]*$`) + +// NormalizeProduto normaliza e valida um nome de produto para uso em: +// - nomes de tabela no Postgres (prefixo nps_) +// - rotas/URLs (parâmetro {produto}) +// +// Regras: +// - minúsculo + trim +// - remove diacríticos +// - converte qualquer caractere fora de [a-z0-9_] para '_' +// - colapsa '_' repetidos +// - valida contra regex e tamanho máximo de identificador +// +// Importante: isso NÃO é usado para exibição ao usuário. +func NormalizeProduto(produtoNome string) (string, error) { + p := strings.ToLower(strings.TrimSpace(produtoNome)) + if p == "" { + return "", fmt.Errorf("produto invalido") + } + + // Remove diacritics (NFD + strip marks) + p = norm.NFD.String(p) + p = strings.Map(func(r rune) rune { + if unicode.Is(unicode.Mn, r) { + return -1 + } + return r + }, p) + + // Replace anything not allowed with underscore + p = strings.Map(func(r rune) rune { + switch { + case r >= 'a' && r <= 'z': + return r + case r >= '0' && r <= '9': + return r + case r == '_': + return r + default: + return '_' + } + }, p) + + // Collapse underscores + for strings.Contains(p, "__") { + p = strings.ReplaceAll(p, "__", "_") + } + p = strings.Trim(p, "_") + + // Postgres identifiers are max 63 chars. Table name is "nps_" + produto. + if len(p) > 59 { + return "", fmt.Errorf("produto invalido") + } + + if !produtoRe.MatchString(p) { + return "", fmt.Errorf("produto invalido") + } + return p, nil +} + +func TableNameForProduto(produto string) string { + return "nps_" + produto +} + +func EnsurePgcrypto(ctx context.Context, pool *pgxpool.Pool) error { + _, err := pool.Exec(ctx, `CREATE EXTENSION IF NOT EXISTS pgcrypto`) + return err +} + +// EnsureNPSTable creates the per-product table + indexes if they do not exist. +// IMPORTANT: tableName must be created from a sanitized product name. +func EnsureNPSTable(ctx context.Context, pool *pgxpool.Pool, tableName string) error { + // Identifiers cannot be passed as $1 parameters, so we must interpolate. + // Safety: tableName is strictly derived from NormalizeProduto + prefix. + q := fmt.Sprintf(` +CREATE TABLE IF NOT EXISTS %s ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + -- Nome do produto como informado pela integração/widget. + -- Importante: NÃO é usado para nome de tabela; é apenas para exibição. + produto_nome text NOT NULL DEFAULT '', + inquilino_codigo text NOT NULL, + inquilino_nome text NOT NULL, + usuario_codigo text, + usuario_nome text NOT NULL, + usuario_email text, + usuario_telefone text, + status text NOT NULL CHECK (status IN ('pedido','respondido')), + pedido_criado_em timestamptz NOT NULL DEFAULT now(), + respondido_em timestamptz NULL, + atualizado_em timestamptz NOT NULL DEFAULT now(), + nota int NULL CHECK (nota BETWEEN 1 AND 10), + justificativa text NULL, + valida bool NOT NULL DEFAULT true, + origem text NOT NULL DEFAULT 'widget_iframe', + user_agent text NULL, + -- IP real do usuário (após middleware RealIP). Pode conter IPv4 ou IPv6. + -- Importante: quando rodar atrás de proxy (ex.: Docker + Nginx/Traefik), + -- garanta que o proxy repasse X-Forwarded-For/X-Real-IP. + ip_real text NULL +); + +ALTER TABLE %s ADD COLUMN IF NOT EXISTS usuario_codigo text; +ALTER TABLE %s ADD COLUMN IF NOT EXISTS produto_nome text NOT NULL DEFAULT ''; +ALTER TABLE %s ADD COLUMN IF NOT EXISTS ip_real text; + +-- NOTE: controle de exibição é por (produto + inquilino_codigo + usuario_codigo) +-- então os índices são baseados em usuario_codigo. + +CREATE INDEX IF NOT EXISTS idx_nps_resp_recente_%s + ON %s (inquilino_codigo, usuario_codigo, respondido_em DESC) + WHERE status='respondido' AND valida=true; + +CREATE INDEX IF NOT EXISTS idx_nps_pedido_aberto_%s + ON %s (inquilino_codigo, usuario_codigo, pedido_criado_em DESC) + WHERE status='pedido'; +`, tableName, tableName, tableName, tableName, tableName, tableName, tableName, tableName) + + _, err := pool.Exec(ctx, q) + return err +} diff --git a/internal/elinps/handlers.go b/internal/elinps/handlers.go new file mode 100644 index 0000000..2c374d3 --- /dev/null +++ b/internal/elinps/handlers.go @@ -0,0 +1,183 @@ +package elinps + +import ( + "encoding/json" + "errors" + "net/http" + "strings" + + "e-li.nps/internal/db" + + "github.com/go-chi/chi/v5" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" +) + +type Handlers struct { + store *Store + tpl *TemplateRenderer +} + +func NewHandlers(pool *pgxpool.Pool) *Handlers { + return &Handlers{ + store: NewStore(pool), + tpl: NewTemplateRenderer(mustParseTemplates()), + } +} + +func (h *Handlers) PostPedido(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + var in PedidoInput + if err := json.NewDecoder(r.Body).Decode(&in); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]any{"error": "json_invalido"}) + return + } + if err := ValidatePedidoInput(&in); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]any{"error": err.Error()}) + return + } + + // Ensure per-product table exists (also normalizes produto). + table, err := h.store.EnsureTableForProduto(ctx, in.ProdutoNome) + if err != nil { + writeJSON(w, http.StatusBadRequest, map[string]any{"error": "produto_invalido"}) + return + } + + // Keep normalized form for the widget to build URLs safely. + // table = "nps_" + produto_normalizado + produtoNormalizado := strings.TrimPrefix(table, "nps_") + + // Rules + respRecente, err := h.store.HasRespostaValidaRecente(ctx, table, in.InquilinoCodigo, in.UsuarioCodigo) + if err != nil { + // Fail-closed + writeJSON(w, http.StatusOK, PedidoResponse{PodeAbrir: false, Motivo: "erro"}) + return + } + if respRecente { + writeJSON(w, http.StatusOK, PedidoResponse{PodeAbrir: false, Motivo: "resposta_recente"}) + return + } + + pedidoAberto, err := h.store.HasPedidoEmAbertoRecente(ctx, table, in.InquilinoCodigo, in.UsuarioCodigo) + if err != nil { + writeJSON(w, http.StatusOK, PedidoResponse{PodeAbrir: false, Motivo: "erro"}) + return + } + if pedidoAberto { + writeJSON(w, http.StatusOK, PedidoResponse{PodeAbrir: false, Motivo: "pedido_em_aberto"}) + return + } + + id, err := h.store.CreatePedido(ctx, table, in, r) + if err != nil { + writeJSON(w, http.StatusOK, PedidoResponse{PodeAbrir: false, Motivo: "erro"}) + return + } + + writeJSON(w, http.StatusOK, map[string]any{"pode_abrir": true, "id": id, "produto": produtoNormalizado}) +} + +func (h *Handlers) PatchResposta(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + produtoParam := chi.URLParam(r, "produto") + id := chi.URLParam(r, "id") + + // produtoParam already in path; sanitize again. + prod, err := db.NormalizeProduto(produtoParam) + if err != nil { + writeJSON(w, http.StatusBadRequest, map[string]any{"error": "produto_invalido"}) + return + } + table := db.TableNameForProduto(prod) + if err := db.EnsureNPSTable(ctx, h.store.pool, table); err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]any{"error": "db"}) + return + } + + var in PatchInput + if err := json.NewDecoder(r.Body).Decode(&in); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]any{"error": "json_invalido"}) + return + } + if err := ValidatePatchInput(&in); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]any{"error": err.Error()}) + return + } + + if in.Nota == nil && in.Justificativa == nil && !in.Finalizar { + writeJSON(w, http.StatusBadRequest, map[string]any{"error": "nada_para_atualizar"}) + return + } + + if err := h.store.PatchRegistro(ctx, table, id, in); err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]any{"error": "db"}) + return + } + + // If called via HTMX, respond with refreshed HTML fragment. + if r.Header.Get("HX-Request") == "true" { + reg, err := h.store.GetRegistro(ctx, table, id) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + w.Write([]byte("db")) + return + } + data := FormPageData{Produto: prod, ID: id, Reg: reg} + w.Header().Set("Content-Type", "text/html; charset=utf-8") + h.tpl.Render(w, "form_inner.html", data) + return + } + + writeJSON(w, http.StatusOK, map[string]any{"ok": true}) +} + +func (h *Handlers) GetForm(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + produtoParam := chi.URLParam(r, "produto") + id := chi.URLParam(r, "id") + + prod, err := db.NormalizeProduto(produtoParam) + if err != nil { + w.WriteHeader(http.StatusBadRequest) + w.Write([]byte("produto invalido")) + return + } + table := db.TableNameForProduto(prod) + if err := db.EnsureNPSTable(ctx, h.store.pool, table); err != nil { + w.WriteHeader(http.StatusInternalServerError) + w.Write([]byte("db")) + return + } + + reg, err := h.store.GetRegistro(ctx, table, id) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + w.WriteHeader(http.StatusNotFound) + w.Write([]byte("nao encontrado")) + return + } + w.WriteHeader(http.StatusInternalServerError) + w.Write([]byte("db")) + return + } + + data := FormPageData{ + Produto: prod, + ID: id, + Reg: reg, + } + + // Always return a standalone HTML page so the widget can use iframe. + // But the inner container is also HTMX-friendly (it swaps itself). + w.Header().Set("Content-Type", "text/html; charset=utf-8") + h.tpl.Render(w, "form_page.html", data) +} + +func writeJSON(w http.ResponseWriter, status int, v any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(v) +} diff --git a/internal/elinps/middleware.go b/internal/elinps/middleware.go new file mode 100644 index 0000000..3884ce0 --- /dev/null +++ b/internal/elinps/middleware.go @@ -0,0 +1,28 @@ +package elinps + +import "net/http" + +func CORSMiddleware() func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Access-Control-Allow-Origin", "*") + w.Header().Set("Access-Control-Allow-Methods", "GET,POST,PATCH,OPTIONS") + w.Header().Set("Access-Control-Allow-Headers", "Content-Type, HX-Request") + w.Header().Set("Access-Control-Max-Age", "600") + if r.Method == http.MethodOptions { + w.WriteHeader(http.StatusNoContent) + return + } + next.ServeHTTP(w, r) + }) + } +} + +func MaxBodyBytesMiddleware(n int64) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + r.Body = http.MaxBytesReader(w, r.Body, n) + next.ServeHTTP(w, r) + }) + } +} diff --git a/internal/elinps/models.go b/internal/elinps/models.go new file mode 100644 index 0000000..5549c5c --- /dev/null +++ b/internal/elinps/models.go @@ -0,0 +1,40 @@ +package elinps + +import "time" + +type PedidoInput struct { + ProdutoNome string `json:"produto_nome"` + InquilinoCodigo string `json:"inquilino_codigo"` + InquilinoNome string `json:"inquilino_nome"` + UsuarioCodigo string `json:"usuario_codigo"` + UsuarioNome string `json:"usuario_nome"` + UsuarioTelefone string `json:"usuario_telefone"` + UsuarioEmail string `json:"usuario_email"` +} + +type PedidoResponse struct { + PodeAbrir bool `json:"pode_abrir"` + Motivo string `json:"motivo,omitempty"` + ID string `json:"id,omitempty"` +} + +type PatchInput struct { + Nota *int `json:"nota,omitempty"` + Justificativa *string `json:"justificativa,omitempty"` + Finalizar bool `json:"finalizar,omitempty"` +} + +type Registro struct { + // ProdutoNome é o nome original do produto como enviado pela integração/widget. + // Ele existe apenas para exibição ao usuário. + // + // Importante: a normalização (remoção de acentos/símbolos) é usada apenas + // para formar o nome da tabela no Postgres e o parâmetro {produto} da rota. + ProdutoNome string + ID string + Status string + Nota *int + Justificativa *string + PedidoCriadoEm time.Time + RespondidoEm *time.Time +} diff --git a/internal/elinps/painel.go b/internal/elinps/painel.go new file mode 100644 index 0000000..90f47cc --- /dev/null +++ b/internal/elinps/painel.go @@ -0,0 +1,319 @@ +package elinps + +import ( + "crypto/subtle" + "fmt" + "html/template" + "net/http" + "net/url" + "strings" + "time" + + "e-li.nps/internal/db" + "github.com/jackc/pgx/v5" +) + +// Proteção simples do painel administrativo. +// +// Objetivo: bloquear acesso ao painel com uma senha definida no .env. +// Implementação: cookie assinado de forma simples (token aleatório por boot). +// +// Observação: é propositalmente simples (sem banco) para manter o projeto leve. +// Se precisar evoluir depois, podemos trocar por JWT ou sessão persistida. +type AuthPainel struct { + Senha string + Token string +} + +func (a AuthPainel) handlerLoginPost(w http.ResponseWriter, r *http.Request) { + if !a.habilitado() { + w.WriteHeader(http.StatusUnauthorized) + w.Write([]byte("painel desabilitado")) + return + } + if err := r.ParseForm(); err != nil { + w.WriteHeader(http.StatusBadRequest) + return + } + senha := r.FormValue("senha") + if subtle.ConstantTimeCompare([]byte(senha), []byte(a.Senha)) != 1 { + w.WriteHeader(http.StatusUnauthorized) + w.Write([]byte("senha invalida")) + return + } + + http.SetCookie(w, &http.Cookie{ + Name: a.cookieName(), + Value: a.Token, + Path: "/", + HttpOnly: true, + SameSite: http.SameSiteLaxMode, + // Secure deve ser true em produção com HTTPS. + Secure: false, + // Expira em 24h (relogin simples). + Expires: time.Now().Add(24 * time.Hour), + }) + + http.Redirect(w, r, "/painel", http.StatusFound) +} + +// NPSMensal representa o cálculo do NPS agregado por mês. +type NPSMensal struct { + Mes string + Detratores int + Neutros int + Promotores int + Total int + NPS int +} + +// RespostaPainel representa uma resposta para listagem no painel. +type RespostaPainel struct { + ID string + RespondidoEm *time.Time + PedidoCriadoEm time.Time + UsuarioCodigo *string + UsuarioNome string + UsuarioEmail *string + Nota *int + Justificativa *string +} + +type PainelDados struct { + Produto string + Produtos []string + Meses []NPSMensal + Respostas []RespostaPainel + Pagina int + SomenteBaixas bool + MsgErro string +} + +func (a AuthPainel) handlerPainel(w http.ResponseWriter, r *http.Request, store *Store) { + ctx := r.Context() + + // Query params + produto := r.URL.Query().Get("produto") + pagina := 1 + if p := r.URL.Query().Get("pagina"); p != "" { + // best-effort parse + _, _ = fmt.Sscanf(p, "%d", &pagina) + if pagina <= 0 { + pagina = 1 + } + } + somenteBaixas := r.URL.Query().Get("baixas") == "1" + + produtos, err := store.ListarProdutos(ctx) + if err != nil { + a.renderPainelHTML(w, PainelDados{MsgErro: "erro ao listar produtos"}) + return + } + if produto == "" && len(produtos) > 0 { + produto = produtos[0] + } + + dados := PainelDados{Produto: produto, Produtos: produtos, Pagina: pagina, SomenteBaixas: somenteBaixas} + if produto == "" { + a.renderPainelHTML(w, dados) + return + } + + // tabela segura + prodNorm, err := db.NormalizeProduto(produto) + if err != nil { + dados.MsgErro = "produto inválido" + a.renderPainelHTML(w, dados) + return + } + tabela := db.TableNameForProduto(prodNorm) + if err := db.EnsureNPSTable(ctx, store.poolRef(), tabela); err != nil { + dados.MsgErro = "erro ao garantir tabela" + a.renderPainelHTML(w, dados) + return + } + + meses, err := store.NPSMesAMes(ctx, tabela, 12) + if err != nil { + dados.MsgErro = "erro ao calcular NPS" + a.renderPainelHTML(w, dados) + return + } + dados.Meses = meses + + respostas, err := store.ListarRespostas(ctx, tabela, ListarRespostasFiltro{SomenteNotasBaixas: somenteBaixas, Pagina: pagina, PorPagina: 50}) + if err != nil { + // Se a tabela ainda não tem coluna ip_real/ etc, EnsureNPSTable deveria ajustar. + if err == pgx.ErrNoRows { + respostas = []RespostaPainel{} + } else { + dados.MsgErro = "erro ao listar respostas" + } + } + dados.Respostas = respostas + + a.renderPainelHTML(w, dados) +} + +func (a AuthPainel) renderPainelHTML(w http.ResponseWriter, d PainelDados) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + + // HTML propositalmente simples (sem template engine) para manter isolado. + // Se quiser evoluir, dá pra migrar para templates. + var b strings.Builder + b.WriteString("") + b.WriteString("e-li.nps • Painel") + b.WriteString(``) + + b.WriteString("
") + b.WriteString("

e-li.nps • Painel

") + if d.MsgErro != "" { + b.WriteString("

" + template.HTMLEscapeString(d.MsgErro) + "

") + } + b.WriteString("
") + b.WriteString("") + b.WriteString("") + chk := "" + if d.SomenteBaixas { + chk = "checked" + } + b.WriteString("") + b.WriteString("") + b.WriteString("
") + b.WriteString("
") + + // NPS mês a mês + b.WriteString("

NPS mês a mês

") + b.WriteString("") + for _, m := range d.Meses { + b.WriteString(fmt.Sprintf("", + template.HTMLEscapeString(m.Mes), m.Detratores, m.Neutros, m.Promotores, m.Total, m.NPS)) + } + b.WriteString("
MêsDetratoresNeutrosPromotoresTotalNPS
%s%d%d%d%d%d
") + + // Respostas + b.WriteString("

Respostas

") + b.WriteString("") + for _, r := range d.Respostas { + data := "-" + if r.RespondidoEm != nil { + data = r.RespondidoEm.Format("2006-01-02 15:04") + } + nota := "-" + if r.Nota != nil { + nota = fmt.Sprintf("%d", *r.Nota) + } + usuario := template.HTMLEscapeString(r.UsuarioNome) + if r.UsuarioCodigo != nil { + usuario += " (" + template.HTMLEscapeString(*r.UsuarioCodigo) + ")" + } + coment := "" + if r.Justificativa != nil { + coment = template.HTMLEscapeString(*r.Justificativa) + } + b.WriteString("") + } + b.WriteString("
DataNotaUsuárioComentário
" + template.HTMLEscapeString(data) + "" + template.HTMLEscapeString(nota) + "" + usuario + "" + coment + "
") + + // Navegação + base := "/painel?produto=" + url.QueryEscape(d.Produto) + if d.SomenteBaixas { + base += "&baixas=1" + } + prev := d.Pagina - 1 + if prev < 1 { + prev = 1 + } + next := d.Pagina + 1 + b.WriteString("
") + b.WriteString("Anterior") + b.WriteString("Página " + fmt.Sprintf("%d", d.Pagina) + "") + b.WriteString("Próxima") + b.WriteString("
") + + b.WriteString("
") + + b.WriteString("") + w.Write([]byte(b.String())) +} + +func (a AuthPainel) habilitado() bool { + return a.Senha != "" && a.Token != "" +} + +func (a AuthPainel) cookieName() string { return "eli_nps_painel" } + +func (a AuthPainel) isAutenticado(r *http.Request) bool { + c, err := r.Cookie(a.cookieName()) + if err != nil { + return false + } + return subtle.ConstantTimeCompare([]byte(c.Value), []byte(a.Token)) == 1 +} + +func (a AuthPainel) middleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !a.habilitado() { + w.WriteHeader(http.StatusUnauthorized) + w.Write([]byte("painel desabilitado")) + return + } + if a.isAutenticado(r) { + next.ServeHTTP(w, r) + return + } + http.Redirect(w, r, "/painel/login", http.StatusFound) + }) +} + +func (a AuthPainel) handlerLoginGet(w http.ResponseWriter, r *http.Request) { + // HTML mínimo para evitar dependências. + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.Write([]byte(` + + + + + e-li.nps • Painel + + + +
+

e-li.nps • Painel

+

Acesso protegido por senha (SENHA_PAINEL).

+
+ + + +
+
+ +`)) +} + +// (handlerLoginPost duplicado removido) diff --git a/internal/elinps/painel_handlers.go b/internal/elinps/painel_handlers.go new file mode 100644 index 0000000..fd10066 --- /dev/null +++ b/internal/elinps/painel_handlers.go @@ -0,0 +1,51 @@ +package elinps + +import ( + "crypto/rand" + "encoding/hex" + "net/http" + + "github.com/go-chi/chi/v5" + "github.com/jackc/pgx/v5/pgxpool" +) + +// PainelHandlers expõe o painel de exploração em /painel. +// +// O painel é protegido por senha via SENHA_PAINEL. +// A sessão é um cookie simples com token gerado a cada inicialização. +type PainelHandlers struct { + auth AuthPainel + store *Store +} + +func NewPainelHandlers(pool *pgxpool.Pool, senha string) *PainelHandlers { + token := gerarTokenPainel() + return &PainelHandlers{ + auth: AuthPainel{Senha: senha, Token: token}, + store: NewStore(pool), + } +} + +// Router monta as rotas do painel. +func (p *PainelHandlers) Router() http.Handler { + r := chi.NewRouter() + + // Login + r.Get("/login", p.auth.handlerLoginGet) + r.Post("/login", p.auth.handlerLoginPost) + + // Dashboard + r.With(func(next http.Handler) http.Handler { return p.auth.middleware(next) }).Get("/", func(w http.ResponseWriter, r *http.Request) { + p.auth.handlerPainel(w, r, p.store) + }) + + return r +} + +func gerarTokenPainel() string { + // Token aleatório para o cookie do painel. + // Importante: muda a cada boot (ao reiniciar o servidor, precisa logar de novo). + b := make([]byte, 32) + _, _ = rand.Read(b) + return hex.EncodeToString(b) +} diff --git a/internal/elinps/painel_queries.go b/internal/elinps/painel_queries.go new file mode 100644 index 0000000..0967164 --- /dev/null +++ b/internal/elinps/painel_queries.go @@ -0,0 +1,157 @@ +package elinps + +import ( + "context" + "fmt" + "strings" + "time" + + "github.com/jackc/pgx/v5" +) + +// ListarProdutos retorna os produtos existentes a partir das tabelas `nps_*`. +// +// Importante: este painel é para exploração interna. Mesmo assim, mantemos uma +// sanitização mínima no nome (prefixo nps_ removido). +func (s *Store) ListarProdutos(ctx context.Context) ([]string, error) { + rows, err := s.pool.Query(ctx, ` +SELECT tablename +FROM pg_catalog.pg_tables +WHERE schemaname='public' AND tablename LIKE 'nps_%' +ORDER BY tablename`) + if err != nil { + return nil, err + } + defer rows.Close() + + produtos := []string{} + for rows.Next() { + var t string + if err := rows.Scan(&t); err != nil { + return nil, err + } + produtos = append(produtos, strings.TrimPrefix(t, "nps_")) + } + return produtos, rows.Err() +} + +// NPSMesAMes calcula o NPS por mês para um produto (tabela `nps_{produto}`). +// +// Regra NPS (1–10): +// - 1–6 detratores +// - 7–8 neutros +// - 9–10 promotores +func (s *Store) NPSMesAMes(ctx context.Context, tabela string, meses int) ([]NPSMensal, error) { + // Segurança: tabela deve ser derivada de NormalizeProduto + prefixo. + q := fmt.Sprintf(` +WITH base AS ( + SELECT + date_trunc('month', respondido_em) AS mes, + nota + FROM %s + WHERE status='respondido' + AND valida=true + AND respondido_em IS NOT NULL + AND respondido_em >= date_trunc('month', now()) - ($1::int * interval '1 month') +) +SELECT + to_char(mes, 'YYYY-MM') AS mes, + SUM(CASE WHEN nota BETWEEN 1 AND 6 THEN 1 ELSE 0 END)::int AS detratores, + SUM(CASE WHEN nota BETWEEN 7 AND 8 THEN 1 ELSE 0 END)::int AS neutros, + SUM(CASE WHEN nota BETWEEN 9 AND 10 THEN 1 ELSE 0 END)::int AS promotores, + COUNT(*)::int AS total +FROM base +GROUP BY mes +ORDER BY mes ASC`, tabela) + + rows, err := s.pool.Query(ctx, q, meses) + if err != nil { + return nil, err + } + defer rows.Close() + + out := []NPSMensal{} + for rows.Next() { + var m NPSMensal + if err := rows.Scan(&m.Mes, &m.Detratores, &m.Neutros, &m.Promotores, &m.Total); err != nil { + return nil, err + } + if m.Total > 0 { + pctProm := float64(m.Promotores) / float64(m.Total) * 100 + pctDet := float64(m.Detratores) / float64(m.Total) * 100 + m.NPS = int((pctProm - pctDet) + 0.5) // arredonda para inteiro + } + out = append(out, m) + } + return out, rows.Err() +} + +type ListarRespostasFiltro struct { + SomenteNotasBaixas bool + Pagina int + PorPagina int +} + +func (f *ListarRespostasFiltro) normalizar() { + if f.Pagina <= 0 { + f.Pagina = 1 + } + if f.PorPagina <= 0 || f.PorPagina > 200 { + f.PorPagina = 50 + } +} + +// ListarRespostas retorna respostas respondidas, com paginação e filtro. +func (s *Store) ListarRespostas(ctx context.Context, tabela string, filtro ListarRespostasFiltro) ([]RespostaPainel, error) { + filtro.normalizar() + offset := (filtro.Pagina - 1) * filtro.PorPagina + + cond := "status='respondido' AND valida=true" + if filtro.SomenteNotasBaixas { + cond += " AND nota BETWEEN 1 AND 6" + } + + q := fmt.Sprintf(` +SELECT + id, + respondido_em, + pedido_criado_em, + usuario_codigo, + usuario_nome, + usuario_email, + nota, + justificativa +FROM %s +WHERE %s +ORDER BY respondido_em DESC NULLS LAST +LIMIT $1 OFFSET $2`, tabela, cond) + + rows, err := s.pool.Query(ctx, q, filtro.PorPagina, offset) + if err != nil { + return nil, err + } + defer rows.Close() + + respostas := []RespostaPainel{} + for rows.Next() { + var r RespostaPainel + if err := rows.Scan( + &r.ID, + &r.RespondidoEm, + &r.PedidoCriadoEm, + &r.UsuarioCodigo, + &r.UsuarioNome, + &r.UsuarioEmail, + &r.Nota, + &r.Justificativa, + ); err != nil { + return nil, err + } + respostas = append(respostas, r) + } + return respostas, rows.Err() +} + +// ensure interface imports +var _ = pgx.ErrNoRows +var _ = time.Second diff --git a/internal/elinps/queries.go b/internal/elinps/queries.go new file mode 100644 index 0000000..134cb6a --- /dev/null +++ b/internal/elinps/queries.go @@ -0,0 +1,154 @@ +package elinps + +import ( + "context" + "fmt" + "net" + "net/http" + "time" + + "e-li.nps/internal/db" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" +) + +type Store struct { + pool *pgxpool.Pool +} + +func NewStore(pool *pgxpool.Pool) *Store { return &Store{pool: pool} } + +func (s *Store) poolRef() *pgxpool.Pool { return s.pool } + +func ipReal(r *http.Request) string { + // IP real do cliente. + // + // Importante: + // - No servidor, usamos middleware.RealIP (chi) que resolve o IP considerando + // headers comuns de proxy (X-Forwarded-For / X-Real-IP). + // - Aqui usamos o r.RemoteAddr já processado e extraímos apenas o host. + // - Se não for possível parsear, retornamos vazio. + ip := r.RemoteAddr + if host, _, err := net.SplitHostPort(ip); err == nil { + ip = host + } + if net.ParseIP(ip) == nil { + return "" + } + return ip +} + +func (s *Store) EnsureTableForProduto(ctx context.Context, produtoNome string) (table string, err error) { + prod, err := db.NormalizeProduto(produtoNome) + if err != nil { + return "", err + } + table = db.TableNameForProduto(prod) + if err := db.EnsureNPSTable(ctx, s.pool, table); err != nil { + return "", err + } + return table, nil +} + +func (s *Store) HasRespostaValidaRecente(ctx context.Context, table, inquilinoCodigo, usuarioCodigo string) (bool, error) { + q := fmt.Sprintf(` +SELECT 1 +FROM %s +WHERE inquilino_codigo=$1 AND usuario_codigo=$2 + AND status='respondido' AND valida=true + AND respondido_em >= now() - interval '45 days' +LIMIT 1`, table) + + var one int + err := s.pool.QueryRow(ctx, q, inquilinoCodigo, usuarioCodigo).Scan(&one) + if err == pgx.ErrNoRows { + return false, nil + } + if err != nil { + return false, err + } + return true, nil +} + +func (s *Store) HasPedidoEmAbertoRecente(ctx context.Context, table, inquilinoCodigo, usuarioCodigo string) (bool, error) { + q := fmt.Sprintf(` +SELECT 1 +FROM %s +WHERE inquilino_codigo=$1 AND usuario_codigo=$2 + AND status='pedido' + AND pedido_criado_em >= now() - interval '10 days' +LIMIT 1`, table) + var one int + err := s.pool.QueryRow(ctx, q, inquilinoCodigo, usuarioCodigo).Scan(&one) + if err == pgx.ErrNoRows { + return false, nil + } + if err != nil { + return false, err + } + return true, nil +} + +func (s *Store) CreatePedido(ctx context.Context, table string, in PedidoInput, r *http.Request) (string, error) { + q := fmt.Sprintf(` +INSERT INTO %s ( + produto_nome, + inquilino_codigo, inquilino_nome, + usuario_codigo, usuario_nome, usuario_email, usuario_telefone, + status, origem, user_agent, ip_real +) VALUES ($1,$2,$3,$4,$5,$6,$7,'pedido','widget_iframe',$8,$9) +RETURNING id`, table) + + var id string + err := s.pool.QueryRow(ctx, q, + in.ProdutoNome, + in.InquilinoCodigo, in.InquilinoNome, + in.UsuarioCodigo, in.UsuarioNome, in.UsuarioEmail, in.UsuarioTelefone, + r.UserAgent(), ipReal(r), + ).Scan(&id) + return id, err +} + +func (s *Store) GetRegistro(ctx context.Context, table, id string) (Registro, error) { + q := fmt.Sprintf(` +SELECT id, produto_nome, status, nota, justificativa, pedido_criado_em, respondido_em +FROM %s +WHERE id=$1`, table) + + var reg Registro + err := s.pool.QueryRow(ctx, q, id).Scan( + ®.ID, ®.ProdutoNome, ®.Status, ®.Nota, ®.Justificativa, ®.PedidoCriadoEm, ®.RespondidoEm, + ) + return reg, err +} + +func (s *Store) PatchRegistro(ctx context.Context, table, id string, in PatchInput) error { + // UPDATE único com campos opcionais. + q := fmt.Sprintf(` +UPDATE %s +SET + nota = COALESCE($2, nota), + justificativa = COALESCE($3, justificativa), + status = CASE WHEN $4 THEN 'respondido' ELSE status END, + respondido_em = CASE WHEN $4 THEN COALESCE(respondido_em, now()) ELSE respondido_em END, + atualizado_em = now() +WHERE id=$1`, table) + + _, err := s.pool.Exec(ctx, q, id, in.Nota, in.Justificativa, in.Finalizar) + return err +} + +func (s *Store) TouchAtualizadoEm(ctx context.Context, table, id string) error { + q := fmt.Sprintf(`UPDATE %s SET atualizado_em=now() WHERE id=$1`, table) + _, err := s.pool.Exec(ctx, q, id) + return err +} + +func (s *Store) CooldownSuggested(reg Registro) time.Duration { + // Não é usado pelo servidor hoje; fica como helper se precisarmos. + if reg.Status == "respondido" { + return 45 * 24 * time.Hour + } + return 24 * time.Hour +} diff --git a/internal/elinps/readme_page.go b/internal/elinps/readme_page.go new file mode 100644 index 0000000..8f5c1ae --- /dev/null +++ b/internal/elinps/readme_page.go @@ -0,0 +1,134 @@ +package elinps + +import ( + "bytes" + "fmt" + "html/template" + "net/http" + "os" + "strings" + "sync" + "time" + + "github.com/yuin/goldmark" +) + +// ReadmePage serve o README.md renderizado como HTML. +// +// Motivação: dar uma "home" simples para o serviço (documentação em tempo real). +// Sem autenticação, conforme solicitado. +// +// Implementação: cache em memória por mtime para evitar renderização em toda request. +type ReadmePage struct { + caminho string + + mu sync.Mutex + ultimoMTime time.Time + html []byte + errMsg string +} + +func NewReadmePage(caminho string) *ReadmePage { + return &ReadmePage{caminho: caminho} +} + +func (p *ReadmePage) ServeHTTP(w http.ResponseWriter, r *http.Request) { + // Só respondemos GET/HEAD. + if r.Method != http.MethodGet && r.Method != http.MethodHead { + w.WriteHeader(http.StatusMethodNotAllowed) + return + } + + html, errMsg := p.renderIfNeeded() + if errMsg != "" { + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + w.WriteHeader(http.StatusNotFound) + w.Write([]byte(errMsg)) + return + } + + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.WriteHeader(http.StatusOK) + if r.Method == http.MethodHead { + return + } + w.Write(html) +} + +func (p *ReadmePage) renderIfNeeded() ([]byte, string) { + p.mu.Lock() + defer p.mu.Unlock() + + st, err := os.Stat(p.caminho) + if err != nil { + p.errMsg = fmt.Sprintf("README não encontrado: %s", p.caminho) + p.html = nil + p.ultimoMTime = time.Time{} + return nil, p.errMsg + } + + // Cache: se o arquivo não mudou, devolve o HTML já renderizado. + if p.html != nil && st.ModTime().Equal(p.ultimoMTime) { + return p.html, "" + } + + md, err := os.ReadFile(p.caminho) + if err != nil { + p.errMsg = "erro ao ler README" + p.html = nil + p.ultimoMTime = time.Time{} + return nil, p.errMsg + } + + var buf bytes.Buffer + if err := goldmark.Convert(md, &buf); err != nil { + p.errMsg = "erro ao renderizar README" + p.html = nil + p.ultimoMTime = time.Time{} + return nil, p.errMsg + } + + // Envelopa em uma página com estilo básico. + // Importante: NÃO usamos fmt.Sprintf com o HTML/CSS diretamente, + // porque o CSS pode conter "%" (ex.: width:100%) e o fmt interpreta + // como placeholders. + page := ` + + + + + e-li.nps • README + + + +
+
+ +

Página gerada automaticamente a partir de README.md

+
+
+ +` + + html := []byte(strings.Replace(page, "", buf.String(), 1)) + + // Sanitização mínima: como o README é do próprio projeto, aceitamos o HTML gerado. + // Se quiser endurecer segurança, podemos usar um sanitizer (bluemonday). + _ = template.HTMLEscapeString + + p.html = html + p.errMsg = "" + p.ultimoMTime = st.ModTime() + return p.html, "" +} diff --git a/internal/elinps/render.go b/internal/elinps/render.go new file mode 100644 index 0000000..adb407d --- /dev/null +++ b/internal/elinps/render.go @@ -0,0 +1,22 @@ +package elinps + +import ( + "html/template" + "net/http" +) + +type TemplateRenderer struct { + t *template.Template +} + +func NewTemplateRenderer(t *template.Template) *TemplateRenderer { return &TemplateRenderer{t: t} } + +func (r *TemplateRenderer) Render(w http.ResponseWriter, name string, data any) { + _ = r.t.ExecuteTemplate(w, name, data) +} + +type FormPageData struct { + Produto string + ID string + Reg Registro +} diff --git a/internal/elinps/templates.go b/internal/elinps/templates.go new file mode 100644 index 0000000..dc87c38 --- /dev/null +++ b/internal/elinps/templates.go @@ -0,0 +1,43 @@ +package elinps + +import ( + "html/template" + "path/filepath" + "strings" +) + +func mustParseTemplates() *template.Template { + // Local filesystem parsing (keeps the repo simple). + // If you want a single-binary deploy, we can switch to go:embed by moving + // templates into internal/elinps and embedding without "..". + funcs := template.FuncMap{ + "seq": func(start, end int) []int { + if end < start { + return []int{} + } + out := make([]int, 0, end-start+1) + for i := start; i <= end; i++ { + out = append(out, i) + } + return out + }, + "noteEq": func(ptr *int, v int) bool { + return ptr != nil && *ptr == v + }, + "produtoLabel": func(produto string) string { + // Best-effort label from normalized produto. + p := strings.ReplaceAll(produto, "_", " ") + parts := strings.Fields(p) + for i := range parts { + if len(parts[i]) == 0 { + continue + } + parts[i] = strings.ToUpper(parts[i][:1]) + parts[i][1:] + } + return strings.Join(parts, " ") + }, + } + + pattern := filepath.ToSlash("web/templates/*.html") + return template.Must(template.New("").Funcs(funcs).ParseGlob(pattern)) +} diff --git a/internal/elinps/validate.go b/internal/elinps/validate.go new file mode 100644 index 0000000..26402d9 --- /dev/null +++ b/internal/elinps/validate.go @@ -0,0 +1,66 @@ +package elinps + +import ( + "errors" + "regexp" + "strings" +) + +var emailRe = regexp.MustCompile(`^[^\s@]+@[^\s@]+\.[^\s@]+$`) + +func normalizeEmail(s string) string { + return strings.ToLower(strings.TrimSpace(s)) +} + +func ValidatePedidoInput(in *PedidoInput) error { + in.ProdutoNome = strings.TrimSpace(in.ProdutoNome) + in.InquilinoCodigo = strings.TrimSpace(in.InquilinoCodigo) + in.InquilinoNome = strings.TrimSpace(in.InquilinoNome) + in.UsuarioCodigo = strings.TrimSpace(in.UsuarioCodigo) + in.UsuarioNome = strings.TrimSpace(in.UsuarioNome) + in.UsuarioTelefone = strings.TrimSpace(in.UsuarioTelefone) + in.UsuarioEmail = normalizeEmail(in.UsuarioEmail) + + if in.ProdutoNome == "" || len(in.ProdutoNome) > 64 { + return errors.New("produto_nome invalido") + } + if in.InquilinoCodigo == "" || len(in.InquilinoCodigo) > 64 { + return errors.New("inquilino_codigo invalido") + } + if in.InquilinoNome == "" || len(in.InquilinoNome) > 128 { + return errors.New("inquilino_nome invalido") + } + if in.UsuarioCodigo == "" || len(in.UsuarioCodigo) > 64 { + return errors.New("usuario_codigo invalido") + } + if in.UsuarioNome == "" || len(in.UsuarioNome) > 128 { + return errors.New("usuario_nome invalido") + } + // E-mail passa a ser opcional: o controle de exibição é por + // (produto + inquilino_codigo + usuario_codigo). + if in.UsuarioEmail != "" { + if len(in.UsuarioEmail) > 254 || !emailRe.MatchString(in.UsuarioEmail) { + return errors.New("usuario_email invalido") + } + } + if len(in.UsuarioTelefone) > 64 { + return errors.New("usuario_telefone invalido") + } + return nil +} + +func ValidatePatchInput(in *PatchInput) error { + if in.Nota != nil { + if *in.Nota < 1 || *in.Nota > 10 { + return errors.New("nota invalida") + } + } + if in.Justificativa != nil { + j := strings.TrimSpace(*in.Justificativa) + if len(j) > 2000 { + return errors.New("justificativa muito longa") + } + *in.Justificativa = j + } + return nil +} diff --git a/migrations/001_init.sql b/migrations/001_init.sql new file mode 100644 index 0000000..bc51904 --- /dev/null +++ b/migrations/001_init.sql @@ -0,0 +1,31 @@ +-- e-li.nps base migration (run once) +CREATE EXTENSION IF NOT EXISTS pgcrypto; + +-- Example product table (optional; app auto-creates per product): +-- CREATE TABLE IF NOT EXISTS nps_exemplo ( +-- id uuid PRIMARY KEY DEFAULT gen_random_uuid(), +-- inquilino_codigo text NOT NULL, +-- inquilino_nome text NOT NULL, +-- usuario_codigo text, +-- usuario_nome text NOT NULL, +-- usuario_email text, +-- usuario_telefone text, +-- status text NOT NULL CHECK (status IN ('pedido','respondido')), +-- pedido_criado_em timestamptz NOT NULL DEFAULT now(), +-- respondido_em timestamptz NULL, +-- atualizado_em timestamptz NOT NULL DEFAULT now(), +-- nota int NULL CHECK (nota BETWEEN 1 AND 10), +-- justificativa text NULL, +-- valida bool NOT NULL DEFAULT true, +-- origem text NOT NULL DEFAULT 'widget_iframe', +-- user_agent text NULL, +-- ip_real text NULL +-- ); +-- +-- CREATE INDEX IF NOT EXISTS idx_nps_resp_recente_exemplo +-- ON nps_exemplo (inquilino_codigo, usuario_codigo, respondido_em DESC) +-- WHERE status='respondido' AND valida=true; +-- +-- CREATE INDEX IF NOT EXISTS idx_nps_pedido_aberto_exemplo +-- ON nps_exemplo (inquilino_codigo, usuario_codigo, pedido_criado_em DESC) +-- WHERE status='pedido'; diff --git a/server b/server new file mode 100755 index 0000000..dd5ff33 Binary files /dev/null and b/server differ diff --git a/web/static/e-li.nps.js b/web/static/e-li.nps.js new file mode 100644 index 0000000..1aff230 --- /dev/null +++ b/web/static/e-li.nps.js @@ -0,0 +1,203 @@ +(function(){ + const DEFAULTS = { + apiBase: '', + cooldownHours: 24, + // Data mínima para permitir abertura do modal. + // Formato ISO (data): YYYY-MM-DD (ex.: "2026-01-01"). + // Se vazio, não aplica bloqueio por data. + data_minima_abertura: '', + }; + + function parseDataMinima(s){ + // Aceita somente ISO (data) YYYY-MM-DD. + // Retorna um Date no início do dia (00:00) no horário local. + const v = String(s || '').trim(); + if(!v) return null; + const m = /^([0-9]{4})-([0-9]{2})-([0-9]{2})$/.exec(v); + if(!m) return null; + const ano = Number(m[1]); + const mes = Number(m[2]); + const dia = Number(m[3]); + if(!ano || mes < 1 || mes > 12 || dia < 1 || dia > 31) return null; + return new Date(ano, mes-1, dia, 0, 0, 0, 0); + } + + function antesDaDataMinima(cfg){ + const d = parseDataMinima(cfg.data_minima_abertura); + if(!d) return false; + return new Date() < d; + } + + function normalizeEmail(email){ + return String(email || '').trim().toLowerCase(); + } + + function cooldownKey(produto, inquilino, usuarioCodigo){ + // Prefixo de storage atualizado para o novo nome do projeto. + return `eli-nps:cooldown:${produto}:${inquilino}:${usuarioCodigo}`; + } + + function nowMs(){ return Date.now(); } + + function withinCooldown(key){ + try{ + const v = localStorage.getItem(key); + if(!v) return false; + const obj = JSON.parse(v); + return obj && obj.until && nowMs() < obj.until; + }catch(e){ return false; } + } + + function setCooldown(key, hours){ + try{ + const until = nowMs() + hours*3600*1000; + localStorage.setItem(key, JSON.stringify({until})); + }catch(e){} + } + + function createModal(){ + const host = document.createElement('div'); + host.id = 'eli-nps-host'; + const shadow = host.attachShadow ? host.attachShadow({mode:'open'}) : host; + + const style = document.createElement('style'); + style.textContent = ` + .eli-nps-backdrop{position:fixed;inset:0;background:rgba(0,0,0,.55);display:flex;align-items:center;justify-content:center;z-index:2147483647;} + /* + Responsividade do modal do widget: + - Em telas pequenas, usa quase a tela toda. + - Em telas maiores, mantém tamanho máximo confortável. + */ + .eli-nps-panel{width:min(560px, calc(100vw - 24px));height:min(540px, calc(100vh - 24px));background:#fff;border-radius:14px;overflow:hidden;box-shadow:0 12px 44px rgba(0,0,0,.35);display:flex;flex-direction:column;} + .eli-nps-header{flex:0 0 auto;display:flex;justify-content:flex-end;align-items:center;padding:10px;border-bottom:1px solid #eee;} + .eli-nps-close{border:1px solid #ddd;background:#fff;border-radius:10px;padding:8px 12px;cursor:pointer;font:600 13px system-ui;} + iframe{width:100%;flex:1 1 auto;border:0;} + + @media (max-width: 480px){ + .eli-nps-panel{width:calc(100vw - 16px);height:calc(100vh - 16px);border-radius:12px;} + .eli-nps-header{padding:8px;} + .eli-nps-close{padding:10px 12px;font-size:14px;} + } + `; + + const backdrop = document.createElement('div'); + backdrop.className = 'eli-nps-backdrop'; + const panel = document.createElement('div'); + panel.className = 'eli-nps-panel'; + + const header = document.createElement('div'); + header.className = 'eli-nps-header'; + + const close = document.createElement('button'); + close.className = 'eli-nps-close'; + close.textContent = 'Fechar'; + + header.appendChild(close); + panel.appendChild(header); + backdrop.appendChild(panel); + shadow.appendChild(style); + shadow.appendChild(backdrop); + + function destroy(){ + try{ host.remove(); }catch(e){} + window.removeEventListener('message', onMsg); + } + + function onMsg(ev){ + if(ev && ev.data && ev.data.type === 'eli-nps:done'){ + destroy(); + } + } + + close.addEventListener('click', destroy); + // Importante: não fechamos o modal ao clicar fora (backdrop). + // Em mobile é comum tocar fora sem querer e perder o formulário. + window.addEventListener('message', onMsg); + + document.body.appendChild(host); + return {shadow, panel, destroy}; + } + + async function postJSON(url, body){ + const res = await fetch(url, { + method: 'POST', + headers: {'Content-Type':'application/json'}, + body: JSON.stringify(body), + }); + return res; + } + + // API pública do widget. + // Nome novo do projeto: e-li.nps + window.ELiNPS = { + init: async function(opts){ + const cfg = Object.assign({}, DEFAULTS, opts || {}); + + // Bloqueio por data mínima (feature flag simples). + // Ex.: não abrir modal antes de 2026-01-01. + if(antesDaDataMinima(cfg)){ + return; + } + + // produto_nome pode ser qualquer string (ex.: "e-licencie", "Cachaça & Churras"). + // Regra do projeto: o tratamento/normalização de caracteres deve ser feito + // apenas no backend, exclusivamente para nome de tabela/rotas. + const produtoNome = String(cfg.produto_nome || '').trim(); + const inquilino = String(cfg.inquilino_codigo || '').trim(); + const usuarioCodigo = String(cfg.usuario_codigo || '').trim(); + const email = normalizeEmail(cfg.usuario_email); + + // controle de exibição: produto + inquilino_codigo + usuario_codigo + if(!produtoNome || !inquilino || !usuarioCodigo){ + return; // missing required context + } + + // A chave do cooldown é “best-effort” e não participa de nenhuma regra + // de segurança. Mantemos o produto como foi informado. + const chaveCooldown = cooldownKey(produtoNome, inquilino, usuarioCodigo); + if(withinCooldown(chaveCooldown)) return; + + // Enviamos exatamente o produto_nome informado. + const payload = { + produto_nome: produtoNome, + inquilino_codigo: inquilino, + inquilino_nome: String(cfg.inquilino_nome || '').trim(), + usuario_codigo: usuarioCodigo, + usuario_nome: String(cfg.usuario_nome || '').trim(), + usuario_telefone: String(cfg.usuario_telefone || '').trim(), + usuario_email: email, + }; + + let data; + try{ + const res = await postJSON(`${cfg.apiBase}/api/e-li.nps/pedido`, payload); + if(!res.ok) return; // fail-closed + data = await res.json(); + }catch(e){ + return; // fail-closed + } + + if(!data || !data.pode_abrir || !data.id){ + // small cooldown to avoid flicker if backend keeps rejecting + setCooldown(chaveCooldown, cfg.cooldownHours); + return; + } + + // Backend can return normalized product; use it for building iframe URL. + const produtoRota = data.produto; + if(!produtoRota){ + // fail-closed (não dá pra montar URL segura) + setCooldown(chaveCooldown, cfg.cooldownHours); + return; + } + + const modal = createModal(); + const iframe = document.createElement('iframe'); + iframe.src = `${cfg.apiBase}/e-li.nps/${produtoRota}/${data.id}/form`; + modal.panel.appendChild(iframe); + + // Visual cooldown so it doesn't keep popping (even if user closes). + setCooldown(chaveCooldown, cfg.cooldownHours); + } + }; +})(); diff --git a/web/static/teste.html b/web/static/teste.html new file mode 100644 index 0000000..a55f76c --- /dev/null +++ b/web/static/teste.html @@ -0,0 +1,87 @@ + + + + + + e-li.nps • Teste + + + +
+

e-li.nps • Página de teste

+

+ Esta página carrega /static/e-li.nps.js e dispara window.ELiNPS.init(). + Se a API permitir, abrirá o modal (iframe) com o formulário HTMX. +

+ +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +

+ +

+ +

+ Dica: se você testar repetidamente, pode cair nas regras (45 dias / 10 dias). + Para forçar reaparecer, use outro e-mail ou limpe a tabela do produto no Postgres. +

+
+ + + + + diff --git a/web/static/vendor/htmx.min.js b/web/static/vendor/htmx.min.js new file mode 100644 index 0000000..de5f0f1 --- /dev/null +++ b/web/static/vendor/htmx.min.js @@ -0,0 +1 @@ +(function(e,t){if(typeof define==="function"&&define.amd){define([],t)}else if(typeof module==="object"&&module.exports){module.exports=t()}else{e.htmx=e.htmx||t()}})(typeof self!=="undefined"?self:this,function(){return function(){"use strict";var Q={onLoad:F,process:zt,on:de,off:ge,trigger:ce,ajax:Nr,find:C,findAll:f,closest:v,values:function(e,t){var r=dr(e,t||"post");return r.values},remove:_,addClass:z,removeClass:n,toggleClass:$,takeClass:W,defineExtension:Ur,removeExtension:Br,logAll:V,logNone:j,logger:null,config:{historyEnabled:true,historyCacheSize:10,refreshOnHistoryMiss:false,defaultSwapStyle:"innerHTML",defaultSwapDelay:0,defaultSettleDelay:20,includeIndicatorStyles:true,indicatorClass:"htmx-indicator",requestClass:"htmx-request",addedClass:"htmx-added",settlingClass:"htmx-settling",swappingClass:"htmx-swapping",allowEval:true,allowScriptTags:true,inlineScriptNonce:"",attributesToSettle:["class","style","width","height"],withCredentials:false,timeout:0,wsReconnectDelay:"full-jitter",wsBinaryType:"blob",disableSelector:"[hx-disable], [data-hx-disable]",useTemplateFragments:false,scrollBehavior:"smooth",defaultFocusScroll:false,getCacheBusterParam:false,globalViewTransitions:false,methodsThatUseUrlParams:["get"],selfRequestsOnly:false,ignoreTitle:false,scrollIntoViewOnBoost:true,triggerSpecsCache:null},parseInterval:d,_:t,createEventSource:function(e){return new EventSource(e,{withCredentials:true})},createWebSocket:function(e){var t=new WebSocket(e,[]);t.binaryType=Q.config.wsBinaryType;return t},version:"1.9.12"};var r={addTriggerHandler:Lt,bodyContains:se,canAccessLocalStorage:U,findThisElement:xe,filterValues:yr,hasAttribute:o,getAttributeValue:te,getClosestAttributeValue:ne,getClosestMatch:c,getExpressionVars:Hr,getHeaders:xr,getInputValues:dr,getInternalData:ae,getSwapSpecification:wr,getTriggerSpecs:it,getTarget:ye,makeFragment:l,mergeObjects:le,makeSettleInfo:T,oobSwap:Ee,querySelectorExt:ue,selectAndSwap:je,settleImmediately:nr,shouldCancel:ut,triggerEvent:ce,triggerErrorEvent:fe,withExtensions:R};var w=["get","post","put","delete","patch"];var i=w.map(function(e){return"[hx-"+e+"], [data-hx-"+e+"]"}).join(", ");var S=e("head"),q=e("title"),H=e("svg",true);function e(e,t){return new RegExp("<"+e+"(\\s[^>]*>|>)([\\s\\S]*?)<\\/"+e+">",!!t?"gim":"im")}function d(e){if(e==undefined){return undefined}let t=NaN;if(e.slice(-2)=="ms"){t=parseFloat(e.slice(0,-2))}else if(e.slice(-1)=="s"){t=parseFloat(e.slice(0,-1))*1e3}else if(e.slice(-1)=="m"){t=parseFloat(e.slice(0,-1))*1e3*60}else{t=parseFloat(e)}return isNaN(t)?undefined:t}function ee(e,t){return e.getAttribute&&e.getAttribute(t)}function o(e,t){return e.hasAttribute&&(e.hasAttribute(t)||e.hasAttribute("data-"+t))}function te(e,t){return ee(e,t)||ee(e,"data-"+t)}function u(e){return e.parentElement}function re(){return document}function c(e,t){while(e&&!t(e)){e=u(e)}return e?e:null}function L(e,t,r){var n=te(t,r);var i=te(t,"hx-disinherit");if(e!==t&&i&&(i==="*"||i.split(" ").indexOf(r)>=0)){return"unset"}else{return n}}function ne(t,r){var n=null;c(t,function(e){return n=L(t,e,r)});if(n!=="unset"){return n}}function h(e,t){var r=e.matches||e.matchesSelector||e.msMatchesSelector||e.mozMatchesSelector||e.webkitMatchesSelector||e.oMatchesSelector;return r&&r.call(e,t)}function A(e){var t=/<([a-z][^\/\0>\x20\t\r\n\f]*)/i;var r=t.exec(e);if(r){return r[1].toLowerCase()}else{return""}}function s(e,t){var r=new DOMParser;var n=r.parseFromString(e,"text/html");var i=n.body;while(t>0){t--;i=i.firstChild}if(i==null){i=re().createDocumentFragment()}return i}function N(e){return/",0);var a=i.querySelector("template").content;if(Q.config.allowScriptTags){oe(a.querySelectorAll("script"),function(e){if(Q.config.inlineScriptNonce){e.nonce=Q.config.inlineScriptNonce}e.htmxExecuted=navigator.userAgent.indexOf("Firefox")===-1})}else{oe(a.querySelectorAll("script"),function(e){_(e)})}return a}switch(r){case"thead":case"tbody":case"tfoot":case"colgroup":case"caption":return s(""+n+"
",1);case"col":return s(""+n+"
",2);case"tr":return s(""+n+"
",2);case"td":case"th":return s(""+n+"
",3);case"script":case"style":return s("
"+n+"
",1);default:return s(n,0)}}function ie(e){if(e){e()}}function I(e,t){return Object.prototype.toString.call(e)==="[object "+t+"]"}function k(e){return I(e,"Function")}function P(e){return I(e,"Object")}function ae(e){var t="htmx-internal-data";var r=e[t];if(!r){r=e[t]={}}return r}function M(e){var t=[];if(e){for(var r=0;r=0}function se(e){if(e.getRootNode&&e.getRootNode()instanceof window.ShadowRoot){return re().body.contains(e.getRootNode().host)}else{return re().body.contains(e)}}function D(e){return e.trim().split(/\s+/)}function le(e,t){for(var r in t){if(t.hasOwnProperty(r)){e[r]=t[r]}}return e}function E(e){try{return JSON.parse(e)}catch(e){b(e);return null}}function U(){var e="htmx:localStorageTest";try{localStorage.setItem(e,e);localStorage.removeItem(e);return true}catch(e){return false}}function B(t){try{var e=new URL(t);if(e){t=e.pathname+e.search}if(!/^\/$/.test(t)){t=t.replace(/\/+$/,"")}return t}catch(e){return t}}function t(e){return Tr(re().body,function(){return eval(e)})}function F(t){var e=Q.on("htmx:load",function(e){t(e.detail.elt)});return e}function V(){Q.logger=function(e,t,r){if(console){console.log(t,e,r)}}}function j(){Q.logger=null}function C(e,t){if(t){return e.querySelector(t)}else{return C(re(),e)}}function f(e,t){if(t){return e.querySelectorAll(t)}else{return f(re(),e)}}function _(e,t){e=p(e);if(t){setTimeout(function(){_(e);e=null},t)}else{e.parentElement.removeChild(e)}}function z(e,t,r){e=p(e);if(r){setTimeout(function(){z(e,t);e=null},r)}else{e.classList&&e.classList.add(t)}}function n(e,t,r){e=p(e);if(r){setTimeout(function(){n(e,t);e=null},r)}else{if(e.classList){e.classList.remove(t);if(e.classList.length===0){e.removeAttribute("class")}}}}function $(e,t){e=p(e);e.classList.toggle(t)}function W(e,t){e=p(e);oe(e.parentElement.children,function(e){n(e,t)});z(e,t)}function v(e,t){e=p(e);if(e.closest){return e.closest(t)}else{do{if(e==null||h(e,t)){return e}}while(e=e&&u(e));return null}}function g(e,t){return e.substring(0,t.length)===t}function G(e,t){return e.substring(e.length-t.length)===t}function J(e){var t=e.trim();if(g(t,"<")&&G(t,"/>")){return t.substring(1,t.length-2)}else{return t}}function Z(e,t){if(t.indexOf("closest ")===0){return[v(e,J(t.substr(8)))]}else if(t.indexOf("find ")===0){return[C(e,J(t.substr(5)))]}else if(t==="next"){return[e.nextElementSibling]}else if(t.indexOf("next ")===0){return[K(e,J(t.substr(5)))]}else if(t==="previous"){return[e.previousElementSibling]}else if(t.indexOf("previous ")===0){return[Y(e,J(t.substr(9)))]}else if(t==="document"){return[document]}else if(t==="window"){return[window]}else if(t==="body"){return[document.body]}else{return re().querySelectorAll(J(t))}}var K=function(e,t){var r=re().querySelectorAll(t);for(var n=0;n=0;n--){var i=r[n];if(i.compareDocumentPosition(e)===Node.DOCUMENT_POSITION_FOLLOWING){return i}}};function ue(e,t){if(t){return Z(e,t)[0]}else{return Z(re().body,e)[0]}}function p(e){if(I(e,"String")){return C(e)}else{return e}}function ve(e,t,r){if(k(t)){return{target:re().body,event:e,listener:t}}else{return{target:p(e),event:t,listener:r}}}function de(t,r,n){jr(function(){var e=ve(t,r,n);e.target.addEventListener(e.event,e.listener)});var e=k(r);return e?r:n}function ge(t,r,n){jr(function(){var e=ve(t,r,n);e.target.removeEventListener(e.event,e.listener)});return k(r)?r:n}var pe=re().createElement("output");function me(e,t){var r=ne(e,t);if(r){if(r==="this"){return[xe(e,t)]}else{var n=Z(e,r);if(n.length===0){b('The selector "'+r+'" on '+t+" returned no matches!");return[pe]}else{return n}}}}function xe(e,t){return c(e,function(e){return te(e,t)!=null})}function ye(e){var t=ne(e,"hx-target");if(t){if(t==="this"){return xe(e,"hx-target")}else{return ue(e,t)}}else{var r=ae(e);if(r.boosted){return re().body}else{return e}}}function be(e){var t=Q.config.attributesToSettle;for(var r=0;r0){o=e.substr(0,e.indexOf(":"));t=e.substr(e.indexOf(":")+1,e.length)}else{o=e}var r=re().querySelectorAll(t);if(r){oe(r,function(e){var t;var r=i.cloneNode(true);t=re().createDocumentFragment();t.appendChild(r);if(!Se(o,e)){t=r}var n={shouldSwap:true,target:e,fragment:t};if(!ce(e,"htmx:oobBeforeSwap",n))return;e=n.target;if(n["shouldSwap"]){Fe(o,e,e,t,a)}oe(a.elts,function(e){ce(e,"htmx:oobAfterSwap",n)})});i.parentNode.removeChild(i)}else{i.parentNode.removeChild(i);fe(re().body,"htmx:oobErrorNoTarget",{content:i})}return e}function Ce(e,t,r){var n=ne(e,"hx-select-oob");if(n){var i=n.split(",");for(var a=0;a0){var r=t.replace("'","\\'");var n=e.tagName.replace(":","\\:");var i=o.querySelector(n+"[id='"+r+"']");if(i&&i!==o){var a=e.cloneNode();we(e,i);s.tasks.push(function(){we(e,a)})}}})}function Oe(e){return function(){n(e,Q.config.addedClass);zt(e);Nt(e);qe(e);ce(e,"htmx:load")}}function qe(e){var t="[autofocus]";var r=h(e,t)?e:e.querySelector(t);if(r!=null){r.focus()}}function a(e,t,r,n){Te(e,r,n);while(r.childNodes.length>0){var i=r.firstChild;z(i,Q.config.addedClass);e.insertBefore(i,t);if(i.nodeType!==Node.TEXT_NODE&&i.nodeType!==Node.COMMENT_NODE){n.tasks.push(Oe(i))}}}function He(e,t){var r=0;while(r-1){var t=e.replace(H,"");var r=t.match(q);if(r){return r[2]}}}function je(e,t,r,n,i,a){i.title=Ve(n);var o=l(n);if(o){Ce(r,o,i);o=Be(r,o,a);Re(o);return Fe(e,r,t,o,i)}}function _e(e,t,r){var n=e.getResponseHeader(t);if(n.indexOf("{")===0){var i=E(n);for(var a in i){if(i.hasOwnProperty(a)){var o=i[a];if(!P(o)){o={value:o}}ce(r,a,o)}}}else{var s=n.split(",");for(var l=0;l0){var o=t[0];if(o==="]"){n--;if(n===0){if(a===null){i=i+"true"}t.shift();i+=")})";try{var s=Tr(e,function(){return Function(i)()},function(){return true});s.source=i;return s}catch(e){fe(re().body,"htmx:syntax:error",{error:e,source:i});return null}}}else if(o==="["){n++}if(Qe(o,a,r)){i+="(("+r+"."+o+") ? ("+r+"."+o+") : (window."+o+"))"}else{i=i+o}a=t.shift()}}}function y(e,t){var r="";while(e.length>0&&!t.test(e[0])){r+=e.shift()}return r}function tt(e){var t;if(e.length>0&&Ze.test(e[0])){e.shift();t=y(e,Ke).trim();e.shift()}else{t=y(e,x)}return t}var rt="input, textarea, select";function nt(e,t,r){var n=[];var i=Ye(t);do{y(i,Je);var a=i.length;var o=y(i,/[,\[\s]/);if(o!==""){if(o==="every"){var s={trigger:"every"};y(i,Je);s.pollInterval=d(y(i,/[,\[\s]/));y(i,Je);var l=et(e,i,"event");if(l){s.eventFilter=l}n.push(s)}else if(o.indexOf("sse:")===0){n.push({trigger:"sse",sseEvent:o.substr(4)})}else{var u={trigger:o};var l=et(e,i,"event");if(l){u.eventFilter=l}while(i.length>0&&i[0]!==","){y(i,Je);var f=i.shift();if(f==="changed"){u.changed=true}else if(f==="once"){u.once=true}else if(f==="consume"){u.consume=true}else if(f==="delay"&&i[0]===":"){i.shift();u.delay=d(y(i,x))}else if(f==="from"&&i[0]===":"){i.shift();if(Ze.test(i[0])){var c=tt(i)}else{var c=y(i,x);if(c==="closest"||c==="find"||c==="next"||c==="previous"){i.shift();var h=tt(i);if(h.length>0){c+=" "+h}}}u.from=c}else if(f==="target"&&i[0]===":"){i.shift();u.target=tt(i)}else if(f==="throttle"&&i[0]===":"){i.shift();u.throttle=d(y(i,x))}else if(f==="queue"&&i[0]===":"){i.shift();u.queue=y(i,x)}else if(f==="root"&&i[0]===":"){i.shift();u[f]=tt(i)}else if(f==="threshold"&&i[0]===":"){i.shift();u[f]=y(i,x)}else{fe(e,"htmx:syntax:error",{token:i.shift()})}}n.push(u)}}if(i.length===a){fe(e,"htmx:syntax:error",{token:i.shift()})}y(i,Je)}while(i[0]===","&&i.shift());if(r){r[t]=n}return n}function it(e){var t=te(e,"hx-trigger");var r=[];if(t){var n=Q.config.triggerSpecsCache;r=n&&n[t]||nt(e,t,n)}if(r.length>0){return r}else if(h(e,"form")){return[{trigger:"submit"}]}else if(h(e,'input[type="button"], input[type="submit"]')){return[{trigger:"click"}]}else if(h(e,rt)){return[{trigger:"change"}]}else{return[{trigger:"click"}]}}function at(e){ae(e).cancelled=true}function ot(e,t,r){var n=ae(e);n.timeout=setTimeout(function(){if(se(e)&&n.cancelled!==true){if(!ct(r,e,Wt("hx:poll:trigger",{triggerSpec:r,target:e}))){t(e)}ot(e,t,r)}},r.pollInterval)}function st(e){return location.hostname===e.hostname&&ee(e,"href")&&ee(e,"href").indexOf("#")!==0}function lt(t,r,e){if(t.tagName==="A"&&st(t)&&(t.target===""||t.target==="_self")||t.tagName==="FORM"){r.boosted=true;var n,i;if(t.tagName==="A"){n="get";i=ee(t,"href")}else{var a=ee(t,"method");n=a?a.toLowerCase():"get";if(n==="get"){}i=ee(t,"action")}e.forEach(function(e){ht(t,function(e,t){if(v(e,Q.config.disableSelector)){m(e);return}he(n,i,e,t)},r,e,true)})}}function ut(e,t){if(e.type==="submit"||e.type==="click"){if(t.tagName==="FORM"){return true}if(h(t,'input[type="submit"], button')&&v(t,"form")!==null){return true}if(t.tagName==="A"&&t.href&&(t.getAttribute("href")==="#"||t.getAttribute("href").indexOf("#")!==0)){return true}}return false}function ft(e,t){return ae(e).boosted&&e.tagName==="A"&&t.type==="click"&&(t.ctrlKey||t.metaKey)}function ct(e,t,r){var n=e.eventFilter;if(n){try{return n.call(t,r)!==true}catch(e){fe(re().body,"htmx:eventFilter:error",{error:e,source:n.source});return true}}return false}function ht(a,o,e,s,l){var u=ae(a);var t;if(s.from){t=Z(a,s.from)}else{t=[a]}if(s.changed){t.forEach(function(e){var t=ae(e);t.lastValue=e.value})}oe(t,function(n){var i=function(e){if(!se(a)){n.removeEventListener(s.trigger,i);return}if(ft(a,e)){return}if(l||ut(e,a)){e.preventDefault()}if(ct(s,a,e)){return}var t=ae(e);t.triggerSpec=s;if(t.handledFor==null){t.handledFor=[]}if(t.handledFor.indexOf(a)<0){t.handledFor.push(a);if(s.consume){e.stopPropagation()}if(s.target&&e.target){if(!h(e.target,s.target)){return}}if(s.once){if(u.triggeredOnce){return}else{u.triggeredOnce=true}}if(s.changed){var r=ae(n);if(r.lastValue===n.value){return}r.lastValue=n.value}if(u.delayed){clearTimeout(u.delayed)}if(u.throttle){return}if(s.throttle>0){if(!u.throttle){o(a,e);u.throttle=setTimeout(function(){u.throttle=null},s.throttle)}}else if(s.delay>0){u.delayed=setTimeout(function(){o(a,e)},s.delay)}else{ce(a,"htmx:trigger");o(a,e)}}};if(e.listenerInfos==null){e.listenerInfos=[]}e.listenerInfos.push({trigger:s.trigger,listener:i,on:n});n.addEventListener(s.trigger,i)})}var vt=false;var dt=null;function gt(){if(!dt){dt=function(){vt=true};window.addEventListener("scroll",dt);setInterval(function(){if(vt){vt=false;oe(re().querySelectorAll("[hx-trigger='revealed'],[data-hx-trigger='revealed']"),function(e){pt(e)})}},200)}}function pt(t){if(!o(t,"data-hx-revealed")&&X(t)){t.setAttribute("data-hx-revealed","true");var e=ae(t);if(e.initHash){ce(t,"revealed")}else{t.addEventListener("htmx:afterProcessNode",function(e){ce(t,"revealed")},{once:true})}}}function mt(e,t,r){var n=D(r);for(var i=0;i=0){var t=wt(n);setTimeout(function(){xt(s,r,n+1)},t)}};t.onopen=function(e){n=0};ae(s).webSocket=t;t.addEventListener("message",function(e){if(yt(s)){return}var t=e.data;R(s,function(e){t=e.transformResponse(t,null,s)});var r=T(s);var n=l(t);var i=M(n.children);for(var a=0;a0){ce(u,"htmx:validation:halted",i);return}t.send(JSON.stringify(l));if(ut(e,u)){e.preventDefault()}})}else{fe(u,"htmx:noWebSocketSourceError")}}function wt(e){var t=Q.config.wsReconnectDelay;if(typeof t==="function"){return t(e)}if(t==="full-jitter"){var r=Math.min(e,6);var n=1e3*Math.pow(2,r);return n*Math.random()}b('htmx.config.wsReconnectDelay must either be a function or the string "full-jitter"')}function St(e,t,r){var n=D(r);for(var i=0;i0){setTimeout(i,n)}else{i()}}function Ht(t,i,e){var a=false;oe(w,function(r){if(o(t,"hx-"+r)){var n=te(t,"hx-"+r);a=true;i.path=n;i.verb=r;e.forEach(function(e){Lt(t,e,i,function(e,t){if(v(e,Q.config.disableSelector)){m(e);return}he(r,n,e,t)})})}});return a}function Lt(n,e,t,r){if(e.sseEvent){Rt(n,r,e.sseEvent)}else if(e.trigger==="revealed"){gt();ht(n,r,t,e);pt(n)}else if(e.trigger==="intersect"){var i={};if(e.root){i.root=ue(n,e.root)}if(e.threshold){i.threshold=parseFloat(e.threshold)}var a=new IntersectionObserver(function(e){for(var t=0;t0){t.polling=true;ot(n,r,e)}else{ht(n,r,t,e)}}function At(e){if(!e.htmxExecuted&&Q.config.allowScriptTags&&(e.type==="text/javascript"||e.type==="module"||e.type==="")){var t=re().createElement("script");oe(e.attributes,function(e){t.setAttribute(e.name,e.value)});t.textContent=e.textContent;t.async=false;if(Q.config.inlineScriptNonce){t.nonce=Q.config.inlineScriptNonce}var r=e.parentElement;try{r.insertBefore(t,e)}catch(e){b(e)}finally{if(e.parentElement){e.parentElement.removeChild(e)}}}}function Nt(e){if(h(e,"script")){At(e)}oe(f(e,"script"),function(e){At(e)})}function It(e){var t=e.attributes;if(!t){return false}for(var r=0;r0){var o=n.shift();var s=o.match(/^\s*([a-zA-Z:\-\.]+:)(.*)/);if(a===0&&s){o.split(":");i=s[1].slice(0,-1);r[i]=s[2]}else{r[i]+=o}a+=Bt(o)}for(var l in r){Ft(e,l,r[l])}}}function jt(e){Ae(e);for(var t=0;tQ.config.historyCacheSize){i.shift()}while(i.length>0){try{localStorage.setItem("htmx-history-cache",JSON.stringify(i));break}catch(e){fe(re().body,"htmx:historyCacheError",{cause:e,cache:i});i.shift()}}}function Yt(e){if(!U()){return null}e=B(e);var t=E(localStorage.getItem("htmx-history-cache"))||[];for(var r=0;r=200&&this.status<400){ce(re().body,"htmx:historyCacheMissLoad",o);var e=l(this.response);e=e.querySelector("[hx-history-elt],[data-hx-history-elt]")||e;var t=Zt();var r=T(t);var n=Ve(this.response);if(n){var i=C("title");if(i){i.innerHTML=n}else{window.document.title=n}}Ue(t,e,r);nr(r.tasks);Jt=a;ce(re().body,"htmx:historyRestore",{path:a,cacheMiss:true,serverResponse:this.response})}else{fe(re().body,"htmx:historyCacheMissLoadError",o)}};e.send()}function ar(e){er();e=e||location.pathname+location.search;var t=Yt(e);if(t){var r=l(t.content);var n=Zt();var i=T(n);Ue(n,r,i);nr(i.tasks);document.title=t.title;setTimeout(function(){window.scrollTo(0,t.scroll)},0);Jt=e;ce(re().body,"htmx:historyRestore",{path:e,item:t})}else{if(Q.config.refreshOnHistoryMiss){window.location.reload(true)}else{ir(e)}}}function or(e){var t=me(e,"hx-indicator");if(t==null){t=[e]}oe(t,function(e){var t=ae(e);t.requestCount=(t.requestCount||0)+1;e.classList["add"].call(e.classList,Q.config.requestClass)});return t}function sr(e){var t=me(e,"hx-disabled-elt");if(t==null){t=[]}oe(t,function(e){var t=ae(e);t.requestCount=(t.requestCount||0)+1;e.setAttribute("disabled","")});return t}function lr(e,t){oe(e,function(e){var t=ae(e);t.requestCount=(t.requestCount||0)-1;if(t.requestCount===0){e.classList["remove"].call(e.classList,Q.config.requestClass)}});oe(t,function(e){var t=ae(e);t.requestCount=(t.requestCount||0)-1;if(t.requestCount===0){e.removeAttribute("disabled")}})}function ur(e,t){for(var r=0;r=0}function wr(e,t){var r=t?t:ne(e,"hx-swap");var n={swapStyle:ae(e).boosted?"innerHTML":Q.config.defaultSwapStyle,swapDelay:Q.config.defaultSwapDelay,settleDelay:Q.config.defaultSettleDelay};if(Q.config.scrollIntoViewOnBoost&&ae(e).boosted&&!br(e)){n["show"]="top"}if(r){var i=D(r);if(i.length>0){for(var a=0;a0?l.join(":"):null;n["scroll"]=u;n["scrollTarget"]=f}else if(o.indexOf("show:")===0){var c=o.substr(5);var l=c.split(":");var h=l.pop();var f=l.length>0?l.join(":"):null;n["show"]=h;n["showTarget"]=f}else if(o.indexOf("focus-scroll:")===0){var v=o.substr("focus-scroll:".length);n["focusScroll"]=v=="true"}else if(a==0){n["swapStyle"]=o}else{b("Unknown modifier in hx-swap: "+o)}}}}return n}function Sr(e){return ne(e,"hx-encoding")==="multipart/form-data"||h(e,"form")&&ee(e,"enctype")==="multipart/form-data"}function Er(t,r,n){var i=null;R(r,function(e){if(i==null){i=e.encodeParameters(t,n,r)}});if(i!=null){return i}else{if(Sr(r)){return mr(n)}else{return pr(n)}}}function T(e){return{tasks:[],elts:[e]}}function Cr(e,t){var r=e[0];var n=e[e.length-1];if(t.scroll){var i=null;if(t.scrollTarget){i=ue(r,t.scrollTarget)}if(t.scroll==="top"&&(r||i)){i=i||r;i.scrollTop=0}if(t.scroll==="bottom"&&(n||i)){i=i||n;i.scrollTop=i.scrollHeight}}if(t.show){var i=null;if(t.showTarget){var a=t.showTarget;if(t.showTarget==="window"){a="body"}i=ue(r,a)}if(t.show==="top"&&(r||i)){i=i||r;i.scrollIntoView({block:"start",behavior:Q.config.scrollBehavior})}if(t.show==="bottom"&&(n||i)){i=i||n;i.scrollIntoView({block:"end",behavior:Q.config.scrollBehavior})}}}function Rr(e,t,r,n){if(n==null){n={}}if(e==null){return n}var i=te(e,t);if(i){var a=i.trim();var o=r;if(a==="unset"){return null}if(a.indexOf("javascript:")===0){a=a.substr(11);o=true}else if(a.indexOf("js:")===0){a=a.substr(3);o=true}if(a.indexOf("{")!==0){a="{"+a+"}"}var s;if(o){s=Tr(e,function(){return Function("return ("+a+")")()},{})}else{s=E(a)}for(var l in s){if(s.hasOwnProperty(l)){if(n[l]==null){n[l]=s[l]}}}}return Rr(u(e),t,r,n)}function Tr(e,t,r){if(Q.config.allowEval){return t()}else{fe(e,"htmx:evalDisallowedError");return r}}function Or(e,t){return Rr(e,"hx-vars",true,t)}function qr(e,t){return Rr(e,"hx-vals",false,t)}function Hr(e){return le(Or(e),qr(e))}function Lr(t,r,n){if(n!==null){try{t.setRequestHeader(r,n)}catch(e){t.setRequestHeader(r,encodeURIComponent(n));t.setRequestHeader(r+"-URI-AutoEncoded","true")}}}function Ar(t){if(t.responseURL&&typeof URL!=="undefined"){try{var e=new URL(t.responseURL);return e.pathname+e.search}catch(e){fe(re().body,"htmx:badResponseUrl",{url:t.responseURL})}}}function O(e,t){return t.test(e.getAllResponseHeaders())}function Nr(e,t,r){e=e.toLowerCase();if(r){if(r instanceof Element||I(r,"String")){return he(e,t,null,null,{targetOverride:p(r),returnPromise:true})}else{return he(e,t,p(r.source),r.event,{handler:r.handler,headers:r.headers,values:r.values,targetOverride:p(r.target),swapOverride:r.swap,select:r.select,returnPromise:true})}}else{return he(e,t,null,null,{returnPromise:true})}}function Ir(e){var t=[];while(e){t.push(e);e=e.parentElement}return t}function kr(e,t,r){var n;var i;if(typeof URL==="function"){i=new URL(t,document.location.href);var a=document.location.origin;n=a===i.origin}else{i=t;n=g(t,document.location.origin)}if(Q.config.selfRequestsOnly){if(!n){return false}}return ce(e,"htmx:validateUrl",le({url:i,sameHost:n},r))}function he(t,r,n,i,a,e){var o=null;var s=null;a=a!=null?a:{};if(a.returnPromise&&typeof Promise!=="undefined"){var l=new Promise(function(e,t){o=e;s=t})}if(n==null){n=re().body}var M=a.handler||Mr;var X=a.select||null;if(!se(n)){ie(o);return l}var u=a.targetOverride||ye(n);if(u==null||u==pe){fe(n,"htmx:targetError",{target:te(n,"hx-target")});ie(s);return l}var f=ae(n);var c=f.lastButtonClicked;if(c){var h=ee(c,"formaction");if(h!=null){r=h}var v=ee(c,"formmethod");if(v!=null){if(v.toLowerCase()!=="dialog"){t=v}}}var d=ne(n,"hx-confirm");if(e===undefined){var D=function(e){return he(t,r,n,i,a,!!e)};var U={target:u,elt:n,path:r,verb:t,triggeringEvent:i,etc:a,issueRequest:D,question:d};if(ce(n,"htmx:confirm",U)===false){ie(o);return l}}var g=n;var p=ne(n,"hx-sync");var m=null;var x=false;if(p){var B=p.split(":");var F=B[0].trim();if(F==="this"){g=xe(n,"hx-sync")}else{g=ue(n,F)}p=(B[1]||"drop").trim();f=ae(g);if(p==="drop"&&f.xhr&&f.abortable!==true){ie(o);return l}else if(p==="abort"){if(f.xhr){ie(o);return l}else{x=true}}else if(p==="replace"){ce(g,"htmx:abort")}else if(p.indexOf("queue")===0){var V=p.split(" ");m=(V[1]||"last").trim()}}if(f.xhr){if(f.abortable){ce(g,"htmx:abort")}else{if(m==null){if(i){var y=ae(i);if(y&&y.triggerSpec&&y.triggerSpec.queue){m=y.triggerSpec.queue}}if(m==null){m="last"}}if(f.queuedRequests==null){f.queuedRequests=[]}if(m==="first"&&f.queuedRequests.length===0){f.queuedRequests.push(function(){he(t,r,n,i,a)})}else if(m==="all"){f.queuedRequests.push(function(){he(t,r,n,i,a)})}else if(m==="last"){f.queuedRequests=[];f.queuedRequests.push(function(){he(t,r,n,i,a)})}ie(o);return l}}var b=new XMLHttpRequest;f.xhr=b;f.abortable=x;var w=function(){f.xhr=null;f.abortable=false;if(f.queuedRequests!=null&&f.queuedRequests.length>0){var e=f.queuedRequests.shift();e()}};var j=ne(n,"hx-prompt");if(j){var S=prompt(j);if(S===null||!ce(n,"htmx:prompt",{prompt:S,target:u})){ie(o);w();return l}}if(d&&!e){if(!confirm(d)){ie(o);w();return l}}var E=xr(n,u,S);if(t!=="get"&&!Sr(n)){E["Content-Type"]="application/x-www-form-urlencoded"}if(a.headers){E=le(E,a.headers)}var _=dr(n,t);var C=_.errors;var R=_.values;if(a.values){R=le(R,a.values)}var z=Hr(n);var $=le(R,z);var T=yr($,n);if(Q.config.getCacheBusterParam&&t==="get"){T["org.htmx.cache-buster"]=ee(u,"id")||"true"}if(r==null||r===""){r=re().location.href}var O=Rr(n,"hx-request");var W=ae(n).boosted;var q=Q.config.methodsThatUseUrlParams.indexOf(t)>=0;var H={boosted:W,useUrlParams:q,parameters:T,unfilteredParameters:$,headers:E,target:u,verb:t,errors:C,withCredentials:a.credentials||O.credentials||Q.config.withCredentials,timeout:a.timeout||O.timeout||Q.config.timeout,path:r,triggeringEvent:i};if(!ce(n,"htmx:configRequest",H)){ie(o);w();return l}r=H.path;t=H.verb;E=H.headers;T=H.parameters;C=H.errors;q=H.useUrlParams;if(C&&C.length>0){ce(n,"htmx:validation:halted",H);ie(o);w();return l}var G=r.split("#");var J=G[0];var L=G[1];var A=r;if(q){A=J;var Z=Object.keys(T).length!==0;if(Z){if(A.indexOf("?")<0){A+="?"}else{A+="&"}A+=pr(T);if(L){A+="#"+L}}}if(!kr(n,A,H)){fe(n,"htmx:invalidPath",H);ie(s);return l}b.open(t.toUpperCase(),A,true);b.overrideMimeType("text/html");b.withCredentials=H.withCredentials;b.timeout=H.timeout;if(O.noHeaders){}else{for(var N in E){if(E.hasOwnProperty(N)){var K=E[N];Lr(b,N,K)}}}var I={xhr:b,target:u,requestConfig:H,etc:a,boosted:W,select:X,pathInfo:{requestPath:r,finalRequestPath:A,anchor:L}};b.onload=function(){try{var e=Ir(n);I.pathInfo.responsePath=Ar(b);M(n,I);lr(k,P);ce(n,"htmx:afterRequest",I);ce(n,"htmx:afterOnLoad",I);if(!se(n)){var t=null;while(e.length>0&&t==null){var r=e.shift();if(se(r)){t=r}}if(t){ce(t,"htmx:afterRequest",I);ce(t,"htmx:afterOnLoad",I)}}ie(o);w()}catch(e){fe(n,"htmx:onLoadError",le({error:e},I));throw e}};b.onerror=function(){lr(k,P);fe(n,"htmx:afterRequest",I);fe(n,"htmx:sendError",I);ie(s);w()};b.onabort=function(){lr(k,P);fe(n,"htmx:afterRequest",I);fe(n,"htmx:sendAbort",I);ie(s);w()};b.ontimeout=function(){lr(k,P);fe(n,"htmx:afterRequest",I);fe(n,"htmx:timeout",I);ie(s);w()};if(!ce(n,"htmx:beforeRequest",I)){ie(o);w();return l}var k=or(n);var P=sr(n);oe(["loadstart","loadend","progress","abort"],function(t){oe([b,b.upload],function(e){e.addEventListener(t,function(e){ce(n,"htmx:xhr:"+t,{lengthComputable:e.lengthComputable,loaded:e.loaded,total:e.total})})})});ce(n,"htmx:beforeSend",I);var Y=q?null:Er(b,n,T);b.send(Y);return l}function Pr(e,t){var r=t.xhr;var n=null;var i=null;if(O(r,/HX-Push:/i)){n=r.getResponseHeader("HX-Push");i="push"}else if(O(r,/HX-Push-Url:/i)){n=r.getResponseHeader("HX-Push-Url");i="push"}else if(O(r,/HX-Replace-Url:/i)){n=r.getResponseHeader("HX-Replace-Url");i="replace"}if(n){if(n==="false"){return{}}else{return{type:i,path:n}}}var a=t.pathInfo.finalRequestPath;var o=t.pathInfo.responsePath;var s=ne(e,"hx-push-url");var l=ne(e,"hx-replace-url");var u=ae(e).boosted;var f=null;var c=null;if(s){f="push";c=s}else if(l){f="replace";c=l}else if(u){f="push";c=o||a}if(c){if(c==="false"){return{}}if(c==="true"){c=o||a}if(t.pathInfo.anchor&&c.indexOf("#")===-1){c=c+"#"+t.pathInfo.anchor}return{type:f,path:c}}else{return{}}}function Mr(l,u){var f=u.xhr;var c=u.target;var e=u.etc;var t=u.requestConfig;var h=u.select;if(!ce(l,"htmx:beforeOnLoad",u))return;if(O(f,/HX-Trigger:/i)){_e(f,"HX-Trigger",l)}if(O(f,/HX-Location:/i)){er();var r=f.getResponseHeader("HX-Location");var v;if(r.indexOf("{")===0){v=E(r);r=v["path"];delete v["path"]}Nr("GET",r,v).then(function(){tr(r)});return}var n=O(f,/HX-Refresh:/i)&&"true"===f.getResponseHeader("HX-Refresh");if(O(f,/HX-Redirect:/i)){location.href=f.getResponseHeader("HX-Redirect");n&&location.reload();return}if(n){location.reload();return}if(O(f,/HX-Retarget:/i)){if(f.getResponseHeader("HX-Retarget")==="this"){u.target=l}else{u.target=ue(l,f.getResponseHeader("HX-Retarget"))}}var d=Pr(l,u);var i=f.status>=200&&f.status<400&&f.status!==204;var g=f.response;var a=f.status>=400;var p=Q.config.ignoreTitle;var o=le({shouldSwap:i,serverResponse:g,isError:a,ignoreTitle:p},u);if(!ce(c,"htmx:beforeSwap",o))return;c=o.target;g=o.serverResponse;a=o.isError;p=o.ignoreTitle;u.target=c;u.failed=a;u.successful=!a;if(o.shouldSwap){if(f.status===286){at(l)}R(l,function(e){g=e.transformResponse(g,f,l)});if(d.type){er()}var s=e.swapOverride;if(O(f,/HX-Reswap:/i)){s=f.getResponseHeader("HX-Reswap")}var v=wr(l,s);if(v.hasOwnProperty("ignoreTitle")){p=v.ignoreTitle}c.classList.add(Q.config.swappingClass);var m=null;var x=null;var y=function(){try{var e=document.activeElement;var t={};try{t={elt:e,start:e?e.selectionStart:null,end:e?e.selectionEnd:null}}catch(e){}var r;if(h){r=h}if(O(f,/HX-Reselect:/i)){r=f.getResponseHeader("HX-Reselect")}if(d.type){ce(re().body,"htmx:beforeHistoryUpdate",le({history:d},u));if(d.type==="push"){tr(d.path);ce(re().body,"htmx:pushedIntoHistory",{path:d.path})}else{rr(d.path);ce(re().body,"htmx:replacedInHistory",{path:d.path})}}var n=T(c);je(v.swapStyle,c,l,g,n,r);if(t.elt&&!se(t.elt)&&ee(t.elt,"id")){var i=document.getElementById(ee(t.elt,"id"));var a={preventScroll:v.focusScroll!==undefined?!v.focusScroll:!Q.config.defaultFocusScroll};if(i){if(t.start&&i.setSelectionRange){try{i.setSelectionRange(t.start,t.end)}catch(e){}}i.focus(a)}}c.classList.remove(Q.config.swappingClass);oe(n.elts,function(e){if(e.classList){e.classList.add(Q.config.settlingClass)}ce(e,"htmx:afterSwap",u)});if(O(f,/HX-Trigger-After-Swap:/i)){var o=l;if(!se(l)){o=re().body}_e(f,"HX-Trigger-After-Swap",o)}var s=function(){oe(n.tasks,function(e){e.call()});oe(n.elts,function(e){if(e.classList){e.classList.remove(Q.config.settlingClass)}ce(e,"htmx:afterSettle",u)});if(u.pathInfo.anchor){var e=re().getElementById(u.pathInfo.anchor);if(e){e.scrollIntoView({block:"start",behavior:"auto"})}}if(n.title&&!p){var t=C("title");if(t){t.innerHTML=n.title}else{window.document.title=n.title}}Cr(n.elts,v);if(O(f,/HX-Trigger-After-Settle:/i)){var r=l;if(!se(l)){r=re().body}_e(f,"HX-Trigger-After-Settle",r)}ie(m)};if(v.settleDelay>0){setTimeout(s,v.settleDelay)}else{s()}}catch(e){fe(l,"htmx:swapError",u);ie(x);throw e}};var b=Q.config.globalViewTransitions;if(v.hasOwnProperty("transition")){b=v.transition}if(b&&ce(l,"htmx:beforeTransition",u)&&typeof Promise!=="undefined"&&document.startViewTransition){var w=new Promise(function(e,t){m=e;x=t});var S=y;y=function(){document.startViewTransition(function(){S();return w})}}if(v.swapDelay>0){setTimeout(y,v.swapDelay)}else{y()}}if(a){fe(l,"htmx:responseError",le({error:"Response Status Error Code "+f.status+" from "+u.pathInfo.requestPath},u))}}var Xr={};function Dr(){return{init:function(e){return null},onEvent:function(e,t){return true},transformResponse:function(e,t,r){return e},isInlineSwap:function(e){return false},handleSwap:function(e,t,r,n){return false},encodeParameters:function(e,t,r){return null}}}function Ur(e,t){if(t.init){t.init(r)}Xr[e]=le(Dr(),t)}function Br(e){delete Xr[e]}function Fr(e,r,n){if(e==undefined){return r}if(r==undefined){r=[]}if(n==undefined){n=[]}var t=te(e,"hx-ext");if(t){oe(t.split(","),function(e){e=e.replace(/ /g,"");if(e.slice(0,7)=="ignore:"){n.push(e.slice(7));return}if(n.indexOf(e)<0){var t=Xr[e];if(t&&r.indexOf(t)<0){r.push(t)}}})}return Fr(u(e),r,n)}var Vr=false;re().addEventListener("DOMContentLoaded",function(){Vr=true});function jr(e){if(Vr||re().readyState==="complete"){e()}else{re().addEventListener("DOMContentLoaded",e)}}function _r(){if(Q.config.includeIndicatorStyles!==false){re().head.insertAdjacentHTML("beforeend","")}}function zr(){var e=re().querySelector('meta[name="htmx-config"]');if(e){return E(e.content)}else{return null}}function $r(){var e=zr();if(e){Q.config=le(Q.config,e)}}jr(function(){$r();_r();var e=re().body;zt(e);var t=re().querySelectorAll("[hx-trigger='restored'],[data-hx-trigger='restored']");e.addEventListener("htmx:abort",function(e){var t=e.target;var r=ae(t);if(r&&r.xhr){r.xhr.abort()}});const r=window.onpopstate?window.onpopstate.bind(window):null;window.onpopstate=function(e){if(e.state&&e.state.htmx){ar();oe(t,function(e){ce(e,"htmx:restored",{document:re(),triggerEvent:ce})})}else{if(r){r(e)}}};setTimeout(function(){ce(e,"htmx:load",{});e=null},0)});return Q}()}); \ No newline at end of file diff --git a/web/static/vendor/json-enc.js b/web/static/vendor/json-enc.js new file mode 100644 index 0000000..db0aa36 --- /dev/null +++ b/web/static/vendor/json-enc.js @@ -0,0 +1,12 @@ +htmx.defineExtension('json-enc', { + onEvent: function (name, evt) { + if (name === "htmx:configRequest") { + evt.detail.headers['Content-Type'] = "application/json"; + } + }, + + encodeParameters : function(xhr, parameters, elt) { + xhr.overrideMimeType('text/json'); + return (JSON.stringify(parameters)); + } +}); \ No newline at end of file diff --git a/web/templates/edit_block.html b/web/templates/edit_block.html new file mode 100644 index 0000000..8bc35f9 --- /dev/null +++ b/web/templates/edit_block.html @@ -0,0 +1,7 @@ +{{define "edit_block.html"}} +

Nota atual: {{if .Reg.Nota}}{{.Reg.Nota}}{{else}}(sem nota){{end}}

+ {{template "nota_block.html" .}} + {{if .Reg.Nota}} + {{template "justificativa_block.html" .}} + {{end}} +{{end}} diff --git a/web/templates/form_inner.html b/web/templates/form_inner.html new file mode 100644 index 0000000..3b2fa33 --- /dev/null +++ b/web/templates/form_inner.html @@ -0,0 +1,18 @@ +{{define "form_inner.html"}} + {{/* This block can be swapped by HTMX (target #eli-nps-modal-body) */}} + {{if eq .Reg.Status "respondido"}} +
+

Obrigado!

+

Sua resposta foi registrada. Você pode editar se quiser.

+ {{template "edit_block.html" .}} +
+ {{else}} +

De 1 a 10, quanto você recomenda {{if .Reg.ProdutoNome}}{{.Reg.ProdutoNome}}{{else}}{{produtoLabel .Produto}}{{end}} para um amigo?

+ {{template "nota_block.html" .}} + {{if .Reg.Nota}} + {{template "justificativa_block.html" .}} + {{end}} + {{end}} + +

e-li.nps • produto: {{if .Reg.ProdutoNome}}{{.Reg.ProdutoNome}}{{else}}{{.Produto}}{{end}} • id: {{.ID}}

+{{end}} diff --git a/web/templates/form_page.html b/web/templates/form_page.html new file mode 100644 index 0000000..c2cd634 --- /dev/null +++ b/web/templates/form_page.html @@ -0,0 +1,117 @@ +{{define "form_page.html"}} + + + + + + e-li.nps + {{/* + Importante: evitamos SRI aqui porque o hash pode mudar entre CDNs/builds. + Servimos arquivos locais para garantir estabilidade do widget. + */}} + + + + + +
+ {{template "form_inner.html" .}} +
+ + +{{end}} diff --git a/web/templates/funcs.html b/web/templates/funcs.html new file mode 100644 index 0000000..ea50acc --- /dev/null +++ b/web/templates/funcs.html @@ -0,0 +1,2 @@ +{{define "funcs"}}{{end}} + diff --git a/web/templates/justificativa_block.html b/web/templates/justificativa_block.html new file mode 100644 index 0000000..a98718f --- /dev/null +++ b/web/templates/justificativa_block.html @@ -0,0 +1,59 @@ +{{define "justificativa_block.html"}} +

Quer nos contar o motivo?

+ +
+ + +
+ + +
+
+ + +{{end}} + diff --git a/web/templates/nota_block.html b/web/templates/nota_block.html new file mode 100644 index 0000000..fadd8a5 --- /dev/null +++ b/web/templates/nota_block.html @@ -0,0 +1,14 @@ +{{define "nota_block.html"}} +
+ {{range $i := seq 1 10}} + + {{end}} +
+{{end}}