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
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
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue