// Package api is geniusrun's HTTP layer: REST handlers over internal/store, // internal/garmin, and internal/sync. package api import ( "context" "encoding/json" "log" "net/http" "sync" "github.com/go-chi/chi/v5" "geniusrun/backend/internal/auth" "geniusrun/backend/internal/garmin" "geniusrun/backend/internal/store" appsync "geniusrun/backend/internal/sync" ) // Server wires the HTTP handlers to the app's dependencies. type Server struct { DB *store.DB Garmin garmin.Client Sync *appsync.Service Auth auth.Verifier Session SessionConfig mu sync.Mutex authStatus garmin.AuthStatus authMessage string syncRunning bool } // NewServer builds a Server. func NewServer(db *store.DB, g garmin.Client, s *appsync.Service, authVerifier auth.Verifier, session SessionConfig) *Server { return &Server{DB: db, Garmin: g, Sync: s, Auth: authVerifier, Session: session} } // Router builds the HTTP routes. func (s *Server) Router() http.Handler { r := chi.NewRouter() r.Use(corsMiddleware) r.Route("/api", func(r chi.Router) { r.Get("/health", s.handleHealth) // Unprotected: these two ARE the login flow, so they can't require // a session yet. r.Get("/session/login", s.handleSessionLogin) r.Get("/session/callback", s.handleSessionCallback) r.Group(func(r chi.Router) { r.Use(auth.RequireSession(s.Session.Secret)) r.Get("/session/me", s.handleSessionMe) r.Post("/session/logout", s.handleSessionLogout) r.Use(s.resolveUser) r.Post("/setup", s.handleSetup) r.Route("/profile", func(r chi.Router) { r.Get("/", s.handleGetProfile) r.Put("/", s.handleUpdateProfile) }) r.Route("/auth", func(r chi.Router) { r.Post("/login", s.handleAuthLogin) r.Post("/mfa", s.handleAuthMFA) r.Get("/status", s.handleAuthStatus) }) r.Route("/sync", func(r chi.Router) { r.Post("/run", s.handleSyncRun) r.Post("/reset", s.handleSyncReset) r.Get("/runs", s.handleSyncRuns) r.Get("/status", s.handleSyncStatus) }) r.Route("/activities", func(r chi.Router) { r.Get("/", s.handleListActivities) r.Get("/{id}", s.handleGetActivity) }) r.Route("/workout-kinds", func(r chi.Router) { r.Get("/", s.handleListWorkoutKinds) r.Get("/{id}", s.handleGetWorkoutKind) r.Put("/{id}", s.handleUpdateWorkoutKind) }) r.Post("/reclassify", s.handleReclassifyAll) r.Route("/review-queue", func(r chi.Router) { r.Get("/", s.handleReviewQueue) r.Post("/{activityID}/resolve", s.handleResolveReview) r.Post("/{activityID}/unlock", s.handleUnlockReview) r.Post("/{activityID}/unassign", s.handleUnassignReview) }) r.Get("/progression/{kindID}", s.handleProgression) }) }) return r } // corsMiddleware allows the frontend dev server (a different port) to call // this API. Reflecting any origin back is safe even with credentials // enabled: this remains a single-operator app whose real access control is // the OIDC login gate (internal/auth), not origin-based CSRF defense. func corsMiddleware(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if origin := r.Header.Get("Origin"); origin != "" { w.Header().Set("Access-Control-Allow-Origin", origin) w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS") w.Header().Set("Access-Control-Allow-Headers", "Content-Type") w.Header().Set("Access-Control-Allow-Credentials", "true") } if r.Method == http.MethodOptions { w.WriteHeader(http.StatusNoContent) return } next.ServeHTTP(w, r) }) } func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) } func writeJSON(w http.ResponseWriter, status int, v any) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) if err := json.NewEncoder(w).Encode(v); err != nil { log.Printf("api: encode response: %v", err) } } func writeError(w http.ResponseWriter, status int, msg string) { writeJSON(w, status, map[string]string{"error": msg}) } // backgroundSync runs fn in a goroutine with a fresh context, guarded so // only one sync operation runs at a time. Returns false if one is already // in progress. func (s *Server) backgroundSync(fn func(ctx context.Context) error) bool { s.mu.Lock() if s.syncRunning { s.mu.Unlock() return false } s.syncRunning = true s.mu.Unlock() go func() { defer func() { s.mu.Lock() s.syncRunning = false s.mu.Unlock() }() if err := fn(context.Background()); err != nil { log.Printf("api: background sync error: %v", err) } }() return true }