diff --git a/cmd/server/main.go b/cmd/server/main.go index cf84abf..bab5978 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -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) { diff --git a/cmd/widgetwasm/main.go b/cmd/widgetwasm/main.go index 1ebaebf..931c4d1 100644 --- a/cmd/widgetwasm/main.go +++ b/cmd/widgetwasm/main.go @@ -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)) diff --git a/internal/contratos/tipos.go b/internal/contratos/tipos.go new file mode 100644 index 0000000..3eba408 --- /dev/null +++ b/internal/contratos/tipos.go @@ -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 +} diff --git a/internal/elinps/handlers.go b/internal/elinps/handlers.go index 5d2b6b1..8fbff19 100644 --- a/internal/elinps/handlers.go +++ b/internal/elinps/handlers.go @@ -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, diff --git a/internal/elinps/models.go b/internal/elinps/models.go deleted file mode 100644 index 5549c5c..0000000 --- a/internal/elinps/models.go +++ /dev/null @@ -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 -} diff --git a/internal/elinps/painel.go b/internal/elinps/painel.go index 90f47cc..cdbeff1 100644 --- a/internal/elinps/painel.go +++ b/internal/elinps/painel.go @@ -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" } diff --git a/internal/elinps/painel_queries.go b/internal/elinps/painel_queries.go index 3676a0b..8b03642 100644 --- a/internal/elinps/painel_queries.go +++ b/internal/elinps/painel_queries.go @@ -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`) // - 1–6 detratores // - 7–8 neutros // - 9–10 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, diff --git a/internal/elinps/queries.go b/internal/elinps/queries.go index 3dbf8d5..4ee3c99 100644 --- a/internal/elinps/queries.go +++ b/internal/elinps/queries.go @@ -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( ®.ID, ®.ProdutoNome, ®.Status, ®.Nota, ®.Justificativa, ®.PedidoCriadoEm, ®.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 diff --git a/internal/elinps/render.go b/internal/elinps/render.go index 91b6713..1ccb2f0 100644 --- a/internal/elinps/render.go +++ b/internal/elinps/render.go @@ -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 diff --git a/internal/elinps/validate.go b/internal/elinps/validate.go index 26402d9..75bf0ae 100644 --- a/internal/elinps/validate.go +++ b/internal/elinps/validate.go @@ -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") diff --git a/web/static/e-li.nps.wasm b/web/static/e-li.nps.wasm index 44f4b34..d533202 100755 Binary files a/web/static/e-li.nps.wasm and b/web/static/e-li.nps.wasm differ diff --git a/web/static/favicon.ico b/web/static/favicon.ico new file mode 100644 index 0000000..09c210d Binary files /dev/null and b/web/static/favicon.ico differ