package api import ( "context" "encoding/json" "net/http" "geniusrun/backend/internal/garmin" applog "geniusrun/backend/internal/log" ) // detailFillBatchSize bounds how many activities' details/splits are fetched // per sync trigger, matching the sequential rate-limited fetch in // internal/sync.Service.FillPendingDetails. const detailFillBatchSize = 50 type authResponse struct { Status string `json:"status"` // "authenticated" | "mfa_required" | "failed" Message string `json:"message"` } func authStatusString(s garmin.AuthStatus) string { switch s { case garmin.AuthSuccess: return "authenticated" case garmin.AuthMFARequired: return "mfa_required" case garmin.AuthFailed: return "failed" default: return "unknown" } } // recordAuthResult updates the in-memory auth status/message for userID, // and -- on a successful authentication -- persists that this account has // connected to Garmin at least once (store.MarkGarminConnected), which is // what the login gate actually checks (the in-memory auth status resets on // every backend restart; this doesn't). func (s *Server) recordAuthResult(ctx context.Context, userID int64, res garmin.AuthResult) { s.mu.Lock() s.userAuthStatus[userID] = res.Status s.userAuthMessage[userID] = res.Message s.mu.Unlock() if res.Status == garmin.AuthSuccess { if err := s.DB.MarkGarminConnected(ctx, userID); err != nil { applog.App("api.Server", "recordAuthResult").Error("mark garmin connected", "user_id", userID, "error", err) } } } func (s *Server) handleGarminAuthLogin(w http.ResponseWriter, r *http.Request) { userID := userIDFromContext(r.Context()) client, err := s.clientFor(r.Context(), userID) if err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return } res, err := client.Authenticate(r.Context()) if err != nil { writeError(w, http.StatusBadGateway, err.Error()) return } s.recordAuthResult(r.Context(), userID, res) writeJSON(w, http.StatusOK, authResponse{Status: authStatusString(res.Status), Message: res.Message}) } func (s *Server) handleGarminAuthMFA(w http.ResponseWriter, r *http.Request) { userID := userIDFromContext(r.Context()) var body struct { Code string `json:"code"` } if err := json.NewDecoder(r.Body).Decode(&body); err != nil { writeError(w, http.StatusBadRequest, "invalid request body") return } if body.Code == "" { writeError(w, http.StatusBadRequest, "code is required") return } client, err := s.clientFor(r.Context(), userID) if err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return } res, err := client.CompleteMFA(r.Context(), body.Code) if err != nil { writeError(w, http.StatusBadGateway, err.Error()) return } s.recordAuthResult(r.Context(), userID, res) writeJSON(w, http.StatusOK, authResponse{Status: authStatusString(res.Status), Message: res.Message}) } func (s *Server) handleGarminAuthStatus(w http.ResponseWriter, r *http.Request) { userID := userIDFromContext(r.Context()) s.mu.Lock() status, msg := s.userAuthStatus[userID], s.userAuthMessage[userID] s.mu.Unlock() writeJSON(w, http.StatusOK, authResponse{Status: authStatusString(status), Message: msg}) } // handleGarminSyncRun does a full sync pass: Backfill first (resumes from the // watermark, so widening the configured history horizon between clicks is // picked up automatically), then IncrementalSync (catches anything new since // the latest known activity), then fills in details for whatever's still // missing them. Activities already fully processed are left untouched -- // see internal/sync.Service.FillPendingDetails. Recorded as a single // FullSync run so "last sync" reports the combined activity count, not just // whichever of Backfill/IncrementalSync happened to finish last. func (s *Server) handleGarminSyncRun(w http.ResponseWriter, r *http.Request) { userID := userIDFromContext(r.Context()) svc, err := s.syncFor(r.Context(), userID) if err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return } ok := s.backgroundSync(userID, func(ctx context.Context) error { return svc.FullSync(ctx, detailFillBatchSize) }) if !ok { writeError(w, http.StatusConflict, "a sync is already in progress") return } writeJSON(w, http.StatusAccepted, map[string]string{"status": "started"}) } // handleGarminSyncReset wipes every synced activity (and its laps/samples/kind // assignments) and rewinds the backfill watermark, so the next Sync Now // performs a genuinely fresh pull from Garmin. Destructive -- the frontend // gates this behind a confirmation. func (s *Server) handleGarminSyncReset(w http.ResponseWriter, r *http.Request) { userID := userIDFromContext(r.Context()) svc, err := s.syncFor(r.Context(), userID) if err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return } ok := s.backgroundSync(userID, func(ctx context.Context) error { return svc.ResetAll(ctx) }) if !ok { writeError(w, http.StatusConflict, "a sync is already in progress") return } writeJSON(w, http.StatusAccepted, map[string]string{"status": "started"}) } func (s *Server) handleGarminSyncRuns(w http.ResponseWriter, r *http.Request) { userID := userIDFromContext(r.Context()) runs, err := s.DB.ListSyncRuns(r.Context(), userID, 20) if err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return } writeJSON(w, http.StatusOK, runs) } func (s *Server) handleGarminSyncStatus(w http.ResponseWriter, r *http.Request) { userID := userIDFromContext(r.Context()) run, ok, err := s.DB.LatestSyncRun(r.Context(), userID) if err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return } pendingDetails, err := s.DB.CountActivitiesMissingDetails(r.Context(), userID) if err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return } pendingWorkouts, err := s.DB.CountActivitiesMissingWorkout(r.Context(), userID) if err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return } s.mu.Lock() inProgress := s.userSyncRunning[userID] s.mu.Unlock() svc, err := s.syncFor(r.Context(), userID) if err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return } progress := svc.Progress() resp := map[string]any{ "in_progress": inProgress, "progress": progress, "activities_pending_details": pendingDetails, "workouts_pending": pendingWorkouts, } if ok { resp["last_run"] = run } writeJSON(w, http.StatusOK, resp) }