refatoração de tipagem go

This commit is contained in:
Luiz Silva 2026-01-01 20:03:47 -03:00
parent 6f78511946
commit 0c41ed4279
12 changed files with 175 additions and 117 deletions

View file

@ -131,6 +131,12 @@ func main() {
http.ServeFile(w, r, "web/static/teste.html")
})
// Conveniência: favicon.
// O arquivo fica em web/static/favicon.ico.
r.Get("/favicon.ico", func(w http.ResponseWriter, r *http.Request) {
http.ServeFile(w, r, "web/static/favicon.ico")
})
// Rotas NPS.
h := elinps.NewHandlers(pool)
r.Route("/api/e-li.nps", func(r chi.Router) {

View file

@ -6,6 +6,8 @@ import (
"regexp"
"strings"
"syscall/js"
"e-li.nps/internal/contratos"
)
// IMPORTANTE (.agent): o backend Go continua sendo a autoridade das regras.
@ -14,17 +16,7 @@ import (
var dataISORe = regexp.MustCompile(`^([0-9]{4})-([0-9]{2})-([0-9]{2})$`)
type cfgWidget struct {
ProdutoNome string
InquilinoCodigo string
InquilinoNome string
UsuarioCodigo string
UsuarioNome string
UsuarioTelefone string
UsuarioEmail string
CooldownHours float64
DataMinimaAbertura string
}
type cfgWidget = contratos.ConfigWidget
func main() {
js.Global().Set("__eli_nps_wasm_preflight", js.FuncOf(preflight))

128
internal/contratos/tipos.go Normal file
View file

@ -0,0 +1,128 @@
package contratos
import "time"
// Tipos centralizados do projeto.
//
// Regra: este arquivo concentra as tipagens (structs) usadas como contratos de
// dados entre camadas (backend, painel e widget/WASM), para manter consistência
// e facilitar auditoria.
// ------------------------------
// API do widget
// ------------------------------
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"`
// Produto normalizado retornado pelo backend para montar URL segura.
Produto string `json:"produto,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
}
// FormPageData é o payload para renderização do formulário no iframe.
type FormPageData struct {
Produto string
ID string
Reg Registro
}
// ------------------------------
// Painel
// ------------------------------
// 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
}
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
}
}
// ------------------------------
// Widget/WASM
// ------------------------------
// ConfigWidget representa as opções passadas para window.ELiNPS.init(...).
// No WASM lemos via syscall/js; aqui fica apenas a tipagem centralizada.
type ConfigWidget struct {
ProdutoNome string
InquilinoCodigo string
InquilinoNome string
UsuarioCodigo string
UsuarioNome string
UsuarioTelefone string
UsuarioEmail string
CooldownHours float64
DataMinimaAbertura string
}

View file

@ -7,6 +7,7 @@ import (
"net/http"
"strings"
"e-li.nps/internal/contratos"
"e-li.nps/internal/db"
"github.com/go-chi/chi/v5"
@ -29,7 +30,7 @@ func NewHandlers(pool *pgxpool.Pool) *Handlers {
func (h *Handlers) PostPedido(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
var in PedidoInput
var in contratos.PedidoInput
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]any{"error": "json_invalido"})
return
@ -55,29 +56,29 @@ func (h *Handlers) PostPedido(w http.ResponseWriter, r *http.Request) {
if err != nil {
slog.Error("erro ao checar resposta recente", "err", err)
// Fail-closed.
writeJSON(w, http.StatusOK, PedidoResponse{PodeAbrir: false, Motivo: "erro"})
writeJSON(w, http.StatusOK, contratos.PedidoResponse{PodeAbrir: false, Motivo: "erro"})
return
}
if respRecente {
writeJSON(w, http.StatusOK, PedidoResponse{PodeAbrir: false, Motivo: "resposta_recente"})
writeJSON(w, http.StatusOK, contratos.PedidoResponse{PodeAbrir: false, Motivo: "resposta_recente"})
return
}
pedidoAberto, err := h.store.HasPedidoEmAbertoRecente(ctx, table, in.InquilinoCodigo, in.UsuarioCodigo)
if err != nil {
slog.Error("erro ao checar pedido em aberto", "err", err)
writeJSON(w, http.StatusOK, PedidoResponse{PodeAbrir: false, Motivo: "erro"})
writeJSON(w, http.StatusOK, contratos.PedidoResponse{PodeAbrir: false, Motivo: "erro"})
return
}
if pedidoAberto {
writeJSON(w, http.StatusOK, PedidoResponse{PodeAbrir: false, Motivo: "pedido_em_aberto"})
writeJSON(w, http.StatusOK, contratos.PedidoResponse{PodeAbrir: false, Motivo: "pedido_em_aberto"})
return
}
id, err := h.store.CreatePedido(ctx, table, in, r)
if err != nil {
slog.Error("erro ao criar pedido", "err", err)
writeJSON(w, http.StatusOK, PedidoResponse{PodeAbrir: false, Motivo: "erro"})
writeJSON(w, http.StatusOK, contratos.PedidoResponse{PodeAbrir: false, Motivo: "erro"})
return
}
@ -102,7 +103,7 @@ func (h *Handlers) PatchResposta(w http.ResponseWriter, r *http.Request) {
return
}
var in PatchInput
var in contratos.PatchInput
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]any{"error": "json_invalido"})
return
@ -132,7 +133,7 @@ func (h *Handlers) PatchResposta(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("db"))
return
}
data := FormPageData{Produto: prod, ID: id, Reg: reg}
data := contratos.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
@ -173,7 +174,7 @@ func (h *Handlers) GetForm(w http.ResponseWriter, r *http.Request) {
return
}
data := FormPageData{
data := contratos.FormPageData{
Produto: prod,
ID: id,
Reg: reg,

View file

@ -1,40 +0,0 @@
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
}

View file

@ -9,6 +9,7 @@ import (
"strings"
"time"
"e-li.nps/internal/contratos"
"e-li.nps/internal/db"
"github.com/jackc/pgx/v5"
)
@ -57,37 +58,11 @@ func (a AuthPainel) handlerLoginPost(w http.ResponseWriter, r *http.Request) {
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
}
type NPSMensal = contratos.NPSMensal
// 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 RespostaPainel = contratos.RespostaPainel
type PainelDados struct {
Produto string
Produtos []string
Meses []NPSMensal
Respostas []RespostaPainel
Pagina int
SomenteBaixas bool
MsgErro string
}
type PainelDados = contratos.PainelDados
func (a AuthPainel) handlerPainel(w http.ResponseWriter, r *http.Request, store *Store) {
ctx := r.Context()
@ -145,7 +120,7 @@ func (a AuthPainel) handlerPainel(w http.ResponseWriter, r *http.Request, store
if err != nil {
// Se a tabela ainda não tem coluna ip_real/ etc, EnsureNPSTable deveria ajustar.
if err == pgx.ErrNoRows {
respostas = []RespostaPainel{}
respostas = []contratos.RespostaPainel{}
} else {
dados.MsgErro = "erro ao listar respostas"
}

View file

@ -6,6 +6,7 @@ import (
"strings"
"time"
"e-li.nps/internal/contratos"
"e-li.nps/internal/db"
"github.com/jackc/pgx/v5"
@ -43,7 +44,7 @@ ORDER BY tablename`)
// - 16 detratores
// - 78 neutros
// - 910 promotores
func (s *Store) NPSMesAMes(ctx context.Context, tabela string, meses int) ([]NPSMensal, error) {
func (s *Store) NPSMesAMes(ctx context.Context, tabela string, meses int) ([]contratos.NPSMensal, error) {
// Segurança: a tabela é um identificador interpolado. Validamos sempre.
if !db.TableNameValido(tabela) {
return nil, fmt.Errorf("tabela invalida")
@ -75,9 +76,9 @@ ORDER BY mes ASC`, tabela)
}
defer rows.Close()
out := []NPSMensal{}
out := []contratos.NPSMensal{}
for rows.Next() {
var m NPSMensal
var m contratos.NPSMensal
if err := rows.Scan(&m.Mes, &m.Detratores, &m.Neutros, &m.Promotores, &m.Total); err != nil {
return nil, err
}
@ -97,17 +98,10 @@ type ListarRespostasFiltro struct {
PorPagina int
}
func (f *ListarRespostasFiltro) normalizar() {
if f.Pagina <= 0 {
f.Pagina = 1
}
if f.PorPagina <= 0 || f.PorPagina > 200 {
f.PorPagina = 50
}
}
func (f *ListarRespostasFiltro) normalizar() { (*contratos.ListarRespostasFiltro)(f).Normalizar() }
// ListarRespostas retorna respostas respondidas, com paginação e filtro.
func (s *Store) ListarRespostas(ctx context.Context, tabela string, filtro ListarRespostasFiltro) ([]RespostaPainel, error) {
func (s *Store) ListarRespostas(ctx context.Context, tabela string, filtro ListarRespostasFiltro) ([]contratos.RespostaPainel, error) {
// Segurança: a tabela é um identificador interpolado. Validamos sempre.
if !db.TableNameValido(tabela) {
return nil, fmt.Errorf("tabela invalida")
@ -145,9 +139,9 @@ LIMIT $1 OFFSET $2`, tabela, cond)
}
defer rows.Close()
respostas := []RespostaPainel{}
respostas := []contratos.RespostaPainel{}
for rows.Next() {
var r RespostaPainel
var r contratos.RespostaPainel
if err := rows.Scan(
&r.ID,
&r.RespondidoEm,

View file

@ -7,6 +7,7 @@ import (
"net/http"
"time"
"e-li.nps/internal/contratos"
"e-li.nps/internal/db"
"github.com/jackc/pgx/v5"
@ -98,7 +99,7 @@ LIMIT 1`, table)
return true, nil
}
func (s *Store) CreatePedido(ctx context.Context, table string, in PedidoInput, r *http.Request) (string, error) {
func (s *Store) CreatePedido(ctx context.Context, table string, in contratos.PedidoInput, r *http.Request) (string, error) {
// Segurança: a tabela é um identificador interpolado. Validamos sempre.
if !db.TableNameValido(table) {
return "", fmt.Errorf("tabela invalida")
@ -122,24 +123,24 @@ RETURNING id`, table)
return id, err
}
func (s *Store) GetRegistro(ctx context.Context, table, id string) (Registro, error) {
func (s *Store) GetRegistro(ctx context.Context, table, id string) (contratos.Registro, error) {
// Segurança: a tabela é um identificador interpolado. Validamos sempre.
if !db.TableNameValido(table) {
return Registro{}, fmt.Errorf("tabela invalida")
return contratos.Registro{}, fmt.Errorf("tabela invalida")
}
q := fmt.Sprintf(`
SELECT id, produto_nome, status, nota, justificativa, pedido_criado_em, respondido_em
FROM %s
WHERE id=$1`, table)
var reg Registro
var reg contratos.Registro
err := s.pool.QueryRow(ctx, q, id).Scan(
&reg.ID, &reg.ProdutoNome, &reg.Status, &reg.Nota, &reg.Justificativa, &reg.PedidoCriadoEm, &reg.RespondidoEm,
)
return reg, err
}
func (s *Store) PatchRegistro(ctx context.Context, table, id string, in PatchInput) error {
func (s *Store) PatchRegistro(ctx context.Context, table, id string, in contratos.PatchInput) error {
// Segurança: a tabela é um identificador interpolado. Validamos sempre.
if !db.TableNameValido(table) {
return fmt.Errorf("tabela invalida")
@ -169,7 +170,7 @@ func (s *Store) TouchAtualizadoEm(ctx context.Context, table, id string) error {
return err
}
func (s *Store) CooldownSuggested(reg Registro) time.Duration {
func (s *Store) CooldownSuggested(reg contratos.Registro) time.Duration {
// Não é usado pelo servidor hoje; fica como helper se precisarmos.
if reg.Status == "respondido" {
return 45 * 24 * time.Hour

View file

@ -4,6 +4,8 @@ import (
"html/template"
"log/slog"
"net/http"
"e-li.nps/internal/contratos"
)
type TemplateRenderer struct {
@ -23,8 +25,5 @@ func (r *TemplateRenderer) Render(w http.ResponseWriter, name string, data any)
}
}
type FormPageData struct {
Produto string
ID string
Reg Registro
}
// Alias para manter as chamadas concisas dentro do pacote elinps.
type FormPageData = contratos.FormPageData

View file

@ -4,6 +4,8 @@ import (
"errors"
"regexp"
"strings"
"e-li.nps/internal/contratos"
)
var emailRe = regexp.MustCompile(`^[^\s@]+@[^\s@]+\.[^\s@]+$`)
@ -12,7 +14,7 @@ func normalizeEmail(s string) string {
return strings.ToLower(strings.TrimSpace(s))
}
func ValidatePedidoInput(in *PedidoInput) error {
func ValidatePedidoInput(in *contratos.PedidoInput) error {
in.ProdutoNome = strings.TrimSpace(in.ProdutoNome)
in.InquilinoCodigo = strings.TrimSpace(in.InquilinoCodigo)
in.InquilinoNome = strings.TrimSpace(in.InquilinoNome)
@ -49,7 +51,7 @@ func ValidatePedidoInput(in *PedidoInput) error {
return nil
}
func ValidatePatchInput(in *PatchInput) error {
func ValidatePatchInput(in *contratos.PatchInput) error {
if in.Nota != nil {
if *in.Nota < 1 || *in.Nota > 10 {
return errors.New("nota invalida")

Binary file not shown.

BIN
web/static/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 97 KiB