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