e-li-nps/cmd/server/main.go

159 lines
4 KiB
Go

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
}