2026-08-04 16:04:18 +02:00
|
|
|
package api
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"context"
|
|
|
|
|
"encoding/json"
|
|
|
|
|
"net/http"
|
|
|
|
|
|
|
|
|
|
"geniusrun/backend/internal/garmin"
|
2026-08-04 16:33:26 +02:00
|
|
|
applog "geniusrun/backend/internal/log"
|
2026-08-04 16:04:18 +02:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
// 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 {
|
refactor(log): unified schema — mandatory type/class/method, request_id removed
Every log record now carries type ('http' for the request middleware
line, 'wrapper' for garmin execute() calls and forwarded wrapper.py
stderr, 'app' for everything else), class (Go type with package, or the
package/module for free functions), and method (the emitting Go/Python
function -- wrapper.py stamps it automatically, so its msg prefixes are
gone). msg is optional and omitted when empty; the redundant messages
('HTTP request', 'wrapper call', 'garmin wrapper stderr') are dropped,
the HTTP verb moves to http_method, source=garmin-wrapper is replaced by
type=wrapper, and the request_id middleware plumbing (applog
WithLogger/FromContext) is removed. applog.App(class, method) is the
tagging helper for app records.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 19:45:30 +02:00
|
|
|
applog.App("api.Server", "recordAuthResult").Error("mark garmin connected", "user_id", userID, "error", err)
|
2026-08-04 16:04:18 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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)
|
|
|
|
|
}
|