primeira versão do e-li-nps construido com IA
This commit is contained in:
commit
06950d6e2c
34 changed files with 2524 additions and 0 deletions
11
.dockerignore
Normal file
11
.dockerignore
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
.git
|
||||
.gitignore
|
||||
.env
|
||||
server
|
||||
|
||||
# IDE
|
||||
.vscode
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
|
||||
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
.env
|
||||
44
Dockerfile
Normal file
44
Dockerfile
Normal 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
298
README.md
Normal 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
159
cmd/server/main.go
Normal 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
17
docker-compose.yml
Normal 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
24
docker-entrypoint.sh
Normal 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
20
go.mod
Normal 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
34
go.sum
Normal 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
35
internal/db/pool.go
Normal 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
133
internal/db/schema.go
Normal 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
183
internal/elinps/handlers.go
Normal 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)
|
||||
}
|
||||
28
internal/elinps/middleware.go
Normal file
28
internal/elinps/middleware.go
Normal 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
40
internal/elinps/models.go
Normal 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
319
internal/elinps/painel.go
Normal 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)
|
||||
51
internal/elinps/painel_handlers.go
Normal file
51
internal/elinps/painel_handlers.go
Normal 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)
|
||||
}
|
||||
157
internal/elinps/painel_queries.go
Normal file
157
internal/elinps/painel_queries.go
Normal 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 (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
|
||||
154
internal/elinps/queries.go
Normal file
154
internal/elinps/queries.go
Normal 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(
|
||||
®.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
|
||||
}
|
||||
134
internal/elinps/readme_page.go
Normal file
134
internal/elinps/readme_page.go
Normal 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
22
internal/elinps/render.go
Normal 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
|
||||
}
|
||||
43
internal/elinps/templates.go
Normal file
43
internal/elinps/templates.go
Normal 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))
|
||||
}
|
||||
66
internal/elinps/validate.go
Normal file
66
internal/elinps/validate.go
Normal 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
31
migrations/001_init.sql
Normal 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
BIN
server
Executable file
Binary file not shown.
203
web/static/e-li.nps.js
Normal file
203
web/static/e-li.nps.js
Normal 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
87
web/static/teste.html
Normal 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
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
12
web/static/vendor/json-enc.js
vendored
Normal 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));
|
||||
}
|
||||
});
|
||||
7
web/templates/edit_block.html
Normal file
7
web/templates/edit_block.html
Normal 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}}
|
||||
18
web/templates/form_inner.html
Normal file
18
web/templates/form_inner.html
Normal 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}}
|
||||
117
web/templates/form_page.html
Normal file
117
web/templates/form_page.html
Normal 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 (1–10) 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
|
||||
*/
|
||||
|
||||
/* 1–6 (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;}
|
||||
|
||||
/* 7–8 (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;}
|
||||
|
||||
/* 9–10 (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
2
web/templates/funcs.html
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
{{define "funcs"}}{{end}}
|
||||
|
||||
59
web/templates/justificativa_block.html
Normal file
59
web/templates/justificativa_block.html
Normal 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}}
|
||||
|
||||
14
web/templates/nota_block.html
Normal file
14
web/templates/nota_block.html
Normal 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}}
|
||||
Loading…
Add table
Add a link
Reference in a new issue