primeira versão do e-li-nps construido com IA

This commit is contained in:
Luiz Silva 2025-12-31 11:18:20 -03:00
commit 06950d6e2c
34 changed files with 2524 additions and 0 deletions

11
.dockerignore Normal file
View file

@ -0,0 +1,11 @@
.git
.gitignore
.env
server
# IDE
.vscode
# OS
.DS_Store

1
.gitignore vendored Normal file
View file

@ -0,0 +1 @@
.env

44
Dockerfile Normal file
View file

@ -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"]

298
README.md Normal file
View file

@ -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> | 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
<!-- Carrega o widget (arquivo único) -->
<script src="http://localhost:8080/static/e-li.nps.js"></script>
<script>
window.ELiNPS.init({
// apiBase (opcional)
// Base da API do e-li.nps.
// - Se o widget estiver sendo servido pelo mesmo host, pode deixar vazio.
// - Se a API estiver em outro host, informe a URL completa.
// Ex.: "https://sua-api.exemplo.com".
apiBase: 'http://localhost:8080',
// cooldownHours (opcional)
// Tempo (em horas) de cooldown visual no navegador para evitar o modal
// reaparecer em sequência.
// Default: 24.
cooldownHours: 24,
// data_minima_abertura (opcional)
// Bloqueia a abertura do modal antes de uma data.
// Formato ISO (data): YYYY-MM-DD (ex.: "2026-01-01").
// Ex.: data_minima_abertura: '2026-01-01',
data_minima_abertura: '',
// produto_nome (obrigatório)
// Nome livre do produto (é exibido ao usuário exatamente como informado).
// Exemplos: "e-licencie.gov", "Cachaça & Churras".
// Importante: o backend normaliza apenas para montar nome de tabela/rotas.
produto_nome: 'e-licencie.gov',
// inquilino_codigo (obrigatório)
// Código do cliente/tenant (usado nas regras de exibição e no banco).
inquilino_codigo: 'acme',
// inquilino_nome (obrigatório)
// Nome do cliente/tenant (exibição / auditoria).
inquilino_nome: 'ACME LTDA',
// usuario_codigo (obrigatório)
// Identificador do usuário.
// Importante: é a chave principal para as regras de exibição.
usuario_codigo: 'u-123',
// usuario_nome (obrigatório)
// Nome do usuário (exibição / auditoria).
usuario_nome: 'Maria',
// usuario_telefone (opcional)
// Telefone do usuário (auditoria). Pode ser vazio.
usuario_telefone: '+55 11 99999-9999',
// usuario_email (opcional)
// Email do usuário. É opcional: o controle de exibição é por
// (produto + inquilino_codigo + usuario_codigo).
usuario_email: 'maria@acme.com',
});
</script>
```
## 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/<id> \
-H 'Content-Type: application/json' \
-d '{"nota":10}'
```
Finalizar:
```bash
curl -sS -X PATCH http://localhost:8080/api/e-li.nps/elicencie_gov/<id> \
-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**.

159
cmd/server/main.go Normal file
View file

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

17
docker-compose.yml Normal file
View file

@ -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"

24
docker-entrypoint.sh Normal file
View file

@ -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

20
go.mod Normal file
View file

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

34
go.sum Normal file
View file

@ -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=

35
internal/db/pool.go Normal file
View file

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

133
internal/db/schema.go Normal file
View file

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

183
internal/elinps/handlers.go Normal file
View file

@ -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)
}

View file

@ -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)
})
}
}

40
internal/elinps/models.go Normal file
View file

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

319
internal/elinps/painel.go Normal file
View file

@ -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("<!doctype html><html lang=\"pt-br\"><head><meta charset=\"utf-8\"/><meta name=\"viewport\" content=\"width=device-width, initial-scale=1\"/>")
b.WriteString("<title>e-li.nps • Painel</title>")
b.WriteString(`<style>
body{font-family:system-ui,-apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif;margin:0;padding:18px;background:#fafafa;color:#111;}
.top{display:flex;gap:12px;flex-wrap:wrap;align-items:center;}
.card{background:#fff;border:1px solid #e5e5e5;border-radius:12px;padding:14px;}
select,input{padding:10px;border:1px solid #ddd;border-radius:10px;}
a{color:#111}
table{width:100%;border-collapse:collapse;font-size:13px;}
th,td{padding:8px;border-bottom:1px solid #eee;text-align:left;vertical-align:top;}
.muted{color:#666;font-size:12px;}
.badge{display:inline-block;padding:2px 8px;border-radius:999px;background:#f2f2f2;border:1px solid #e5e5e5;font-size:12px;}
</style></head><body>`)
b.WriteString("<div class=\"top\">")
b.WriteString("<div class=\"card\"><h1 style=\"margin:0 0 8px\">e-li.nps • Painel</h1>")
if d.MsgErro != "" {
b.WriteString("<p class=\"badge\">" + template.HTMLEscapeString(d.MsgErro) + "</p>")
}
b.WriteString("<form method=\"GET\" action=\"/painel\" style=\"display:flex;gap:10px;flex-wrap:wrap;align-items:center\">")
b.WriteString("<label class=\"muted\">Produto</label>")
b.WriteString("<select name=\"produto\">")
for _, p := range d.Produtos {
sel := ""
if p == d.Produto {
sel = " selected"
}
b.WriteString("<option value=\"" + template.HTMLEscapeString(p) + "\"" + sel + ">" + template.HTMLEscapeString(p) + "</option>")
}
b.WriteString("</select>")
chk := ""
if d.SomenteBaixas {
chk = "checked"
}
b.WriteString("<label class=\"muted\"><input type=\"checkbox\" name=\"baixas\" value=\"1\" " + chk + "/> notas baixas (<=6)</label>")
b.WriteString("<button type=\"submit\" style=\"padding:10px 14px;border-radius:10px;border:1px solid #111;background:#111;color:#fff;cursor:pointer\">Aplicar</button>")
b.WriteString("</form></div>")
b.WriteString("</div>")
// NPS mês a mês
b.WriteString("<div class=\"card\" style=\"margin-top:12px\"><h2 style=\"margin:0 0 8px\">NPS mês a mês</h2>")
b.WriteString("<table><thead><tr><th>Mês</th><th>Detratores</th><th>Neutros</th><th>Promotores</th><th>Total</th><th>NPS</th></tr></thead><tbody>")
for _, m := range d.Meses {
b.WriteString(fmt.Sprintf("<tr><td>%s</td><td>%d</td><td>%d</td><td>%d</td><td>%d</td><td><b>%d</b></td></tr>",
template.HTMLEscapeString(m.Mes), m.Detratores, m.Neutros, m.Promotores, m.Total, m.NPS))
}
b.WriteString("</tbody></table></div>")
// Respostas
b.WriteString("<div class=\"card\" style=\"margin-top:12px\"><h2 style=\"margin:0 0 8px\">Respostas</h2>")
b.WriteString("<table><thead><tr><th>Data</th><th>Nota</th><th>Usuário</th><th>Comentário</th></tr></thead><tbody>")
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 += " <span class=\"muted\">(" + template.HTMLEscapeString(*r.UsuarioCodigo) + ")</span>"
}
coment := ""
if r.Justificativa != nil {
coment = template.HTMLEscapeString(*r.Justificativa)
}
b.WriteString("<tr><td>" + template.HTMLEscapeString(data) + "</td><td><b>" + template.HTMLEscapeString(nota) + "</b></td><td>" + usuario + "</td><td>" + coment + "</td></tr>")
}
b.WriteString("</tbody></table>")
// 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("<div style=\"display:flex;gap:10px;justify-content:flex-end;margin-top:10px\">")
b.WriteString("<a class=\"badge\" href=\"" + base + "&pagina=" + fmt.Sprintf("%d", prev) + "\">Anterior</a>")
b.WriteString("<span class=\"muted\">Página " + fmt.Sprintf("%d", d.Pagina) + "</span>")
b.WriteString("<a class=\"badge\" href=\"" + base + "&pagina=" + fmt.Sprintf("%d", next) + "\">Próxima</a>")
b.WriteString("</div>")
b.WriteString("</div>")
b.WriteString("</body></html>")
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(`<!doctype html>
<html lang="pt-br">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>e-li.nps Painel</title>
<style>
body{font-family:system-ui,-apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif;padding:24px;}
.card{max-width:420px;margin:0 auto;border:1px solid #e5e5e5;border-radius:12px;padding:16px;}
label{display:block;font-size:12px;color:#444;margin-bottom:6px;}
input{width:100%;padding:10px;border:1px solid #ddd;border-radius:10px;}
button{margin-top:12px;width:100%;padding:10px 14px;border-radius:10px;border:1px solid #111;background:#111;color:#fff;cursor:pointer;}
.muted{color:#555;font-size:13px;}
</style>
</head>
<body>
<div class="card">
<h1>e-li.nps Painel</h1>
<p class="muted">Acesso protegido por senha (SENHA_PAINEL).</p>
<form method="POST" action="/painel/login">
<label>Senha</label>
<input type="password" name="senha" autocomplete="current-password" />
<button type="submit">Entrar</button>
</form>
</div>
</body>
</html>`))
}
// (handlerLoginPost duplicado removido)

View file

@ -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)
}

View file

@ -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 (110):
// - 16 detratores
// - 78 neutros
// - 910 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

154
internal/elinps/queries.go Normal file
View file

@ -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(
&reg.ID, &reg.ProdutoNome, &reg.Status, &reg.Nota, &reg.Justificativa, &reg.PedidoCriadoEm, &reg.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
}

View file

@ -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 := `<!doctype html>
<html lang="pt-br">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>e-li.nps README</title>
<style>
body{font-family:system-ui,-apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif;margin:0;background:#fafafa;color:#111;}
.wrap{max-width:980px;margin:0 auto;padding:24px;}
.card{background:#fff;border:1px solid #e5e5e5;border-radius:12px;padding:22px;}
h1,h2,h3{margin-top:1.2em;}
pre{background:#0b1020;color:#e6e6e6;padding:14px;border-radius:12px;overflow:auto;}
code{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,\"Liberation Mono\",\"Courier New\",monospace;}
table{border-collapse:collapse;width:100%;}
th,td{border:1px solid #e5e5e5;padding:8px;text-align:left;}
a{color:#111;}
.muted{color:#666;font-size:12px;}
</style>
</head>
<body>
<div class="wrap">
<div class="card">
<!--CONTEUDO_README-->
<p class="muted" style="margin-top:16px;">Página gerada automaticamente a partir de README.md</p>
</div>
</div>
</body>
</html>`
html := []byte(strings.Replace(page, "<!--CONTEUDO_README-->", 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, ""
}

22
internal/elinps/render.go Normal file
View file

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

View file

@ -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))
}

View file

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

31
migrations/001_init.sql Normal file
View file

@ -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';

BIN
server Executable file

Binary file not shown.

203
web/static/e-li.nps.js Normal file
View file

@ -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);
}
};
})();

87
web/static/teste.html Normal file
View file

@ -0,0 +1,87 @@
<!doctype html>
<html lang="pt-br">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>e-li.nps • Teste</title>
<style>
body{font-family:system-ui,-apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif;padding:24px;}
.card{max-width:760px;margin:0 auto;border:1px solid #e5e5e5;border-radius:12px;padding:16px;}
.row{display:flex;gap:12px;flex-wrap:wrap;}
label{display:block;font-size:12px;color:#444;margin-bottom:6px;}
input{width:280px;max-width:100%;padding:10px;border:1px solid #ddd;border-radius:10px;}
button{padding:10px 14px;border-radius:10px;border:1px solid #111;background:#111;color:#fff;cursor:pointer;}
code{background:#f6f6f6;padding:2px 6px;border-radius:6px;}
.muted{color:#555;font-size:13px;}
</style>
</head>
<body>
<div class="card">
<h1>e-li.nps • Página de teste</h1>
<p class="muted">
Esta página carrega <code>/static/e-li.nps.js</code> e dispara <code>window.ELiNPS.init()</code>.
Se a API permitir, abrirá o modal (iframe) com o formulário HTMX.
</p>
<div class="row">
<div>
<label>produto_nome</label>
<input id="produto" value="e-licencie.ind" />
</div>
<div>
<label>inquilino_codigo</label>
<input id="inquilino_codigo" value="acme" />
</div>
<div>
<label>inquilino_nome</label>
<input id="inquilino_nome" value="ACME LTDA" />
</div>
<div>
<label>usuario_codigo</label>
<input id="usuario_codigo" value="u-123" />
</div>
<div>
<label>usuario_nome</label>
<input id="usuario_nome" value="Maria" />
</div>
<div>
<label>usuario_telefone</label>
<input id="usuario_telefone" value="+55 11 99999-9999" />
</div>
<div>
<label>usuario_email (opcional)</label>
<input id="usuario_email" value="maria@acme.com" />
</div>
</div>
<p style="margin-top:16px;">
<button id="btn">Abrir NPS</button>
</p>
<p class="muted">
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.
</p>
</div>
<script src="/static/e-li.nps.js"></script>
<script>
function read(id){ return document.getElementById(id).value; }
document.getElementById('btn').addEventListener('click', function(){
window.ELiNPS.init({
apiBase: window.location.origin,
// Bloqueia abertura antes de uma data (YYYY-MM-DD).
// Ex.: "2026-01-01".
// data_minima_abertura: '2026-01-01',
produto_nome: read('produto'),
inquilino_codigo: read('inquilino_codigo'),
inquilino_nome: read('inquilino_nome'),
usuario_codigo: read('usuario_codigo'),
usuario_nome: read('usuario_nome'),
usuario_telefone: read('usuario_telefone'),
usuario_email: read('usuario_email')
});
});
</script>
</body>
</html>

1
web/static/vendor/htmx.min.js vendored Normal file

File diff suppressed because one or more lines are too long

12
web/static/vendor/json-enc.js vendored Normal file
View file

@ -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));
}
});

View file

@ -0,0 +1,7 @@
{{define "edit_block.html"}}
<p class="eli-nps-sub">Nota atual: {{if .Reg.Nota}}{{.Reg.Nota}}{{else}}(sem nota){{end}}</p>
{{template "nota_block.html" .}}
{{if .Reg.Nota}}
{{template "justificativa_block.html" .}}
{{end}}
{{end}}

View file

@ -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"}}
<div class="eli-nps-ok">
<p class="eli-nps-title">Obrigado!</p>
<p class="eli-nps-sub">Sua resposta foi registrada. Você pode editar se quiser.</p>
{{template "edit_block.html" .}}
</div>
{{else}}
<p class="eli-nps-title">De 1 a 10, quanto você recomenda {{if .Reg.ProdutoNome}}{{.Reg.ProdutoNome}}{{else}}{{produtoLabel .Produto}}{{end}} para um amigo?</p>
{{template "nota_block.html" .}}
{{if .Reg.Nota}}
{{template "justificativa_block.html" .}}
{{end}}
{{end}}
<p class="eli-nps-foot">e-li.nps • produto: {{if .Reg.ProdutoNome}}{{.Reg.ProdutoNome}}{{else}}{{.Produto}}{{end}} • id: {{.ID}}</p>
{{end}}

View file

@ -0,0 +1,117 @@
{{define "form_page.html"}}
<!doctype html>
<html lang="pt-br">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>e-li.nps</title>
{{/*
Importante: evitamos SRI aqui porque o hash pode mudar entre CDNs/builds.
Servimos arquivos locais para garantir estabilidade do widget.
*/}}
<script src="/static/vendor/htmx.min.js"></script>
<script src="/static/vendor/json-enc.js"></script>
<style>
.eli-nps-wrap{font-family:system-ui,-apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif;color:#111;margin:0;padding:0;}
.eli-nps-card{max-width:520px;margin:0 auto;padding:16px;}
.eli-nps-title{font-size:18px;font-weight:700;margin:0 0 8px;}
.eli-nps-sub{margin:0 0 12px;color:#444;font-size:14px;}
.eli-nps-scale{display:grid;grid-template-columns:repeat(10,1fr);gap:8px;margin:12px 0;}
/*
Botões de nota (110) com escala vermelho → verde.
Importante: a cor base é determinada pelo valor; quando selecionado,
o botão ganha destaque (borda/sombra) mas mantém a cor.
*/
.eli-nps-btn{
border:1px solid #ddd;
background:#fff;
border-radius:8px;
padding:10px 0;
cursor:pointer;
font-weight:700;
color:#111;
transition:
transform 80ms ease,
box-shadow 120ms ease,
border-color 120ms ease,
background-color 120ms ease,
color 120ms ease;
}
/*
Base mais “clara” (pastel) e destaque mais “forte” no hover/seleção.
Isso evita começar chamativo demais, mas deixa bem evidente ao interagir.
*/
.eli-nps-btn:hover,
.eli-nps-btn-selected{transform:translateY(-1px);box-shadow:0 6px 14px rgba(0,0,0,.14);border-color:#111;}
.eli-nps-btn:active{transform:translateY(0) scale(0.98);}
/*
Paleta por grupo:
- 1 a 6: tons de vermelho
- 7 e 8: tons de amarelo
- 9 e 10: tons de verde
*/
/* 16 (vermelho) */
.eli-nps-btn-1{background:#ffebee;border-color:#ffcdd2;}
.eli-nps-btn-1:hover,.eli-nps-btn-1.eli-nps-btn-selected{background:#ef5350;color:#fff;border-color:#ef5350;}
.eli-nps-btn-2{background:#ffebee;border-color:#ffcdd2;}
.eli-nps-btn-2:hover,.eli-nps-btn-2.eli-nps-btn-selected{background:#e53935;color:#fff;border-color:#e53935;}
.eli-nps-btn-3{background:#ffebee;border-color:#ffcdd2;}
.eli-nps-btn-3:hover,.eli-nps-btn-3.eli-nps-btn-selected{background:#d32f2f;color:#fff;border-color:#d32f2f;}
.eli-nps-btn-4{background:#ffebee;border-color:#ffcdd2;}
.eli-nps-btn-4:hover,.eli-nps-btn-4.eli-nps-btn-selected{background:#c62828;color:#fff;border-color:#c62828;}
.eli-nps-btn-5{background:#ffebee;border-color:#ffcdd2;}
.eli-nps-btn-5:hover,.eli-nps-btn-5.eli-nps-btn-selected{background:#b71c1c;color:#fff;border-color:#b71c1c;}
.eli-nps-btn-6{background:#ffebee;border-color:#ffcdd2;}
.eli-nps-btn-6:hover,.eli-nps-btn-6.eli-nps-btn-selected{background:#8e0000;color:#fff;border-color:#8e0000;}
/* 78 (amarelo) */
.eli-nps-btn-7{background:#fffde7;border-color:#fff9c4;}
.eli-nps-btn-7:hover,.eli-nps-btn-7.eli-nps-btn-selected{background:#fdd835;color:#111;border-color:#fdd835;}
.eli-nps-btn-8{background:#fff8e1;border-color:#ffecb3;}
.eli-nps-btn-8:hover,.eli-nps-btn-8.eli-nps-btn-selected{background:#ffb300;color:#111;border-color:#ffb300;}
/* 910 (verde) */
.eli-nps-btn-9{background:#e8f5e9;border-color:#c8e6c9;}
.eli-nps-btn-9:hover,.eli-nps-btn-9.eli-nps-btn-selected{background:#2e7d32;color:#fff;border-color:#2e7d32;}
.eli-nps-btn-10{background:#e8f5e9;border-color:#c8e6c9;}
.eli-nps-btn-10:hover,.eli-nps-btn-10.eli-nps-btn-selected{background:#1b5e20;color:#fff;border-color:#1b5e20;}
.eli-nps-textarea{width:100%;min-height:100px;border:1px solid #ddd;border-radius:10px;padding:10px;font-size:14px;}
.eli-nps-actions{display:flex;gap:10px;margin-top:12px;}
.eli-nps-primary{background:#111;color:#fff;border:1px solid #111;border-radius:10px;padding:10px 14px;cursor:pointer;}
.eli-nps-secondary{background:#fff;color:#111;border:1px solid #ddd;border-radius:10px;padding:10px 14px;cursor:pointer;}
.eli-nps-foot{margin-top:10px;color:#666;font-size:12px;}
.eli-nps-ok{padding:16px;border:1px solid #e5e5e5;border-radius:12px;background:#fafafa;}
/*
Ajustes para dispositivos móveis.
Objetivo: manter leitura confortável e botões clicáveis sem ficar apertado.
*/
@media (max-width: 480px){
.eli-nps-card{max-width:none;padding:12px;}
.eli-nps-title{font-size:16px;line-height:1.25;}
.eli-nps-sub{font-size:13px;}
.eli-nps-scale{grid-template-columns:repeat(5,1fr);gap:10px;}
.eli-nps-btn{padding:12px 0;border-radius:10px;}
.eli-nps-actions{flex-direction:column;}
.eli-nps-primary,.eli-nps-secondary{width:100%;}
.eli-nps-textarea{min-height:120px;}
}
</style>
</head>
<body class="eli-nps-wrap">
<div class="eli-nps-card" id="eli-nps-modal-body">
{{template "form_inner.html" .}}
</div>
</body>
</html>
{{end}}

2
web/templates/funcs.html Normal file
View file

@ -0,0 +1,2 @@
{{define "funcs"}}{{end}}

View file

@ -0,0 +1,59 @@
{{define "justificativa_block.html"}}
<p class="eli-nps-sub" style="margin-top:16px;">Quer nos contar o motivo?</p>
<form
hx-patch="/api/e-li.nps/{{.Produto}}/{{.ID}}"
hx-target="#eli-nps-modal-body"
hx-swap="innerHTML"
hx-ext="json-enc"
>
<textarea
class="eli-nps-textarea"
name="justificativa"
id="eli-nps-justificativa"
>{{if .Reg.Justificativa}}{{.Reg.Justificativa}}{{end}}</textarea>
<div class="eli-nps-actions">
<button class="eli-nps-primary" type="button" onclick="window.__eliNpsSubmitJust('{{.Produto}}','{{.ID}}')">Enviar</button>
<button class="eli-nps-secondary" type="button" onclick="window.__eliNpsFinalizar('{{.Produto}}','{{.ID}}')">Finalizar</button>
</div>
</form>
<script>
// Funções auxiliares usadas pelo iframe.
// Elas notificarão o widget pai (janela que contém o iframe) para fechar o modal.
window.__eliNpsSubmitJust = async function(produto, id){
const v = document.getElementById('eli-nps-justificativa')?.value || '';
// Regra do produto: ao clicar em "Enviar" após escolher a nota,
// consideramos a resposta como finalizada e fechamos o modal.
const res = await fetch(`/api/e-li.nps/${produto}/${id}`, {
method: 'PATCH',
headers: {'Content-Type':'application/json','HX-Request':'true'},
body: JSON.stringify({justificativa: v, finalizar:true})
});
if(!res.ok){ return; }
// Close parent widget modal (iframe).
try{ parent.postMessage({type:'eli-nps:done', id:id, produto:produto}, '*'); }catch(e){}
}
window.__eliNpsFinalizar = async function(produto, id){
const v = document.getElementById('eli-nps-justificativa')?.value || '';
const res = await fetch(`/api/e-li.nps/${produto}/${id}`, {
method: 'PATCH',
headers: {'Content-Type':'application/json','HX-Request':'true'},
body: JSON.stringify({justificativa: v, finalizar:true})
});
if(!res.ok){ return; }
const html = await res.text();
document.getElementById('eli-nps-modal-body').innerHTML = html;
// notify parent widget to close
try{ parent.postMessage({type:'eli-nps:done', id:id, produto:produto}, '*'); }catch(e){}
}
</script>
{{end}}

View file

@ -0,0 +1,14 @@
{{define "nota_block.html"}}
<div class="eli-nps-scale">
{{range $i := seq 1 10}}
<button
class="eli-nps-btn eli-nps-btn-{{$i}} {{if noteEq $.Reg.Nota $i}}eli-nps-btn-selected{{end}}"
hx-patch="/api/e-li.nps/{{$.Produto}}/{{$.ID}}"
hx-target="#eli-nps-modal-body"
hx-swap="innerHTML"
hx-ext="json-enc"
hx-vals='{"nota":{{$i}}}'
>{{$i}}</button>
{{end}}
</div>
{{end}}