diff --git a/backend/cmd/geniusrund/main.go b/backend/cmd/geniusrund/main.go index 126b47d..8e25c03 100644 --- a/backend/cmd/geniusrund/main.go +++ b/backend/cmd/geniusrund/main.go @@ -31,24 +31,12 @@ func main() { } defer db.Close() - profile, err := db.GetProfile(context.Background()) - if err != nil { - log.Fatalf("load profile: %v", err) + if cfg.LegacyOwnerOIDCSub != "" { + if err := db.ClaimLegacyOwner(context.Background(), cfg.LegacyOwnerOIDCSub); err != nil { + log.Fatalf("claim legacy owner: %v", err) + } } - garminClient := garmin.NewClient(garmin.Config{ - PythonPath: cfg.GarminPythonPath, - ServerPath: cfg.GarminServerPath, - GarminEmail: profile.GarminEmail, - GarminPassword: profile.GarminPassword, - TokenStorePath: cfg.GarminTokenStore, - }) - defer garminClient.Close() - - syncSvc := appsync.NewService(garminClient, db, appsync.Config{ - MinConfidence: cfg.MinConfidence, - }, nil) - authVerifier, err := auth.NewOIDCVerifier(context.Background(), auth.OIDCConfig{ IssuerURL: cfg.OIDCIssuerURL, ClientID: cfg.OIDCClientID, @@ -60,7 +48,13 @@ func main() { log.Fatalf("oidc: %v", err) } - server := api.NewServer(db, garminClient, syncSvc, authVerifier, api.SessionConfig{ + server := api.NewServer(db, garmin.NewClient, garmin.Config{ + PythonPath: cfg.GarminPythonPath, + ServerPath: cfg.GarminServerPath, + TokenStorePath: cfg.GarminTokenStoreRoot, + }, appsync.Config{ + MinConfidence: cfg.MinConfidence, + }, authVerifier, api.SessionConfig{ Secret: cfg.SessionSecret, Duration: cfg.SessionDuration, Secure: cfg.SessionSecure, @@ -70,7 +64,7 @@ func main() { ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) defer stop() - go runIncrementalSyncLoop(ctx, syncSvc, cfg.IncrementalSyncEvery) + go runIncrementalSyncLoop(ctx, server, cfg.IncrementalSyncEvery) httpServer := &http.Server{Addr: cfg.Addr, Handler: server.Router()} go func() { @@ -89,9 +83,10 @@ func main() { } } -// runIncrementalSyncLoop periodically syncs new activities in the -// background so the frontend doesn't need to trigger every sync manually. -func runIncrementalSyncLoop(ctx context.Context, svc *appsync.Service, every time.Duration) { +// runIncrementalSyncLoop periodically syncs new activities for every +// provisioned user in the background so the frontend doesn't need to +// trigger every sync manually. +func runIncrementalSyncLoop(ctx context.Context, server *api.Server, every time.Duration) { ticker := time.NewTicker(every) defer ticker.Stop() for { @@ -99,13 +94,7 @@ func runIncrementalSyncLoop(ctx context.Context, svc *appsync.Service, every tim case <-ctx.Done(): return case <-ticker.C: - if err := svc.IncrementalSync(ctx); err != nil { - log.Printf("incremental sync: %v", err) - continue - } - if err := svc.FillPendingDetails(ctx, 50); err != nil { - log.Printf("fill pending details: %v", err) - } + server.RunIncrementalSyncForAllUsers(ctx) } } } diff --git a/backend/internal/api/api_test.go b/backend/internal/api/api_test.go index 16174c7..63b1d53 100644 --- a/backend/internal/api/api_test.go +++ b/backend/internal/api/api_test.go @@ -15,6 +15,7 @@ import ( "geniusrun/backend/internal/auth" authmock "geniusrun/backend/internal/auth/mock" + "geniusrun/backend/internal/garmin" "geniusrun/backend/internal/garmin/mock" "geniusrun/backend/internal/store" appsync "geniusrun/backend/internal/sync" @@ -37,9 +38,14 @@ func newTestServer(t *testing.T) (*Server, *store.DB) { } t.Cleanup(func() { db.Close() }) + if _, err := db.ProvisionUser(context.Background(), "test-user", "Test User"); err != nil { + t.Fatalf("ProvisionUser: %v", err) + } + m := &mock.Client{} - svc := appsync.NewService(m, db, appsync.Config{}, func() time.Time { return time.Date(2026, 7, 11, 0, 0, 0, 0, time.UTC) }) - return NewServer(db, m, svc, &authmock.Verifier{}, testSessionConfig), db + garminFactory := func(garmin.Config) garmin.Client { return m } + s := NewServer(db, garminFactory, garmin.Config{}, appsync.Config{}, &authmock.Verifier{}, testSessionConfig) + return s, db } func doJSON(t *testing.T, handler http.Handler, method, path string, body any) *httptest.ResponseRecorder { @@ -323,7 +329,7 @@ func TestReviewQueue_PaginatesByCursor(t *testing.T) { for i := 1; i <= 5; i++ { activityID, err := db.UpsertActivity(ctx, store.Activity{ GarminActivityID: int64(i), - StartTimeUTC: fmt.Sprintf("2026-07-0%d 06:00:00", i), RawJSON: "{}", + StartTimeUTC: fmt.Sprintf("2026-07-0%d 06:00:00", i), RawJSON: "{}", }) if err != nil { t.Fatalf("UpsertActivity: %v", err) @@ -407,7 +413,7 @@ func TestReviewQueue_FiltersByKindAndUnclassifiedStayPaginated(t *testing.T) { makeActivity := func(n int64, kindID *int64) { activityID, err := db.UpsertActivity(ctx, store.Activity{ GarminActivityID: n, - StartTimeUTC: fmt.Sprintf("2026-07-0%d 06:00:00", n), RawJSON: "{}", + StartTimeUTC: fmt.Sprintf("2026-07-0%d 06:00:00", n), RawJSON: "{}", }) if err != nil { t.Fatalf("UpsertActivity: %v", err) diff --git a/backend/internal/api/server.go b/backend/internal/api/server.go index b0a2686..5bf5f83 100644 --- a/backend/internal/api/server.go +++ b/backend/internal/api/server.go @@ -5,8 +5,11 @@ package api import ( "context" "encoding/json" + "fmt" "log" "net/http" + "path/filepath" + "strconv" "sync" "github.com/go-chi/chi/v5" @@ -17,23 +20,125 @@ import ( appsync "geniusrun/backend/internal/sync" ) -// Server wires the HTTP handlers to the app's dependencies. +// Server wires the HTTP handlers to the app's dependencies. garmin.Client +// and sync.Service are per-user (each user might have their own Garmin +// account), built lazily on first use via GarminFactory and cached. 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 + // GarminFactory builds a real (or fake, in tests) garmin.Client from a + // fully-resolved per-user Config. Production wiring passes + // garmin.NewClient; tests inject a factory returning a shared + // *mock.Client (see newTestServer in api_test.go). + GarminFactory func(garmin.Config) garmin.Client + // GarminBase holds the plumbing shared by every user's garmin.Config + // (subprocess paths + the token-store root directory); only + // GarminEmail/GarminPassword/TokenStorePath vary per user, filled in by + // garminFor. + GarminBase garmin.Config + SyncConfig appsync.Config + + mu sync.Mutex + userGarmin map[int64]garmin.Client + userSync map[int64]*appsync.Service + userAuthStatus map[int64]garmin.AuthStatus + userAuthMessage map[int64]string + userSyncRunning map[int64]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} +func NewServer(db *store.DB, garminFactory func(garmin.Config) garmin.Client, garminBase garmin.Config, syncConfig appsync.Config, authVerifier auth.Verifier, session SessionConfig) *Server { + return &Server{ + DB: db, GarminFactory: garminFactory, GarminBase: garminBase, SyncConfig: syncConfig, + Auth: authVerifier, Session: session, + userGarmin: map[int64]garmin.Client{}, + userSync: map[int64]*appsync.Service{}, + userAuthStatus: map[int64]garmin.AuthStatus{}, + userAuthMessage: map[int64]string{}, + userSyncRunning: map[int64]bool{}, + } +} + +// garminFor returns userID's garmin.Client, building and caching it (from +// userID's own profile row) on first use. +func (s *Server) garminFor(ctx context.Context, userID int64) (garmin.Client, error) { + s.mu.Lock() + if c, ok := s.userGarmin[userID]; ok { + s.mu.Unlock() + return c, nil + } + s.mu.Unlock() + + profile, err := s.DB.GetProfile(ctx, userID) + if err != nil { + return nil, fmt.Errorf("load profile for garmin client (user %d): %w", userID, err) + } + cfg := s.GarminBase + cfg.GarminEmail = profile.GarminEmail + cfg.GarminPassword = profile.GarminPassword + if cfg.TokenStorePath != "" { + cfg.TokenStorePath = filepath.Join(cfg.TokenStorePath, strconv.FormatInt(userID, 10)) + } + + s.mu.Lock() + defer s.mu.Unlock() + if c, ok := s.userGarmin[userID]; ok { + return c, nil // built concurrently by another request between our unlock and re-lock + } + client := s.GarminFactory(cfg) + s.userGarmin[userID] = client + return client, nil +} + +// syncFor returns userID's sync.Service, building and caching it on first use. +func (s *Server) syncFor(ctx context.Context, userID int64) (*appsync.Service, error) { + s.mu.Lock() + if svc, ok := s.userSync[userID]; ok { + s.mu.Unlock() + return svc, nil + } + s.mu.Unlock() + + client, err := s.garminFor(ctx, userID) + if err != nil { + return nil, err + } + + s.mu.Lock() + defer s.mu.Unlock() + if svc, ok := s.userSync[userID]; ok { + return svc, nil + } + svc := appsync.NewService(client, s.DB, userID, s.SyncConfig, nil) + s.userSync[userID] = svc + return svc, nil +} + +// RunIncrementalSyncForAllUsers is called on a timer (see main.go) to sync +// every provisioned user in turn, replacing the old single-global-Service +// background loop. +func (s *Server) RunIncrementalSyncForAllUsers(ctx context.Context) { + users, err := s.DB.ListUsers(ctx) + if err != nil { + log.Printf("api: list users for incremental sync: %v", err) + return + } + for _, u := range users { + svc, err := s.syncFor(ctx, u.ID) + if err != nil { + log.Printf("api: sync service for user %d: %v", u.ID, err) + continue + } + if err := svc.IncrementalSync(ctx); err != nil { + log.Printf("api: incremental sync for user %d: %v", u.ID, err) + continue + } + if err := svc.FillPendingDetails(ctx, 50); err != nil { + log.Printf("api: fill pending details for user %d: %v", u.ID, err) + } + } } // Router builds the HTTP routes. @@ -56,45 +161,49 @@ func (s *Server) Router() http.Handler { r.Post("/session/logout", s.handleSessionLogout) r.Post("/setup", s.handleSetup) - r.Route("/profile", func(r chi.Router) { - r.Get("/", s.handleGetProfile) - r.Put("/", s.handleUpdateProfile) + r.Group(func(r chi.Router) { + r.Use(requireProvisionedUser) + + 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) }) - - 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 @@ -137,25 +246,25 @@ func writeError(w http.ResponseWriter, status int, msg string) { } // 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 { +// only one sync operation per userID runs at a time. Returns false if one +// is already in progress for that user. +func (s *Server) backgroundSync(userID int64, fn func(ctx context.Context) error) bool { s.mu.Lock() - if s.syncRunning { + if s.userSyncRunning[userID] { s.mu.Unlock() return false } - s.syncRunning = true + s.userSyncRunning[userID] = true s.mu.Unlock() go func() { defer func() { s.mu.Lock() - s.syncRunning = false + s.userSyncRunning[userID] = false s.mu.Unlock() }() if err := fn(context.Background()); err != nil { - log.Printf("api: background sync error: %v", err) + log.Printf("api: background sync error (user %d): %v", userID, err) } }() return true diff --git a/backend/internal/api/setup_test.go b/backend/internal/api/setup_test.go index 0379664..8b0d767 100644 --- a/backend/internal/api/setup_test.go +++ b/backend/internal/api/setup_test.go @@ -4,11 +4,27 @@ import ( "encoding/json" "net/http" "net/http/httptest" + "path/filepath" "testing" + + authmock "geniusrun/backend/internal/auth/mock" + "geniusrun/backend/internal/garmin" + "geniusrun/backend/internal/garmin/mock" + "geniusrun/backend/internal/store" + appsync "geniusrun/backend/internal/sync" ) func TestSetup_ProvisionsNewUserWithDisplayName(t *testing.T) { - s, db := newTestServer(t) + // This test specifically needs an *unprovisioned* session, unlike every + // other test in this package -- build the server without the + // newTestServer helper's automatic ProvisionUser call. + db, err := store.Open(filepath.Join(t.TempDir(), "geniusrun_test.db")) + if err != nil { + t.Fatalf("store.Open: %v", err) + } + t.Cleanup(func() { db.Close() }) + m := &mock.Client{} + s := NewServer(db, func(garmin.Config) garmin.Client { return m }, garmin.Config{}, appsync.Config{}, &authmock.Verifier{}, testSessionConfig) router := s.Router() rec := doJSON(t, router, http.MethodGet, "/api/session/me", nil) @@ -39,7 +55,14 @@ func TestSetup_ProvisionsNewUserWithDisplayName(t *testing.T) { } func TestSetup_RejectsEmptyDisplayName(t *testing.T) { - s, _ := newTestServer(t) + db, err := store.Open(filepath.Join(t.TempDir(), "geniusrun_test.db")) + if err != nil { + t.Fatalf("store.Open: %v", err) + } + t.Cleanup(func() { db.Close() }) + m := &mock.Client{} + s := NewServer(db, func(garmin.Config) garmin.Client { return m }, garmin.Config{}, appsync.Config{}, &authmock.Verifier{}, testSessionConfig) + rec := doJSON(t, s.Router(), http.MethodPost, "/api/setup", map[string]any{"display_name": ""}) if rec.Code != http.StatusBadRequest { t.Fatalf("status = %d, want 400", rec.Code) @@ -47,10 +70,7 @@ func TestSetup_RejectsEmptyDisplayName(t *testing.T) { } func TestSetup_RejectsWhenAlreadyProvisioned(t *testing.T) { - s, db := newTestServer(t) - if _, err := db.ProvisionUser(newCtx(), "test-user", "Already Here"); err != nil { - t.Fatalf("ProvisionUser: %v", err) - } + s, _ := newTestServer(t) rec := doJSON(t, s.Router(), http.MethodPost, "/api/setup", map[string]any{"display_name": "Someone Else"}) if rec.Code != http.StatusConflict { t.Fatalf("status = %d, want 409, body = %s", rec.Code, rec.Body.String())