Files
geniusrun/backend/internal/api/server.go
Christophe Vila f689f74ae0 Initial commit: smartrun MVP
Garmin run classification and progression tracker. Go backend (MCP
client to mcp-garmin, SQLite store, deterministic rule engine, REST
API) and React/TS frontend (Dashboard, Review Queue, Workout Kinds).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-17 18:33:06 +02:00

138 lines
3.7 KiB
Go

// 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
}