// Package api is smartrun'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" "smartrun/backend/internal/garmin" "smartrun/backend/internal/store" appsync "smartrun/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 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) *Server { return &Server{DB: db, Garmin: g, Sync: s} } // 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) 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("/backfill", s.handleSyncBackfill) 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.Post("/", s.handleCreateWorkoutKind) r.Get("/{id}", s.handleGetWorkoutKind) r.Put("/{id}", s.handleUpdateWorkoutKind) r.Delete("/{id}", s.handleDeleteWorkoutKind) r.Post("/{id}/reclassify", s.handleReclassifyKind) }) r.Route("/review-queue", func(r chi.Router) { r.Get("/", s.handleReviewQueue) r.Post("/{activityID}/resolve", s.handleResolveReview) }) r.Get("/progression/{kindID}", s.handleProgression) }) return r } // corsMiddleware allows the frontend dev server (a different port) to call // this API. Single-user local app, so reflecting any origin is fine -- // there's no session/cookie auth to protect against CSRF. 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") } 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 }