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 }