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/cmd/seedsample/main.go b/backend/cmd/seedsample/main.go index da9d41d..7110ef2 100644 --- a/backend/cmd/seedsample/main.go +++ b/backend/cmd/seedsample/main.go @@ -29,13 +29,16 @@ func main() { } defer db.Close() + userID, err := db.ProvisionUser(ctx, "seedsample-user", "Sample") + must(err) + // Set a max heart rate on the profile so avg_hr_pct_max is computed // during classification below (it's nil/unset by default). - profile, err := db.GetProfile(ctx) + profile, err := db.GetProfile(ctx, userID) must(err) maxHR := 190.0 profile.MaxHeartRate = &maxHR - must(db.UpdateProfile(ctx, profile)) + must(db.UpdateProfile(ctx, userID, profile)) // Easy Run and Tempo's pace/HR ranges deliberately overlap a little // (330-340 sec/km, 0.70-0.85 HR%max) so a run that lands in that overlap @@ -43,8 +46,8 @@ func main() { // gap between disjoint ranges. The taxonomy migration already seeded // these rows (by fixed name) -- update their rules in place rather than // creating new ones, since names are unique. - easyID := mustFindKindID(ctx, db, "Easy") - must(db.UpdateWorkoutKind(ctx, store.WorkoutKind{ + easyID := mustFindKindID(ctx, db, userID, "Easy") + must(db.UpdateWorkoutKind(ctx, userID, store.WorkoutKind{ ID: easyID, Name: "Easy", Description: "Easy conversational runs", Color: "#22c55e", RuleJSON: `{"match":"all","conditions":[ {"metric":"avg_pace_sec_per_km","op":"between","value":[330,420]}, @@ -53,8 +56,8 @@ func main() { IsActive: true, })) - tempoID := mustFindKindID(ctx, db, "Tempo") - must(db.UpdateWorkoutKind(ctx, store.WorkoutKind{ + tempoID := mustFindKindID(ctx, db, userID, "Tempo") + must(db.UpdateWorkoutKind(ctx, userID, store.WorkoutKind{ ID: tempoID, Name: "Tempo", Description: "Comfortably hard sustained effort", Color: "#f59e0b", RuleJSON: `{"match":"all","conditions":[ {"metric":"avg_pace_sec_per_km","op":"between","value":[300,340]}, @@ -63,15 +66,15 @@ func main() { IsActive: true, })) - intervalID := mustFindKindID(ctx, db, "Intervals") - must(db.UpdateWorkoutKind(ctx, store.WorkoutKind{ + intervalID := mustFindKindID(ctx, db, userID, "Intervals") + must(db.UpdateWorkoutKind(ctx, userID, store.WorkoutKind{ ID: intervalID, Name: "Intervals", Description: "Structured work/rest intervals", Color: "#ef4444", RuleJSON: `{"match":"all","conditions":[{"metric":"lap_interval_pattern","op":"==","value":true}]}`, IsActive: true, })) m := &mock.Client{} - svc := appsync.NewService(m, db, appsync.Config{MinConfidence: 0.6}, nil) + svc := appsync.NewService(m, db, userID, appsync.Config{MinConfidence: 0.6}, nil) today := time.Now() activityIDs := []int64{} @@ -81,7 +84,7 @@ func main() { start := today.AddDate(0, 0, -60+i*10) speed := 1000.0 / (390 - float64(i)*5) // pace improving from 390 -> 370 sec/km hr := 125.0 + float64(i) // pct-of-max stays comfortably under the 0.75 ceiling - id := seedActivity(ctx, db, seedParams{ + id := seedActivity(ctx, db, userID, seedParams{ garminID: 1000 + int64(i), name: "Easy morning run", start: start, distance: 8000, duration: 8000 / (speed) * 1, speedMps: speed, avgHR: hr, aerobicTE: 2.5, anaerobicTE: 0.3, @@ -93,7 +96,7 @@ func main() { for i := 0; i < 3; i++ { start := today.AddDate(0, 0, -45+i*15) speed := 1000.0 / 320.0 // centered in Tempo's [300,340] range - id := seedActivity(ctx, db, seedParams{ + id := seedActivity(ctx, db, userID, seedParams{ garminID: 2000 + int64(i), name: "Tempo run", start: start, distance: 6000, duration: 1800, speedMps: speed, avgHR: 168, aerobicTE: 3.8, anaerobicTE: 1.2, @@ -104,12 +107,12 @@ func main() { // 1 Interval workout, with alternating ACTIVE/REST laps + HR samples // showing drift on the work intervals and recovery on the rest ones. { - id := seedActivity(ctx, db, seedParams{ + id := seedActivity(ctx, db, userID, seedParams{ garminID: 3000, name: "Track intervals", start: today.AddDate(0, 0, -5), distance: 8000, duration: 2400, speedMps: 1000.0 / 240.0, avgHR: 165, aerobicTE: 3.0, anaerobicTE: 3.5, }) - seedIntervalLapsAndSamples(ctx, db, id) + seedIntervalLapsAndSamples(ctx, db, userID, id) activityIDs = append(activityIDs, id) } @@ -118,7 +121,7 @@ func main() { // and Tempo's >=0.70 -- so it should land in the review queue with two // candidates, not a clean single match. { - id := seedActivity(ctx, db, seedParams{ + id := seedActivity(ctx, db, userID, seedParams{ garminID: 4000, name: "Ambiguous run", start: today.AddDate(0, 0, -2), distance: 7000, duration: 2200, speedMps: 1000.0 / 335.0, avgHR: 148, aerobicTE: 3.0, anaerobicTE: 0.8, @@ -145,12 +148,12 @@ type seedParams struct { anaerobicTE float64 } -func seedActivity(ctx context.Context, db *store.DB, p seedParams) int64 { +func seedActivity(ctx context.Context, db *store.DB, userID int64, p seedParams) int64 { speed := p.speedMps hr := p.avgHR aerobic := p.aerobicTE anaerobic := p.anaerobicTE - id, err := db.UpsertActivity(ctx, store.Activity{ + id, err := db.UpsertActivity(ctx, userID, store.Activity{ GarminActivityID: p.garminID, StartTimeUTC: p.start.Format("2006-01-02 15:04:05"), DurationSeconds: p.duration, @@ -171,7 +174,7 @@ func seedActivity(ctx context.Context, db *store.DB, p seedParams) int64 { // seedIntervalLapsAndSamples gives one activity 6 alternating ACTIVE/REST // laps plus per-second HR samples: rising HR within each ACTIVE lap (drift) // and falling HR within each REST lap (recovery). -func seedIntervalLapsAndSamples(ctx context.Context, db *store.DB, activityID int64) { +func seedIntervalLapsAndSamples(ctx context.Context, db *store.DB, userID, activityID int64) { var laps []store.Lap var samples []store.Sample elapsed := 0.0 @@ -225,8 +228,8 @@ func seedIntervalLapsAndSamples(ctx context.Context, db *store.DB, activityID in elapsed += lapDuration } - must(db.ReplaceActivitySamples(ctx, activityID, samples)) - must(db.ReplaceLaps(ctx, activityID, laps)) + must(db.ReplaceActivitySamples(ctx, userID, activityID, samples)) + must(db.ReplaceLaps(ctx, userID, activityID, laps)) } func must(err error) { @@ -235,14 +238,14 @@ func must(err error) { } } -func mustFindKindID(ctx context.Context, db *store.DB, name string) int64 { - kinds, err := db.ListWorkoutKinds(ctx, false) +func mustFindKindID(ctx context.Context, db *store.DB, userID int64, name string) int64 { + kinds, err := db.ListWorkoutKinds(ctx, userID, false) must(err) for _, k := range kinds { if k.Name == name { return k.ID } } - log.Fatalf("seedsample: no workout kind named %q found (did migration 0004 run?)", name) + log.Fatalf("seedsample: no workout kind named %q found for the seeded user (did ProvisionUser seed the taxonomy?)", name) return 0 } diff --git a/backend/internal/api/activities.go b/backend/internal/api/activities.go index 1527673..defc327 100644 --- a/backend/internal/api/activities.go +++ b/backend/internal/api/activities.go @@ -25,6 +25,7 @@ type activityListItem struct { } func (s *Server) handleListActivities(w http.ResponseWriter, r *http.Request) { + userID := userIDFromContext(r.Context()) q := r.URL.Query() filter := store.ActivityFilter{ FromDate: q.Get("from"), @@ -37,13 +38,13 @@ func (s *Server) handleListActivities(w http.ResponseWriter, r *http.Request) { filter.Offset = offset } - activities, err := s.DB.ListActivities(r.Context(), filter) + activities, err := s.DB.ListActivities(r.Context(), userID, filter) if err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return } - kinds, err := s.DB.ListWorkoutKinds(r.Context(), false) + kinds, err := s.DB.ListWorkoutKinds(r.Context(), userID, false) if err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return @@ -56,7 +57,7 @@ func (s *Server) handleListActivities(w http.ResponseWriter, r *http.Request) { resp := make([]activityListItem, 0, len(activities)) for _, a := range activities { item := activityListItem{activityResponse: toActivityResponse(a)} - assignment, ok, err := s.DB.CurrentAssignment(r.Context(), a.ID) + assignment, ok, err := s.DB.CurrentAssignment(r.Context(), userID, a.ID) if err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return @@ -79,13 +80,14 @@ func (s *Server) handleListActivities(w http.ResponseWriter, r *http.Request) { } func (s *Server) handleGetActivity(w http.ResponseWriter, r *http.Request) { + userID := userIDFromContext(r.Context()) id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64) if err != nil { writeError(w, http.StatusBadRequest, "invalid activity id") return } - activity, ok, err := s.DB.GetActivity(r.Context(), id) + activity, ok, err := s.DB.GetActivity(r.Context(), userID, id) if err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return @@ -95,13 +97,13 @@ func (s *Server) handleGetActivity(w http.ResponseWriter, r *http.Request) { return } - laps, err := s.DB.LapsForActivity(r.Context(), id) + laps, err := s.DB.LapsForActivity(r.Context(), userID, id) if err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return } - assignment, hasAssignment, err := s.DB.CurrentAssignment(r.Context(), id) + assignment, hasAssignment, err := s.DB.CurrentAssignment(r.Context(), userID, id) if err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return diff --git a/backend/internal/api/api_test.go b/backend/internal/api/api_test.go index 16174c7..a5cbd8b 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" @@ -29,7 +30,7 @@ var testSessionConfig = SessionConfig{ PublicBaseURL: "https://geniusrun.example.com", } -func newTestServer(t *testing.T) (*Server, *store.DB) { +func newTestServer(t *testing.T) (*Server, *store.DB, int64) { t.Helper() db, err := store.Open(filepath.Join(t.TempDir(), "geniusrun_test.db")) if err != nil { @@ -37,9 +38,15 @@ func newTestServer(t *testing.T) (*Server, *store.DB) { } t.Cleanup(func() { db.Close() }) + userID, err := db.ProvisionUser(context.Background(), "test-user", "Test User") + if 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, userID } func doJSON(t *testing.T, handler http.Handler, method, path string, body any) *httptest.ResponseRecorder { @@ -67,7 +74,7 @@ func doJSON(t *testing.T, handler http.Handler, method, path string, body any) * } func TestHealth(t *testing.T) { - s, _ := newTestServer(t) + s, _, _ := newTestServer(t) rec := doJSON(t, s.Router(), http.MethodGet, "/api/health", nil) if rec.Code != http.StatusOK { t.Fatalf("status = %d, want 200", rec.Code) @@ -75,7 +82,7 @@ func TestHealth(t *testing.T) { } func TestWorkoutKindList_ReturnsEightSeededTypesWithPaceFields(t *testing.T) { - s, _ := newTestServer(t) + s, _, _ := newTestServer(t) rec := doJSON(t, s.Router(), http.MethodGet, "/api/workout-kinds/", nil) if rec.Code != http.StatusOK { t.Fatalf("status = %d", rec.Code) @@ -95,7 +102,7 @@ func TestWorkoutKindList_ReturnsEightSeededTypesWithPaceFields(t *testing.T) { } func TestWorkoutKindUpdate_SetsRuleAndPace(t *testing.T) { - s, _ := newTestServer(t) + s, _, _ := newTestServer(t) router := s.Router() rec := doJSON(t, router, http.MethodGet, "/api/workout-kinds/", nil) @@ -132,7 +139,7 @@ func TestWorkoutKindUpdate_SetsRuleAndPace(t *testing.T) { } func TestWorkoutKindUpdate_RejectsInvalidRule(t *testing.T) { - s, _ := newTestServer(t) + s, _, _ := newTestServer(t) router := s.Router() rec := doJSON(t, router, http.MethodGet, "/api/workout-kinds/", nil) @@ -149,7 +156,7 @@ func TestWorkoutKindUpdate_RejectsInvalidRule(t *testing.T) { } func TestWorkoutKindUpdate_RejectsInvertedHRRange(t *testing.T) { - s, _ := newTestServer(t) + s, _, _ := newTestServer(t) router := s.Router() rec := doJSON(t, router, http.MethodGet, "/api/workout-kinds/", nil) @@ -168,7 +175,7 @@ func TestWorkoutKindUpdate_RejectsInvertedHRRange(t *testing.T) { } func TestWorkoutKindUpdate_RejectsInvertedPaceRange(t *testing.T) { - s, _ := newTestServer(t) + s, _, _ := newTestServer(t) router := s.Router() rec := doJSON(t, router, http.MethodGet, "/api/workout-kinds/", nil) @@ -187,31 +194,31 @@ func TestWorkoutKindUpdate_RejectsInvertedPaceRange(t *testing.T) { } func TestReviewQueueResolve(t *testing.T) { - s, db := newTestServer(t) + s, db, userID := newTestServer(t) ctx := newCtx() - activityID, err := db.UpsertActivity(ctx, store.Activity{GarminActivityID: 1, StartTimeUTC: "2026-07-11 06:00:00", RawJSON: "{}"}) + activityID, err := db.UpsertActivity(ctx, userID, store.Activity{GarminActivityID: 1, StartTimeUTC: "2026-07-11 06:00:00", RawJSON: "{}"}) if err != nil { t.Fatalf("UpsertActivity: %v", err) } - kindID, err := db.CreateWorkoutKind(ctx, store.WorkoutKind{Name: "Test Kind", RuleJSON: `{"match":"all","conditions":[]}`, IsActive: true}) + kindID, err := db.CreateWorkoutKind(ctx, userID, store.WorkoutKind{Name: "Test Kind", RuleJSON: `{"match":"all","conditions":[]}`, IsActive: true}) if err != nil { t.Fatalf("CreateWorkoutKind: %v", err) } - if _, err := db.InsertKindAssignment(ctx, store.KindAssignment{ + if _, err := db.InsertKindAssignment(ctx, userID, store.KindAssignment{ ActivityID: activityID, AssignmentSource: store.AssignmentSourceRuleEngine, Status: store.AssignmentStatusNeedsReview, CandidateKindsJSON: "[]", }); err != nil { t.Fatalf("InsertKindAssignment: %v", err) } targetLow, targetHigh := 3.0, 3.5 - if err := db.ReplaceLaps(ctx, activityID, []store.Lap{ + if err := db.ReplaceLaps(ctx, userID, activityID, []store.Lap{ {LapIndex: 1, TargetPaceLowMps: &targetLow, TargetPaceHighMps: &targetHigh}, }); err != nil { t.Fatalf("ReplaceLaps: %v", err) } hr := 150.0 - if err := db.ReplaceActivitySamples(ctx, activityID, []store.Sample{ + if err := db.ReplaceActivitySamples(ctx, userID, activityID, []store.Sample{ {ElapsedSeconds: 0, HeartRate: &hr}, }); err != nil { t.Fatalf("ReplaceActivitySamples: %v", err) @@ -316,19 +323,19 @@ func TestReviewQueueResolve(t *testing.T) { } func TestReviewQueue_PaginatesByCursor(t *testing.T) { - s, db := newTestServer(t) + s, db, userID := newTestServer(t) ctx := newCtx() // 5 activities, newest first once sorted: 2026-07-05 .. 2026-07-01. for i := 1; i <= 5; i++ { - activityID, err := db.UpsertActivity(ctx, store.Activity{ + activityID, err := db.UpsertActivity(ctx, userID, 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) } - if _, err := db.InsertKindAssignment(ctx, store.KindAssignment{ + if _, err := db.InsertKindAssignment(ctx, userID, store.KindAssignment{ ActivityID: activityID, AssignmentSource: store.AssignmentSourceRuleEngine, Status: store.AssignmentStatusNeedsReview, CandidateKindsJSON: "[]", }); err != nil { @@ -394,10 +401,10 @@ func TestReviewQueue_PaginatesByCursor(t *testing.T) { } func TestReviewQueue_FiltersByKindAndUnclassifiedStayPaginated(t *testing.T) { - s, db := newTestServer(t) + s, db, userID := newTestServer(t) ctx := newCtx() - kinds, err := db.ListWorkoutKinds(ctx, true) + kinds, err := db.ListWorkoutKinds(ctx, userID, true) if err != nil || len(kinds) < 2 { t.Fatalf("ListWorkoutKinds: %v (len=%d)", err, len(kinds)) } @@ -405,9 +412,9 @@ func TestReviewQueue_FiltersByKindAndUnclassifiedStayPaginated(t *testing.T) { // 2 activities assigned to kindA, 1 to kindB, 1 unclassified. makeActivity := func(n int64, kindID *int64) { - activityID, err := db.UpsertActivity(ctx, store.Activity{ + activityID, err := db.UpsertActivity(ctx, userID, 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) @@ -416,7 +423,7 @@ func TestReviewQueue_FiltersByKindAndUnclassifiedStayPaginated(t *testing.T) { if kindID == nil { status = store.AssignmentStatusNeedsReview } - if _, err := db.InsertKindAssignment(ctx, store.KindAssignment{ + if _, err := db.InsertKindAssignment(ctx, userID, store.KindAssignment{ ActivityID: activityID, WorkoutKindID: kindID, AssignmentSource: store.AssignmentSourceRuleEngine, Status: status, CandidateKindsJSON: "[]", }); err != nil { @@ -481,14 +488,14 @@ func TestReviewQueue_FiltersByKindAndUnclassifiedStayPaginated(t *testing.T) { } func TestResolveReview_RejectsRaceKind(t *testing.T) { - s, db := newTestServer(t) + s, db, userID := newTestServer(t) ctx := newCtx() - activityID, err := db.UpsertActivity(ctx, store.Activity{GarminActivityID: 1, StartTimeUTC: "2026-07-11 06:00:00", RawJSON: "{}"}) + activityID, err := db.UpsertActivity(ctx, userID, store.Activity{GarminActivityID: 1, StartTimeUTC: "2026-07-11 06:00:00", RawJSON: "{}"}) if err != nil { t.Fatalf("UpsertActivity: %v", err) } - raceKind, ok, err := db.GetWorkoutKindByName(ctx, "Race") + raceKind, ok, err := db.GetWorkoutKindByName(ctx, userID, "Race") if err != nil || !ok { t.Fatalf("GetWorkoutKindByName(Race): ok=%v err=%v", ok, err) } @@ -500,35 +507,35 @@ func TestResolveReview_RejectsRaceKind(t *testing.T) { } func TestReclassifyAll_SkipsManualAndRaceAssignments(t *testing.T) { - s, db := newTestServer(t) + s, db, userID := newTestServer(t) ctx := newCtx() - easyID, err := db.CreateWorkoutKind(ctx, store.WorkoutKind{Name: "Test Easy", RuleJSON: `{"match":"all","conditions":[{"metric":"distance_meters","op":">=","value":0}]}`, IsActive: true}) + easyID, err := db.CreateWorkoutKind(ctx, userID, store.WorkoutKind{Name: "Test Easy", RuleJSON: `{"match":"all","conditions":[{"metric":"distance_meters","op":">=","value":0}]}`, IsActive: true}) if err != nil { t.Fatalf("CreateWorkoutKind: %v", err) } - raceKind, ok, err := db.GetWorkoutKindByName(ctx, "Race") + raceKind, ok, err := db.GetWorkoutKindByName(ctx, userID, "Race") if err != nil || !ok { t.Fatalf("GetWorkoutKindByName(Race): ok=%v err=%v", ok, err) } - ruleEngineActivity, _ := db.UpsertActivity(ctx, store.Activity{GarminActivityID: 1, StartTimeUTC: "2026-07-01 06:00:00", RawJSON: "{}"}) - manualActivity, _ := db.UpsertActivity(ctx, store.Activity{GarminActivityID: 2, StartTimeUTC: "2026-07-02 06:00:00", RawJSON: "{}"}) - raceActivity, _ := db.UpsertActivity(ctx, store.Activity{GarminActivityID: 3, EventTypeKey: "race", StartTimeUTC: "2026-07-03 06:00:00", RawJSON: "{}"}) + ruleEngineActivity, _ := db.UpsertActivity(ctx, userID, store.Activity{GarminActivityID: 1, StartTimeUTC: "2026-07-01 06:00:00", RawJSON: "{}"}) + manualActivity, _ := db.UpsertActivity(ctx, userID, store.Activity{GarminActivityID: 2, StartTimeUTC: "2026-07-02 06:00:00", RawJSON: "{}"}) + raceActivity, _ := db.UpsertActivity(ctx, userID, store.Activity{GarminActivityID: 3, EventTypeKey: "race", StartTimeUTC: "2026-07-03 06:00:00", RawJSON: "{}"}) - if _, err := db.InsertKindAssignment(ctx, store.KindAssignment{ + if _, err := db.InsertKindAssignment(ctx, userID, store.KindAssignment{ ActivityID: ruleEngineActivity, WorkoutKindID: &easyID, AssignmentSource: store.AssignmentSourceRuleEngine, Status: store.AssignmentStatusAssigned, CandidateKindsJSON: "[]", }); err != nil { t.Fatalf("InsertKindAssignment (rule engine): %v", err) } - if _, err := db.InsertKindAssignment(ctx, store.KindAssignment{ + if _, err := db.InsertKindAssignment(ctx, userID, store.KindAssignment{ ActivityID: manualActivity, WorkoutKindID: &easyID, AssignmentSource: store.AssignmentSourceManual, Status: store.AssignmentStatusAssigned, CandidateKindsJSON: "[]", }); err != nil { t.Fatalf("InsertKindAssignment (manual): %v", err) } - if _, err := db.InsertKindAssignment(ctx, store.KindAssignment{ + if _, err := db.InsertKindAssignment(ctx, userID, store.KindAssignment{ ActivityID: raceActivity, WorkoutKindID: &raceKind.ID, AssignmentSource: store.AssignmentSourceRuleEngine, Status: store.AssignmentStatusAssigned, CandidateKindsJSON: "[]", }); err != nil { @@ -549,7 +556,7 @@ func TestReclassifyAll_SkipsManualAndRaceAssignments(t *testing.T) { t.Fatalf("reclassified = %d, want 1 (only the non-locked rule-engine activity)", body.Reclassified) } - manualAssignment, ok, err := db.CurrentAssignment(ctx, manualActivity) + manualAssignment, ok, err := db.CurrentAssignment(ctx, userID, manualActivity) if err != nil || !ok { t.Fatalf("CurrentAssignment(manual): ok=%v err=%v", ok, err) } @@ -557,7 +564,7 @@ func TestReclassifyAll_SkipsManualAndRaceAssignments(t *testing.T) { t.Errorf("manual assignment was overwritten: %+v", manualAssignment) } - raceAssignment, ok, err := db.CurrentAssignment(ctx, raceActivity) + raceAssignment, ok, err := db.CurrentAssignment(ctx, userID, raceActivity) if err != nil || !ok { t.Fatalf("CurrentAssignment(race): ok=%v err=%v", ok, err) } @@ -567,25 +574,25 @@ func TestReclassifyAll_SkipsManualAndRaceAssignments(t *testing.T) { } func TestListActivities_ReportsLockedForManualAndRaceAssignments(t *testing.T) { - s, db := newTestServer(t) + s, db, userID := newTestServer(t) ctx := newCtx() - easyID, err := db.CreateWorkoutKind(ctx, store.WorkoutKind{Name: "Test Easy Locked", RuleJSON: `{"match":"all","conditions":[]}`, IsActive: true}) + easyID, err := db.CreateWorkoutKind(ctx, userID, store.WorkoutKind{Name: "Test Easy Locked", RuleJSON: `{"match":"all","conditions":[]}`, IsActive: true}) if err != nil { t.Fatalf("CreateWorkoutKind: %v", err) } - raceKind, ok, err := db.GetWorkoutKindByName(ctx, "Race") + raceKind, ok, err := db.GetWorkoutKindByName(ctx, userID, "Race") if err != nil || !ok { t.Fatalf("GetWorkoutKindByName(Race): ok=%v err=%v", ok, err) } - ruleEngineActivity, _ := db.UpsertActivity(ctx, store.Activity{GarminActivityID: 1, StartTimeUTC: "2026-07-01 06:00:00", RawJSON: "{}"}) - manualActivity, _ := db.UpsertActivity(ctx, store.Activity{GarminActivityID: 2, StartTimeUTC: "2026-07-02 06:00:00", RawJSON: "{}"}) - raceActivity, _ := db.UpsertActivity(ctx, store.Activity{GarminActivityID: 3, EventTypeKey: "race", StartTimeUTC: "2026-07-03 06:00:00", RawJSON: "{}"}) + ruleEngineActivity, _ := db.UpsertActivity(ctx, userID, store.Activity{GarminActivityID: 1, StartTimeUTC: "2026-07-01 06:00:00", RawJSON: "{}"}) + manualActivity, _ := db.UpsertActivity(ctx, userID, store.Activity{GarminActivityID: 2, StartTimeUTC: "2026-07-02 06:00:00", RawJSON: "{}"}) + raceActivity, _ := db.UpsertActivity(ctx, userID, store.Activity{GarminActivityID: 3, EventTypeKey: "race", StartTimeUTC: "2026-07-03 06:00:00", RawJSON: "{}"}) - db.InsertKindAssignment(ctx, store.KindAssignment{ActivityID: ruleEngineActivity, WorkoutKindID: &easyID, AssignmentSource: store.AssignmentSourceRuleEngine, Status: store.AssignmentStatusAssigned, CandidateKindsJSON: "[]"}) - db.InsertKindAssignment(ctx, store.KindAssignment{ActivityID: manualActivity, WorkoutKindID: &easyID, AssignmentSource: store.AssignmentSourceManual, Status: store.AssignmentStatusAssigned, CandidateKindsJSON: "[]"}) - db.InsertKindAssignment(ctx, store.KindAssignment{ActivityID: raceActivity, WorkoutKindID: &raceKind.ID, AssignmentSource: store.AssignmentSourceRuleEngine, Status: store.AssignmentStatusAssigned, CandidateKindsJSON: "[]"}) + db.InsertKindAssignment(ctx, userID, store.KindAssignment{ActivityID: ruleEngineActivity, WorkoutKindID: &easyID, AssignmentSource: store.AssignmentSourceRuleEngine, Status: store.AssignmentStatusAssigned, CandidateKindsJSON: "[]"}) + db.InsertKindAssignment(ctx, userID, store.KindAssignment{ActivityID: manualActivity, WorkoutKindID: &easyID, AssignmentSource: store.AssignmentSourceManual, Status: store.AssignmentStatusAssigned, CandidateKindsJSON: "[]"}) + db.InsertKindAssignment(ctx, userID, store.KindAssignment{ActivityID: raceActivity, WorkoutKindID: &raceKind.ID, AssignmentSource: store.AssignmentSourceRuleEngine, Status: store.AssignmentStatusAssigned, CandidateKindsJSON: "[]"}) rec := doJSON(t, s.Router(), http.MethodGet, "/api/activities/", nil) if rec.Code != http.StatusOK { @@ -618,10 +625,10 @@ func TestListActivities_ReportsLockedForManualAndRaceAssignments(t *testing.T) { } func TestSyncReset_DeletesActivitiesAndBackfillEndpointIsGone(t *testing.T) { - s, db := newTestServer(t) + s, db, userID := newTestServer(t) ctx := newCtx() - if _, err := db.UpsertActivity(ctx, store.Activity{GarminActivityID: 1, StartTimeUTC: "2026-07-11 06:00:00", RawJSON: "{}"}); err != nil { + if _, err := db.UpsertActivity(ctx, userID, store.Activity{GarminActivityID: 1, StartTimeUTC: "2026-07-11 06:00:00", RawJSON: "{}"}); err != nil { t.Fatalf("UpsertActivity: %v", err) } @@ -633,7 +640,7 @@ func TestSyncReset_DeletesActivitiesAndBackfillEndpointIsGone(t *testing.T) { deadline := time.Now().Add(2 * time.Second) for { - activities, err := db.ListActivities(ctx, store.ActivityFilter{}) + activities, err := db.ListActivities(ctx, userID, store.ActivityFilter{}) if err != nil { t.Fatalf("ListActivities: %v", err) } @@ -654,17 +661,17 @@ func TestSyncReset_DeletesActivitiesAndBackfillEndpointIsGone(t *testing.T) { } func TestProgression_ReturnsSortedTimeSeries(t *testing.T) { - s, db := newTestServer(t) + s, db, userID := newTestServer(t) ctx := newCtx() - kindID, _ := db.CreateWorkoutKind(ctx, store.WorkoutKind{Name: "Test Kind", RuleJSON: `{}`, IsActive: true}) + kindID, _ := db.CreateWorkoutKind(ctx, userID, store.WorkoutKind{Name: "Test Kind", RuleJSON: `{}`, IsActive: true}) speed := 3.0 - a1, _ := db.UpsertActivity(ctx, store.Activity{GarminActivityID: 1, StartTimeUTC: "2026-07-01 06:00:00", AvgSpeedMps: &speed, RawJSON: "{}"}) - a2, _ := db.UpsertActivity(ctx, store.Activity{GarminActivityID: 2, StartTimeUTC: "2026-07-05 06:00:00", AvgSpeedMps: &speed, RawJSON: "{}"}) + a1, _ := db.UpsertActivity(ctx, userID, store.Activity{GarminActivityID: 1, StartTimeUTC: "2026-07-01 06:00:00", AvgSpeedMps: &speed, RawJSON: "{}"}) + a2, _ := db.UpsertActivity(ctx, userID, store.Activity{GarminActivityID: 2, StartTimeUTC: "2026-07-05 06:00:00", AvgSpeedMps: &speed, RawJSON: "{}"}) for _, id := range []int64{a2, a1} { // insert out of order on purpose - db.InsertKindAssignment(ctx, store.KindAssignment{ActivityID: id, WorkoutKindID: &kindID, AssignmentSource: store.AssignmentSourceManual, Status: store.AssignmentStatusAssigned, CandidateKindsJSON: "[]"}) + db.InsertKindAssignment(ctx, userID, store.KindAssignment{ActivityID: id, WorkoutKindID: &kindID, AssignmentSource: store.AssignmentSourceManual, Status: store.AssignmentStatusAssigned, CandidateKindsJSON: "[]"}) } rec := doJSON(t, s.Router(), http.MethodGet, "/api/progression/"+itoa(kindID)+"?metric=pace", nil) @@ -707,7 +714,7 @@ func itoa(v int64) string { } func TestProfile_GetDefaultsThenUpdate(t *testing.T) { - s, _ := newTestServer(t) + s, _, _ := newTestServer(t) router := s.Router() rec := doJSON(t, router, http.MethodGet, "/api/profile", nil) @@ -739,7 +746,7 @@ func TestProfile_GetDefaultsThenUpdate(t *testing.T) { } func TestProfile_RejectsInvalidHRZones(t *testing.T) { - s, _ := newTestServer(t) + s, _, _ := newTestServer(t) router := s.Router() rec := doJSON(t, router, http.MethodGet, "/api/profile", nil) @@ -758,13 +765,13 @@ func TestProfile_RejectsInvalidHRZones(t *testing.T) { func newTestServerWithAuth(t *testing.T, verifier auth.Verifier) (*Server, *store.DB) { t.Helper() - s, db := newTestServer(t) + s, db, _ := newTestServer(t) s.Auth = verifier return s, db } func TestHealth_NoSessionRequired(t *testing.T) { - s, _ := newTestServer(t) + s, _, _ := newTestServer(t) req := httptest.NewRequest(http.MethodGet, "/api/health", nil) rec := httptest.NewRecorder() s.Router().ServeHTTP(rec, req) @@ -774,7 +781,7 @@ func TestHealth_NoSessionRequired(t *testing.T) { } func TestProtectedRoute_RejectsMissingSession(t *testing.T) { - s, _ := newTestServer(t) + s, _, _ := newTestServer(t) req := httptest.NewRequest(http.MethodGet, "/api/profile/", nil) rec := httptest.NewRecorder() s.Router().ServeHTTP(rec, req) @@ -893,7 +900,7 @@ func TestSessionMe_ReturnsAuthenticatedUser(t *testing.T) { func mustServerRouter(t *testing.T) http.Handler { t.Helper() - s, _ := newTestServer(t) + s, _, _ := newTestServer(t) return s.Router() } diff --git a/backend/internal/api/auth.go b/backend/internal/api/auth.go index a6de82c..295e2ff 100644 --- a/backend/internal/api/auth.go +++ b/backend/internal/api/auth.go @@ -25,24 +25,31 @@ func authStatusString(s garmin.AuthStatus) string { } } -func (s *Server) recordAuthResult(res garmin.AuthResult) { +func (s *Server) recordAuthResult(userID int64, res garmin.AuthResult) { s.mu.Lock() - s.authStatus = res.Status - s.authMessage = res.Message + s.userAuthStatus[userID] = res.Status + s.userAuthMessage[userID] = res.Message s.mu.Unlock() } func (s *Server) handleAuthLogin(w http.ResponseWriter, r *http.Request) { - res, err := s.Garmin.Authenticate(r.Context()) + userID := userIDFromContext(r.Context()) + client, err := s.garminFor(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(res) + s.recordAuthResult(userID, res) writeJSON(w, http.StatusOK, authResponse{Status: authStatusString(res.Status), Message: res.Message}) } func (s *Server) handleAuthMFA(w http.ResponseWriter, r *http.Request) { + userID := userIDFromContext(r.Context()) var body struct { Code string `json:"code"` } @@ -55,18 +62,24 @@ func (s *Server) handleAuthMFA(w http.ResponseWriter, r *http.Request) { return } - res, err := s.Garmin.CompleteMFA(r.Context(), body.Code) + client, err := s.garminFor(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(res) + s.recordAuthResult(userID, res) writeJSON(w, http.StatusOK, authResponse{Status: authStatusString(res.Status), Message: res.Message}) } func (s *Server) handleAuthStatus(w http.ResponseWriter, r *http.Request) { + userID := userIDFromContext(r.Context()) s.mu.Lock() - status, msg := s.authStatus, s.authMessage + status, msg := s.userAuthStatus[userID], s.userAuthMessage[userID] s.mu.Unlock() writeJSON(w, http.StatusOK, authResponse{Status: authStatusString(status), Message: msg}) } diff --git a/backend/internal/api/isolation_test.go b/backend/internal/api/isolation_test.go new file mode 100644 index 0000000..25704eb --- /dev/null +++ b/backend/internal/api/isolation_test.go @@ -0,0 +1,155 @@ +package api + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "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" +) + +// doJSONAs is doJSON but for an explicit session Sub, for tests that need +// two distinct logged-in users against the same server (doJSON itself +// always mints a cookie for Sub: "test-user", the identity newTestServer +// pre-provisions). +func doJSONAs(t *testing.T, handler http.Handler, sub, method, path string, body any) *httptest.ResponseRecorder { + t.Helper() + var reader *bytes.Reader + if body != nil { + b, err := json.Marshal(body) + if err != nil { + t.Fatalf("marshal request body: %v", err) + } + reader = bytes.NewReader(b) + } else { + reader = bytes.NewReader(nil) + } + req := httptest.NewRequest(method, path, reader) + req.Header.Set("Content-Type", "application/json") + cookie, err := auth.MintSessionCookie(auth.Claims{Sub: sub, Name: sub, Email: sub + "@example.com"}, testSessionConfig.Secret, testSessionConfig.Duration, testSessionConfig.Secure) + if err != nil { + t.Fatalf("mint test session cookie: %v", err) + } + req.AddCookie(cookie) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + return rec +} + +func TestIsolation_ActivityDetailNotVisibleToOtherUser(t *testing.T) { + s, db, _ := newTestServer(t) // provisions "test-user" (userA) + userB, err := db.ProvisionUser(newCtx(), "user-b", "B") + if err != nil { + t.Fatalf("ProvisionUser(b): %v", err) + } + + userA, found, err := db.GetUserBySub(newCtx(), "test-user") + if err != nil || !found { + t.Fatalf("GetUserBySub(test-user): found=%v err=%v", found, err) + } + activityID, err := db.UpsertActivity(newCtx(), userA.ID, store.Activity{GarminActivityID: 1, StartTimeUTC: "2026-07-11 06:00:00", RawJSON: "{}"}) + if err != nil { + t.Fatalf("UpsertActivity: %v", err) + } + _ = userB + + router := s.Router() + + // userA (the default doJSON identity) can see it. + rec := doJSON(t, router, http.MethodGet, "/api/activities/"+itoa(activityID), nil) + if rec.Code != http.StatusOK { + t.Fatalf("userA GetActivity status = %d, body = %s", rec.Code, rec.Body.String()) + } + + // userB, given the exact same activity id, gets 404 -- not another + // user's data, and not a 500 that would leak existence either way. + rec = doJSONAs(t, router, "user-b", http.MethodGet, "/api/activities/"+itoa(activityID), nil) + if rec.Code != http.StatusNotFound { + t.Fatalf("userB GetActivity(userA's id) status = %d, want 404, body = %s", rec.Code, rec.Body.String()) + } +} + +func TestIsolation_WorkoutKindUpdateCannotTargetOtherUsersKind(t *testing.T) { + s, db, _ := newTestServer(t) // provisions "test-user" (userA) + if _, err := db.ProvisionUser(newCtx(), "user-b", "B"); err != nil { + t.Fatalf("ProvisionUser(b): %v", err) + } + userA, _, _ := db.GetUserBySub(newCtx(), "test-user") + kindsA, err := db.ListWorkoutKinds(newCtx(), userA.ID, false) + if err != nil || len(kindsA) == 0 { + t.Fatalf("ListWorkoutKinds(a): len=%d err=%v", len(kindsA), err) + } + targetID := kindsA[0].ID + originalName := kindsA[0].Name + + router := s.Router() + rec := doJSONAs(t, router, "user-b", http.MethodPut, "/api/workout-kinds/"+itoa(targetID), map[string]any{ + "name": "Hijacked", + "rule": json.RawMessage(`{"match":"all","conditions":[]}`), + }) + if rec.Code != http.StatusNotFound { + t.Fatalf("userB updating userA's kind status = %d, want 404, body = %s", rec.Code, rec.Body.String()) + } + + rec = doJSON(t, router, http.MethodGet, "/api/workout-kinds/"+itoa(targetID), nil) + var got workoutKindResponse + json.Unmarshal(rec.Body.Bytes(), &got) + if got.Name != originalName { + t.Fatalf("userA's kind name changed to %q despite userB's update being rejected", got.Name) + } +} + +func TestIsolation_ReviewQueueOnlyShowsOwnActivities(t *testing.T) { + s, db, _ := newTestServer(t) // provisions "test-user" (userA) + userB, err := db.ProvisionUser(newCtx(), "user-b", "B") + if err != nil { + t.Fatalf("ProvisionUser(b): %v", err) + } + if _, err := db.UpsertActivity(newCtx(), userB, store.Activity{GarminActivityID: 42, StartTimeUTC: "2026-07-11 06:00:00", RawJSON: "{}"}); err != nil { + t.Fatalf("UpsertActivity(b): %v", err) + } + if _, err := db.InsertKindAssignment(newCtx(), userB, store.KindAssignment{ + ActivityID: func() int64 { + acts, _ := db.ListActivities(newCtx(), userB, store.ActivityFilter{}) + return acts[0].ID + }(), + AssignmentSource: store.AssignmentSourceRuleEngine, Status: store.AssignmentStatusNeedsReview, CandidateKindsJSON: "[]", + }); err != nil { + t.Fatalf("InsertKindAssignment(b): %v", err) + } + + rec := doJSON(t, s.Router(), http.MethodGet, "/api/review-queue/", nil) // as userA + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String()) + } + var page struct { + Items []map[string]any `json:"items"` + Total int `json:"total"` + } + json.Unmarshal(rec.Body.Bytes(), &page) + if page.Total != 0 || len(page.Items) != 0 { + t.Fatalf("expected userA's review queue to be empty (the only assignment belongs to userB), got total=%d items=%d", page.Total, len(page.Items)) + } +} + +func TestIsolation_RequireProvisionedUser_BlocksDataRoutesUntilSetup(t *testing.T) { + db, err := store.Open(t.TempDir() + "/isolation_test.db") + if err != nil { + t.Fatalf("store.Open: %v", err) + } + defer 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.MethodGet, "/api/activities/", nil) // "test-user" sub, never provisioned + if rec.Code != http.StatusForbidden { + t.Fatalf("status = %d, want 403 (no profile provisioned yet), body = %s", rec.Code, rec.Body.String()) + } +} diff --git a/backend/internal/api/kinds.go b/backend/internal/api/kinds.go index 11f86ab..ad2e91e 100644 --- a/backend/internal/api/kinds.go +++ b/backend/internal/api/kinds.go @@ -24,7 +24,8 @@ type workoutKindResponse struct { } func (s *Server) toWorkoutKindResponse(r *http.Request, k store.WorkoutKind) (workoutKindResponse, error) { - pace, err := s.DB.GetWorkoutTypePace(r.Context(), k.ID) + userID := userIDFromContext(r.Context()) + pace, err := s.DB.GetWorkoutTypePace(r.Context(), userID, k.ID) if err != nil { return workoutKindResponse{}, err } @@ -71,8 +72,9 @@ func (req workoutKindRequest) validate() (classify.Node, error) { } func (s *Server) handleListWorkoutKinds(w http.ResponseWriter, r *http.Request) { + userID := userIDFromContext(r.Context()) activeOnly := r.URL.Query().Get("include_inactive") != "true" - kinds, err := s.DB.ListWorkoutKinds(r.Context(), activeOnly) + kinds, err := s.DB.ListWorkoutKinds(r.Context(), userID, activeOnly) if err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return @@ -90,12 +92,13 @@ func (s *Server) handleListWorkoutKinds(w http.ResponseWriter, r *http.Request) } func (s *Server) handleGetWorkoutKind(w http.ResponseWriter, r *http.Request) { + userID := userIDFromContext(r.Context()) id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64) if err != nil { writeError(w, http.StatusBadRequest, "invalid workout kind id") return } - kind, ok, err := s.DB.GetWorkoutKind(r.Context(), id) + kind, ok, err := s.DB.GetWorkoutKind(r.Context(), userID, id) if err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return @@ -113,12 +116,13 @@ func (s *Server) handleGetWorkoutKind(w http.ResponseWriter, r *http.Request) { } func (s *Server) handleUpdateWorkoutKind(w http.ResponseWriter, r *http.Request) { + userID := userIDFromContext(r.Context()) id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64) if err != nil { writeError(w, http.StatusBadRequest, "invalid workout kind id") return } - existing, ok, err := s.DB.GetWorkoutKind(r.Context(), id) + existing, ok, err := s.DB.GetWorkoutKind(r.Context(), userID, id) if err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return @@ -143,14 +147,14 @@ func (s *Server) handleUpdateWorkoutKind(w http.ResponseWriter, r *http.Request) isActive = *req.IsActive } - if err := s.DB.UpdateWorkoutKind(r.Context(), store.WorkoutKind{ + if err := s.DB.UpdateWorkoutKind(r.Context(), userID, store.WorkoutKind{ ID: id, Name: req.Name, Description: req.Description, Color: req.Color, RuleJSON: string(req.Rule), Priority: req.Priority, IsActive: isActive, }); err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return } - if err := s.DB.UpdateWorkoutTypePace(r.Context(), store.WorkoutTypePace{ + if err := s.DB.UpdateWorkoutTypePace(r.Context(), userID, store.WorkoutTypePace{ WorkoutKindID: id, PaceMinSecPerKm: req.PaceMinSecPerKm, PaceMaxSecPerKm: req.PaceMaxSecPerKm, @@ -161,7 +165,7 @@ func (s *Server) handleUpdateWorkoutKind(w http.ResponseWriter, r *http.Request) return } - kind, _, _ := s.DB.GetWorkoutKind(r.Context(), id) + kind, _, _ := s.DB.GetWorkoutKind(r.Context(), userID, id) resp, err := s.toWorkoutKindResponse(r, kind) if err != nil { writeError(w, http.StatusInternalServerError, err.Error()) diff --git a/backend/internal/api/profile.go b/backend/internal/api/profile.go index 63df4dc..6b477ba 100644 --- a/backend/internal/api/profile.go +++ b/backend/internal/api/profile.go @@ -36,7 +36,8 @@ func validateProfile(p store.Profile) error { } func (s *Server) handleGetProfile(w http.ResponseWriter, r *http.Request) { - p, err := s.DB.GetProfile(r.Context()) + userID := userIDFromContext(r.Context()) + p, err := s.DB.GetProfile(r.Context(), userID) if err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return @@ -45,6 +46,7 @@ func (s *Server) handleGetProfile(w http.ResponseWriter, r *http.Request) { } func (s *Server) handleUpdateProfile(w http.ResponseWriter, r *http.Request) { + userID := userIDFromContext(r.Context()) var p store.Profile if err := json.NewDecoder(r.Body).Decode(&p); err != nil { writeError(w, http.StatusBadRequest, "invalid request body") @@ -55,13 +57,18 @@ func (s *Server) handleUpdateProfile(w http.ResponseWriter, r *http.Request) { return } - if err := s.DB.UpdateProfile(r.Context(), p); err != nil { + if err := s.DB.UpdateProfile(r.Context(), userID, p); err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return } - s.Garmin.UpdateCredentials(p.GarminEmail, p.GarminPassword) + client, err := s.garminFor(r.Context(), userID) + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + client.UpdateCredentials(p.GarminEmail, p.GarminPassword) - updated, err := s.DB.GetProfile(r.Context()) + updated, err := s.DB.GetProfile(r.Context(), userID) if err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return diff --git a/backend/internal/api/progression.go b/backend/internal/api/progression.go index 6ca681d..fe3dbae 100644 --- a/backend/internal/api/progression.go +++ b/backend/internal/api/progression.go @@ -61,6 +61,7 @@ func metricValue(metric string, a store.Activity) (float64, bool) { // handleProgression returns a time series of the requested metric for every // activity currently assigned to a workout kind, for progression charts. func (s *Server) handleProgression(w http.ResponseWriter, r *http.Request) { + userID := userIDFromContext(r.Context()) kindID, err := strconv.ParseInt(chi.URLParam(r, "kindID"), 10, 64) if err != nil { writeError(w, http.StatusBadRequest, "invalid workout kind id") @@ -72,7 +73,7 @@ func (s *Server) handleProgression(w http.ResponseWriter, r *http.Request) { } from, to := r.URL.Query().Get("from"), r.URL.Query().Get("to") - assignments, err := s.DB.AssignmentsForKind(r.Context(), kindID) + assignments, err := s.DB.AssignmentsForKind(r.Context(), userID, kindID) if err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return @@ -80,7 +81,7 @@ func (s *Server) handleProgression(w http.ResponseWriter, r *http.Request) { points := []progressionPoint{} for _, a := range assignments { - activity, ok, err := s.DB.GetActivity(r.Context(), a.ActivityID) + activity, ok, err := s.DB.GetActivity(r.Context(), userID, a.ActivityID) if err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return diff --git a/backend/internal/api/reclassify.go b/backend/internal/api/reclassify.go index 1ece10a..a5dafe8 100644 --- a/backend/internal/api/reclassify.go +++ b/backend/internal/api/reclassify.go @@ -18,13 +18,14 @@ import ( // It's a synchronous, bounded operation (unlike sync), so it runs inline // rather than in the background. func (s *Server) handleReclassifyAll(w http.ResponseWriter, r *http.Request) { - raceKind, hasRaceKind, err := s.DB.GetWorkoutKindByName(r.Context(), "Race") + userID := userIDFromContext(r.Context()) + raceKind, hasRaceKind, err := s.DB.GetWorkoutKindByName(r.Context(), userID, "Race") if err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return } - assignments, err := s.DB.AllCurrentAssignments(r.Context()) + assignments, err := s.DB.AllCurrentAssignments(r.Context(), userID) if err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return @@ -41,8 +42,13 @@ func (s *Server) handleReclassifyAll(w http.ResponseWriter, r *http.Request) { activityIDs = append(activityIDs, a.ActivityID) } + svc, err := s.syncFor(r.Context(), userID) + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } for _, activityID := range activityIDs { - if err := s.Sync.ClassifyActivity(r.Context(), activityID); err != nil { + if err := svc.ClassifyActivity(r.Context(), activityID); err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return } diff --git a/backend/internal/api/review.go b/backend/internal/api/review.go index 467a617..4928809 100644 --- a/backend/internal/api/review.go +++ b/backend/internal/api/review.go @@ -35,6 +35,7 @@ type reviewQueueItem struct { // that cursor slicing too, so a filtered view still only loads (and // chart-renders) one page at a time instead of the whole matching backlog. func (s *Server) handleReviewQueue(w http.ResponseWriter, r *http.Request) { + userID := userIDFromContext(r.Context()) limit := defaultReviewQueuePageSize if v := r.URL.Query().Get("limit"); v != "" { if n, err := strconv.Atoi(v); err == nil && n > 0 { @@ -51,7 +52,7 @@ func (s *Server) handleReviewQueue(w http.ResponseWriter, r *http.Request) { } unclassifiedOnly := r.URL.Query().Get("unclassified") == "true" - queue, err := s.DB.AllCurrentAssignments(r.Context()) + queue, err := s.DB.AllCurrentAssignments(r.Context(), userID) if err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return @@ -69,7 +70,7 @@ func (s *Server) handleReviewQueue(w http.ResponseWriter, r *http.Request) { if unclassifiedOnly && a.WorkoutKindID != nil { continue } - activity, ok, err := s.DB.GetActivity(r.Context(), a.ActivityID) + activity, ok, err := s.DB.GetActivity(r.Context(), userID, a.ActivityID) if err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return @@ -102,7 +103,7 @@ func (s *Server) handleReviewQueue(w http.ResponseWriter, r *http.Request) { items := make([]reviewQueueItem, 0, len(all)) for _, wa := range all { - laps, err := s.DB.LapsForActivity(r.Context(), wa.assignment.ActivityID) + laps, err := s.DB.LapsForActivity(r.Context(), userID, wa.assignment.ActivityID) if err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return @@ -110,7 +111,7 @@ func (s *Server) handleReviewQueue(w http.ResponseWriter, r *http.Request) { // Per-second telemetry, not just per-lap averages, so the chart can // show real within-lap variation instead of one flat segment per lap // (most activities only have a handful of laps). - samples, err := s.DB.SamplesForActivity(r.Context(), wa.assignment.ActivityID) + samples, err := s.DB.SamplesForActivity(r.Context(), userID, wa.assignment.ActivityID) if err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return @@ -137,6 +138,7 @@ func (s *Server) handleReviewQueue(w http.ResponseWriter, r *http.Request) { } func (s *Server) handleResolveReview(w http.ResponseWriter, r *http.Request) { + userID := userIDFromContext(r.Context()) activityID, err := strconv.ParseInt(chi.URLParam(r, "activityID"), 10, 64) if err != nil { writeError(w, http.StatusBadRequest, "invalid activity id") @@ -155,7 +157,7 @@ func (s *Server) handleResolveReview(w http.ResponseWriter, r *http.Request) { return } - kind, ok, err := s.DB.GetWorkoutKind(r.Context(), body.WorkoutKindID) + kind, ok, err := s.DB.GetWorkoutKind(r.Context(), userID, body.WorkoutKindID) if err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return @@ -169,7 +171,7 @@ func (s *Server) handleResolveReview(w http.ResponseWriter, r *http.Request) { } kindID := body.WorkoutKindID - if _, err := s.DB.InsertKindAssignment(r.Context(), store.KindAssignment{ + if _, err := s.DB.InsertKindAssignment(r.Context(), userID, store.KindAssignment{ ActivityID: activityID, WorkoutKindID: &kindID, AssignmentSource: store.AssignmentSourceManual, @@ -191,13 +193,14 @@ func (s *Server) handleResolveReview(w http.ResponseWriter, r *http.Request) { // different kind), but the backend doesn't re-enforce that here, matching // handleResolveReview's own lack of a lock precondition check. func (s *Server) handleUnassignReview(w http.ResponseWriter, r *http.Request) { + userID := userIDFromContext(r.Context()) activityID, err := strconv.ParseInt(chi.URLParam(r, "activityID"), 10, 64) if err != nil { writeError(w, http.StatusBadRequest, "invalid activity id") return } - if _, err := s.DB.InsertKindAssignment(r.Context(), store.KindAssignment{ + if _, err := s.DB.InsertKindAssignment(r.Context(), userID, store.KindAssignment{ ActivityID: activityID, WorkoutKindID: nil, AssignmentSource: store.AssignmentSourceManual, @@ -216,13 +219,14 @@ func (s *Server) handleUnassignReview(w http.ResponseWriter, r *http.Request) { // handleResolveReview, not a delete: the kind stays visible as-is until // something actually reclassifies it. func (s *Server) handleUnlockReview(w http.ResponseWriter, r *http.Request) { + userID := userIDFromContext(r.Context()) activityID, err := strconv.ParseInt(chi.URLParam(r, "activityID"), 10, 64) if err != nil { writeError(w, http.StatusBadRequest, "invalid activity id") return } - current, ok, err := s.DB.CurrentAssignment(r.Context(), activityID) + current, ok, err := s.DB.CurrentAssignment(r.Context(), userID, activityID) if err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return @@ -240,7 +244,7 @@ func (s *Server) handleUnlockReview(w http.ResponseWriter, r *http.Request) { if current.WorkoutKindID == nil { status = store.AssignmentStatusNeedsReview } - if _, err := s.DB.InsertKindAssignment(r.Context(), store.KindAssignment{ + if _, err := s.DB.InsertKindAssignment(r.Context(), userID, store.KindAssignment{ ActivityID: activityID, WorkoutKindID: current.WorkoutKindID, AssignmentSource: store.AssignmentSourceRuleEngine, diff --git a/backend/internal/api/server.go b/backend/internal/api/server.go index c9b3658..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. @@ -50,49 +155,55 @@ func (s *Server) Router() http.Handler { r.Group(func(r chi.Router) { r.Use(auth.RequireSession(s.Session.Secret)) + r.Use(s.resolveUser) r.Get("/session/me", s.handleSessionMe) 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 @@ -135,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/session.go b/backend/internal/api/session.go index 4d0aefe..50e3e53 100644 --- a/backend/internal/api/session.go +++ b/backend/internal/api/session.go @@ -24,8 +24,10 @@ type SessionConfig struct { } type sessionMeResponse struct { - Name string `json:"name"` - Email string `json:"email"` + Name string `json:"name"` + Email string `json:"email"` + HasProfile bool `json:"has_profile"` + DisplayName string `json:"display_name,omitempty"` } func (s *Server) handleSessionLogin(w http.ResponseWriter, r *http.Request) { @@ -92,5 +94,10 @@ func (s *Server) handleSessionMe(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusUnauthorized, "not authenticated") return } - writeJSON(w, http.StatusOK, sessionMeResponse{Name: claims.Name, Email: claims.Email}) + resp := sessionMeResponse{Name: claims.Name, Email: claims.Email} + if u, found := userFromContext(r.Context()); found { + resp.HasProfile = true + resp.DisplayName = u.DisplayName + } + writeJSON(w, http.StatusOK, resp) } diff --git a/backend/internal/api/setup.go b/backend/internal/api/setup.go new file mode 100644 index 0000000..5143d8c --- /dev/null +++ b/backend/internal/api/setup.go @@ -0,0 +1,39 @@ +package api + +import ( + "encoding/json" + "net/http" + + "geniusrun/backend/internal/auth" +) + +func (s *Server) handleSetup(w http.ResponseWriter, r *http.Request) { + claims, ok := auth.ClaimsFromContext(r.Context()) + if !ok { + writeError(w, http.StatusUnauthorized, "not authenticated") + return + } + if _, found := userFromContext(r.Context()); found { + writeError(w, http.StatusConflict, "profile already exists for this account") + return + } + + var body struct { + DisplayName string `json:"display_name"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + writeError(w, http.StatusBadRequest, "invalid request body") + return + } + if body.DisplayName == "" { + writeError(w, http.StatusBadRequest, "display_name is required") + return + } + + userID, err := s.DB.ProvisionUser(r.Context(), claims.Sub, body.DisplayName) + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + writeJSON(w, http.StatusOK, map[string]any{"user_id": userID, "display_name": body.DisplayName}) +} diff --git a/backend/internal/api/setup_test.go b/backend/internal/api/setup_test.go new file mode 100644 index 0000000..f8ddf0d --- /dev/null +++ b/backend/internal/api/setup_test.go @@ -0,0 +1,85 @@ +package api + +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) { + // 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) + var me sessionMeResponse + unmarshalBody(t, rec, &me) + if me.HasProfile { + t.Fatal("expected a brand-new session to have no profile yet") + } + + rec = doJSON(t, router, http.MethodPost, "/api/setup", map[string]any{"display_name": "Lucie"}) + if rec.Code != http.StatusOK { + t.Fatalf("setup status = %d, body = %s", rec.Code, rec.Body.String()) + } + + rec = doJSON(t, router, http.MethodGet, "/api/session/me", nil) + unmarshalBody(t, rec, &me) + if !me.HasProfile || me.DisplayName != "Lucie" { + t.Fatalf("session/me after setup = %+v, want HasProfile=true DisplayName=Lucie", me) + } + + u, found, err := db.GetUserBySub(newCtx(), "test-user") // doJSON's test cookie's Sub + if err != nil || !found { + t.Fatalf("GetUserBySub: found=%v err=%v", found, err) + } + if u.DisplayName != "Lucie" { + t.Errorf("stored DisplayName = %q, want Lucie", u.DisplayName) + } +} + +func TestSetup_RejectsEmptyDisplayName(t *testing.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) + } +} + +func TestSetup_RejectsWhenAlreadyProvisioned(t *testing.T) { + 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()) + } +} + +func unmarshalBody(t *testing.T, rec *httptest.ResponseRecorder, v any) { + t.Helper() + if err := json.Unmarshal(rec.Body.Bytes(), v); err != nil { + t.Fatalf("unmarshal response body %q: %v", rec.Body.String(), err) + } +} diff --git a/backend/internal/api/sync.go b/backend/internal/api/sync.go index 63f26d0..6a9e5f5 100644 --- a/backend/internal/api/sync.go +++ b/backend/internal/api/sync.go @@ -19,8 +19,14 @@ const detailFillBatchSize = 50 // FullSync run so "last sync" reports the combined activity count, not just // whichever of Backfill/IncrementalSync happened to finish last. func (s *Server) handleSyncRun(w http.ResponseWriter, r *http.Request) { - ok := s.backgroundSync(func(ctx context.Context) error { - return s.Sync.FullSync(ctx, detailFillBatchSize) + 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") @@ -34,8 +40,14 @@ func (s *Server) handleSyncRun(w http.ResponseWriter, r *http.Request) { // performs a genuinely fresh pull from Garmin. Destructive -- the frontend // gates this behind a confirmation. func (s *Server) handleSyncReset(w http.ResponseWriter, r *http.Request) { - ok := s.backgroundSync(func(ctx context.Context) error { - return s.Sync.ResetAll(ctx) + 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") @@ -45,7 +57,8 @@ func (s *Server) handleSyncReset(w http.ResponseWriter, r *http.Request) { } func (s *Server) handleSyncRuns(w http.ResponseWriter, r *http.Request) { - runs, err := s.DB.ListSyncRuns(r.Context(), 20) + userID := userIDFromContext(r.Context()) + runs, err := s.DB.ListSyncRuns(r.Context(), userID, 20) if err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return @@ -54,22 +67,28 @@ func (s *Server) handleSyncRuns(w http.ResponseWriter, r *http.Request) { } func (s *Server) handleSyncStatus(w http.ResponseWriter, r *http.Request) { - run, ok, err := s.DB.LatestSyncRun(r.Context()) + userID := userIDFromContext(r.Context()) + run, ok, err := s.DB.LatestSyncRun(r.Context(), userID) if err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return } - remaining, err := s.DB.CountActivitiesMissingDetails(r.Context()) + remaining, err := s.DB.CountActivitiesMissingDetails(r.Context(), userID) if err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return } s.mu.Lock() - inProgress := s.syncRunning + inProgress := s.userSyncRunning[userID] s.mu.Unlock() - progress := s.Sync.Progress() + 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, diff --git a/backend/internal/api/usercontext.go b/backend/internal/api/usercontext.go new file mode 100644 index 0000000..7595bf4 --- /dev/null +++ b/backend/internal/api/usercontext.go @@ -0,0 +1,81 @@ +package api + +import ( + "context" + "net/http" + + "geniusrun/backend/internal/auth" +) + +type userContextKey int + +const resolvedUserContextKey userContextKey = iota + +// resolvedUser is the geniusrun account (if any) bound to the current +// session's OIDC subject. +type resolvedUser struct { + ID int64 + DisplayName string +} + +// resolveUser runs after auth.RequireSession on every request and looks up +// whether the session's OIDC subject has a provisioned geniusrun user. It +// never blocks the request itself -- it only attaches the result (found or +// not) to context -- since a couple of routes (session/me, setup) must stay +// reachable for an authorized-but-not-yet-provisioned session. Routes that +// require a provisioned user are wrapped in requireProvisionedUser as well. +func (s *Server) resolveUser(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + claims, ok := auth.ClaimsFromContext(r.Context()) + if !ok { + // Unreachable in practice -- RequireSession already 401s before + // this middleware runs -- but fail closed rather than panic. + next.ServeHTTP(w, r) + return + } + u, found, err := s.DB.GetUserBySub(r.Context(), claims.Sub) + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + ctx := r.Context() + if found { + ctx = context.WithValue(ctx, resolvedUserContextKey, resolvedUser{ID: u.ID, DisplayName: u.DisplayName}) + } + next.ServeHTTP(w, r.WithContext(ctx)) + }) +} + +// requireProvisionedUser wraps routes that operate on a user's data: it +// 403s if the session's OIDC identity has no provisioned geniusrun user yet +// (resolveUser must run earlier in the chain). This -- not any +// client-supplied id -- is the only source of truth for "which user's data" +// a request may touch. +func requireProvisionedUser(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if _, ok := userFromContext(r.Context()); !ok { + writeError(w, http.StatusForbidden, "no profile provisioned for this account yet") + return + } + next.ServeHTTP(w, r) + }) +} + +// userFromContext returns the resolved user for the current session, as +// populated by resolveUser. +func userFromContext(ctx context.Context) (resolvedUser, bool) { + u, ok := ctx.Value(resolvedUserContextKey).(resolvedUser) + return u, ok +} + +// userIDFromContext is a convenience for the overwhelming majority of +// handlers, which only need the id. Panics if called somewhere +// requireProvisionedUser didn't already guarantee a resolved user -- that +// would be a routing bug, not a runtime condition to handle gracefully. +func userIDFromContext(ctx context.Context) int64 { + u, ok := userFromContext(ctx) + if !ok { + panic("api: userIDFromContext called without requireProvisionedUser in the middleware chain") + } + return u.ID +} diff --git a/backend/internal/api/usercontext_test.go b/backend/internal/api/usercontext_test.go new file mode 100644 index 0000000..595a800 --- /dev/null +++ b/backend/internal/api/usercontext_test.go @@ -0,0 +1,68 @@ +package api + +import ( + "net/http" + "net/http/httptest" + "path/filepath" + "testing" + + "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" +) + +func TestResolveUser_AttachesResolvedUserWhenProvisioned(t *testing.T) { + s, _, userID := newTestServer(t) + + var gotUserID int64 + var gotOK bool + handler := auth.RequireSession(testSessionConfig.Secret)(s.resolveUser(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + u, ok := userFromContext(r.Context()) + gotUserID, gotOK = u.ID, ok + }))) + + rec := doJSON(t, handler, http.MethodGet, "/", nil) // doJSON's test cookie carries Sub: "test-user" + _ = rec + if !gotOK || gotUserID != userID { + t.Fatalf("resolveUser: ok=%v userID=%d, want ok=true userID=%d", gotOK, gotUserID, userID) + } +} + +func TestResolveUser_LeavesContextEmptyWhenNotProvisioned(t *testing.T) { + // Deliberately not newTestServer(t): that helper auto-provisions the + // "test-user" sub that doJSON's cookie always carries, which would + // defeat the point of this test. Build a server against a bare DB + // instead, same pattern as setup_test.go's unprovisioned-session tests. + 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) + + var gotOK bool + handler := auth.RequireSession(testSessionConfig.Secret)(s.resolveUser(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, gotOK = userFromContext(r.Context()) + }))) + + doJSON(t, handler, http.MethodGet, "/", nil) // "test-user" sub, never provisioned in this test + if gotOK { + t.Fatal("expected userFromContext to report not-found for an unprovisioned sub") + } +} + +func TestRequireProvisionedUser_RejectsWhenNoUserResolved(t *testing.T) { + handler := requireProvisionedUser(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Fatal("handler should not be reached") + })) + req := httptest.NewRequest(http.MethodGet, "/", nil) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + if rec.Code != http.StatusForbidden { + t.Fatalf("status = %d, want 403", rec.Code) + } +} diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index 7ab2cb0..944108c 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -7,7 +7,9 @@ package config import ( "fmt" + "log" "os" + "path/filepath" "strconv" "strings" "time" @@ -24,9 +26,14 @@ type Config struct { GarminPythonPath string // GarminServerPath is mcp-garmin's server.py. GarminServerPath string - // GarminTokenStore, if set, overrides mcp-garmin's default ~/.garth - // session cache location. - GarminTokenStore string + // GarminTokenStoreRoot is the root directory under which each user's + // mcp-garmin session cache lives (one subdirectory per user id, e.g. + // "/3"). Read from the GARMIN_TOKENSTORE env var; if unset, it + // defaults to a "garmin-tokenstores" directory next to DBPath so every + // deployment gets per-user isolation automatically -- multi-tenant + // operation always relies on this being a real, distinct-per-user path + // (see api.Server.garminFor), so it can never be silently left empty. + GarminTokenStoreRoot string MinConfidence float64 IncrementalSyncEvery time.Duration @@ -45,6 +52,13 @@ type Config struct { SessionSecret []byte SessionDuration time.Duration SessionSecure bool + + // LegacyOwnerOIDCSub, if set, is used exactly once at startup (via + // store.ClaimLegacyOwner) to bind this deployment's pre-existing + // single-tenant data to one named OIDC subject after upgrading to + // per-user profiles. Safe to leave set indefinitely -- ClaimLegacyOwner + // no-ops once any user already exists. + LegacyOwnerOIDCSub string } // Load reads configuration from environment variables, applying defaults @@ -55,7 +69,7 @@ func Load() (Config, error) { DBPath: getEnvDefault("GENIUSRUN_DB_PATH", "geniusrun.db"), GarminPythonPath: os.Getenv("MCP_GARMIN_PYTHON"), GarminServerPath: os.Getenv("MCP_GARMIN_SERVER"), - GarminTokenStore: os.Getenv("GARMIN_TOKENSTORE"), + GarminTokenStoreRoot: os.Getenv("GARMIN_TOKENSTORE"), MinConfidence: getEnvFloat("GENIUSRUN_MIN_CONFIDENCE", 0.6), IncrementalSyncEvery: getEnvDuration("GENIUSRUN_INCREMENTAL_SYNC_EVERY", 6*time.Hour), PublicBaseURL: strings.TrimRight(os.Getenv("GENIUSRUN_PUBLIC_BASE_URL"), "/"), @@ -64,6 +78,12 @@ func Load() (Config, error) { OIDCClientSecret: os.Getenv("GENIUSRUN_OIDC_CLIENT_SECRET"), OIDCRequiredRole: getEnvDefault("GENIUSRUN_OIDC_REQUIRED_ROLE", "geniusrun-user"), SessionDuration: getEnvDuration("GENIUSRUN_SESSION_DURATION", 720*time.Hour), + LegacyOwnerOIDCSub: os.Getenv("GENIUSRUN_LEGACY_OWNER_OIDC_SUB"), + } + + if cfg.GarminTokenStoreRoot == "" { + cfg.GarminTokenStoreRoot = filepath.Join(filepath.Dir(cfg.DBPath), "garmin-tokenstores") + log.Printf("GARMIN_TOKENSTORE not set, defaulting to %q", cfg.GarminTokenStoreRoot) } if cfg.GarminPythonPath == "" { diff --git a/backend/internal/config/config_test.go b/backend/internal/config/config_test.go index 0a4f470..1e50def 100644 --- a/backend/internal/config/config_test.go +++ b/backend/internal/config/config_test.go @@ -1,6 +1,7 @@ package config import ( + "path/filepath" "testing" "time" ) @@ -80,6 +81,35 @@ func TestLoad_SessionSecretTooShort(t *testing.T) { } } +func TestLoad_GarminTokenStoreRootDefaultsNextToDBPath(t *testing.T) { + setRequiredEnv(t) + t.Setenv("GARMIN_TOKENSTORE", "") + t.Setenv("GENIUSRUN_DB_PATH", "/var/lib/geniusrun/geniusrun.db") + + cfg, err := Load() + if err != nil { + t.Fatalf("Load: %v", err) + } + want := filepath.Join("/var/lib/geniusrun", "garmin-tokenstores") + if cfg.GarminTokenStoreRoot != want { + t.Errorf("GarminTokenStoreRoot = %q, want %q", cfg.GarminTokenStoreRoot, want) + } +} + +func TestLoad_GarminTokenStoreRootExplicitOverridesDefault(t *testing.T) { + setRequiredEnv(t) + t.Setenv("GARMIN_TOKENSTORE", "/custom/tokenstores") + t.Setenv("GENIUSRUN_DB_PATH", "/var/lib/geniusrun/geniusrun.db") + + cfg, err := Load() + if err != nil { + t.Fatalf("Load: %v", err) + } + if cfg.GarminTokenStoreRoot != "/custom/tokenstores" { + t.Errorf("GarminTokenStoreRoot = %q, want explicit override to take precedence", cfg.GarminTokenStoreRoot) + } +} + func TestLoad_CustomRoleAndDuration(t *testing.T) { setRequiredEnv(t) t.Setenv("GENIUSRUN_OIDC_REQUIRED_ROLE", "admin") diff --git a/backend/internal/store/activities.go b/backend/internal/store/activities.go index 6c107c5..888a99c 100644 --- a/backend/internal/store/activities.go +++ b/backend/internal/store/activities.go @@ -49,19 +49,19 @@ type Activity struct { UpdatedAt string } -// UpsertActivity inserts a new activity or updates the existing row for the -// same garmin_activity_id (idempotent, safe to call on every sync pass), and -// returns its internal id. -func (db *DB) UpsertActivity(ctx context.Context, a Activity) (int64, error) { +// UpsertActivity inserts a new activity for userID or updates the existing +// row for the same (userID, garmin_activity_id) pair (idempotent, safe to +// call on every sync pass), and returns its internal id. +func (db *DB) UpsertActivity(ctx context.Context, userID int64, a Activity) (int64, error) { _, err := db.ExecContext(ctx, ` INSERT INTO activities ( - garmin_activity_id, event_type_key, workout_id, start_time_utc, + user_id, garmin_activity_id, event_type_key, workout_id, start_time_utc, duration_seconds, distance_meters, avg_hr, max_hr, avg_speed_mps, elevation_gain_m, aerobic_training_effect, anaerobic_training_effect, vo2max_value, raw_json, updated_at - ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?, datetime('now')) - ON CONFLICT(garmin_activity_id) DO UPDATE SET + ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?, datetime('now')) + ON CONFLICT(user_id, garmin_activity_id) DO UPDATE SET event_type_key=excluded.event_type_key, workout_id=excluded.workout_id, start_time_utc=excluded.start_time_utc, @@ -77,19 +77,19 @@ func (db *DB) UpsertActivity(ctx context.Context, a Activity) (int64, error) { raw_json=excluded.raw_json, updated_at=datetime('now') `, - a.GarminActivityID, a.EventTypeKey, a.WorkoutID, a.StartTimeUTC, + userID, a.GarminActivityID, a.EventTypeKey, a.WorkoutID, a.StartTimeUTC, a.DurationSeconds, a.DistanceMeters, a.AvgHR, a.MaxHR, a.AvgSpeedMps, a.ElevationGainM, a.AerobicTrainingEffect, a.AnaerobicTrainingEffect, a.VO2MaxValue, a.RawJSON, ) if err != nil { - return 0, fmt.Errorf("upsert activity %d: %w", a.GarminActivityID, err) + return 0, fmt.Errorf("upsert activity %d for user %d: %w", a.GarminActivityID, userID, err) } var id int64 - if err := db.QueryRowContext(ctx, `SELECT id FROM activities WHERE garmin_activity_id = ?`, a.GarminActivityID).Scan(&id); err != nil { - return 0, fmt.Errorf("fetch id for activity %d: %w", a.GarminActivityID, err) + if err := db.QueryRowContext(ctx, `SELECT id FROM activities WHERE user_id = ? AND garmin_activity_id = ?`, userID, a.GarminActivityID).Scan(&id); err != nil { + return 0, fmt.Errorf("fetch id for activity %d (user %d): %w", a.GarminActivityID, userID, err) } return id, nil } @@ -116,15 +116,15 @@ const activityColumns = ` created_at, updated_at ` -// GetActivity fetches one activity by its internal id. -func (db *DB) GetActivity(ctx context.Context, id int64) (Activity, bool, error) { - row := db.QueryRowContext(ctx, `SELECT `+activityColumns+` FROM activities WHERE id = ?`, id) +// GetActivity fetches one activity by its internal id, scoped to userID. +func (db *DB) GetActivity(ctx context.Context, userID, id int64) (Activity, bool, error) { + row := db.QueryRowContext(ctx, `SELECT `+activityColumns+` FROM activities WHERE id = ? AND user_id = ?`, id, userID) a, err := scanActivity(row) if err == sql.ErrNoRows { return Activity{}, false, nil } if err != nil { - return Activity{}, false, fmt.Errorf("get activity %d: %w", id, err) + return Activity{}, false, fmt.Errorf("get activity %d for user %d: %w", id, userID, err) } return a, true, nil } @@ -137,10 +137,11 @@ type ActivityFilter struct { Offset int } -// ListActivities returns activities newest-first, optionally filtered by date range. -func (db *DB) ListActivities(ctx context.Context, f ActivityFilter) ([]Activity, error) { - query := `SELECT ` + activityColumns + ` FROM activities WHERE 1=1` - var args []any +// ListActivities returns userID's activities newest-first, optionally +// filtered by date range. +func (db *DB) ListActivities(ctx context.Context, userID int64, f ActivityFilter) ([]Activity, error) { + query := `SELECT ` + activityColumns + ` FROM activities WHERE user_id = ?` + args := []any{userID} if f.FromDate != "" { query += ` AND start_time_utc >= ?` args = append(args, f.FromDate) @@ -157,7 +158,7 @@ func (db *DB) ListActivities(ctx context.Context, f ActivityFilter) ([]Activity, rows, err := db.QueryContext(ctx, query, args...) if err != nil { - return nil, fmt.Errorf("list activities: %w", err) + return nil, fmt.Errorf("list activities for user %d: %w", userID, err) } defer rows.Close() @@ -172,83 +173,79 @@ func (db *DB) ListActivities(ctx context.Context, f ActivityFilter) ([]Activity, return activities, rows.Err() } -// ActivityExists reports whether an activity with this garmin_activity_id is -// already stored, checked before each upsert during a sync pass so the -// reported "activities fetched" count reflects genuinely new activities, not -// every activity Garmin's API happens to return for the queried date range -// (which, thanks to the incremental overlap window and backfill's -// already-covered history, is almost always a re-listing of known ones). -func (db *DB) ActivityExists(ctx context.Context, garminActivityID int64) (bool, error) { +// ActivityExists reports whether userID already has an activity with this +// garmin_activity_id stored. +func (db *DB) ActivityExists(ctx context.Context, userID, garminActivityID int64) (bool, error) { var id int64 - err := db.QueryRowContext(ctx, `SELECT id FROM activities WHERE garmin_activity_id = ?`, garminActivityID).Scan(&id) + err := db.QueryRowContext(ctx, `SELECT id FROM activities WHERE user_id = ? AND garmin_activity_id = ?`, userID, garminActivityID).Scan(&id) if err == sql.ErrNoRows { return false, nil } if err != nil { - return false, fmt.Errorf("check activity %d exists: %w", garminActivityID, err) + return false, fmt.Errorf("check activity %d exists for user %d: %w", garminActivityID, userID, err) } return true, nil } -// LatestActivityStartTime returns the start_time_utc of the most recently -// started activity we have, used to compute the incremental sync window. -func (db *DB) LatestActivityStartTime(ctx context.Context) (string, bool, error) { +// LatestActivityStartTime returns userID's most recently started activity's +// start_time_utc, used to compute the incremental sync window. +func (db *DB) LatestActivityStartTime(ctx context.Context, userID int64) (string, bool, error) { var t string - err := db.QueryRowContext(ctx, `SELECT start_time_utc FROM activities ORDER BY start_time_utc DESC LIMIT 1`).Scan(&t) + err := db.QueryRowContext(ctx, `SELECT start_time_utc FROM activities WHERE user_id = ? ORDER BY start_time_utc DESC LIMIT 1`, userID).Scan(&t) if err == sql.ErrNoRows { return "", false, nil } if err != nil { - return "", false, fmt.Errorf("latest activity start time: %w", err) + return "", false, fmt.Errorf("latest activity start time for user %d: %w", userID, err) } return t, true, nil } // SetActivityDetails records that get_activity_details has been fetched for // this activity, storing the raw response for future reprocessing. -func (db *DB) SetActivityDetails(ctx context.Context, activityID int64, rawJSON string) error { +func (db *DB) SetActivityDetails(ctx context.Context, userID, activityID int64, rawJSON string) error { _, err := db.ExecContext(ctx, ` UPDATE activities SET details_fetched_at = datetime('now'), details_raw_json = ?, updated_at = datetime('now') - WHERE id = ?`, rawJSON, activityID) + WHERE id = ? AND user_id = ?`, rawJSON, activityID, userID) if err != nil { - return fmt.Errorf("set activity %d details: %w", activityID, err) + return fmt.Errorf("set activity %d details for user %d: %w", activityID, userID, err) } return nil } // SetActivityWorkout stores the raw get_workout_by_id() response used to // compute this activity's laps' target pace/HR bands. -func (db *DB) SetActivityWorkout(ctx context.Context, activityID int64, rawJSON string) error { +func (db *DB) SetActivityWorkout(ctx context.Context, userID, activityID int64, rawJSON string) error { _, err := db.ExecContext(ctx, ` UPDATE activities SET workout_raw_json = ?, updated_at = datetime('now') - WHERE id = ?`, rawJSON, activityID) + WHERE id = ? AND user_id = ?`, rawJSON, activityID, userID) if err != nil { - return fmt.Errorf("set activity %d workout: %w", activityID, err) + return fmt.Errorf("set activity %d workout for user %d: %w", activityID, userID, err) } return nil } // SetActivitySplitsFetched records that get_activity_splits has been fetched // for this activity. -func (db *DB) SetActivitySplitsFetched(ctx context.Context, activityID int64) error { +func (db *DB) SetActivitySplitsFetched(ctx context.Context, userID, activityID int64) error { _, err := db.ExecContext(ctx, ` UPDATE activities SET splits_fetched_at = datetime('now'), updated_at = datetime('now') - WHERE id = ?`, activityID) + WHERE id = ? AND user_id = ?`, activityID, userID) if err != nil { - return fmt.Errorf("set activity %d splits fetched: %w", activityID, err) + return fmt.Errorf("set activity %d splits fetched for user %d: %w", activityID, userID, err) } return nil } -// ActivitiesMissingDetails returns activities that haven't had +// ActivitiesMissingDetails returns userID's activities that haven't had // get_activity_details/get_activity_splits fetched yet, for the lazy // background detail-fill pass. -func (db *DB) ActivitiesMissingDetails(ctx context.Context, limit int) ([]Activity, error) { +func (db *DB) ActivitiesMissingDetails(ctx context.Context, userID int64, limit int) ([]Activity, error) { rows, err := db.QueryContext(ctx, `SELECT `+activityColumns+` FROM activities - WHERE details_fetched_at IS NULL OR splits_fetched_at IS NULL - ORDER BY start_time_utc DESC LIMIT ?`, limit) + WHERE user_id = ? AND (details_fetched_at IS NULL OR splits_fetched_at IS NULL) + ORDER BY start_time_utc DESC LIMIT ?`, userID, limit) if err != nil { - return nil, fmt.Errorf("list activities missing details: %w", err) + return nil, fmt.Errorf("list activities missing details for user %d: %w", userID, err) } defer rows.Close() @@ -263,15 +260,15 @@ func (db *DB) ActivitiesMissingDetails(ctx context.Context, limit int) ([]Activi return activities, rows.Err() } -// CountActivitiesMissingDetails returns how many activities still need -// get_activity_details/get_activity_splits fetched, regardless of any -// per-call batch limit -- used to report overall remaining work. -func (db *DB) CountActivitiesMissingDetails(ctx context.Context) (int, error) { +// CountActivitiesMissingDetails returns how many of userID's activities +// still need get_activity_details/get_activity_splits fetched, regardless +// of any per-call batch limit -- used to report overall remaining work. +func (db *DB) CountActivitiesMissingDetails(ctx context.Context, userID int64) (int, error) { var n int err := db.QueryRowContext(ctx, `SELECT COUNT(*) FROM activities - WHERE details_fetched_at IS NULL OR splits_fetched_at IS NULL`).Scan(&n) + WHERE user_id = ? AND (details_fetched_at IS NULL OR splits_fetched_at IS NULL)`, userID).Scan(&n) if err != nil { - return 0, fmt.Errorf("count activities missing details: %w", err) + return 0, fmt.Errorf("count activities missing details for user %d: %w", userID, err) } return n, nil } diff --git a/backend/internal/store/assignments.go b/backend/internal/store/assignments.go index 3e4631a..0561fab 100644 --- a/backend/internal/store/assignments.go +++ b/backend/internal/store/assignments.go @@ -28,8 +28,18 @@ type KindAssignment struct { CreatedAt string } -// InsertKindAssignment appends a new assignment row for an activity. -func (db *DB) InsertKindAssignment(ctx context.Context, a KindAssignment) (int64, error) { +// InsertKindAssignment appends a new assignment row for an activity owned +// by userID. +func (db *DB) InsertKindAssignment(ctx context.Context, userID int64, a KindAssignment) (int64, error) { + var exists int + err := db.QueryRowContext(ctx, `SELECT 1 FROM activities WHERE id = ? AND user_id = ?`, a.ActivityID, userID).Scan(&exists) + if err == sql.ErrNoRows { + return 0, fmt.Errorf("insert kind assignment: activity %d not found for user %d", a.ActivityID, userID) + } + if err != nil { + return 0, fmt.Errorf("insert kind assignment for activity %d (user %d): %w", a.ActivityID, userID, err) + } + res, err := db.ExecContext(ctx, ` INSERT INTO kind_assignments (activity_id, workout_kind_id, assignment_source, status, confidence, candidate_kinds_json) VALUES (?,?,?,?,?,?)`, @@ -46,29 +56,37 @@ func scanKindAssignment(row interface{ Scan(...any) error }) (KindAssignment, er return a, err } -const kindAssignmentColumns = `id, activity_id, workout_kind_id, assignment_source, status, confidence, candidate_kinds_json, created_at` +const kindAssignmentColumns = `a.id, a.activity_id, a.workout_kind_id, a.assignment_source, a.status, a.confidence, a.candidate_kinds_json, a.created_at` -// CurrentAssignment returns the latest assignment for an activity, if any. -func (db *DB) CurrentAssignment(ctx context.Context, activityID int64) (KindAssignment, bool, error) { - row := db.QueryRowContext(ctx, `SELECT `+kindAssignmentColumns+` FROM current_kind_assignment WHERE activity_id = ?`, activityID) +// CurrentAssignment returns the latest assignment for an activity owned by +// userID, if any. +func (db *DB) CurrentAssignment(ctx context.Context, userID, activityID int64) (KindAssignment, bool, error) { + row := db.QueryRowContext(ctx, ` + SELECT `+kindAssignmentColumns+` + FROM current_kind_assignment a + JOIN activities ON activities.id = a.activity_id + WHERE a.activity_id = ? AND activities.user_id = ?`, activityID, userID) a, err := scanKindAssignment(row) if err == sql.ErrNoRows { return KindAssignment{}, false, nil } if err != nil { - return KindAssignment{}, false, fmt.Errorf("current assignment for activity %d: %w", activityID, err) + return KindAssignment{}, false, fmt.Errorf("current assignment for activity %d (user %d): %w", activityID, userID, err) } return a, true, nil } -// ReviewQueue returns activities whose current assignment status is -// needs_review, newest first. -func (db *DB) ReviewQueue(ctx context.Context) ([]KindAssignment, error) { +// ReviewQueue returns userID's activities whose current assignment status +// is needs_review, newest first. +func (db *DB) ReviewQueue(ctx context.Context, userID int64) ([]KindAssignment, error) { rows, err := db.QueryContext(ctx, ` - SELECT `+kindAssignmentColumns+` FROM current_kind_assignment - WHERE status = ? ORDER BY created_at DESC`, AssignmentStatusNeedsReview) + SELECT `+kindAssignmentColumns+` + FROM current_kind_assignment a + JOIN activities ON activities.id = a.activity_id + WHERE activities.user_id = ? AND a.status = ? + ORDER BY a.created_at DESC`, userID, AssignmentStatusNeedsReview) if err != nil { - return nil, fmt.Errorf("review queue: %w", err) + return nil, fmt.Errorf("review queue for user %d: %w", userID, err) } defer rows.Close() @@ -83,37 +101,44 @@ func (db *DB) ReviewQueue(ctx context.Context) ([]KindAssignment, error) { return assignments, rows.Err() } -// AllCurrentAssignments returns the latest assignment for every activity -// that has one, regardless of status or source -- the basis for deciding -// which activities a global reclassify pass is allowed to touch. -func (db *DB) AllCurrentAssignments(ctx context.Context) ([]KindAssignment, error) { - rows, err := db.QueryContext(ctx, `SELECT `+kindAssignmentColumns+` FROM current_kind_assignment`) - if err != nil { - return nil, fmt.Errorf("all current assignments: %w", err) - } - defer rows.Close() - - assignments := []KindAssignment{} - for rows.Next() { - a, err := scanKindAssignment(rows) - if err != nil { - return nil, fmt.Errorf("scan kind assignment row: %w", err) - } - assignments = append(assignments, a) - } - return assignments, rows.Err() -} - -// AssignmentsForKind returns every historical assignment where the given -// workout kind was the resolved kind (regardless of source), oldest first -- -// the basis for progression-over-time charts. -func (db *DB) AssignmentsForKind(ctx context.Context, workoutKindID int64) ([]KindAssignment, error) { +// AllCurrentAssignments returns the latest assignment for every one of +// userID's activities that has one, regardless of status or source -- the +// basis for deciding which activities a global reclassify pass may touch. +func (db *DB) AllCurrentAssignments(ctx context.Context, userID int64) ([]KindAssignment, error) { rows, err := db.QueryContext(ctx, ` - SELECT `+kindAssignmentColumns+` FROM current_kind_assignment - WHERE workout_kind_id = ? AND status = ? ORDER BY created_at ASC`, - workoutKindID, AssignmentStatusAssigned) + SELECT `+kindAssignmentColumns+` + FROM current_kind_assignment a + JOIN activities ON activities.id = a.activity_id + WHERE activities.user_id = ?`, userID) if err != nil { - return nil, fmt.Errorf("assignments for kind %d: %w", workoutKindID, err) + return nil, fmt.Errorf("all current assignments for user %d: %w", userID, err) + } + defer rows.Close() + + assignments := []KindAssignment{} + for rows.Next() { + a, err := scanKindAssignment(rows) + if err != nil { + return nil, fmt.Errorf("scan kind assignment row: %w", err) + } + assignments = append(assignments, a) + } + return assignments, rows.Err() +} + +// AssignmentsForKind returns every historical assignment (for userID's +// activities) where the given workout kind was the resolved kind (regardless +// of source), oldest first -- the basis for progression-over-time charts. +func (db *DB) AssignmentsForKind(ctx context.Context, userID, workoutKindID int64) ([]KindAssignment, error) { + rows, err := db.QueryContext(ctx, ` + SELECT `+kindAssignmentColumns+` + FROM current_kind_assignment a + JOIN activities ON activities.id = a.activity_id + WHERE activities.user_id = ? AND a.workout_kind_id = ? AND a.status = ? + ORDER BY a.created_at ASC`, + userID, workoutKindID, AssignmentStatusAssigned) + if err != nil { + return nil, fmt.Errorf("assignments for kind %d (user %d): %w", workoutKindID, userID, err) } defer rows.Close() diff --git a/backend/internal/store/db.go b/backend/internal/store/db.go index 5083b19..4862864 100644 --- a/backend/internal/store/db.go +++ b/backend/internal/store/db.go @@ -78,21 +78,65 @@ func (db *DB) migrate() error { if err != nil { return fmt.Errorf("read migration %s: %w", name, err) } + + // Migrations 0023, 0024, 0025, and 0026 rebuild tables that have + // incoming foreign keys (kind_assignments/workout_type_paces + // reference workout_kinds; sync_state is referenced by + // activities/sync_runs; laps/activity_samples/kind_assignments + // reference activities). These require temporary FK disable in + // autocommit mode (before the transaction begins) so the DROP + // TABLE succeeds; a mid-transaction PRAGMA is a no-op with + // modernc.org/sqlite. + tableRebuildMigrations := map[string]bool{ + "0023_profile_user_scoped.sql": true, + "0024_workout_kinds_user_scoped.sql": true, + "0025_sync_state_user_scoped.sql": true, + "0026_activities_unique_constraint.sql": true, + } + needsFKToggle := tableRebuildMigrations[name] + + if needsFKToggle { + if _, err := db.Exec(`PRAGMA foreign_keys = OFF`); err != nil { + return fmt.Errorf("disable foreign keys before migration %s: %w", name, err) + } + } + tx, err := db.Begin() if err != nil { + if needsFKToggle { + db.Exec(`PRAGMA foreign_keys = ON`) + } return fmt.Errorf("begin migration tx for %s: %w", name, err) } + if _, err := tx.Exec(string(content)); err != nil { tx.Rollback() + if needsFKToggle { + db.Exec(`PRAGMA foreign_keys = ON`) + } return fmt.Errorf("apply migration %s: %w", name, err) } + if _, err := tx.Exec(`INSERT INTO schema_migrations (filename) VALUES (?)`, name); err != nil { tx.Rollback() + if needsFKToggle { + db.Exec(`PRAGMA foreign_keys = ON`) + } return fmt.Errorf("record migration %s: %w", name, err) } + if err := tx.Commit(); err != nil { + if needsFKToggle { + db.Exec(`PRAGMA foreign_keys = ON`) + } return fmt.Errorf("commit migration %s: %w", name, err) } + + if needsFKToggle { + if _, err := db.Exec(`PRAGMA foreign_keys = ON`); err != nil { + return fmt.Errorf("enable foreign keys after migration %s: %w", name, err) + } + } } return nil } diff --git a/backend/internal/store/isolation_test.go b/backend/internal/store/isolation_test.go new file mode 100644 index 0000000..e714717 --- /dev/null +++ b/backend/internal/store/isolation_test.go @@ -0,0 +1,163 @@ +package store + +import ( + "context" + "testing" +) + +// TestIsolation_ProfileNeverLeaksAcrossUsers confirms GetProfile only ever +// returns the row matching the given userID, and that two users' profiles +// can diverge independently. +func TestIsolation_ProfileNeverLeaksAcrossUsers(t *testing.T) { + db := openTestDB(t) + ctx := context.Background() + + userA, err := db.ProvisionUser(ctx, "sub-a", "A") + if err != nil { + t.Fatalf("ProvisionUser(a): %v", err) + } + userB, err := db.ProvisionUser(ctx, "sub-b", "B") + if err != nil { + t.Fatalf("ProvisionUser(b): %v", err) + } + + profileA, err := db.GetProfile(ctx, userA) + if err != nil { + t.Fatalf("GetProfile(a): %v", err) + } + profileA.GarminEmail = "a@example.com" + if err := db.UpdateProfile(ctx, userA, profileA); err != nil { + t.Fatalf("UpdateProfile(a): %v", err) + } + + profileB, err := db.GetProfile(ctx, userB) + if err != nil { + t.Fatalf("GetProfile(b): %v", err) + } + if profileB.GarminEmail == "a@example.com" { + t.Fatal("userB's profile picked up userA's GarminEmail update") + } + + // Attempting to update B's profile "as A" (i.e. calling UpdateProfile + // with userA but a struct that happens to describe B's desired state) + // only ever touches the row WHERE user_id = userA -- confirm B is + // unaffected by any such call. + if err := db.UpdateProfile(ctx, userA, Profile{GarminEmail: "still-a-only@example.com"}); err != nil { + t.Fatalf("UpdateProfile(a) second call: %v", err) + } + profileB2, err := db.GetProfile(ctx, userB) + if err != nil { + t.Fatalf("GetProfile(b) after A's update: %v", err) + } + if profileB2.GarminEmail == "still-a-only@example.com" { + t.Fatal("userA's UpdateProfile call leaked into userB's row") + } +} + +// TestIsolation_WorkoutKindsAndPacesNeverLeakAcrossUsers confirms +// GetWorkoutKind/GetWorkoutTypePace return not-found for a kind id that +// exists but belongs to a different user, and that UpdateWorkoutKind / +// UpdateWorkoutTypePace can never mutate another user's row even if handed +// that row's real id. +func TestIsolation_WorkoutKindsAndPacesNeverLeakAcrossUsers(t *testing.T) { + db := openTestDB(t) + ctx := context.Background() + + userA, err := db.ProvisionUser(ctx, "sub-a", "A") + if err != nil { + t.Fatalf("ProvisionUser(a): %v", err) + } + userB, err := db.ProvisionUser(ctx, "sub-b", "B") + if err != nil { + t.Fatalf("ProvisionUser(b): %v", err) + } + + kindsA, err := db.ListWorkoutKinds(ctx, userA, false) + if err != nil || len(kindsA) == 0 { + t.Fatalf("ListWorkoutKinds(a): len=%d err=%v", len(kindsA), err) + } + targetKindID := kindsA[0].ID + + if _, found, err := db.GetWorkoutKind(ctx, userB, targetKindID); err != nil || found { + t.Fatalf("expected userB not to see userA's kind %d, found=%v err=%v", targetKindID, found, err) + } + + // Attempt to update A's kind "as B" -- must silently affect zero rows, + // not A's real row. + if err := db.UpdateWorkoutKind(ctx, userB, WorkoutKind{ID: targetKindID, Name: "Hijacked", RuleJSON: "{}"}); err != nil { + t.Fatalf("UpdateWorkoutKind(as b): %v", err) + } + stillA, found, err := db.GetWorkoutKind(ctx, userA, targetKindID) + if err != nil || !found { + t.Fatalf("GetWorkoutKind(a) after B's attempted update: found=%v err=%v", found, err) + } + if stillA.Name == "Hijacked" { + t.Fatal("userB's UpdateWorkoutKind call was able to mutate userA's kind") + } + + if _, err := db.GetWorkoutTypePace(ctx, userB, targetKindID); err != nil { + t.Fatalf("GetWorkoutTypePace(as b) should return a zero-value pace, not an error, got: %v", err) + } + minPace := 300.0 + if err := db.UpdateWorkoutTypePace(ctx, userB, WorkoutTypePace{WorkoutKindID: targetKindID, PaceMinSecPerKm: &minPace}); err != nil { + t.Fatalf("UpdateWorkoutTypePace(as b): %v", err) + } + paceA, err := db.GetWorkoutTypePace(ctx, userA, targetKindID) + if err != nil { + t.Fatalf("GetWorkoutTypePace(a): %v", err) + } + if paceA.PaceMinSecPerKm != nil { + t.Fatal("userB's UpdateWorkoutTypePace call was able to mutate userA's pace row") + } +} + +// TestIsolation_SyncStateAndRunsNeverLeakAcrossUsers confirms each user's +// backfill watermark and sync run history are independent. +func TestIsolation_SyncStateAndRunsNeverLeakAcrossUsers(t *testing.T) { + db := openTestDB(t) + ctx := context.Background() + + userA, err := db.ProvisionUser(ctx, "sub-a", "A") + if err != nil { + t.Fatalf("ProvisionUser(a): %v", err) + } + userB, err := db.ProvisionUser(ctx, "sub-b", "B") + if err != nil { + t.Fatalf("ProvisionUser(b): %v", err) + } + + if err := db.UpdateSyncState(ctx, userA, "2020-01-01", true); err != nil { + t.Fatalf("UpdateSyncState(a): %v", err) + } + stateB, err := db.GetSyncState(ctx, userB) + if err != nil { + t.Fatalf("GetSyncState(b): %v", err) + } + if stateB.BackfillComplete || stateB.EarliestSyncedDate != nil { + t.Fatalf("userA's UpdateSyncState leaked into userB's sync_state: %+v", stateB) + } + + runID, err := db.StartSyncRun(ctx, userA, SyncKindBackfill) + if err != nil { + t.Fatalf("StartSyncRun(a): %v", err) + } + runsB, err := db.ListSyncRuns(ctx, userB, 10) + if err != nil { + t.Fatalf("ListSyncRuns(b): %v", err) + } + if len(runsB) != 0 { + t.Fatalf("expected userB to have 0 sync runs, got %d (userA's run id=%d)", len(runsB), runID) + } + + // Finishing A's run "as B" must not succeed against A's row. + if err := db.FinishSyncRun(ctx, userB, runID, 5, nil); err != nil { + t.Fatalf("FinishSyncRun(as b): %v", err) + } + latestA, found, err := db.LatestSyncRun(ctx, userA) + if err != nil || !found { + t.Fatalf("LatestSyncRun(a): found=%v err=%v", found, err) + } + if latestA.Status == SyncStatusSuccess { + t.Fatal("userB's FinishSyncRun call was able to mutate userA's sync run") + } +} diff --git a/backend/internal/store/laps.go b/backend/internal/store/laps.go index 5d72942..08ede5e 100644 --- a/backend/internal/store/laps.go +++ b/backend/internal/store/laps.go @@ -2,6 +2,7 @@ package store import ( "context" + "database/sql" "fmt" ) @@ -35,9 +36,19 @@ type Lap struct { RawJSON string } -// ReplaceLaps deletes any existing laps for activityID and inserts the given -// set, so re-syncing an activity's splits is idempotent. -func (db *DB) ReplaceLaps(ctx context.Context, activityID int64, laps []Lap) error { +// ReplaceLaps deletes any existing laps for activityID (owned by userID) +// and inserts the given set, so re-syncing an activity's splits is +// idempotent. +func (db *DB) ReplaceLaps(ctx context.Context, userID, activityID int64, laps []Lap) error { + var exists int + err := db.QueryRowContext(ctx, `SELECT 1 FROM activities WHERE id = ? AND user_id = ?`, activityID, userID).Scan(&exists) + if err == sql.ErrNoRows { + return fmt.Errorf("replace laps: activity %d not found for user %d", activityID, userID) + } + if err != nil { + return fmt.Errorf("replace laps for activity %d (user %d): %w", activityID, userID, err) + } + tx, err := db.BeginTx(ctx, nil) if err != nil { return fmt.Errorf("begin replace laps tx: %w", err) @@ -66,15 +77,19 @@ func (db *DB) ReplaceLaps(ctx context.Context, activityID int64, laps []Lap) err return tx.Commit() } -// LapsForActivity returns all laps for an activity, ordered by lap_index. -func (db *DB) LapsForActivity(ctx context.Context, activityID int64) ([]Lap, error) { +// LapsForActivity returns all laps for an activity owned by userID, +// ordered by lap_index. +func (db *DB) LapsForActivity(ctx context.Context, userID, activityID int64) ([]Lap, error) { rows, err := db.QueryContext(ctx, ` - SELECT id, activity_id, lap_index, avg_speed_mps, - intensity_type, hr_drift_bpm_per_min, hr_recovery_bpm_per_min, - target_pace_low_mps, target_pace_high_mps, target_hr_low_bpm, target_hr_high_bpm, raw_json - FROM laps WHERE activity_id = ? ORDER BY lap_index`, activityID) + SELECT laps.id, laps.activity_id, laps.lap_index, laps.avg_speed_mps, + laps.intensity_type, laps.hr_drift_bpm_per_min, laps.hr_recovery_bpm_per_min, + laps.target_pace_low_mps, laps.target_pace_high_mps, laps.target_hr_low_bpm, laps.target_hr_high_bpm, laps.raw_json + FROM laps + JOIN activities ON activities.id = laps.activity_id + WHERE laps.activity_id = ? AND activities.user_id = ? + ORDER BY laps.lap_index`, activityID, userID) if err != nil { - return nil, fmt.Errorf("laps for activity %d: %w", activityID, err) + return nil, fmt.Errorf("laps for activity %d (user %d): %w", activityID, userID, err) } defer rows.Close() diff --git a/backend/internal/store/legacy_claim.go b/backend/internal/store/legacy_claim.go new file mode 100644 index 0000000..6216765 --- /dev/null +++ b/backend/internal/store/legacy_claim.go @@ -0,0 +1,54 @@ +package store + +import ( + "context" + "fmt" +) + +// ClaimLegacyOwner is a one-time upgrade step, not a normal runtime +// operation: it binds every pre-multi-tenancy row (user_id IS NULL, left +// that way by migrations that can't take runtime parameters -- see +// docs/superpowers/specs/2026-07-25-per-user-profile-design.md) to a single +// new user identified by oidcSub. Safe to call on every startup: once the +// users table is non-empty, it's a no-op, so leaving +// GENIUSRUN_LEGACY_OWNER_OIDC_SUB set after the first successful run causes +// no harm. +func (db *DB) ClaimLegacyOwner(ctx context.Context, oidcSub string) error { + var userCount int + if err := db.QueryRowContext(ctx, `SELECT COUNT(*) FROM users`).Scan(&userCount); err != nil { + return fmt.Errorf("count users: %w", err) + } + if userCount > 0 { + return nil // already bootstrapped (either claimed already, or real signups exist) + } + + var displayName string + err := db.QueryRowContext(ctx, `SELECT name FROM profile WHERE user_id IS NULL LIMIT 1`).Scan(&displayName) + if err != nil { + return fmt.Errorf("find legacy profile: %w", err) + } + + tx, err := db.BeginTx(ctx, nil) + if err != nil { + return fmt.Errorf("begin claim legacy owner tx: %w", err) + } + defer tx.Rollback() + + res, err := tx.ExecContext(ctx, `INSERT INTO users (oidc_sub, display_name) VALUES (?, ?)`, oidcSub, displayName) + if err != nil { + return fmt.Errorf("create legacy owner user: %w", err) + } + userID, err := res.LastInsertId() + if err != nil { + return err + } + + // table is always one of the fixed literals below, never user input. + for _, table := range []string{"profile", "workout_kinds", "activities", "sync_state", "sync_runs"} { + if _, err := tx.ExecContext(ctx, `UPDATE `+table+` SET user_id = ? WHERE user_id IS NULL`, userID); err != nil { + return fmt.Errorf("claim legacy %s rows: %w", table, err) + } + } + + return tx.Commit() +} diff --git a/backend/internal/store/legacy_claim_test.go b/backend/internal/store/legacy_claim_test.go new file mode 100644 index 0000000..c267c8d --- /dev/null +++ b/backend/internal/store/legacy_claim_test.go @@ -0,0 +1,97 @@ +package store + +import ( + "context" + "testing" +) + +func TestClaimLegacyOwner_BindsExistingSingletonRowsToOneNewUser(t *testing.T) { + db := openTestDB(t) + ctx := context.Background() + + // Simulate the pre-migration state: a fresh DB already has one + // migration-seeded profile row (id=1, user_id=NULL) and 8 workout_kinds + // rows (user_id=NULL) -- exactly what a real upgraded deployment looks + // like right after Task 1's migrations run, before any user exists. + if _, err := db.ExecContext(ctx, `UPDATE profile SET name = 'Kriss', garmin_email = 'kriss@example.com' WHERE user_id IS NULL`); err != nil { + t.Fatalf("seed legacy profile: %v", err) + } + if _, err := db.ExecContext(ctx, `INSERT INTO activities (user_id, garmin_activity_id, start_time_utc, duration_seconds, distance_meters, raw_json) VALUES (NULL, 1, '2026-01-01 00:00:00', 1800, 5000, '{}')`); err != nil { + t.Fatalf("seed legacy activity: %v", err) + } + + if err := db.ClaimLegacyOwner(ctx, "kriss-sub"); err != nil { + t.Fatalf("ClaimLegacyOwner: %v", err) + } + + u, found, err := db.GetUserBySub(ctx, "kriss-sub") + if err != nil || !found { + t.Fatalf("GetUserBySub: found=%v err=%v", found, err) + } + if u.DisplayName != "Kriss" { + t.Errorf("DisplayName = %q, want %q (from the legacy profile's name column)", u.DisplayName, "Kriss") + } + + profile, err := db.GetProfile(ctx, u.ID) + if err != nil { + t.Fatalf("GetProfile(claimed user): %v", err) + } + if profile.GarminEmail != "kriss@example.com" { + t.Errorf("claimed profile GarminEmail = %q, want kriss@example.com", profile.GarminEmail) + } + + kinds, err := db.ListWorkoutKinds(ctx, u.ID, false) + if err != nil { + t.Fatalf("ListWorkoutKinds(claimed user): %v", err) + } + if len(kinds) != 8 { + t.Fatalf("expected the 8 legacy workout_kinds rows to be claimed, got %d", len(kinds)) + } + + activities, err := db.ListActivities(ctx, u.ID, ActivityFilter{}) + if err != nil { + t.Fatalf("ListActivities(claimed user): %v", err) + } + if len(activities) != 1 { + t.Fatalf("expected the 1 legacy activity to be claimed, got %d", len(activities)) + } + + var remainingNullUserIDRows int + for _, table := range []string{"profile", "workout_kinds", "activities", "sync_state"} { + var n int + if err := db.QueryRowContext(ctx, `SELECT COUNT(*) FROM `+table+` WHERE user_id IS NULL`).Scan(&n); err != nil { + t.Fatalf("count NULL user_id in %s: %v", table, err) + } + remainingNullUserIDRows += n + } + if remainingNullUserIDRows != 0 { + t.Errorf("expected every legacy row to be claimed, %d rows still have NULL user_id", remainingNullUserIDRows) + } +} + +func TestClaimLegacyOwner_NoOpOnceAUserAlreadyExists(t *testing.T) { + db := openTestDB(t) + ctx := context.Background() + + firstUserID, err := db.ProvisionUser(ctx, "already-here", "Someone") + if err != nil { + t.Fatalf("ProvisionUser: %v", err) + } + + // A second call (simulating a later restart with the env var still set) + // must not create a second user or touch anything. + if err := db.ClaimLegacyOwner(ctx, "kriss-sub"); err != nil { + t.Fatalf("ClaimLegacyOwner (should be a no-op): %v", err) + } + + if _, found, err := db.GetUserBySub(ctx, "kriss-sub"); err != nil || found { + t.Fatalf("expected no user created for kriss-sub, found=%v err=%v", found, err) + } + users, err := db.ListUsers(ctx) + if err != nil { + t.Fatalf("ListUsers: %v", err) + } + if len(users) != 1 || users[0].ID != firstUserID { + t.Fatalf("expected exactly the 1 pre-existing user to remain, got %+v", users) + } +} diff --git a/backend/internal/store/migrations/0021_users_table.sql b/backend/internal/store/migrations/0021_users_table.sql new file mode 100644 index 0000000..47c00ee --- /dev/null +++ b/backend/internal/store/migrations/0021_users_table.sql @@ -0,0 +1,6 @@ +CREATE TABLE users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + oidc_sub TEXT NOT NULL UNIQUE, + display_name TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); diff --git a/backend/internal/store/migrations/0022_activities_and_syncruns_user_id.sql b/backend/internal/store/migrations/0022_activities_and_syncruns_user_id.sql new file mode 100644 index 0000000..a7abe08 --- /dev/null +++ b/backend/internal/store/migrations/0022_activities_and_syncruns_user_id.sql @@ -0,0 +1,15 @@ +-- user_id is nullable here even though every row will eventually need one: +-- migrations can't take runtime parameters, so the actual owner isn't known +-- yet. A one-time Go bootstrap step (store.ClaimLegacyOwner, run at +-- geniusrund startup) backfills every existing row to one user once given +-- that user's OIDC subject; from then on every store method requires a +-- non-nil userID and this column is never NULL again in practice. +ALTER TABLE activities ADD COLUMN user_id INTEGER REFERENCES users(id); +ALTER TABLE sync_runs ADD COLUMN user_id INTEGER REFERENCES users(id); + +-- Used by UpsertActivity's ON CONFLICT target going forward. The original +-- UNIQUE(garmin_activity_id) constraint (migration 0001) stays in place too +-- -- Garmin's own activity ids are already globally unique in practice, so +-- the stricter constraint is harmless, and SQLite can't drop a column-level +-- constraint without a full table rebuild, which isn't worth the risk here. +CREATE UNIQUE INDEX ux_activities_user_garmin_id ON activities(user_id, garmin_activity_id); diff --git a/backend/internal/store/migrations/0023_profile_user_scoped.sql b/backend/internal/store/migrations/0023_profile_user_scoped.sql new file mode 100644 index 0000000..fb6ec01 --- /dev/null +++ b/backend/internal/store/migrations/0023_profile_user_scoped.sql @@ -0,0 +1,76 @@ +-- profile.id's CHECK(id = 1) singleton constraint (migration 0003) can't be +-- dropped via ALTER TABLE -- SQLite has no DROP CONSTRAINT -- so this +-- rebuilds the table via SQLite's documented rename/recreate/copy/drop +-- pattern instead. Always create the replacement under a different name and +-- RENAME it into place at the end (never rename the live table away first) +-- -- verified directly against SQLite that this ordering is what keeps +-- other tables' foreign keys intact when they exist (not the case for +-- profile, but kept consistent with migrations 0024/0025 for the same +-- pattern). user_id is nullable for the same not-yet-known-owner reason as +-- migration 0022 -- see its comment. +CREATE TABLE profile_new ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER REFERENCES users(id), + name TEXT NOT NULL DEFAULT 'Default', + garmin_email TEXT NOT NULL DEFAULT '', + garmin_password TEXT NOT NULL DEFAULT '', + rolling_window_days INTEGER NOT NULL DEFAULT 90, + backfill_horizon_days INTEGER NOT NULL DEFAULT 1095, + max_heart_rate REAL, + resting_heart_rate REAL, + hr_zone1_min_pct REAL NOT NULL DEFAULT 50, + hr_zone1_max_pct REAL NOT NULL DEFAULT 60, + hr_zone2_min_pct REAL NOT NULL DEFAULT 60, + hr_zone2_max_pct REAL NOT NULL DEFAULT 70, + hr_zone3_min_pct REAL NOT NULL DEFAULT 70, + hr_zone3_max_pct REAL NOT NULL DEFAULT 80, + hr_zone4_min_pct REAL NOT NULL DEFAULT 80, + hr_zone4_max_pct REAL NOT NULL DEFAULT 90, + hr_zone5_min_pct REAL NOT NULL DEFAULT 90, + hr_zone5_max_pct REAL NOT NULL DEFAULT 100, + warmup_minutes REAL NOT NULL DEFAULT 10, + cooldown_minutes REAL NOT NULL DEFAULT 5, + min_representative_pace_sec_per_km REAL NOT NULL DEFAULT 720, + min_representative_time_seconds REAL NOT NULL DEFAULT 3, + pace_color TEXT NOT NULL DEFAULT '#3b82f6', + heart_rate_color TEXT NOT NULL DEFAULT '#ef4444', + warmup_color TEXT NOT NULL DEFAULT '#c2410c', + effort_color TEXT NOT NULL DEFAULT '#7c3aed', + recovery_color TEXT NOT NULL DEFAULT '#15803d', + cooldown_color TEXT NOT NULL DEFAULT '#fb923c', + main_line_tint_pct REAL NOT NULL DEFAULT 20, + background_darken_pct REAL NOT NULL DEFAULT 35, + target_brighten_pct REAL NOT NULL DEFAULT 20, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + UNIQUE(user_id) +); + +INSERT INTO profile_new ( + id, user_id, name, garmin_email, garmin_password, rolling_window_days, backfill_horizon_days, + max_heart_rate, resting_heart_rate, + hr_zone1_min_pct, hr_zone1_max_pct, hr_zone2_min_pct, hr_zone2_max_pct, + hr_zone3_min_pct, hr_zone3_max_pct, hr_zone4_min_pct, hr_zone4_max_pct, + hr_zone5_min_pct, hr_zone5_max_pct, + warmup_minutes, cooldown_minutes, + min_representative_pace_sec_per_km, min_representative_time_seconds, + pace_color, heart_rate_color, warmup_color, effort_color, recovery_color, cooldown_color, + main_line_tint_pct, background_darken_pct, target_brighten_pct, + created_at, updated_at +) +SELECT + id, NULL, name, garmin_email, garmin_password, rolling_window_days, backfill_horizon_days, + max_heart_rate, resting_heart_rate, + hr_zone1_min_pct, hr_zone1_max_pct, hr_zone2_min_pct, hr_zone2_max_pct, + hr_zone3_min_pct, hr_zone3_max_pct, hr_zone4_min_pct, hr_zone4_max_pct, + hr_zone5_min_pct, hr_zone5_max_pct, + warmup_minutes, cooldown_minutes, + min_representative_pace_sec_per_km, min_representative_time_seconds, + pace_color, heart_rate_color, warmup_color, effort_color, recovery_color, cooldown_color, + main_line_tint_pct, background_darken_pct, target_brighten_pct, + created_at, updated_at +FROM profile; + +DROP TABLE profile; + +ALTER TABLE profile_new RENAME TO profile; diff --git a/backend/internal/store/migrations/0024_workout_kinds_user_scoped.sql b/backend/internal/store/migrations/0024_workout_kinds_user_scoped.sql new file mode 100644 index 0000000..e54e54f --- /dev/null +++ b/backend/internal/store/migrations/0024_workout_kinds_user_scoped.sql @@ -0,0 +1,31 @@ +-- workout_kinds.name's UNIQUE(name) constraint (migration 0001) must become +-- per-user (UNIQUE(user_id, name)) now that every user gets their own copy +-- of the same 8-kind taxonomy -- otherwise a second user could never be +-- provisioned (inserting the same seeded name would collide). kind_assignments +-- and workout_type_paces hold foreign keys into this table +-- (workout_kind_id REFERENCES workout_kinds(id)) -- the create-new-name/ +-- copy/drop-old/rename-into-place order below (verified against a real +-- SQLite database) leaves those foreign keys' schema text untouched +-- throughout, so they resolve correctly again the instant the final +-- `ALTER TABLE workout_kinds_new RENAME TO workout_kinds` completes. +CREATE TABLE workout_kinds_new ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER REFERENCES users(id), + name TEXT NOT NULL, + description TEXT NOT NULL DEFAULT '', + color TEXT NOT NULL DEFAULT '', + rule_json TEXT NOT NULL, + priority INTEGER NOT NULL DEFAULT 0, + is_active INTEGER NOT NULL DEFAULT 1, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + UNIQUE(user_id, name) +); + +INSERT INTO workout_kinds_new (id, user_id, name, description, color, rule_json, priority, is_active, created_at, updated_at) +SELECT id, NULL, name, description, color, rule_json, priority, is_active, created_at, updated_at +FROM workout_kinds; + +DROP TABLE workout_kinds; + +ALTER TABLE workout_kinds_new RENAME TO workout_kinds; diff --git a/backend/internal/store/migrations/0025_sync_state_user_scoped.sql b/backend/internal/store/migrations/0025_sync_state_user_scoped.sql new file mode 100644 index 0000000..421ecd9 --- /dev/null +++ b/backend/internal/store/migrations/0025_sync_state_user_scoped.sql @@ -0,0 +1,16 @@ +-- Same CHECK(id = 1) rebuild reasoning as migration 0023's profile rebuild. +CREATE TABLE sync_state_new ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER REFERENCES users(id), + earliest_synced_date TEXT, + backfill_complete INTEGER NOT NULL DEFAULT 0, + UNIQUE(user_id) +); + +INSERT INTO sync_state_new (id, user_id, earliest_synced_date, backfill_complete) +SELECT id, NULL, earliest_synced_date, backfill_complete +FROM sync_state; + +DROP TABLE sync_state; + +ALTER TABLE sync_state_new RENAME TO sync_state; diff --git a/backend/internal/store/migrations/0026_activities_unique_constraint.sql b/backend/internal/store/migrations/0026_activities_unique_constraint.sql new file mode 100644 index 0000000..d185a29 --- /dev/null +++ b/backend/internal/store/migrations/0026_activities_unique_constraint.sql @@ -0,0 +1,55 @@ +-- activities.garmin_activity_id's UNIQUE constraint (migration 0001) must become +-- per-user (UNIQUE(user_id, garmin_activity_id)) now that the per-user-profile +-- design allows multiple users to share the same Garmin activity ID. The old +-- constraint alone was intentionally left in place by migration 0022 as a +-- belts-and-suspenders safeguard (Garmin IDs are globally unique in practice), +-- but it must be removed now to allow Task 6's cross-user test to pass. +-- +-- The FK enforcement toggle needed for this rebuild (DROP TABLE on a table +-- with real inbound foreign keys) happens in Go code around this migration's +-- execution (db.go's tableRebuildMigrations map), in autocommit mode before +-- the transaction begins -- a mid-transaction PRAGMA foreign_keys statement +-- is a documented no-op with modernc.org/sqlite, so it must not appear here. +-- +-- event_type_key keeps the DEFAULT '' that migration 0007 originally gave +-- it (ALTER TABLE ... ADD COLUMN event_type_key TEXT NOT NULL DEFAULT ''); +-- dropping the default here (as an earlier draft of this migration did) +-- broke every INSERT that omits event_type_key and relies on that default, +-- which several existing tests (e.g. TestClaimLegacyOwner_*) do. + +CREATE TABLE activities_new ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER REFERENCES users(id), + garmin_activity_id INTEGER NOT NULL, + event_type_key TEXT NOT NULL DEFAULT '', + workout_id INTEGER, + start_time_utc TEXT NOT NULL, + duration_seconds REAL NOT NULL, + distance_meters REAL NOT NULL, + avg_hr REAL, + max_hr REAL, + avg_speed_mps REAL, + elevation_gain_m REAL, + aerobic_training_effect REAL, + anaerobic_training_effect REAL, + vo2max_value REAL, + raw_json TEXT NOT NULL, + details_fetched_at TEXT, + details_raw_json TEXT, + splits_fetched_at TEXT, + workout_raw_json TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + UNIQUE(user_id, garmin_activity_id) +); + +INSERT INTO activities_new (id, user_id, garmin_activity_id, event_type_key, workout_id, start_time_utc, duration_seconds, distance_meters, avg_hr, max_hr, avg_speed_mps, elevation_gain_m, aerobic_training_effect, anaerobic_training_effect, vo2max_value, raw_json, details_fetched_at, details_raw_json, splits_fetched_at, workout_raw_json, created_at, updated_at) +SELECT id, user_id, garmin_activity_id, event_type_key, workout_id, start_time_utc, duration_seconds, distance_meters, avg_hr, max_hr, avg_speed_mps, elevation_gain_m, aerobic_training_effect, anaerobic_training_effect, vo2max_value, raw_json, details_fetched_at, details_raw_json, splits_fetched_at, workout_raw_json, created_at, updated_at +FROM activities; + +DROP TABLE activities; + +ALTER TABLE activities_new RENAME TO activities; + +CREATE INDEX idx_activities_start_time ON activities(start_time_utc); +CREATE INDEX idx_activities_user_garmin_id ON activities(user_id, garmin_activity_id); diff --git a/backend/internal/store/profile.go b/backend/internal/store/profile.go index c7dad75..b7e8bcb 100644 --- a/backend/internal/store/profile.go +++ b/backend/internal/store/profile.go @@ -81,10 +81,10 @@ const profileColumns = ` created_at, updated_at ` -// GetProfile returns the single profile row. -func (db *DB) GetProfile(ctx context.Context) (Profile, error) { +// GetProfile returns the profile row for userID. +func (db *DB) GetProfile(ctx context.Context, userID int64) (Profile, error) { var p Profile - err := db.QueryRowContext(ctx, `SELECT `+profileColumns+` FROM profile WHERE id = 1`).Scan( + err := db.QueryRowContext(ctx, `SELECT `+profileColumns+` FROM profile WHERE user_id = ?`, userID).Scan( &p.Name, &p.GarminEmail, &p.GarminPassword, &p.RollingWindowDays, &p.BackfillHorizonDays, &p.MaxHeartRate, &p.RestingHeartRate, &p.HRZone1MinPct, &p.HRZone1MaxPct, &p.HRZone2MinPct, &p.HRZone2MaxPct, &p.HRZone3MinPct, &p.HRZone3MaxPct, &p.HRZone4MinPct, &p.HRZone4MaxPct, @@ -96,15 +96,15 @@ func (db *DB) GetProfile(ctx context.Context) (Profile, error) { &p.CreatedAt, &p.UpdatedAt, ) if err != nil { - return Profile{}, fmt.Errorf("get profile: %w", err) + return Profile{}, fmt.Errorf("get profile for user %d: %w", userID, err) } return p, nil } -// UpdateProfile overwrites the single profile row. Callers should read via +// UpdateProfile overwrites userID's profile row. Callers should read via // GetProfile first and modify the fields they intend to change, since this // replaces every column. -func (db *DB) UpdateProfile(ctx context.Context, p Profile) error { +func (db *DB) UpdateProfile(ctx context.Context, userID int64, p Profile) error { _, err := db.ExecContext(ctx, ` UPDATE profile SET name=?, garmin_email=?, garmin_password=?, rolling_window_days=?, backfill_horizon_days=?, max_heart_rate=?, resting_heart_rate=?, @@ -116,7 +116,7 @@ func (db *DB) UpdateProfile(ctx context.Context, p Profile) error { pace_color=?, heart_rate_color=?, warmup_color=?, effort_color=?, recovery_color=?, cooldown_color=?, main_line_tint_pct=?, background_darken_pct=?, target_brighten_pct=?, updated_at=datetime('now') - WHERE id = 1`, + WHERE user_id = ?`, p.Name, p.GarminEmail, p.GarminPassword, p.RollingWindowDays, p.BackfillHorizonDays, p.MaxHeartRate, p.RestingHeartRate, p.HRZone1MinPct, p.HRZone1MaxPct, p.HRZone2MinPct, p.HRZone2MaxPct, p.HRZone3MinPct, p.HRZone3MaxPct, p.HRZone4MinPct, p.HRZone4MaxPct, @@ -125,9 +125,10 @@ func (db *DB) UpdateProfile(ctx context.Context, p Profile) error { p.MinRepresentativePaceSecPerKm, p.MinRepresentativeTimeSeconds, p.PaceColor, p.HeartRateColor, p.WarmupColor, p.EffortColor, p.RecoveryColor, p.CooldownColor, p.MainLineTintPct, p.BackgroundDarkenPct, p.TargetBrightenPct, + userID, ) if err != nil { - return fmt.Errorf("update profile: %w", err) + return fmt.Errorf("update profile for user %d: %w", userID, err) } return nil } diff --git a/backend/internal/store/profile_test.go b/backend/internal/store/profile_test.go index f779319..34eb22e 100644 --- a/backend/internal/store/profile_test.go +++ b/backend/internal/store/profile_test.go @@ -8,8 +8,12 @@ import ( func TestProfile_DefaultsThenUpdate(t *testing.T) { db := openTestDB(t) ctx := context.Background() + userID, err := db.ProvisionUser(ctx, "test-sub", "Default") + if err != nil { + t.Fatalf("ProvisionUser: %v", err) + } - p, err := db.GetProfile(ctx) + p, err := db.GetProfile(ctx, userID) if err != nil { t.Fatalf("GetProfile: %v", err) } @@ -64,11 +68,11 @@ func TestProfile_DefaultsThenUpdate(t *testing.T) { p.MinRepresentativePaceSecPerKm = 600 p.MinRepresentativeTimeSeconds = 5 - if err := db.UpdateProfile(ctx, p); err != nil { + if err := db.UpdateProfile(ctx, userID, p); err != nil { t.Fatalf("UpdateProfile: %v", err) } - got, err := db.GetProfile(ctx) + got, err := db.GetProfile(ctx, userID) if err != nil { t.Fatalf("GetProfile after update: %v", err) } diff --git a/backend/internal/store/reset.go b/backend/internal/store/reset.go index 521cde6..2e7347c 100644 --- a/backend/internal/store/reset.go +++ b/backend/internal/store/reset.go @@ -5,23 +5,23 @@ import ( "fmt" ) -// ResetAllSyncedData deletes every synced activity (cascading to its laps, +// ResetAllSyncedData deletes every synced activity for userID (cascading to its laps, // activity_samples, and kind_assignments via ON DELETE CASCADE) and rewinds // the backfill watermark to its initial state, so a subsequent Backfill // starts a genuinely fresh pull instead of thinking history is already // covered. Workout kinds (the user's taxonomy) are left untouched. -func (db *DB) ResetAllSyncedData(ctx context.Context) error { +func (db *DB) ResetAllSyncedData(ctx context.Context, userID int64) error { tx, err := db.BeginTx(ctx, nil) if err != nil { return fmt.Errorf("begin reset tx: %w", err) } defer tx.Rollback() - if _, err := tx.ExecContext(ctx, `DELETE FROM activities`); err != nil { - return fmt.Errorf("delete activities: %w", err) + if _, err := tx.ExecContext(ctx, `DELETE FROM activities WHERE user_id = ?`, userID); err != nil { + return fmt.Errorf("delete activities for user %d: %w", userID, err) } - if _, err := tx.ExecContext(ctx, `UPDATE sync_state SET earliest_synced_date = NULL, backfill_complete = 0 WHERE id = 1`); err != nil { - return fmt.Errorf("reset sync state: %w", err) + if _, err := tx.ExecContext(ctx, `UPDATE sync_state SET earliest_synced_date = NULL, backfill_complete = 0 WHERE user_id = ?`, userID); err != nil { + return fmt.Errorf("reset sync state for user %d: %w", userID, err) } return tx.Commit() } diff --git a/backend/internal/store/reset_test.go b/backend/internal/store/reset_test.go index f53488a..23065b1 100644 --- a/backend/internal/store/reset_test.go +++ b/backend/internal/store/reset_test.go @@ -8,51 +8,55 @@ import ( func TestResetAllSyncedData_DeletesActivitiesCascadesAndRewindsWatermark(t *testing.T) { db := openTestDB(t) ctx := context.Background() + userID, err := db.ProvisionUser(ctx, "test-sub", "Test User") + if err != nil { + t.Fatalf("ProvisionUser: %v", err) + } - activityID, err := db.UpsertActivity(ctx, Activity{GarminActivityID: 1, StartTimeUTC: "2026-07-11 06:00:00", RawJSON: "{}"}) + activityID, err := db.UpsertActivity(ctx, userID, Activity{GarminActivityID: 1, StartTimeUTC: "2026-07-11 06:00:00", RawJSON: "{}"}) if err != nil { t.Fatalf("UpsertActivity: %v", err) } - kindID, err := db.CreateWorkoutKind(ctx, WorkoutKind{Name: "Test Reset Kind", RuleJSON: `{"match":"all","conditions":[]}`, IsActive: true}) + kindID, err := db.CreateWorkoutKind(ctx, userID, WorkoutKind{Name: "Test Reset Kind", RuleJSON: `{"match":"all","conditions":[]}`, IsActive: true}) if err != nil { t.Fatalf("CreateWorkoutKind: %v", err) } - if err := db.ReplaceLaps(ctx, activityID, []Lap{{LapIndex: 1}}); err != nil { + if err := db.ReplaceLaps(ctx, userID, activityID, []Lap{{LapIndex: 1}}); err != nil { t.Fatalf("ReplaceLaps: %v", err) } - if _, err := db.InsertKindAssignment(ctx, KindAssignment{ + if _, err := db.InsertKindAssignment(ctx, userID, KindAssignment{ ActivityID: activityID, WorkoutKindID: &kindID, AssignmentSource: AssignmentSourceRuleEngine, Status: AssignmentStatusAssigned, CandidateKindsJSON: "[]", }); err != nil { t.Fatalf("InsertKindAssignment: %v", err) } - if err := db.UpdateSyncState(ctx, "2020-01-01", true); err != nil { + if err := db.UpdateSyncState(ctx, userID, "2020-01-01", true); err != nil { t.Fatalf("UpdateSyncState: %v", err) } - if err := db.ResetAllSyncedData(ctx); err != nil { + if err := db.ResetAllSyncedData(ctx, userID); err != nil { t.Fatalf("ResetAllSyncedData: %v", err) } - activities, err := db.ListActivities(ctx, ActivityFilter{}) + activities, err := db.ListActivities(ctx, userID, ActivityFilter{}) if err != nil { t.Fatalf("ListActivities: %v", err) } if len(activities) != 0 { t.Errorf("expected 0 activities after reset, got %d", len(activities)) } - laps, err := db.LapsForActivity(ctx, activityID) + laps, err := db.LapsForActivity(ctx, userID, activityID) if err != nil { t.Fatalf("LapsForActivity: %v", err) } if len(laps) != 0 { t.Errorf("expected laps to cascade-delete, got %d", len(laps)) } - if _, ok, err := db.CurrentAssignment(ctx, activityID); err != nil || ok { + if _, ok, err := db.CurrentAssignment(ctx, userID, activityID); err != nil || ok { t.Errorf("expected kind assignment to cascade-delete, ok=%v err=%v", ok, err) } - state, err := db.GetSyncState(ctx) + state, err := db.GetSyncState(ctx, userID) if err != nil { t.Fatalf("GetSyncState: %v", err) } @@ -61,7 +65,7 @@ func TestResetAllSyncedData_DeletesActivitiesCascadesAndRewindsWatermark(t *test } // Workout kinds (the user's taxonomy) must survive a reset. - kind, ok, err := db.GetWorkoutKind(ctx, kindID) + kind, ok, err := db.GetWorkoutKind(ctx, userID, kindID) if err != nil || !ok { t.Fatalf("GetWorkoutKind after reset: ok=%v err=%v", ok, err) } diff --git a/backend/internal/store/samples.go b/backend/internal/store/samples.go index 57d70a5..3fefa0f 100644 --- a/backend/internal/store/samples.go +++ b/backend/internal/store/samples.go @@ -2,6 +2,7 @@ package store import ( "context" + "database/sql" "fmt" ) @@ -15,9 +16,19 @@ type Sample struct { ElevationM *float64 } -// ReplaceActivitySamples deletes any existing samples for activityID and -// bulk-inserts the given set, so re-syncing an activity's details is idempotent. -func (db *DB) ReplaceActivitySamples(ctx context.Context, activityID int64, samples []Sample) error { +// ReplaceActivitySamples deletes any existing samples for activityID (owned +// by userID) and bulk-inserts the given set, so re-syncing an activity's +// details is idempotent. +func (db *DB) ReplaceActivitySamples(ctx context.Context, userID, activityID int64, samples []Sample) error { + var exists int + err := db.QueryRowContext(ctx, `SELECT 1 FROM activities WHERE id = ? AND user_id = ?`, activityID, userID).Scan(&exists) + if err == sql.ErrNoRows { + return fmt.Errorf("replace activity samples: activity %d not found for user %d", activityID, userID) + } + if err != nil { + return fmt.Errorf("replace activity samples for activity %d (user %d): %w", activityID, userID, err) + } + tx, err := db.BeginTx(ctx, nil) if err != nil { return fmt.Errorf("begin replace samples tx: %w", err) @@ -44,13 +55,18 @@ func (db *DB) ReplaceActivitySamples(ctx context.Context, activityID int64, samp return tx.Commit() } -// SamplesForActivity returns all samples for an activity, ordered by elapsed_seconds. -func (db *DB) SamplesForActivity(ctx context.Context, activityID int64) ([]Sample, error) { +// SamplesForActivity returns all samples for an activity owned by userID, +// ordered by elapsed_seconds. +func (db *DB) SamplesForActivity(ctx context.Context, userID, activityID int64) ([]Sample, error) { rows, err := db.QueryContext(ctx, ` - SELECT elapsed_seconds, timestamp_ms, heart_rate, speed_mps, distance_m, elevation_m - FROM activity_samples WHERE activity_id = ? ORDER BY elapsed_seconds`, activityID) + SELECT activity_samples.elapsed_seconds, activity_samples.timestamp_ms, activity_samples.heart_rate, + activity_samples.speed_mps, activity_samples.distance_m, activity_samples.elevation_m + FROM activity_samples + JOIN activities ON activities.id = activity_samples.activity_id + WHERE activity_samples.activity_id = ? AND activities.user_id = ? + ORDER BY activity_samples.elapsed_seconds`, activityID, userID) if err != nil { - return nil, fmt.Errorf("samples for activity %d: %w", activityID, err) + return nil, fmt.Errorf("samples for activity %d (user %d): %w", activityID, userID, err) } defer rows.Close() diff --git a/backend/internal/store/store_test.go b/backend/internal/store/store_test.go index db166ca..2526979 100644 --- a/backend/internal/store/store_test.go +++ b/backend/internal/store/store_test.go @@ -2,7 +2,11 @@ package store import ( "context" + "database/sql" + "io/fs" "path/filepath" + "sort" + "strings" "testing" ) @@ -37,6 +41,10 @@ func TestMigrateIsIdempotent(t *testing.T) { func TestUpsertActivity_InsertThenUpdateIsIdempotent(t *testing.T) { db := openTestDB(t) ctx := context.Background() + userID, err := db.ProvisionUser(ctx, "test-sub", "Test") + if err != nil { + t.Fatalf("ProvisionUser: %v", err) + } a := Activity{ GarminActivityID: 23554066504, @@ -47,13 +55,13 @@ func TestUpsertActivity_InsertThenUpdateIsIdempotent(t *testing.T) { RawJSON: `{"activityId":23554066504,"activityName":"Auriol - W2-5-Base Endurance","activityType":{"typeKey":"running"}}`, } - id, err := db.UpsertActivity(ctx, a) + id, err := db.UpsertActivity(ctx, userID, a) if err != nil { t.Fatalf("UpsertActivity (insert): %v", err) } a.AvgHR = f(150) // simulate a re-sync with a corrected value - id2, err := db.UpsertActivity(ctx, a) + id2, err := db.UpsertActivity(ctx, userID, a) if err != nil { t.Fatalf("UpsertActivity (update): %v", err) } @@ -61,7 +69,7 @@ func TestUpsertActivity_InsertThenUpdateIsIdempotent(t *testing.T) { t.Fatalf("expected same internal id across upserts, got %d then %d", id, id2) } - got, ok, err := db.GetActivity(ctx, id) + got, ok, err := db.GetActivity(ctx, userID, id) if err != nil || !ok { t.Fatalf("GetActivity: ok=%v err=%v", ok, err) } @@ -69,7 +77,7 @@ func TestUpsertActivity_InsertThenUpdateIsIdempotent(t *testing.T) { t.Errorf("AvgHR = %v, want 150 (update should have applied)", *got.AvgHR) } - all, err := db.ListActivities(ctx, ActivityFilter{}) + all, err := db.ListActivities(ctx, userID, ActivityFilter{}) if err != nil { t.Fatalf("ListActivities: %v", err) } @@ -78,11 +86,45 @@ func TestUpsertActivity_InsertThenUpdateIsIdempotent(t *testing.T) { } } +func TestUpsertActivity_SameGarminActivityIDAllowedAcrossDifferentUsers(t *testing.T) { + db := openTestDB(t) + ctx := context.Background() + userA, err := db.ProvisionUser(ctx, "sub-a", "A") + if err != nil { + t.Fatalf("ProvisionUser(a): %v", err) + } + userB, err := db.ProvisionUser(ctx, "sub-b", "B") + if err != nil { + t.Fatalf("ProvisionUser(b): %v", err) + } + + activity := Activity{GarminActivityID: 999, StartTimeUTC: "2026-07-11 05:00:00", RawJSON: "{}"} + if _, err := db.UpsertActivity(ctx, userA, activity); err != nil { + t.Fatalf("UpsertActivity(a): %v", err) + } + if _, err := db.UpsertActivity(ctx, userB, activity); err != nil { + t.Fatalf("expected the same garmin_activity_id to be allowed for a different user, got: %v", err) + } + + aActivities, err := db.ListActivities(ctx, userA, ActivityFilter{}) + if err != nil || len(aActivities) != 1 { + t.Fatalf("ListActivities(a): got %d, err=%v", len(aActivities), err) + } + bActivities, err := db.ListActivities(ctx, userB, ActivityFilter{}) + if err != nil || len(bActivities) != 1 { + t.Fatalf("ListActivities(b): got %d, err=%v", len(bActivities), err) + } +} + func TestKindAssignment_AppendOnlyHistoryAndCurrentView(t *testing.T) { db := openTestDB(t) ctx := context.Background() + userID, err := db.ProvisionUser(ctx, "test-sub", "Test") + if err != nil { + t.Fatalf("ProvisionUser: %v", err) + } - activityID, err := db.UpsertActivity(ctx, Activity{ + activityID, err := db.UpsertActivity(ctx, userID, Activity{ GarminActivityID: 1, StartTimeUTC: "2026-07-11 05:00:00", RawJSON: "{}", @@ -91,7 +133,7 @@ func TestKindAssignment_AppendOnlyHistoryAndCurrentView(t *testing.T) { t.Fatalf("UpsertActivity: %v", err) } - kindID, err := db.CreateWorkoutKind(ctx, WorkoutKind{ + kindID, err := db.CreateWorkoutKind(ctx, userID, WorkoutKind{ Name: "Test Assignment Custom", RuleJSON: `{"match":"all","conditions":[]}`, IsActive: true, @@ -101,7 +143,7 @@ func TestKindAssignment_AppendOnlyHistoryAndCurrentView(t *testing.T) { } // First: rule engine says needs_review (ambiguous). - if _, err := db.InsertKindAssignment(ctx, KindAssignment{ + if _, err := db.InsertKindAssignment(ctx, userID, KindAssignment{ ActivityID: activityID, AssignmentSource: AssignmentSourceRuleEngine, Status: AssignmentStatusNeedsReview, @@ -110,7 +152,7 @@ func TestKindAssignment_AppendOnlyHistoryAndCurrentView(t *testing.T) { t.Fatalf("InsertKindAssignment (rule engine): %v", err) } - queue, err := db.ReviewQueue(ctx) + queue, err := db.ReviewQueue(ctx, userID) if err != nil { t.Fatalf("ReviewQueue: %v", err) } @@ -119,7 +161,7 @@ func TestKindAssignment_AppendOnlyHistoryAndCurrentView(t *testing.T) { } // Then: user manually resolves it. - if _, err := db.InsertKindAssignment(ctx, KindAssignment{ + if _, err := db.InsertKindAssignment(ctx, userID, KindAssignment{ ActivityID: activityID, WorkoutKindID: &kindID, AssignmentSource: AssignmentSourceManual, @@ -128,7 +170,7 @@ func TestKindAssignment_AppendOnlyHistoryAndCurrentView(t *testing.T) { t.Fatalf("InsertKindAssignment (manual): %v", err) } - queue, err = db.ReviewQueue(ctx) + queue, err = db.ReviewQueue(ctx, userID) if err != nil { t.Fatalf("ReviewQueue after resolve: %v", err) } @@ -136,7 +178,7 @@ func TestKindAssignment_AppendOnlyHistoryAndCurrentView(t *testing.T) { t.Fatalf("expected 0 items in review queue after manual resolve, got %d", len(queue)) } - current, ok, err := db.CurrentAssignment(ctx, activityID) + current, ok, err := db.CurrentAssignment(ctx, userID, activityID) if err != nil || !ok { t.Fatalf("CurrentAssignment: ok=%v err=%v", ok, err) } @@ -153,7 +195,7 @@ func TestKindAssignment_AppendOnlyHistoryAndCurrentView(t *testing.T) { t.Errorf("expected 2 historical assignment rows (rule engine + manual), got %d", historyCount) } - forKind, err := db.AssignmentsForKind(ctx, kindID) + forKind, err := db.AssignmentsForKind(ctx, userID, kindID) if err != nil { t.Fatalf("AssignmentsForKind: %v", err) } @@ -165,8 +207,12 @@ func TestKindAssignment_AppendOnlyHistoryAndCurrentView(t *testing.T) { func TestReplaceLapsIsIdempotent(t *testing.T) { db := openTestDB(t) ctx := context.Background() + userID, err := db.ProvisionUser(ctx, "test-sub", "Test") + if err != nil { + t.Fatalf("ProvisionUser: %v", err) + } - activityID, err := db.UpsertActivity(ctx, Activity{ + activityID, err := db.UpsertActivity(ctx, userID, Activity{ GarminActivityID: 2, StartTimeUTC: "2026-07-11 05:00:00", RawJSON: "{}", @@ -179,15 +225,15 @@ func TestReplaceLapsIsIdempotent(t *testing.T) { {LapIndex: 1, RawJSON: "{}"}, {LapIndex: 2, RawJSON: "{}"}, } - if err := db.ReplaceLaps(ctx, activityID, laps); err != nil { + if err := db.ReplaceLaps(ctx, userID, activityID, laps); err != nil { t.Fatalf("ReplaceLaps (first): %v", err) } // Re-sync with a different set (e.g. corrected data) should fully replace, not append. - if err := db.ReplaceLaps(ctx, activityID, laps[:1]); err != nil { + if err := db.ReplaceLaps(ctx, userID, activityID, laps[:1]); err != nil { t.Fatalf("ReplaceLaps (second): %v", err) } - got, err := db.LapsForActivity(ctx, activityID) + got, err := db.LapsForActivity(ctx, userID, activityID) if err != nil { t.Fatalf("LapsForActivity: %v", err) } @@ -195,3 +241,371 @@ func TestReplaceLapsIsIdempotent(t *testing.T) { t.Fatalf("expected 1 lap after replace, got %d", len(got)) } } + +func TestUsersAndOwnershipSchema_AppliesCleanly(t *testing.T) { + db := openTestDB(t) + ctx := context.Background() + + // A fresh DB has no legacy singleton rows, so every user_id column + // should already be backfilled to nothing (fresh install, no rows at + // all yet in these tables besides the migration-seeded workout_kinds -- + // which do have NULL user_id until a real user is provisioned). + var nullableCount int + if err := db.QueryRowContext(ctx, `SELECT COUNT(*) FROM workout_kinds WHERE user_id IS NULL`).Scan(&nullableCount); err != nil { + t.Fatalf("count workout_kinds: %v", err) + } + if nullableCount != 8 { + t.Fatalf("expected 8 migration-seeded workout_kinds with NULL user_id on a fresh DB, got %d", nullableCount) + } + + // UNIQUE(user_id, name) allows the same name across two different users. + if _, err := db.ExecContext(ctx, `INSERT INTO users (oidc_sub, display_name) VALUES ('sub-a', 'A'), ('sub-b', 'B')`); err != nil { + t.Fatalf("insert users: %v", err) + } + if _, err := db.ExecContext(ctx, ` + INSERT INTO workout_kinds (user_id, name, rule_json) + VALUES ((SELECT id FROM users WHERE oidc_sub='sub-a'), 'Custom Kind', '{}'), + ((SELECT id FROM users WHERE oidc_sub='sub-b'), 'Custom Kind', '{}')`); err != nil { + t.Fatalf("expected same workout_kind name to be allowed across two different users, got: %v", err) + } + + // profile/sync_state UNIQUE(user_id) still rejects a genuine duplicate. + if _, err := db.ExecContext(ctx, `INSERT INTO profile (user_id) VALUES ((SELECT id FROM users WHERE oidc_sub='sub-a'))`); err != nil { + t.Fatalf("insert profile for sub-a: %v", err) + } + if _, err := db.ExecContext(ctx, `INSERT INTO profile (user_id) VALUES ((SELECT id FROM users WHERE oidc_sub='sub-a'))`); err == nil { + t.Fatal("expected a second profile row for the same user_id to violate UNIQUE(user_id)") + } +} + +func TestForeignKeyEnforcementPostMigration(t *testing.T) { + db := openTestDB(t) + ctx := context.Background() + + // Verify that FK enforcement is correctly active after migrations complete. + // This tests that the PRAGMA foreign_keys toggle in db.migrate() (for + // migrations 0023/0024/0025) is properly scoped and re-enabled after each + // table rebuild. + + userID, err := db.ProvisionUser(ctx, "test-sub", "Test") + if err != nil { + t.Fatalf("ProvisionUser: %v", err) + } + + // Insert an activity that we can reference. + activityID, err := db.UpsertActivity(ctx, userID, Activity{ + GarminActivityID: 1001, + StartTimeUTC: "2026-07-11 05:00:00", + RawJSON: "{}", + }) + if err != nil { + t.Fatalf("UpsertActivity: %v", err) + } + + // Get the ID of one of the 8 migration-seeded workout_kinds. + var seedKindID int64 + if err := db.QueryRowContext(ctx, `SELECT id FROM workout_kinds LIMIT 1`).Scan(&seedKindID); err != nil { + t.Fatalf("query seeded workout_kind: %v", err) + } + + // Inserting a kind_assignment with a valid FK reference should succeed. + assignmentID, err := db.InsertKindAssignment(ctx, userID, KindAssignment{ + ActivityID: activityID, + WorkoutKindID: &seedKindID, + AssignmentSource: AssignmentSourceManual, + Status: AssignmentStatusAssigned, + }) + if err != nil { + t.Fatalf("InsertKindAssignment with valid FK: %v", err) + } + if assignmentID == 0 { + t.Fatal("expected non-zero assignment ID") + } + + // Inserting a kind_assignment with a nonexistent workout_kind_id should + // be rejected by the FK constraint, proving FK enforcement is ON. + nonexistentKindID := int64(999999) + _, err = db.InsertKindAssignment(ctx, userID, KindAssignment{ + ActivityID: activityID, + WorkoutKindID: &nonexistentKindID, + AssignmentSource: AssignmentSourceManual, + Status: AssignmentStatusAssigned, + }) + if err == nil { + t.Fatal("expected FK constraint violation for nonexistent workout_kind_id, but insert succeeded") + } + // The error message should mention FOREIGN KEY or similar constraint issue. + if !contains(err.Error(), "FOREIGN KEY", "constraint", "UNIQUE") { + t.Logf("FK error message: %v", err) + // Still pass, but log it; the exact error text varies by driver/context. + } +} + +// TestRebuildMigrationsPreserveForeignKeyReferences proves the specific +// property none of the other migration tests cover: every other test opens +// a fresh DB via store.Open, which runs migrations 0001-0026 in one +// uninterrupted pass over an empty database, so migrations 0023/0024/0025/0026's +// table-rebuild (create-new/copy/drop-old/rename-into-place) never has any +// real pre-existing rows to carry across. A real single-tenant deployment +// upgrading to this schema version has months of synced activities, laps, +// activity_samples, and kind_assignments rows referencing real activities/ +// workout_kinds rows by foreign key (per this repo's CLAUDE.md: one profile, +// real Garmin history, a review queue with manual overrides already +// recorded). This test manually applies migrations up through 0022, inserts +// rows simulating that pre-existing install, then applies 0023/0024/0025/0026 +// and verifies every FK-referencing row still resolves correctly -- and that +// FK enforcement is genuinely back on afterward -- rather than just checking +// that migrations apply to an empty DB without erroring. This is the +// regression guard for a real bug: migration 0026 (activities table rebuild) +// originally issued its own mid-transaction `PRAGMA foreign_keys = OFF/ON`, +// which SQLite documents as a no-op once a transaction is open, so +// `DROP TABLE activities` silently cascade-deleted every laps/ +// activity_samples/kind_assignments row for every activity via +// `ON DELETE CASCADE` -- with no error at all. It was masked because every +// other test runs migrations back-to-back on an empty database with no +// pre-existing child rows. +func TestRebuildMigrationsPreserveForeignKeyReferences(t *testing.T) { + ctx := context.Background() + path := filepath.Join(t.TempDir(), "geniusrun_rebuild_test.db") + + // Deliberately not store.Open: that always applies every migration in + // one uninterrupted pass with no way to stop partway through. The sqlite + // driver itself is already registered via db.go's blank import. + sqlDB, err := sql.Open("sqlite", path+"?_pragma=foreign_keys(1)") + if err != nil { + t.Fatalf("open sqlite: %v", err) + } + defer sqlDB.Close() + + if _, err := sqlDB.ExecContext(ctx, `CREATE TABLE IF NOT EXISTS schema_migrations ( + filename TEXT PRIMARY KEY, + applied_at TEXT NOT NULL DEFAULT (datetime('now')) + )`); err != nil { + t.Fatalf("create schema_migrations table: %v", err) + } + + rebuildMigrations := map[string]bool{ + "0023_profile_user_scoped.sql": true, + "0024_workout_kinds_user_scoped.sql": true, + "0025_sync_state_user_scoped.sql": true, + "0026_activities_unique_constraint.sql": true, + } + + // Mirrors db.go's migrate() method: FK toggle in autocommit mode around + // the transaction, only for the four table-rebuild migrations. + applyMigration := func(name string) { + t.Helper() + content, err := migrationsFS.ReadFile("migrations/" + name) + if err != nil { + t.Fatalf("read migration %s: %v", name, err) + } + + needsFKToggle := rebuildMigrations[name] + if needsFKToggle { + if _, err := sqlDB.ExecContext(ctx, `PRAGMA foreign_keys = OFF`); err != nil { + t.Fatalf("disable foreign keys before migration %s: %v", name, err) + } + } + + tx, err := sqlDB.BeginTx(ctx, nil) + if err != nil { + t.Fatalf("begin migration tx for %s: %v", name, err) + } + if _, err := tx.ExecContext(ctx, string(content)); err != nil { + tx.Rollback() + t.Fatalf("apply migration %s: %v", name, err) + } + if _, err := tx.ExecContext(ctx, `INSERT INTO schema_migrations (filename) VALUES (?)`, name); err != nil { + tx.Rollback() + t.Fatalf("record migration %s: %v", name, err) + } + if err := tx.Commit(); err != nil { + t.Fatalf("commit migration %s: %v", name, err) + } + + if needsFKToggle { + if _, err := sqlDB.ExecContext(ctx, `PRAGMA foreign_keys = ON`); err != nil { + t.Fatalf("enable foreign keys after migration %s: %v", name, err) + } + } + } + + entries, err := fs.Glob(migrationsFS, "migrations/*.sql") + if err != nil { + t.Fatalf("glob migrations: %v", err) + } + sort.Strings(entries) + + // Apply every migration up to (but not including) the three rebuilds. + for _, entry := range entries { + name := entry[len("migrations/"):] + if rebuildMigrations[name] { + continue + } + applyMigration(name) + } + + // Simulate a real pre-existing single-tenant install at this point in + // schema history: a workout kind, a synced activity, and a + // kind_assignment referencing both by foreign key. + res, err := sqlDB.ExecContext(ctx, `INSERT INTO workout_kinds (name, rule_json) VALUES ('Pre-Existing Custom Kind', '{}')`) + if err != nil { + t.Fatalf("insert pre-existing workout_kinds row: %v", err) + } + kindID, err := res.LastInsertId() + if err != nil { + t.Fatalf("workout_kinds LastInsertId: %v", err) + } + + res, err = sqlDB.ExecContext(ctx, ` + INSERT INTO activities (garmin_activity_id, start_time_utc, duration_seconds, distance_meters, raw_json) + VALUES (123456789, '2026-01-01 06:00:00', 1800, 5000, '{}')`) + if err != nil { + t.Fatalf("insert pre-existing activities row: %v", err) + } + activityID, err := res.LastInsertId() + if err != nil { + t.Fatalf("activities LastInsertId: %v", err) + } + + res, err = sqlDB.ExecContext(ctx, ` + INSERT INTO kind_assignments (activity_id, workout_kind_id, assignment_source, status) + VALUES (?, ?, 'rule_engine', 'assigned')`, activityID, kindID) + if err != nil { + t.Fatalf("insert pre-existing kind_assignments row: %v", err) + } + assignmentID, err := res.LastInsertId() + if err != nil { + t.Fatalf("kind_assignments LastInsertId: %v", err) + } + + res, err = sqlDB.ExecContext(ctx, ` + INSERT INTO laps (activity_id, lap_index, raw_json) + VALUES (?, 0, '{}')`, activityID) + if err != nil { + t.Fatalf("insert pre-existing laps row: %v", err) + } + lapID, err := res.LastInsertId() + if err != nil { + t.Fatalf("laps LastInsertId: %v", err) + } + + res, err = sqlDB.ExecContext(ctx, ` + INSERT INTO activity_samples (activity_id, elapsed_seconds, timestamp_ms, heart_rate) + VALUES (?, 60, 1735711260000, 150)`, activityID) + if err != nil { + t.Fatalf("insert pre-existing activity_samples row: %v", err) + } + sampleID, err := res.LastInsertId() + if err != nil { + t.Fatalf("activity_samples LastInsertId: %v", err) + } + + // Now apply the four rebuild migrations that drop/recreate profile, + // workout_kinds, sync_state, and (0026) activities itself. + for _, name := range []string{ + "0023_profile_user_scoped.sql", + "0024_workout_kinds_user_scoped.sql", + "0025_sync_state_user_scoped.sql", + "0026_activities_unique_constraint.sql", + } { + applyMigration(name) + } + + // The pre-existing kind_assignment row must still resolve to the same + // workout_kind, by the same name, across the drop/recreate/rename. + var resolvedName string + if err := sqlDB.QueryRowContext(ctx, ` + SELECT wk.name FROM kind_assignments ka + JOIN workout_kinds wk ON wk.id = ka.workout_kind_id + WHERE ka.id = ?`, assignmentID).Scan(&resolvedName); err != nil { + t.Fatalf("join kind_assignments -> workout_kinds after rebuild: %v", err) + } + if resolvedName != "Pre-Existing Custom Kind" { + t.Fatalf("expected pre-existing kind_assignment to still resolve to 'Pre-Existing Custom Kind' after rebuild, got %q", resolvedName) + } + + // The pre-existing laps row must still exist and still reference the + // same activity -- this is the exact regression guard for migration + // 0026's DROP TABLE activities silently cascade-deleting laps via + // ON DELETE CASCADE when FK enforcement wasn't actually disabled. + var lapActivityID int64 + if err := sqlDB.QueryRowContext(ctx, ` + SELECT activity_id FROM laps WHERE id = ?`, lapID).Scan(&lapActivityID); err != nil { + t.Fatalf("query pre-existing laps row after rebuild: %v", err) + } + if lapActivityID != activityID { + t.Fatalf("expected pre-existing laps row to still reference activity %d after rebuild, got %d", activityID, lapActivityID) + } + + // Same guard for activity_samples. + var sampleActivityID int64 + if err := sqlDB.QueryRowContext(ctx, ` + SELECT activity_id FROM activity_samples WHERE id = ?`, sampleID).Scan(&sampleActivityID); err != nil { + t.Fatalf("query pre-existing activity_samples row after rebuild: %v", err) + } + if sampleActivityID != activityID { + t.Fatalf("expected pre-existing activity_samples row to still reference activity %d after rebuild, got %d", activityID, sampleActivityID) + } + + // FK enforcement must be genuinely active again post-migration: a bogus + // workout_kind_id, activity_id (laps), and activity_id (activity_samples) + // must all be rejected, not silently accepted. + if _, err := sqlDB.ExecContext(ctx, ` + INSERT INTO kind_assignments (activity_id, workout_kind_id, assignment_source, status) + VALUES (?, 999999, 'rule_engine', 'assigned')`, activityID); err == nil { + t.Fatal("expected FK constraint violation for nonexistent workout_kind_id after rebuild, but insert succeeded") + } + if _, err := sqlDB.ExecContext(ctx, ` + INSERT INTO laps (activity_id, lap_index, raw_json) + VALUES (999999, 0, '{}')`); err == nil { + t.Fatal("expected FK constraint violation for nonexistent activity_id in laps after rebuild, but insert succeeded") + } + if _, err := sqlDB.ExecContext(ctx, ` + INSERT INTO activity_samples (activity_id, elapsed_seconds, timestamp_ms, heart_rate) + VALUES (999999, 60, 1735711260000, 150)`); err == nil { + t.Fatal("expected FK constraint violation for nonexistent activity_id in activity_samples after rebuild, but insert succeeded") + } +} + +func TestCurrentAssignment_ScopedToOwningUser(t *testing.T) { + db := openTestDB(t) + ctx := context.Background() + userA, err := db.ProvisionUser(ctx, "sub-a", "A") + if err != nil { + t.Fatalf("ProvisionUser(a): %v", err) + } + userB, err := db.ProvisionUser(ctx, "sub-b", "B") + if err != nil { + t.Fatalf("ProvisionUser(b): %v", err) + } + + activityID, err := db.UpsertActivity(ctx, userA, Activity{GarminActivityID: 1, StartTimeUTC: "2026-07-11 05:00:00", RawJSON: "{}"}) + if err != nil { + t.Fatalf("UpsertActivity: %v", err) + } + if _, err := db.InsertKindAssignment(ctx, userA, KindAssignment{ + ActivityID: activityID, AssignmentSource: AssignmentSourceRuleEngine, Status: AssignmentStatusNeedsReview, CandidateKindsJSON: "[]", + }); err != nil { + t.Fatalf("InsertKindAssignment: %v", err) + } + + if _, found, err := db.CurrentAssignment(ctx, userB, activityID); err != nil || found { + t.Fatalf("expected userB to not see userA's activity assignment, found=%v err=%v", found, err) + } + if _, err := db.InsertKindAssignment(ctx, userB, KindAssignment{ + ActivityID: activityID, AssignmentSource: AssignmentSourceManual, Status: AssignmentStatusAssigned, CandidateKindsJSON: "[]", + }); err == nil { + t.Fatal("expected InsertKindAssignment to reject an activity that doesn't belong to userB") + } +} + +func contains(s string, substrs ...string) bool { + lower := strings.ToLower(s) + for _, substr := range substrs { + if strings.Contains(lower, strings.ToLower(substr)) { + return true + } + } + return false +} diff --git a/backend/internal/store/syncruns.go b/backend/internal/store/syncruns.go index 67ac013..ad04c36 100644 --- a/backend/internal/store/syncruns.go +++ b/backend/internal/store/syncruns.go @@ -31,56 +31,56 @@ type SyncRun struct { ErrorMessage *string } -// StartSyncRun records a new in-progress sync run and returns its id. -func (db *DB) StartSyncRun(ctx context.Context, kind string) (int64, error) { +// StartSyncRun records a new in-progress sync run for userID and returns its id. +func (db *DB) StartSyncRun(ctx context.Context, userID int64, kind string) (int64, error) { res, err := db.ExecContext(ctx, ` - INSERT INTO sync_runs (kind, started_at, status) VALUES (?, datetime('now'), ?)`, - kind, SyncStatusRunning) + INSERT INTO sync_runs (user_id, kind, started_at, status) VALUES (?, ?, datetime('now'), ?)`, + userID, kind, SyncStatusRunning) if err != nil { - return 0, fmt.Errorf("start sync run: %w", err) + return 0, fmt.Errorf("start sync run for user %d: %w", userID, err) } return res.LastInsertId() } -// FinishSyncRun marks a sync run as finished, recording how many activities -// were fetched and whether it succeeded. -func (db *DB) FinishSyncRun(ctx context.Context, id int64, activitiesFetched int, errMsg *string) error { +// FinishSyncRun marks a sync run (owned by userID) as finished, recording +// how many activities were fetched and whether it succeeded. +func (db *DB) FinishSyncRun(ctx context.Context, userID, id int64, activitiesFetched int, errMsg *string) error { status := SyncStatusSuccess if errMsg != nil { status = SyncStatusError } _, err := db.ExecContext(ctx, ` UPDATE sync_runs SET finished_at = datetime('now'), activities_fetched = ?, status = ?, error_message = ? - WHERE id = ?`, activitiesFetched, status, errMsg, id) + WHERE id = ? AND user_id = ?`, activitiesFetched, status, errMsg, id, userID) if err != nil { - return fmt.Errorf("finish sync run %d: %w", id, err) + return fmt.Errorf("finish sync run %d for user %d: %w", id, userID, err) } return nil } -// LatestSyncRun returns the most recent sync run, if any. -func (db *DB) LatestSyncRun(ctx context.Context) (SyncRun, bool, error) { +// LatestSyncRun returns userID's most recent sync run, if any. +func (db *DB) LatestSyncRun(ctx context.Context, userID int64) (SyncRun, bool, error) { row := db.QueryRowContext(ctx, ` SELECT id, kind, started_at, finished_at, activities_fetched, status, error_message - FROM sync_runs ORDER BY id DESC LIMIT 1`) + FROM sync_runs WHERE user_id = ? ORDER BY id DESC LIMIT 1`, userID) var r SyncRun err := row.Scan(&r.ID, &r.Kind, &r.StartedAt, &r.FinishedAt, &r.ActivitiesFetched, &r.Status, &r.ErrorMessage) if err == sql.ErrNoRows { return SyncRun{}, false, nil } if err != nil { - return SyncRun{}, false, fmt.Errorf("latest sync run: %w", err) + return SyncRun{}, false, fmt.Errorf("latest sync run for user %d: %w", userID, err) } return r, true, nil } -// ListSyncRuns returns recent sync runs, newest first. -func (db *DB) ListSyncRuns(ctx context.Context, limit int) ([]SyncRun, error) { +// ListSyncRuns returns userID's recent sync runs, newest first. +func (db *DB) ListSyncRuns(ctx context.Context, userID int64, limit int) ([]SyncRun, error) { rows, err := db.QueryContext(ctx, ` SELECT id, kind, started_at, finished_at, activities_fetched, status, error_message - FROM sync_runs ORDER BY id DESC LIMIT ?`, limit) + FROM sync_runs WHERE user_id = ? ORDER BY id DESC LIMIT ?`, userID, limit) if err != nil { - return nil, fmt.Errorf("list sync runs: %w", err) + return nil, fmt.Errorf("list sync runs for user %d: %w", userID, err) } defer rows.Close() diff --git a/backend/internal/store/syncstate.go b/backend/internal/store/syncstate.go index c6aba91..7310211 100644 --- a/backend/internal/store/syncstate.go +++ b/backend/internal/store/syncstate.go @@ -13,25 +13,24 @@ type SyncState struct { BackfillComplete bool // true once backfill has reached the configured horizon (or Garmin's own history start) } -// GetSyncState returns the current backfill watermark (the singleton row, -// created by migration 0002). -func (db *DB) GetSyncState(ctx context.Context) (SyncState, error) { +// GetSyncState returns the current backfill watermark for userID. +func (db *DB) GetSyncState(ctx context.Context, userID int64) (SyncState, error) { var s SyncState - err := db.QueryRowContext(ctx, `SELECT earliest_synced_date, backfill_complete FROM sync_state WHERE id = 1`). + err := db.QueryRowContext(ctx, `SELECT earliest_synced_date, backfill_complete FROM sync_state WHERE user_id = ?`, userID). Scan(&s.EarliestSyncedDate, &s.BackfillComplete) if err != nil { - return SyncState{}, fmt.Errorf("get sync state: %w", err) + return SyncState{}, fmt.Errorf("get sync state for user %d: %w", userID, err) } return s, nil } -// UpdateSyncState records progress of a backfill run. -func (db *DB) UpdateSyncState(ctx context.Context, earliestSyncedDate string, complete bool) error { +// UpdateSyncState records progress of a backfill run for userID. +func (db *DB) UpdateSyncState(ctx context.Context, userID int64, earliestSyncedDate string, complete bool) error { _, err := db.ExecContext(ctx, ` - UPDATE sync_state SET earliest_synced_date = ?, backfill_complete = ? WHERE id = 1`, - earliestSyncedDate, complete) + UPDATE sync_state SET earliest_synced_date = ?, backfill_complete = ? WHERE user_id = ?`, + earliestSyncedDate, complete, userID) if err != nil { - return fmt.Errorf("update sync state: %w", err) + return fmt.Errorf("update sync state for user %d: %w", userID, err) } return nil } diff --git a/backend/internal/store/syncstate_test.go b/backend/internal/store/syncstate_test.go index d7b9108..cf20f87 100644 --- a/backend/internal/store/syncstate_test.go +++ b/backend/internal/store/syncstate_test.go @@ -8,8 +8,12 @@ import ( func TestSyncState_DefaultsAndUpdate(t *testing.T) { db := openTestDB(t) ctx := context.Background() + userID, err := db.ProvisionUser(ctx, "test-sub", "Test User") + if err != nil { + t.Fatalf("ProvisionUser: %v", err) + } - state, err := db.GetSyncState(ctx) + state, err := db.GetSyncState(ctx, userID) if err != nil { t.Fatalf("GetSyncState: %v", err) } @@ -17,11 +21,11 @@ func TestSyncState_DefaultsAndUpdate(t *testing.T) { t.Fatalf("expected fresh DB to have no watermark, got %+v", state) } - if err := db.UpdateSyncState(ctx, "2023-01-01", true); err != nil { + if err := db.UpdateSyncState(ctx, userID, "2023-01-01", true); err != nil { t.Fatalf("UpdateSyncState: %v", err) } - state, err = db.GetSyncState(ctx) + state, err = db.GetSyncState(ctx, userID) if err != nil { t.Fatalf("GetSyncState after update: %v", err) } diff --git a/backend/internal/store/users.go b/backend/internal/store/users.go new file mode 100644 index 0000000..601c0ef --- /dev/null +++ b/backend/internal/store/users.go @@ -0,0 +1,133 @@ +package store + +import ( + "context" + "database/sql" + "fmt" +) + +// User is one geniusrun account, bound 1:1 to an OIDC subject. Every +// synced/classified dataset (profile, workout kinds, activities, sync +// state) is scoped to exactly one User -- see +// docs/superpowers/specs/2026-07-25-per-user-profile-design.md. +type User struct { + ID int64 + OIDCSub string + DisplayName string + CreatedAt string +} + +// CreateUser inserts a bare users row. Most callers want ProvisionUser +// instead, which also seeds the profile/taxonomy/sync-state a fresh account +// needs; CreateUser alone exists for ClaimLegacyOwner (Task 3), which +// attaches an *existing* profile/taxonomy rather than seeding new ones. +func (db *DB) CreateUser(ctx context.Context, oidcSub, displayName string) (int64, error) { + res, err := db.ExecContext(ctx, `INSERT INTO users (oidc_sub, display_name) VALUES (?, ?)`, oidcSub, displayName) + if err != nil { + return 0, fmt.Errorf("create user %q: %w", oidcSub, err) + } + return res.LastInsertId() +} + +// GetUserBySub looks up a user by their OIDC subject -- the only lookup key +// the session-resolution middleware (Task 11) ever uses. +func (db *DB) GetUserBySub(ctx context.Context, oidcSub string) (User, bool, error) { + var u User + err := db.QueryRowContext(ctx, `SELECT id, oidc_sub, display_name, created_at FROM users WHERE oidc_sub = ?`, oidcSub). + Scan(&u.ID, &u.OIDCSub, &u.DisplayName, &u.CreatedAt) + if err == sql.ErrNoRows { + return User{}, false, nil + } + if err != nil { + return User{}, false, fmt.Errorf("get user by sub: %w", err) + } + return u, true, nil +} + +// ListUsers returns every provisioned user, for the background incremental +// sync loop (Task 13) to iterate. +func (db *DB) ListUsers(ctx context.Context) ([]User, error) { + rows, err := db.QueryContext(ctx, `SELECT id, oidc_sub, display_name, created_at FROM users ORDER BY id`) + if err != nil { + return nil, fmt.Errorf("list users: %w", err) + } + defer rows.Close() + + users := []User{} + for rows.Next() { + var u User + if err := rows.Scan(&u.ID, &u.OIDCSub, &u.DisplayName, &u.CreatedAt); err != nil { + return nil, fmt.Errorf("scan user row: %w", err) + } + users = append(users, u) + } + return users, rows.Err() +} + +// neverMatchRule is the same placeholder every fresh install's rule-engine +// kinds start with (migration 0004) -- every activity lands in needs_review +// until the user tunes real rules. +const neverMatchRule = `{"match":"all","conditions":[{"metric":"distance_meters","op":"<","value":0}]}` + +// defaultWorkoutKindSeeds mirrors the 8 kinds migrations 0004/0007 seed for +// a fresh install, so a newly-provisioned user starts with the same +// taxonomy instead of an empty one. +var defaultWorkoutKindSeeds = []WorkoutKind{ + {Name: "Easy", Color: "#22c55e", RuleJSON: neverMatchRule, Priority: 80, IsActive: true}, + {Name: "Long", Color: "#3b82f6", RuleJSON: neverMatchRule, Priority: 70, IsActive: true}, + {Name: "60' Threshold", Color: "#f59e0b", RuleJSON: neverMatchRule, Priority: 60, IsActive: true}, + {Name: "30' Threshold", Color: "#f59e0b", RuleJSON: neverMatchRule, Priority: 50, IsActive: true}, + {Name: "Tempo", Color: "#eab308", RuleJSON: neverMatchRule, Priority: 40, IsActive: true}, + {Name: "Intervals", Color: "#ef4444", RuleJSON: neverMatchRule, Priority: 30, IsActive: true}, + {Name: "MAS Test", Color: "#a855f7", RuleJSON: neverMatchRule, Priority: 20, IsActive: true}, + {Name: "Race", Color: "#dc2626", RuleJSON: `{"match":"all","conditions":[{"metric":"is_race","op":"==","value":true}]}`, Priority: 10, IsActive: true}, +} + +// ProvisionUser creates a brand-new geniusrun account for an OIDC subject: +// the users row, a default profile (name defaulted to displayName), the 8 +// default workout kinds (with their paired workout_type_paces rows), and an +// initial sync_state row -- all in one transaction, so a partially +// provisioned user is never observable. +func (db *DB) ProvisionUser(ctx context.Context, oidcSub, displayName string) (int64, error) { + tx, err := db.BeginTx(ctx, nil) + if err != nil { + return 0, fmt.Errorf("begin provision user tx: %w", err) + } + defer tx.Rollback() + + res, err := tx.ExecContext(ctx, `INSERT INTO users (oidc_sub, display_name) VALUES (?, ?)`, oidcSub, displayName) + if err != nil { + return 0, fmt.Errorf("create user %q: %w", oidcSub, err) + } + userID, err := res.LastInsertId() + if err != nil { + return 0, err + } + + if _, err := tx.ExecContext(ctx, `INSERT INTO profile (user_id, name) VALUES (?, ?)`, userID, displayName); err != nil { + return 0, fmt.Errorf("create profile for user %d: %w", userID, err) + } + + for _, k := range defaultWorkoutKindSeeds { + res, err := tx.ExecContext(ctx, ` + INSERT INTO workout_kinds (user_id, name, description, color, rule_json, priority, is_active) + VALUES (?,?,?,?,?,?,?)`, + userID, k.Name, k.Description, k.Color, k.RuleJSON, k.Priority, k.IsActive) + if err != nil { + return 0, fmt.Errorf("seed workout kind %q for user %d: %w", k.Name, userID, err) + } + kindID, err := res.LastInsertId() + if err != nil { + return 0, err + } + if _, err := tx.ExecContext(ctx, `INSERT INTO workout_type_paces (workout_kind_id) VALUES (?)`, kindID); err != nil { + return 0, fmt.Errorf("seed workout type pace for kind %d: %w", kindID, err) + } + } + + if _, err := tx.ExecContext(ctx, `INSERT INTO sync_state (user_id, earliest_synced_date, backfill_complete) VALUES (?, NULL, 0)`, userID); err != nil { + return 0, fmt.Errorf("create sync state for user %d: %w", userID, err) + } + + return userID, tx.Commit() +} diff --git a/backend/internal/store/users_test.go b/backend/internal/store/users_test.go new file mode 100644 index 0000000..d44e8f3 --- /dev/null +++ b/backend/internal/store/users_test.go @@ -0,0 +1,91 @@ +package store + +import ( + "context" + "testing" +) + +func TestGetUserBySub_NotFoundReturnsFalseNotError(t *testing.T) { + db := openTestDB(t) + _, found, err := db.GetUserBySub(context.Background(), "no-such-sub") + if err != nil { + t.Fatalf("GetUserBySub: %v", err) + } + if found { + t.Fatal("expected found=false for an unknown sub") + } +} + +func TestProvisionUser_SeedsProfileTaxonomyAndSyncState(t *testing.T) { + db := openTestDB(t) + ctx := context.Background() + + userID, err := db.ProvisionUser(ctx, "sub-123", "Lucie") + if err != nil { + t.Fatalf("ProvisionUser: %v", err) + } + + u, found, err := db.GetUserBySub(ctx, "sub-123") + if err != nil || !found { + t.Fatalf("GetUserBySub: found=%v err=%v", found, err) + } + if u.ID != userID || u.DisplayName != "Lucie" { + t.Fatalf("got %+v, want ID=%d DisplayName=Lucie", u, userID) + } + + profile, err := db.GetProfile(ctx, userID) + if err != nil { + t.Fatalf("GetProfile: %v", err) + } + if profile.Name != "Lucie" { + t.Errorf("profile.Name = %q, want %q", profile.Name, "Lucie") + } + if profile.RollingWindowDays != 90 { + t.Errorf("profile.RollingWindowDays = %d, want 90 (default)", profile.RollingWindowDays) + } + + kinds, err := db.ListWorkoutKinds(ctx, userID, false) + if err != nil { + t.Fatalf("ListWorkoutKinds: %v", err) + } + if len(kinds) != 8 { + t.Fatalf("expected 8 seeded workout kinds, got %d", len(kinds)) + } + + state, err := db.GetSyncState(ctx, userID) + if err != nil { + t.Fatalf("GetSyncState: %v", err) + } + if state.EarliestSyncedDate != nil || state.BackfillComplete { + t.Errorf("expected fresh sync state, got %+v", state) + } +} + +func TestProvisionUser_TwoUsersGetIndependentTaxonomies(t *testing.T) { + db := openTestDB(t) + ctx := context.Background() + + userA, err := db.ProvisionUser(ctx, "sub-a", "A") + if err != nil { + t.Fatalf("ProvisionUser(a): %v", err) + } + userB, err := db.ProvisionUser(ctx, "sub-b", "B") + if err != nil { + t.Fatalf("ProvisionUser(b): %v", err) + } + + kindsA, err := db.ListWorkoutKinds(ctx, userA, false) + if err != nil { + t.Fatalf("ListWorkoutKinds(a): %v", err) + } + kindsB, err := db.ListWorkoutKinds(ctx, userB, false) + if err != nil { + t.Fatalf("ListWorkoutKinds(b): %v", err) + } + if len(kindsA) != 8 || len(kindsB) != 8 { + t.Fatalf("expected 8 kinds each, got a=%d b=%d", len(kindsA), len(kindsB)) + } + if kindsA[0].ID == kindsB[0].ID { + t.Fatal("expected each user's seeded kinds to be distinct rows") + } +} diff --git a/backend/internal/store/workoutkinds.go b/backend/internal/store/workoutkinds.go index 5d062b7..0d40e43 100644 --- a/backend/internal/store/workoutkinds.go +++ b/backend/internal/store/workoutkinds.go @@ -29,67 +29,69 @@ func scanWorkoutKind(row interface{ Scan(...any) error }) (WorkoutKind, error) { const workoutKindColumns = `id, name, description, color, rule_json, priority, is_active, created_at, updated_at` // CreateWorkoutKind inserts a new workout kind and returns its id. -func (db *DB) CreateWorkoutKind(ctx context.Context, k WorkoutKind) (int64, error) { +func (db *DB) CreateWorkoutKind(ctx context.Context, userID int64, k WorkoutKind) (int64, error) { res, err := db.ExecContext(ctx, ` - INSERT INTO workout_kinds (name, description, color, rule_json, priority, is_active) - VALUES (?,?,?,?,?,?)`, - k.Name, k.Description, k.Color, k.RuleJSON, k.Priority, k.IsActive) + INSERT INTO workout_kinds (user_id, name, description, color, rule_json, priority, is_active) + VALUES (?,?,?,?,?,?,?)`, + userID, k.Name, k.Description, k.Color, k.RuleJSON, k.Priority, k.IsActive) if err != nil { - return 0, fmt.Errorf("create workout kind %q: %w", k.Name, err) + return 0, fmt.Errorf("create workout kind %q for user %d: %w", k.Name, userID, err) } return res.LastInsertId() } -// UpdateWorkoutKind updates an existing workout kind's editable fields. -func (db *DB) UpdateWorkoutKind(ctx context.Context, k WorkoutKind) error { +// UpdateWorkoutKind updates an existing workout kind's editable fields, +// scoped so it can only ever affect a row owned by userID. +func (db *DB) UpdateWorkoutKind(ctx context.Context, userID int64, k WorkoutKind) error { _, err := db.ExecContext(ctx, ` UPDATE workout_kinds SET name=?, description=?, color=?, rule_json=?, priority=?, is_active=?, updated_at=datetime('now') - WHERE id=?`, - k.Name, k.Description, k.Color, k.RuleJSON, k.Priority, k.IsActive, k.ID) + WHERE id=? AND user_id=?`, + k.Name, k.Description, k.Color, k.RuleJSON, k.Priority, k.IsActive, k.ID, userID) if err != nil { - return fmt.Errorf("update workout kind %d: %w", k.ID, err) + return fmt.Errorf("update workout kind %d for user %d: %w", k.ID, userID, err) } return nil } -// GetWorkoutKind fetches one workout kind by id. -func (db *DB) GetWorkoutKind(ctx context.Context, id int64) (WorkoutKind, bool, error) { - row := db.QueryRowContext(ctx, `SELECT `+workoutKindColumns+` FROM workout_kinds WHERE id = ?`, id) +// GetWorkoutKind fetches one workout kind by id, scoped to userID. +func (db *DB) GetWorkoutKind(ctx context.Context, userID, id int64) (WorkoutKind, bool, error) { + row := db.QueryRowContext(ctx, `SELECT `+workoutKindColumns+` FROM workout_kinds WHERE id = ? AND user_id = ?`, id, userID) k, err := scanWorkoutKind(row) if err == sql.ErrNoRows { return WorkoutKind{}, false, nil } if err != nil { - return WorkoutKind{}, false, fmt.Errorf("get workout kind %d: %w", id, err) + return WorkoutKind{}, false, fmt.Errorf("get workout kind %d for user %d: %w", id, userID, err) } return k, true, nil } -// GetWorkoutKindByName fetches one workout kind by its unique name. -func (db *DB) GetWorkoutKindByName(ctx context.Context, name string) (WorkoutKind, bool, error) { - row := db.QueryRowContext(ctx, `SELECT `+workoutKindColumns+` FROM workout_kinds WHERE name = ?`, name) +// GetWorkoutKindByName fetches one workout kind by name, scoped to userID +// (the same name can exist for different users -- see UNIQUE(user_id, name)). +func (db *DB) GetWorkoutKindByName(ctx context.Context, userID int64, name string) (WorkoutKind, bool, error) { + row := db.QueryRowContext(ctx, `SELECT `+workoutKindColumns+` FROM workout_kinds WHERE name = ? AND user_id = ?`, name, userID) k, err := scanWorkoutKind(row) if err == sql.ErrNoRows { return WorkoutKind{}, false, nil } if err != nil { - return WorkoutKind{}, false, fmt.Errorf("get workout kind %q: %w", name, err) + return WorkoutKind{}, false, fmt.Errorf("get workout kind %q for user %d: %w", name, userID, err) } return k, true, nil } -// ListWorkoutKinds returns workout kinds. If activeOnly, soft-deleted +// ListWorkoutKinds returns workout kinds for userID. If activeOnly, soft-deleted // (is_active=0) kinds are excluded. -func (db *DB) ListWorkoutKinds(ctx context.Context, activeOnly bool) ([]WorkoutKind, error) { - query := `SELECT ` + workoutKindColumns + ` FROM workout_kinds` +func (db *DB) ListWorkoutKinds(ctx context.Context, userID int64, activeOnly bool) ([]WorkoutKind, error) { + query := `SELECT ` + workoutKindColumns + ` FROM workout_kinds WHERE user_id = ?` if activeOnly { - query += ` WHERE is_active = 1` + query += ` AND is_active = 1` } query += ` ORDER BY priority DESC, name` - rows, err := db.QueryContext(ctx, query) + rows, err := db.QueryContext(ctx, query, userID) if err != nil { - return nil, fmt.Errorf("list workout kinds: %w", err) + return nil, fmt.Errorf("list workout kinds for user %d: %w", userID, err) } defer rows.Close() @@ -105,11 +107,11 @@ func (db *DB) ListWorkoutKinds(ctx context.Context, activeOnly bool) ([]WorkoutK } // SoftDeleteWorkoutKind sets is_active=0, keeping history (kind_assignments -// referencing it) intact. -func (db *DB) SoftDeleteWorkoutKind(ctx context.Context, id int64) error { - _, err := db.ExecContext(ctx, `UPDATE workout_kinds SET is_active=0, updated_at=datetime('now') WHERE id=?`, id) +// referencing it) intact. Scoped to userID. +func (db *DB) SoftDeleteWorkoutKind(ctx context.Context, userID, id int64) error { + _, err := db.ExecContext(ctx, `UPDATE workout_kinds SET is_active=0, updated_at=datetime('now') WHERE id=? AND user_id=?`, id, userID) if err != nil { - return fmt.Errorf("soft delete workout kind %d: %w", id, err) + return fmt.Errorf("soft delete workout kind %d for user %d: %w", id, userID, err) } return nil } diff --git a/backend/internal/store/workoutkinds_taxonomy_test.go b/backend/internal/store/workoutkinds_taxonomy_test.go index bb50cb4..cc92ab5 100644 --- a/backend/internal/store/workoutkinds_taxonomy_test.go +++ b/backend/internal/store/workoutkinds_taxonomy_test.go @@ -8,8 +8,12 @@ import ( func TestWorkoutTaxonomy_SeededWithEightFixedTypes(t *testing.T) { db := openTestDB(t) ctx := context.Background() + userID, err := db.ProvisionUser(ctx, "test-sub", "Test User") + if err != nil { + t.Fatalf("ProvisionUser: %v", err) + } - kinds, err := db.ListWorkoutKinds(ctx, true) + kinds, err := db.ListWorkoutKinds(ctx, userID, true) if err != nil { t.Fatalf("ListWorkoutKinds: %v", err) } @@ -42,8 +46,12 @@ func TestWorkoutTaxonomy_SeededWithEightFixedTypes(t *testing.T) { func TestWorkoutTaxonomy_ListedInFixedDisplayOrder(t *testing.T) { db := openTestDB(t) ctx := context.Background() + userID, err := db.ProvisionUser(ctx, "test-sub", "Test User") + if err != nil { + t.Fatalf("ProvisionUser: %v", err) + } - kinds, err := db.ListWorkoutKinds(ctx, true) + kinds, err := db.ListWorkoutKinds(ctx, userID, true) if err != nil { t.Fatalf("ListWorkoutKinds: %v", err) } diff --git a/backend/internal/store/workoutpaces.go b/backend/internal/store/workoutpaces.go index 13ddfe7..2eed3fb 100644 --- a/backend/internal/store/workoutpaces.go +++ b/backend/internal/store/workoutpaces.go @@ -9,7 +9,9 @@ import ( // WorkoutTypePace is a workout kind's user-declared target pace range and HR // range (percent of heart rate reserve). Informational only -- never read by // the classification rule engine. No history: fields are overwritten in -// place. +// place. Has no user_id column of its own -- ownership is checked via a +// join to workout_kinds.user_id, since it's always accessed 1:1 through a +// specific workout kind. type WorkoutTypePace struct { WorkoutKindID int64 PaceMinSecPerKm *float64 @@ -24,38 +26,49 @@ func scanWorkoutTypePace(row interface{ Scan(...any) error }) (WorkoutTypePace, return p, err } -const workoutTypePaceColumns = `workout_kind_id, pace_min_sec_per_km, pace_max_sec_per_km, hr_min_pct_hrr, hr_max_pct_hrr` +const workoutTypePaceColumns = `wtp.workout_kind_id, wtp.pace_min_sec_per_km, wtp.pace_max_sec_per_km, wtp.hr_min_pct_hrr, wtp.hr_max_pct_hrr` -// GetWorkoutTypePace fetches the pace/zone row for one workout kind. -func (db *DB) GetWorkoutTypePace(ctx context.Context, workoutKindID int64) (WorkoutTypePace, error) { - row := db.QueryRowContext(ctx, `SELECT `+workoutTypePaceColumns+` FROM workout_type_paces WHERE workout_kind_id = ?`, workoutKindID) +// GetWorkoutTypePace fetches the pace/zone row for one workout kind, scoped +// to userID via a join to workout_kinds. +func (db *DB) GetWorkoutTypePace(ctx context.Context, userID, workoutKindID int64) (WorkoutTypePace, error) { + row := db.QueryRowContext(ctx, ` + SELECT `+workoutTypePaceColumns+` + FROM workout_type_paces wtp + JOIN workout_kinds wk ON wk.id = wtp.workout_kind_id + WHERE wtp.workout_kind_id = ? AND wk.user_id = ?`, workoutKindID, userID) p, err := scanWorkoutTypePace(row) if err == sql.ErrNoRows { return WorkoutTypePace{WorkoutKindID: workoutKindID}, nil } if err != nil { - return WorkoutTypePace{}, fmt.Errorf("get workout type pace for kind %d: %w", workoutKindID, err) + return WorkoutTypePace{}, fmt.Errorf("get workout type pace for kind %d (user %d): %w", workoutKindID, userID, err) } return p, nil } -// UpdateWorkoutTypePace overwrites the pace/zone row for one workout kind. -func (db *DB) UpdateWorkoutTypePace(ctx context.Context, p WorkoutTypePace) error { +// UpdateWorkoutTypePace overwrites the pace/zone row for one workout kind, +// scoped so it can only ever affect a kind owned by userID. +func (db *DB) UpdateWorkoutTypePace(ctx context.Context, userID int64, p WorkoutTypePace) error { _, err := db.ExecContext(ctx, ` UPDATE workout_type_paces SET pace_min_sec_per_km=?, pace_max_sec_per_km=?, hr_min_pct_hrr=?, hr_max_pct_hrr=? - WHERE workout_kind_id=?`, - p.PaceMinSecPerKm, p.PaceMaxSecPerKm, p.HRMinPctHRR, p.HRMaxPctHRR, p.WorkoutKindID) + WHERE workout_kind_id=? AND workout_kind_id IN (SELECT id FROM workout_kinds WHERE user_id=?)`, + p.PaceMinSecPerKm, p.PaceMaxSecPerKm, p.HRMinPctHRR, p.HRMaxPctHRR, p.WorkoutKindID, userID) if err != nil { - return fmt.Errorf("update workout type pace for kind %d: %w", p.WorkoutKindID, err) + return fmt.Errorf("update workout type pace for kind %d (user %d): %w", p.WorkoutKindID, userID, err) } return nil } -// ListWorkoutTypePaces returns every workout kind's pace/zone row. -func (db *DB) ListWorkoutTypePaces(ctx context.Context) ([]WorkoutTypePace, error) { - rows, err := db.QueryContext(ctx, `SELECT `+workoutTypePaceColumns+` FROM workout_type_paces ORDER BY workout_kind_id`) +// ListWorkoutTypePaces returns every one of userID's workout kinds' pace/zone rows. +func (db *DB) ListWorkoutTypePaces(ctx context.Context, userID int64) ([]WorkoutTypePace, error) { + rows, err := db.QueryContext(ctx, ` + SELECT `+workoutTypePaceColumns+` + FROM workout_type_paces wtp + JOIN workout_kinds wk ON wk.id = wtp.workout_kind_id + WHERE wk.user_id = ? + ORDER BY wtp.workout_kind_id`, userID) if err != nil { - return nil, fmt.Errorf("list workout type paces: %w", err) + return nil, fmt.Errorf("list workout type paces for user %d: %w", userID, err) } defer rows.Close() diff --git a/backend/internal/store/workoutpaces_test.go b/backend/internal/store/workoutpaces_test.go index 42f6c17..7196816 100644 --- a/backend/internal/store/workoutpaces_test.go +++ b/backend/internal/store/workoutpaces_test.go @@ -9,7 +9,12 @@ func TestWorkoutTypePaces_SeededOnePerKindThenUpdate(t *testing.T) { db := openTestDB(t) ctx := context.Background() - all, err := db.ListWorkoutTypePaces(ctx) + userID, err := db.ProvisionUser(ctx, "test-sub", "Test") + if err != nil { + t.Fatalf("ProvisionUser: %v", err) + } + + all, err := db.ListWorkoutTypePaces(ctx, userID) if err != nil { t.Fatalf("ListWorkoutTypePaces: %v", err) } @@ -29,11 +34,11 @@ func TestWorkoutTypePaces_SeededOnePerKindThenUpdate(t *testing.T) { target.HRMinPctHRR = &hrMin target.HRMaxPctHRR = &hrMax - if err := db.UpdateWorkoutTypePace(ctx, target); err != nil { + if err := db.UpdateWorkoutTypePace(ctx, userID, target); err != nil { t.Fatalf("UpdateWorkoutTypePace: %v", err) } - got, err := db.GetWorkoutTypePace(ctx, target.WorkoutKindID) + got, err := db.GetWorkoutTypePace(ctx, userID, target.WorkoutKindID) if err != nil { t.Fatalf("GetWorkoutTypePace: %v", err) } diff --git a/backend/internal/sync/service.go b/backend/internal/sync/service.go index b921544..080c713 100644 --- a/backend/internal/sync/service.go +++ b/backend/internal/sync/service.go @@ -61,10 +61,12 @@ type Progress struct { Total int } -// Service is the sync orchestrator. +// Service is the sync orchestrator, scoped to one user -- every store call +// it makes is for userID's data only. type Service struct { garmin garmin.Client db *store.DB + userID int64 cfg Config now func() time.Time @@ -72,13 +74,13 @@ type Service struct { progress Progress } -// NewService builds a Service. now defaults to time.Now if nil (tests can -// override it for deterministic date windows). -func NewService(g garmin.Client, db *store.DB, cfg Config, now func() time.Time) *Service { +// NewService builds a Service scoped to userID. now defaults to time.Now if +// nil (tests can override it for deterministic date windows). +func NewService(g garmin.Client, db *store.DB, userID int64, cfg Config, now func() time.Time) *Service { if now == nil { now = time.Now } - return &Service{garmin: g, db: db, cfg: cfg.withDefaults(), now: now} + return &Service{garmin: g, db: db, userID: userID, cfg: cfg.withDefaults(), now: now} } // Progress returns the current detail-fill progress (0/0 when idle). @@ -106,7 +108,7 @@ func (s *Service) setProgress(done, total int) { // history. Widening the horizon between calls resumes further back instead // of re-fetching everything. func (s *Service) Backfill(ctx context.Context) error { - runID, err := s.db.StartSyncRun(ctx, store.SyncKindBackfill) + runID, err := s.db.StartSyncRun(ctx, s.userID, store.SyncKindBackfill) if err != nil { return err } @@ -114,10 +116,10 @@ func (s *Service) Backfill(ctx context.Context) error { total, err := s.backfillCore(ctx) if err != nil { msg := err.Error() - s.db.FinishSyncRun(ctx, runID, total, &msg) + s.db.FinishSyncRun(ctx, s.userID, runID, total, &msg) return err } - return s.db.FinishSyncRun(ctx, runID, total, nil) + return s.db.FinishSyncRun(ctx, s.userID, runID, total, nil) } // backfillCore holds Backfill's actual fetch logic, without the SyncRun @@ -126,13 +128,13 @@ func (s *Service) Backfill(ctx context.Context) error { // whatever was fetched even when an error is also returned, matching // Backfill's own partial-progress-on-error behavior. func (s *Service) backfillCore(ctx context.Context) (int, error) { - profile, err := s.db.GetProfile(ctx) + profile, err := s.db.GetProfile(ctx, s.userID) if err != nil { return 0, fmt.Errorf("load profile: %w", err) } horizon := s.now().AddDate(0, 0, -profile.BackfillHorizonDays) - state, err := s.db.GetSyncState(ctx) + state, err := s.db.GetSyncState(ctx, s.userID) if err != nil { return 0, err } @@ -167,12 +169,12 @@ func (s *Service) backfillCore(ctx context.Context) (int, error) { // Empty page: reached the start of this account's history, // regardless of the configured horizon. reachedStartOfHistory = true - if err := s.db.UpdateSyncState(ctx, dateStr(start), true); err != nil { + if err := s.db.UpdateSyncState(ctx, s.userID, dateStr(start), true); err != nil { return total, err } break } - if err := s.db.UpdateSyncState(ctx, dateStr(start), false); err != nil { + if err := s.db.UpdateSyncState(ctx, s.userID, dateStr(start), false); err != nil { return total, err } end = start.AddDate(0, 0, -1) @@ -181,7 +183,7 @@ func (s *Service) backfillCore(ctx context.Context) (int, error) { if !reachedStartOfHistory { // Reached the configured horizon (not Garmin's actual history // start) -- mark complete relative to that horizon. - if err := s.db.UpdateSyncState(ctx, dateStr(horizon), true); err != nil { + if err := s.db.UpdateSyncState(ctx, s.userID, dateStr(horizon), true); err != nil { return total, err } } @@ -192,7 +194,7 @@ func (s *Service) backfillCore(ctx context.Context) (int, error) { // IncrementalSync fetches activities from just before the latest known // activity (or a short recent window if none exist yet) through today. func (s *Service) IncrementalSync(ctx context.Context) error { - runID, err := s.db.StartSyncRun(ctx, store.SyncKindIncremental) + runID, err := s.db.StartSyncRun(ctx, s.userID, store.SyncKindIncremental) if err != nil { return err } @@ -200,17 +202,17 @@ func (s *Service) IncrementalSync(ctx context.Context) error { n, err := s.incrementalSyncCore(ctx) if err != nil { msg := err.Error() - s.db.FinishSyncRun(ctx, runID, n, &msg) + s.db.FinishSyncRun(ctx, s.userID, runID, n, &msg) return err } - return s.db.FinishSyncRun(ctx, runID, n, nil) + return s.db.FinishSyncRun(ctx, s.userID, runID, n, nil) } // incrementalSyncCore holds IncrementalSync's actual fetch logic, without // the SyncRun bookkeeping -- see backfillCore. func (s *Service) incrementalSyncCore(ctx context.Context) (int, error) { start := s.now().AddDate(0, 0, -s.cfg.IncrementalOverlapDays) - if latest, ok, err := s.db.LatestActivityStartTime(ctx); err == nil && ok { + if latest, ok, err := s.db.LatestActivityStartTime(ctx, s.userID); err == nil && ok { if t, err := time.Parse("2006-01-02 15:04:05", latest); err == nil { start = t.AddDate(0, 0, -s.cfg.IncrementalOverlapDays) } @@ -229,7 +231,7 @@ func (s *Service) incrementalSyncCore(ctx context.Context) (int, error) { // would silently hide however many activities Backfill fetched. Recording // one combined run makes the reported count match the whole action. func (s *Service) FullSync(ctx context.Context, detailFillLimit int) error { - runID, err := s.db.StartSyncRun(ctx, store.SyncKindFull) + runID, err := s.db.StartSyncRun(ctx, s.userID, store.SyncKindFull) if err != nil { return err } @@ -237,7 +239,7 @@ func (s *Service) FullSync(ctx context.Context, detailFillLimit int) error { backfillCount, err := s.backfillCore(ctx) if err != nil { msg := err.Error() - s.db.FinishSyncRun(ctx, runID, backfillCount, &msg) + s.db.FinishSyncRun(ctx, s.userID, runID, backfillCount, &msg) return err } @@ -245,17 +247,17 @@ func (s *Service) FullSync(ctx context.Context, detailFillLimit int) error { total := backfillCount + incrementalCount if err != nil { msg := err.Error() - s.db.FinishSyncRun(ctx, runID, total, &msg) + s.db.FinishSyncRun(ctx, s.userID, runID, total, &msg) return err } if err := s.FillPendingDetails(ctx, detailFillLimit); err != nil { msg := err.Error() - s.db.FinishSyncRun(ctx, runID, total, &msg) + s.db.FinishSyncRun(ctx, s.userID, runID, total, &msg) return err } - return s.db.FinishSyncRun(ctx, runID, total, nil) + return s.db.FinishSyncRun(ctx, s.userID, runID, total, nil) } // ResetAll deletes every synced activity (and its laps/samples/kind @@ -263,7 +265,7 @@ func (s *Service) FullSync(ctx context.Context, detailFillLimit int) error { // call performs a genuinely fresh pull from Garmin instead of resuming from // wherever the previous one left off. Workout kinds are left untouched. func (s *Service) ResetAll(ctx context.Context) error { - return s.db.ResetAllSyncedData(ctx) + return s.db.ResetAllSyncedData(ctx, s.userID) } // fetchAndStoreWindow returns two counts: rawCount is every activity Garmin's @@ -290,11 +292,11 @@ func (s *Service) fetchAndStoreWindow(ctx context.Context, startDate, endDate st if !isRunningActivityType(a.ActivityType.TypeKey) { continue } - exists, err := s.db.ActivityExists(ctx, a.ActivityID) + exists, err := s.db.ActivityExists(ctx, s.userID, a.ActivityID) if err != nil { return 0, 0, err } - if _, err := s.db.UpsertActivity(ctx, toActivityRow(a)); err != nil { + if _, err := s.db.UpsertActivity(ctx, s.userID, toActivityRow(a)); err != nil { return 0, 0, fmt.Errorf("store activity %d: %w", a.ActivityID, err) } if !exists { @@ -309,11 +311,11 @@ func (s *Service) fetchAndStoreWindow(ctx context.Context, startDate, endDate st // Calls are made sequentially with Config.InterCallDelay between them to // avoid Garmin/Cloudflare rate limiting. func (s *Service) FillPendingDetails(ctx context.Context, limit int) error { - pending, err := s.db.ActivitiesMissingDetails(ctx, limit) + pending, err := s.db.ActivitiesMissingDetails(ctx, s.userID, limit) if err != nil { return err } - profile, err := s.db.GetProfile(ctx) + profile, err := s.db.GetProfile(ctx, s.userID) if err != nil { return fmt.Errorf("load profile: %w", err) } @@ -357,41 +359,41 @@ func (s *Service) fillActivityDetails(ctx context.Context, a store.Activity, pro log.Printf("sync: get_workout_by_id(%d) for activity %d failed, continuing without target zones: %v", *a.WorkoutID, a.GarminActivityID, err) } else { targets = alignWorkoutTargets(splits.Laps, workout) - if err := s.db.SetActivityWorkout(ctx, a.ID, string(workout.Raw)); err != nil { + if err := s.db.SetActivityWorkout(ctx, s.userID, a.ID, string(workout.Raw)); err != nil { return err } } } samples := garmin.ExtractSamples(details) - if err := s.db.ReplaceActivitySamples(ctx, a.ID, toSampleRows(samples)); err != nil { + if err := s.db.ReplaceActivitySamples(ctx, s.userID, a.ID, toSampleRows(samples)); err != nil { return err } - if err := s.db.ReplaceLaps(ctx, a.ID, toLapRows(splits.Laps, samples, targets, profile)); err != nil { + if err := s.db.ReplaceLaps(ctx, s.userID, a.ID, toLapRows(splits.Laps, samples, targets, profile)); err != nil { return err } - if err := s.db.SetActivityDetails(ctx, a.ID, string(details.Raw)); err != nil { + if err := s.db.SetActivityDetails(ctx, s.userID, a.ID, string(details.Raw)); err != nil { return err } - return s.db.SetActivitySplitsFetched(ctx, a.ID) + return s.db.SetActivitySplitsFetched(ctx, s.userID, a.ID) } // ClassifyActivity (re)runs the rule engine for one activity against the // currently active workout kinds and appends a new kind_assignments row. // Safe to call repeatedly (e.g. after editing a workout kind's rule). func (s *Service) ClassifyActivity(ctx context.Context, activityID int64) error { - activity, ok, err := s.db.GetActivity(ctx, activityID) + activity, ok, err := s.db.GetActivity(ctx, s.userID, activityID) if err != nil { return err } if !ok { return fmt.Errorf("activity %d not found", activityID) } - laps, err := s.db.LapsForActivity(ctx, activityID) + laps, err := s.db.LapsForActivity(ctx, s.userID, activityID) if err != nil { return err } - kindRows, err := s.db.ListWorkoutKinds(ctx, true) + kindRows, err := s.db.ListWorkoutKinds(ctx, s.userID, true) if err != nil { return err } @@ -399,7 +401,7 @@ func (s *Service) ClassifyActivity(ctx context.Context, activityID int64) error if err != nil { return fmt.Errorf("parse workout kind rules: %w", err) } - profile, err := s.db.GetProfile(ctx) + profile, err := s.db.GetProfile(ctx, s.userID) if err != nil { return fmt.Errorf("load profile: %w", err) } @@ -416,7 +418,7 @@ func (s *Service) ClassifyActivity(ctx context.Context, activityID int64) error return err } - _, err = s.db.InsertKindAssignment(ctx, store.KindAssignment{ + _, err = s.db.InsertKindAssignment(ctx, s.userID, store.KindAssignment{ ActivityID: activityID, WorkoutKindID: result.WorkoutKindID, AssignmentSource: store.AssignmentSourceRuleEngine, diff --git a/backend/internal/sync/service_test.go b/backend/internal/sync/service_test.go index 471cd20..3b93bae 100644 --- a/backend/internal/sync/service_test.go +++ b/backend/internal/sync/service_test.go @@ -28,17 +28,26 @@ func fixedNow(t time.Time) func() time.Time { return func() time.Time { return t } } +func provisionTestUser(t *testing.T, db *store.DB) int64 { + t.Helper() + userID, err := db.ProvisionUser(context.Background(), "test-sub", "Test") + if err != nil { + t.Fatalf("ProvisionUser: %v", err) + } + return userID +} + // setBackfillHorizon sets Profile.BackfillHorizonDays, which Backfill reads // fresh on every call (it's no longer part of Config). -func setBackfillHorizon(t *testing.T, db *store.DB, days int) { +func setBackfillHorizon(t *testing.T, db *store.DB, userID int64, days int) { t.Helper() ctx := context.Background() - profile, err := db.GetProfile(ctx) + profile, err := db.GetProfile(ctx, userID) if err != nil { t.Fatalf("GetProfile: %v", err) } profile.BackfillHorizonDays = days - if err := db.UpdateProfile(ctx, profile); err != nil { + if err := db.UpdateProfile(ctx, userID, profile); err != nil { t.Fatalf("UpdateProfile: %v", err) } } @@ -46,18 +55,19 @@ func setBackfillHorizon(t *testing.T, db *store.DB, days int) { func TestBackfill_StoresActivitiesAndRecordsSyncRun(t *testing.T) { db := openTestDB(t) ctx := context.Background() + userID := provisionTestUser(t, db) m := &mock.Client{Activities: []garmin.Activity{ {ActivityID: 1, ActivityName: "Morning Run", ActivityType: garmin.ActivityType{TypeKey: "running"}, StartTimeGMT: "2026-07-01 06:00:00", Distance: 5000, Duration: 1500, AverageSpeed: 3.33, AverageHR: 145}, }} - svc := NewService(m, db, Config{}, fixedNow(time.Date(2026, 7, 11, 0, 0, 0, 0, time.UTC))) + svc := NewService(m, db, userID, Config{}, fixedNow(time.Date(2026, 7, 11, 0, 0, 0, 0, time.UTC))) if err := svc.Backfill(ctx); err != nil { t.Fatalf("Backfill: %v", err) } - activities, err := db.ListActivities(ctx, store.ActivityFilter{}) + activities, err := db.ListActivities(ctx, userID, store.ActivityFilter{}) if err != nil { t.Fatalf("ListActivities: %v", err) } @@ -68,7 +78,7 @@ func TestBackfill_StoresActivitiesAndRecordsSyncRun(t *testing.T) { t.Errorf("GarminActivityID = %d, want 1", activities[0].GarminActivityID) } - runs, err := db.ListSyncRuns(ctx, 10) + runs, err := db.ListSyncRuns(ctx, userID, 10) if err != nil { t.Fatalf("ListSyncRuns: %v", err) } @@ -188,6 +198,7 @@ func TestTargetHRRange_CustomRangeAndZoneNumberViaKarvonen(t *testing.T) { func TestBackfill_SkipsNonRunningActivities(t *testing.T) { db := openTestDB(t) ctx := context.Background() + userID := provisionTestUser(t, db) m := &mock.Client{Activities: []garmin.Activity{ {ActivityID: 1, ActivityType: garmin.ActivityType{TypeKey: "running"}, @@ -199,13 +210,13 @@ func TestBackfill_SkipsNonRunningActivities(t *testing.T) { {ActivityID: 4, ActivityType: garmin.ActivityType{TypeKey: "indoor_cycling"}, StartTimeGMT: "2026-07-04 06:00:00", Distance: 0, Duration: 1800}, }} - svc := NewService(m, db, Config{}, fixedNow(time.Date(2026, 7, 11, 0, 0, 0, 0, time.UTC))) + svc := NewService(m, db, userID, Config{}, fixedNow(time.Date(2026, 7, 11, 0, 0, 0, 0, time.UTC))) if err := svc.Backfill(ctx); err != nil { t.Fatalf("Backfill: %v", err) } - activities, err := db.ListActivities(ctx, store.ActivityFilter{}) + activities, err := db.ListActivities(ctx, userID, store.ActivityFilter{}) if err != nil { t.Fatalf("ListActivities: %v", err) } @@ -222,6 +233,7 @@ func TestBackfill_SkipsNonRunningActivities(t *testing.T) { func TestFillPendingDetailsAndClassify_EndToEnd(t *testing.T) { db := openTestDB(t) ctx := context.Background() + userID := provisionTestUser(t, db) const garminActivityID = 42 m := &mock.Client{ @@ -244,7 +256,7 @@ func TestFillPendingDetailsAndClassify_EndToEnd(t *testing.T) { }, }, } - svc := NewService(m, db, Config{MinConfidence: 0.5}, fixedNow(time.Date(2026, 7, 11, 0, 0, 0, 0, time.UTC))) + svc := NewService(m, db, userID, Config{MinConfidence: 0.5}, fixedNow(time.Date(2026, 7, 11, 0, 0, 0, 0, time.UTC))) if err := svc.Backfill(ctx); err != nil { t.Fatalf("Backfill: %v", err) @@ -252,7 +264,7 @@ func TestFillPendingDetailsAndClassify_EndToEnd(t *testing.T) { // A workout kind that should cleanly match the seeded activity's pace. ruleJSON := `{"match":"all","conditions":[{"metric":"avg_pace_sec_per_km","op":"between","value":[280,320]}]}` - if _, err := db.CreateWorkoutKind(ctx, store.WorkoutKind{Name: "Test Classification Tempo", RuleJSON: ruleJSON, IsActive: true}); err != nil { + if _, err := db.CreateWorkoutKind(ctx, userID, store.WorkoutKind{Name: "Test Classification Tempo", RuleJSON: ruleJSON, IsActive: true}); err != nil { t.Fatalf("CreateWorkoutKind: %v", err) } @@ -260,7 +272,7 @@ func TestFillPendingDetailsAndClassify_EndToEnd(t *testing.T) { t.Fatalf("FillPendingDetails: %v", err) } - activities, err := db.ListActivities(ctx, store.ActivityFilter{}) + activities, err := db.ListActivities(ctx, userID, store.ActivityFilter{}) if err != nil || len(activities) != 1 { t.Fatalf("ListActivities: %v, %+v", err, activities) } @@ -273,12 +285,12 @@ func TestFillPendingDetailsAndClassify_EndToEnd(t *testing.T) { t.Error("expected SplitsFetchedAt to be set after FillPendingDetails") } - laps, err := db.LapsForActivity(ctx, activityID) + laps, err := db.LapsForActivity(ctx, userID, activityID) if err != nil || len(laps) != 1 { t.Fatalf("LapsForActivity: %v, %+v", err, laps) } - assignment, ok, err := db.CurrentAssignment(ctx, activityID) + assignment, ok, err := db.CurrentAssignment(ctx, userID, activityID) if err != nil || !ok { t.Fatalf("CurrentAssignment: ok=%v err=%v", ok, err) } @@ -290,6 +302,7 @@ func TestFillPendingDetailsAndClassify_EndToEnd(t *testing.T) { func TestFillPendingDetails_ResolvesWorkoutTargetsOntoLaps(t *testing.T) { db := openTestDB(t) ctx := context.Background() + userID := provisionTestUser(t, db) const garminActivityID = 55 const workoutID = 999 @@ -313,7 +326,7 @@ func TestFillPendingDetails_ResolvesWorkoutTargetsOntoLaps(t *testing.T) { }}, }, } - svc := NewService(m, db, Config{}, fixedNow(time.Date(2026, 7, 11, 0, 0, 0, 0, time.UTC))) + svc := NewService(m, db, userID, Config{}, fixedNow(time.Date(2026, 7, 11, 0, 0, 0, 0, time.UTC))) if err := svc.Backfill(ctx); err != nil { t.Fatalf("Backfill: %v", err) @@ -322,11 +335,11 @@ func TestFillPendingDetails_ResolvesWorkoutTargetsOntoLaps(t *testing.T) { t.Fatalf("FillPendingDetails: %v", err) } - activities, err := db.ListActivities(ctx, store.ActivityFilter{}) + activities, err := db.ListActivities(ctx, userID, store.ActivityFilter{}) if err != nil || len(activities) != 1 { t.Fatalf("ListActivities: %v, %+v", err, activities) } - laps, err := db.LapsForActivity(ctx, activities[0].ID) + laps, err := db.LapsForActivity(ctx, userID, activities[0].ID) if err != nil || len(laps) != 1 { t.Fatalf("LapsForActivity: %v, %+v", err, laps) } @@ -341,6 +354,7 @@ func TestFillPendingDetails_ResolvesWorkoutTargetsOntoLaps(t *testing.T) { func TestFillPendingDetails_OneExtraTrailingLapKeepsOtherLapsTargets(t *testing.T) { db := openTestDB(t) ctx := context.Background() + userID := provisionTestUser(t, db) const garminActivityID = 56 const workoutID = 1000 @@ -369,7 +383,7 @@ func TestFillPendingDetails_OneExtraTrailingLapKeepsOtherLapsTargets(t *testing. }}, }, } - svc := NewService(m, db, Config{}, fixedNow(time.Date(2026, 7, 11, 0, 0, 0, 0, time.UTC))) + svc := NewService(m, db, userID, Config{}, fixedNow(time.Date(2026, 7, 11, 0, 0, 0, 0, time.UTC))) if err := svc.Backfill(ctx); err != nil { t.Fatalf("Backfill: %v", err) @@ -378,8 +392,8 @@ func TestFillPendingDetails_OneExtraTrailingLapKeepsOtherLapsTargets(t *testing. t.Fatalf("FillPendingDetails: %v", err) } - activities, _ := db.ListActivities(ctx, store.ActivityFilter{}) - laps, err := db.LapsForActivity(ctx, activities[0].ID) + activities, _ := db.ListActivities(ctx, userID, store.ActivityFilter{}) + laps, err := db.LapsForActivity(ctx, userID, activities[0].ID) if err != nil || len(laps) != 2 { t.Fatalf("LapsForActivity: %v, %+v", err, laps) } @@ -443,13 +457,14 @@ func TestBuildMetricContext_DerivesIsRace(t *testing.T) { func TestBackfill_SecondRunIsANoOpOnceHorizonFullyCovered(t *testing.T) { db := openTestDB(t) ctx := context.Background() + userID := provisionTestUser(t, db) m := &mock.Client{Activities: []garmin.Activity{ {ActivityID: 1, ActivityType: garmin.ActivityType{TypeKey: "running"}, StartTimeGMT: "2026-07-05 06:00:00", Distance: 5000, Duration: 1500}, }} - svc := NewService(m, db, Config{BackfillWindowDays: 10}, + svc := NewService(m, db, userID, Config{BackfillWindowDays: 10}, fixedNow(time.Date(2026, 7, 11, 0, 0, 0, 0, time.UTC))) - setBackfillHorizon(t, db, 10) + setBackfillHorizon(t, db, userID, 10) if err := svc.Backfill(ctx); err != nil { t.Fatalf("first Backfill: %v", err) @@ -459,7 +474,7 @@ func TestBackfill_SecondRunIsANoOpOnceHorizonFullyCovered(t *testing.T) { t.Fatal("expected first backfill to call GetActivities at least once") } - state, err := db.GetSyncState(ctx) + state, err := db.GetSyncState(ctx, userID) if err != nil { t.Fatalf("GetSyncState: %v", err) } @@ -479,13 +494,14 @@ func TestBackfill_SecondRunIsANoOpOnceHorizonFullyCovered(t *testing.T) { func TestResetAll_AllowsFreshBackfillAfterwards(t *testing.T) { db := openTestDB(t) ctx := context.Background() + userID := provisionTestUser(t, db) m := &mock.Client{Activities: []garmin.Activity{ {ActivityID: 1, ActivityType: garmin.ActivityType{TypeKey: "running"}, StartTimeGMT: "2026-07-05 06:00:00", Distance: 5000, Duration: 1500}, }} - svc := NewService(m, db, Config{BackfillWindowDays: 10}, + svc := NewService(m, db, userID, Config{BackfillWindowDays: 10}, fixedNow(time.Date(2026, 7, 11, 0, 0, 0, 0, time.UTC))) - setBackfillHorizon(t, db, 10) + setBackfillHorizon(t, db, userID, 10) if err := svc.Backfill(ctx); err != nil { t.Fatalf("first Backfill: %v", err) @@ -495,7 +511,7 @@ func TestResetAll_AllowsFreshBackfillAfterwards(t *testing.T) { if err := svc.ResetAll(ctx); err != nil { t.Fatalf("ResetAll: %v", err) } - activities, err := db.ListActivities(ctx, store.ActivityFilter{}) + activities, err := db.ListActivities(ctx, userID, store.ActivityFilter{}) if err != nil { t.Fatalf("ListActivities: %v", err) } @@ -509,7 +525,7 @@ func TestResetAll_AllowsFreshBackfillAfterwards(t *testing.T) { if m.GetActivitiesCalls <= firstCallCount { t.Errorf("expected Backfill after ResetAll to call GetActivities again (fresh pull), call count stayed at %d", m.GetActivitiesCalls) } - activities, err = db.ListActivities(ctx, store.ActivityFilter{}) + activities, err = db.ListActivities(ctx, userID, store.ActivityFilter{}) if err != nil { t.Fatalf("ListActivities after re-backfill: %v", err) } @@ -521,14 +537,15 @@ func TestResetAll_AllowsFreshBackfillAfterwards(t *testing.T) { func TestBackfill_ResumesFromWatermarkWhenHorizonGrows(t *testing.T) { db := openTestDB(t) ctx := context.Background() + userID := provisionTestUser(t, db) m := &mock.Client{Activities: []garmin.Activity{ {ActivityID: 1, ActivityType: garmin.ActivityType{TypeKey: "running"}, StartTimeGMT: "2026-07-05 06:00:00", Distance: 5000, Duration: 1500}, }} now := fixedNow(time.Date(2026, 7, 11, 0, 0, 0, 0, time.UTC)) - svc := NewService(m, db, Config{BackfillWindowDays: 10}, now) - setBackfillHorizon(t, db, 10) + svc := NewService(m, db, userID, Config{BackfillWindowDays: 10}, now) + setBackfillHorizon(t, db, userID, 10) if err := svc.Backfill(ctx); err != nil { t.Fatalf("first Backfill: %v", err) } @@ -537,8 +554,8 @@ func TestBackfill_ResumesFromWatermarkWhenHorizonGrows(t *testing.T) { // Simulate the user widening the horizon later -- should resume from the // watermark (not re-fetch the already-covered recent window) but still // make progress toward the new, deeper horizon. - setBackfillHorizon(t, db, 30) - svc2 := NewService(m, db, Config{BackfillWindowDays: 10}, now) + setBackfillHorizon(t, db, userID, 30) + svc2 := NewService(m, db, userID, Config{BackfillWindowDays: 10}, now) if err := svc2.Backfill(ctx); err != nil { t.Fatalf("second Backfill: %v", err) } @@ -546,7 +563,7 @@ func TestBackfill_ResumesFromWatermarkWhenHorizonGrows(t *testing.T) { t.Errorf("expected additional GetActivities calls when horizon grows, got %d total (was %d)", m.GetActivitiesCalls, firstCallCount) } - state, err := db.GetSyncState(ctx) + state, err := db.GetSyncState(ctx, userID) if err != nil { t.Fatalf("GetSyncState: %v", err) } @@ -558,6 +575,7 @@ func TestBackfill_ResumesFromWatermarkWhenHorizonGrows(t *testing.T) { func TestFullSync_RecordsOneCombinedSyncRun(t *testing.T) { db := openTestDB(t) ctx := context.Background() + userID := provisionTestUser(t, db) m := &mock.Client{ Activities: []garmin.Activity{ @@ -571,15 +589,15 @@ func TestFullSync_RecordsOneCombinedSyncRun(t *testing.T) { 1: {ActivityID: 1}, 2: {ActivityID: 2}, }, } - svc := NewService(m, db, Config{BackfillWindowDays: 10}, + svc := NewService(m, db, userID, Config{BackfillWindowDays: 10}, fixedNow(time.Date(2026, 7, 11, 0, 0, 0, 0, time.UTC))) - setBackfillHorizon(t, db, 10) + setBackfillHorizon(t, db, userID, 10) if err := svc.FullSync(ctx, 10); err != nil { t.Fatalf("FullSync: %v", err) } - runs, err := db.ListSyncRuns(ctx, 10) + runs, err := db.ListSyncRuns(ctx, userID, 10) if err != nil { t.Fatalf("ListSyncRuns: %v", err) } @@ -606,7 +624,7 @@ func TestFullSync_RecordsOneCombinedSyncRun(t *testing.T) { t.Errorf("ActivitiesFetched = %d, want 2 (genuinely new activities, deduped across stages)", run.ActivitiesFetched) } - activities, err := db.ListActivities(ctx, store.ActivityFilter{}) + activities, err := db.ListActivities(ctx, userID, store.ActivityFilter{}) if err != nil { t.Fatalf("ListActivities: %v", err) } @@ -618,6 +636,7 @@ func TestFullSync_RecordsOneCombinedSyncRun(t *testing.T) { func TestFillPendingDetails_ReportsLiveProgress(t *testing.T) { db := openTestDB(t) ctx := context.Background() + userID := provisionTestUser(t, db) m := &mock.Client{ Activities: []garmin.Activity{}, @@ -634,7 +653,7 @@ func TestFillPendingDetails_ReportsLiveProgress(t *testing.T) { m.Details[i] = garmin.ActivityDetails{ActivityID: i} } - svc := NewService(m, db, Config{InterCallDelay: 150 * time.Millisecond}, + svc := NewService(m, db, userID, Config{InterCallDelay: 150 * time.Millisecond}, fixedNow(time.Date(2026, 7, 11, 0, 0, 0, 0, time.UTC))) if err := svc.Backfill(ctx); err != nil { t.Fatalf("Backfill: %v", err) @@ -663,3 +682,47 @@ func TestFillPendingDetails_ReportsLiveProgress(t *testing.T) { t.Errorf("Progress after completion = %+v, want zero value (idle)", final) } } + +func TestService_TwoUsersSyncIndependently(t *testing.T) { + db := openTestDB(t) + ctx := context.Background() + userA, err := db.ProvisionUser(ctx, "sub-a", "A") + if err != nil { + t.Fatalf("ProvisionUser(a): %v", err) + } + userB, err := db.ProvisionUser(ctx, "sub-b", "B") + if err != nil { + t.Fatalf("ProvisionUser(b): %v", err) + } + + mA := &mock.Client{Activities: []garmin.Activity{ + {ActivityID: 1, ActivityType: garmin.ActivityType{TypeKey: "running"}, StartTimeGMT: "2026-07-01 06:00:00", Distance: 5000, Duration: 1500}, + }} + mB := &mock.Client{Activities: []garmin.Activity{ + {ActivityID: 2, ActivityType: garmin.ActivityType{TypeKey: "running"}, StartTimeGMT: "2026-07-01 06:00:00", Distance: 8000, Duration: 2400}, + }} + svcA := NewService(mA, db, userA, Config{}, fixedNow(time.Date(2026, 7, 11, 0, 0, 0, 0, time.UTC))) + svcB := NewService(mB, db, userB, Config{}, fixedNow(time.Date(2026, 7, 11, 0, 0, 0, 0, time.UTC))) + + if err := svcA.Backfill(ctx); err != nil { + t.Fatalf("Backfill(a): %v", err) + } + if err := svcB.Backfill(ctx); err != nil { + t.Fatalf("Backfill(b): %v", err) + } + + activitiesA, err := db.ListActivities(ctx, userA, store.ActivityFilter{}) + if err != nil { + t.Fatalf("ListActivities(a): %v", err) + } + activitiesB, err := db.ListActivities(ctx, userB, store.ActivityFilter{}) + if err != nil { + t.Fatalf("ListActivities(b): %v", err) + } + if len(activitiesA) != 1 || activitiesA[0].GarminActivityID != 1 { + t.Fatalf("userA's activities = %+v, want exactly garmin id 1", activitiesA) + } + if len(activitiesB) != 1 || activitiesB[0].GarminActivityID != 2 { + t.Fatalf("userB's activities = %+v, want exactly garmin id 2", activitiesB) + } +} diff --git a/docs/superpowers/plans/2026-07-25-per-user-profile.md b/docs/superpowers/plans/2026-07-25-per-user-profile.md new file mode 100644 index 0000000..c26f7fd --- /dev/null +++ b/docs/superpowers/plans/2026-07-25-per-user-profile.md @@ -0,0 +1,4691 @@ +# Per-user profile & data isolation Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Give every OIDC-authenticated user their own profile, Garmin credentials, and fully isolated synced/classified dataset, reversing the "single shared profile" design from `docs/superpowers/specs/2026-07-24-oidc-authentication-design.md`. + +**Architecture:** A new `users` table (keyed by OIDC `sub`) anchors per-user rows added to `profile`, `workout_kinds`, `activities`, `sync_state`, and `sync_runs` in the existing single SQLite database; `laps`/`activity_samples`/`kind_assignments`/`workout_type_paces` stay unscoped in schema but are always accessed via an ownership-checked join to their parent. `garmin.Client`/`sync.Service` become per-user instances built lazily and cached in `api.Server`. Every handler derives `user_id` only from the signed session cookie (never from client input) via a new context-based middleware. + +**Tech Stack:** Go (chi router, `database/sql` + `modernc.org/sqlite`), React/TypeScript frontend, existing OIDC session auth (`internal/auth`) reused unchanged. + +**Design reference:** `docs/superpowers/specs/2026-07-25-per-user-profile-design.md` — read it first for the full rationale; this plan implements it task-by-task. + +## Global Constraints + +- Every store method that touches `profile`/`workout_kinds`/`activities`/`sync_state`/`sync_runs` (or anything joined off them) takes an explicit `userID int64` parameter — never infer it from anything but the caller-supplied value, and every HTTP handler must obtain that value only from `userIDFromContext(r.Context())`, never a URL param, query string, or request body field. This is the entire security boundary of this feature. +- `gofmt -l .` must report nothing; `go build ./...` and `go vet ./...` must be clean before any task is considered done. +- Migrations are added as new numbered files in `backend/internal/store/migrations/`, never editing an already-applied one. +- No admin/impersonation path is introduced anywhere. No frontend profile switcher. +- `sync.Service` and `garmin.Client` are constructed per-user (one instance per logged-in user, cached), not per-request. + +--- + +## Task 1: Schema migration — `users` table + `user_id` scoping + +**Files:** +- Create: `backend/internal/store/migrations/0021_users_table.sql` +- Create: `backend/internal/store/migrations/0022_activities_and_syncruns_user_id.sql` +- Create: `backend/internal/store/migrations/0023_profile_user_scoped.sql` +- Create: `backend/internal/store/migrations/0024_workout_kinds_user_scoped.sql` +- Create: `backend/internal/store/migrations/0025_sync_state_user_scoped.sql` +- Test: `backend/internal/store/store_test.go` (extend `TestMigrateIsIdempotent`, add a new schema-shape test) + +**Interfaces:** +- Produces: a `users` table (`id`, `oidc_sub` UNIQUE, `display_name`, `created_at`); `profile.user_id` (nullable, `UNIQUE(user_id)`, no more `CHECK(id=1)`); `workout_kinds.user_id` (nullable, `UNIQUE(user_id, name)` replacing the old global `UNIQUE(name)`); `activities.user_id` (nullable) plus a new `UNIQUE(user_id, garmin_activity_id)` index; `sync_state.user_id` (nullable, `UNIQUE(user_id)`, no more `CHECK(id=1)`); `sync_runs.user_id` (nullable). +- `user_id` columns are deliberately nullable at the SQL level — migrations can't take runtime parameters, so the real owner of pre-existing rows isn't known yet. Task 3's Go-level bootstrap step fills them in. Every store method added in later tasks requires a non-nil `userID` parameter regardless; application code should never itself write or rely on a NULL `user_id`. + +This task rebuilds `profile`, `workout_kinds`, and `sync_state` (SQLite has no `DROP CONSTRAINT`, so dropping `CHECK(id=1)` or changing a `UNIQUE` column constraint requires the documented create-new-table/copy/drop-old/rename-into-place recipe). `activities` and `sync_runs` only need a plain `ADD COLUMN` since nothing about their existing constraints blocks multi-tenancy. The rebuild recipe below was verified directly against a real SQLite database (via `sqlite3` CLI, matching this project's actual FK/index behavior) before being written here — in particular, always create the replacement table under a **different** name and `RENAME` it into the final name at the end (never rename the *existing* table away first) so that other tables' foreign keys (e.g. `kind_assignments.workout_kind_id REFERENCES workout_kinds(id)`) are never left dangling, and always drop the old table (not rename it away) — `DROP TABLE` on a table with existing inbound foreign key references only succeeds once the transaction's FK checks are deferred. + +- [ ] **Step 1: Write `0021_users_table.sql`** + +```sql +CREATE TABLE users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + oidc_sub TEXT NOT NULL UNIQUE, + display_name TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); +``` + +- [ ] **Step 2: Write `0022_activities_and_syncruns_user_id.sql`** + +```sql +-- user_id is nullable here even though every row will eventually need one: +-- migrations can't take runtime parameters, so the actual owner isn't known +-- yet. A one-time Go bootstrap step (store.ClaimLegacyOwner, run at +-- geniusrund startup) backfills every existing row to one user once given +-- that user's OIDC subject; from then on every store method requires a +-- non-nil userID and this column is never NULL again in practice. +ALTER TABLE activities ADD COLUMN user_id INTEGER REFERENCES users(id); +ALTER TABLE sync_runs ADD COLUMN user_id INTEGER REFERENCES users(id); + +-- Used by UpsertActivity's ON CONFLICT target going forward. The original +-- UNIQUE(garmin_activity_id) constraint (migration 0001) stays in place too +-- -- Garmin's own activity ids are already globally unique in practice, so +-- the stricter constraint is harmless, and SQLite can't drop a column-level +-- constraint without a full table rebuild, which isn't worth the risk here. +CREATE UNIQUE INDEX ux_activities_user_garmin_id ON activities(user_id, garmin_activity_id); +``` + +- [ ] **Step 3: Write `0023_profile_user_scoped.sql`** + +```sql +-- profile.id's CHECK(id = 1) singleton constraint (migration 0003) can't be +-- dropped via ALTER TABLE -- SQLite has no DROP CONSTRAINT -- so this +-- rebuilds the table via SQLite's documented rename/recreate/copy/drop +-- pattern instead. Always create the replacement under a different name and +-- RENAME it into place at the end (never rename the live table away first) +-- -- verified directly against SQLite that this ordering is what keeps +-- other tables' foreign keys intact when they exist (not the case for +-- profile, but kept consistent with migrations 0024/0025 for the same +-- pattern). user_id is nullable for the same not-yet-known-owner reason as +-- migration 0022 -- see its comment. +PRAGMA defer_foreign_keys = ON; + +CREATE TABLE profile_new ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER REFERENCES users(id), + name TEXT NOT NULL DEFAULT 'Default', + garmin_email TEXT NOT NULL DEFAULT '', + garmin_password TEXT NOT NULL DEFAULT '', + rolling_window_days INTEGER NOT NULL DEFAULT 90, + backfill_horizon_days INTEGER NOT NULL DEFAULT 1095, + max_heart_rate REAL, + resting_heart_rate REAL, + hr_zone1_min_pct REAL NOT NULL DEFAULT 50, + hr_zone1_max_pct REAL NOT NULL DEFAULT 60, + hr_zone2_min_pct REAL NOT NULL DEFAULT 60, + hr_zone2_max_pct REAL NOT NULL DEFAULT 70, + hr_zone3_min_pct REAL NOT NULL DEFAULT 70, + hr_zone3_max_pct REAL NOT NULL DEFAULT 80, + hr_zone4_min_pct REAL NOT NULL DEFAULT 80, + hr_zone4_max_pct REAL NOT NULL DEFAULT 90, + hr_zone5_min_pct REAL NOT NULL DEFAULT 90, + hr_zone5_max_pct REAL NOT NULL DEFAULT 100, + warmup_minutes REAL NOT NULL DEFAULT 10, + cooldown_minutes REAL NOT NULL DEFAULT 5, + min_representative_pace_sec_per_km REAL NOT NULL DEFAULT 720, + min_representative_time_seconds REAL NOT NULL DEFAULT 3, + pace_color TEXT NOT NULL DEFAULT '#3b82f6', + heart_rate_color TEXT NOT NULL DEFAULT '#ef4444', + warmup_color TEXT NOT NULL DEFAULT '#c2410c', + effort_color TEXT NOT NULL DEFAULT '#7c3aed', + recovery_color TEXT NOT NULL DEFAULT '#15803d', + cooldown_color TEXT NOT NULL DEFAULT '#fb923c', + main_line_tint_pct REAL NOT NULL DEFAULT 20, + background_darken_pct REAL NOT NULL DEFAULT 35, + target_brighten_pct REAL NOT NULL DEFAULT 20, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + UNIQUE(user_id) +); + +INSERT INTO profile_new ( + id, user_id, name, garmin_email, garmin_password, rolling_window_days, backfill_horizon_days, + max_heart_rate, resting_heart_rate, + hr_zone1_min_pct, hr_zone1_max_pct, hr_zone2_min_pct, hr_zone2_max_pct, + hr_zone3_min_pct, hr_zone3_max_pct, hr_zone4_min_pct, hr_zone4_max_pct, + hr_zone5_min_pct, hr_zone5_max_pct, + warmup_minutes, cooldown_minutes, + min_representative_pace_sec_per_km, min_representative_time_seconds, + pace_color, heart_rate_color, warmup_color, effort_color, recovery_color, cooldown_color, + main_line_tint_pct, background_darken_pct, target_brighten_pct, + created_at, updated_at +) +SELECT + id, NULL, name, garmin_email, garmin_password, rolling_window_days, backfill_horizon_days, + max_heart_rate, resting_heart_rate, + hr_zone1_min_pct, hr_zone1_max_pct, hr_zone2_min_pct, hr_zone2_max_pct, + hr_zone3_min_pct, hr_zone3_max_pct, hr_zone4_min_pct, hr_zone4_max_pct, + hr_zone5_min_pct, hr_zone5_max_pct, + warmup_minutes, cooldown_minutes, + min_representative_pace_sec_per_km, min_representative_time_seconds, + pace_color, heart_rate_color, warmup_color, effort_color, recovery_color, cooldown_color, + main_line_tint_pct, background_darken_pct, target_brighten_pct, + created_at, updated_at +FROM profile; + +DROP TABLE profile; + +ALTER TABLE profile_new RENAME TO profile; +``` + +- [ ] **Step 4: Write `0024_workout_kinds_user_scoped.sql`** + +```sql +-- workout_kinds.name's UNIQUE(name) constraint (migration 0001) must become +-- per-user (UNIQUE(user_id, name)) now that every user gets their own copy +-- of the same 8-kind taxonomy -- otherwise a second user could never be +-- provisioned (inserting the same seeded name would collide). kind_assignments +-- and workout_type_paces hold foreign keys into this table +-- (workout_kind_id REFERENCES workout_kinds(id)) -- the create-new-name/ +-- copy/drop-old/rename-into-place order below (verified against a real +-- SQLite database) leaves those foreign keys' schema text untouched +-- throughout, so they resolve correctly again the instant the final +-- `ALTER TABLE workout_kinds_new RENAME TO workout_kinds` completes. +PRAGMA defer_foreign_keys = ON; + +CREATE TABLE workout_kinds_new ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER REFERENCES users(id), + name TEXT NOT NULL, + description TEXT NOT NULL DEFAULT '', + color TEXT NOT NULL DEFAULT '', + rule_json TEXT NOT NULL, + priority INTEGER NOT NULL DEFAULT 0, + is_active INTEGER NOT NULL DEFAULT 1, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + UNIQUE(user_id, name) +); + +INSERT INTO workout_kinds_new (id, user_id, name, description, color, rule_json, priority, is_active, created_at, updated_at) +SELECT id, NULL, name, description, color, rule_json, priority, is_active, created_at, updated_at +FROM workout_kinds; + +DROP TABLE workout_kinds; + +ALTER TABLE workout_kinds_new RENAME TO workout_kinds; +``` + +- [ ] **Step 5: Write `0025_sync_state_user_scoped.sql`** + +```sql +-- Same CHECK(id = 1) rebuild reasoning as migration 0023's profile rebuild. +PRAGMA defer_foreign_keys = ON; + +CREATE TABLE sync_state_new ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER REFERENCES users(id), + earliest_synced_date TEXT, + backfill_complete INTEGER NOT NULL DEFAULT 0, + UNIQUE(user_id) +); + +INSERT INTO sync_state_new (id, user_id, earliest_synced_date, backfill_complete) +SELECT id, NULL, earliest_synced_date, backfill_complete +FROM sync_state; + +DROP TABLE sync_state; + +ALTER TABLE sync_state_new RENAME TO sync_state; +``` + +- [ ] **Step 6: Run the existing migration test to confirm it still applies cleanly** + +Run: `cd backend && go test ./internal/store/... -run TestMigrateIsIdempotent -v` +Expected: PASS (this only checks migrations apply without error on a fresh DB and re-apply as a no-op on an existing one; it does not yet assert the new schema shape). + +- [ ] **Step 7: Add a schema-shape test to `backend/internal/store/store_test.go`** + +```go +func TestUsersAndOwnershipSchema_AppliesCleanly(t *testing.T) { + db := openTestDB(t) + ctx := context.Background() + + // A fresh DB has no legacy singleton rows, so every user_id column + // should already be backfilled to nothing (fresh install, no rows at + // all yet in these tables besides the migration-seeded workout_kinds -- + // which do have NULL user_id until a real user is provisioned). + var nullableCount int + if err := db.QueryRowContext(ctx, `SELECT COUNT(*) FROM workout_kinds WHERE user_id IS NULL`).Scan(&nullableCount); err != nil { + t.Fatalf("count workout_kinds: %v", err) + } + if nullableCount != 8 { + t.Fatalf("expected 8 migration-seeded workout_kinds with NULL user_id on a fresh DB, got %d", nullableCount) + } + + // UNIQUE(user_id, name) allows the same name across two different users. + if _, err := db.ExecContext(ctx, `INSERT INTO users (oidc_sub, display_name) VALUES ('sub-a', 'A'), ('sub-b', 'B')`); err != nil { + t.Fatalf("insert users: %v", err) + } + if _, err := db.ExecContext(ctx, ` + INSERT INTO workout_kinds (user_id, name, rule_json) + VALUES ((SELECT id FROM users WHERE oidc_sub='sub-a'), 'Custom Kind', '{}'), + ((SELECT id FROM users WHERE oidc_sub='sub-b'), 'Custom Kind', '{}')`); err != nil { + t.Fatalf("expected same workout_kind name to be allowed across two different users, got: %v", err) + } + + // profile/sync_state UNIQUE(user_id) still rejects a genuine duplicate. + if _, err := db.ExecContext(ctx, `INSERT INTO profile (user_id) VALUES ((SELECT id FROM users WHERE oidc_sub='sub-a'))`); err != nil { + t.Fatalf("insert profile for sub-a: %v", err) + } + if _, err := db.ExecContext(ctx, `INSERT INTO profile (user_id) VALUES ((SELECT id FROM users WHERE oidc_sub='sub-a'))`); err == nil { + t.Fatal("expected a second profile row for the same user_id to violate UNIQUE(user_id)") + } +} +``` + +- [ ] **Step 8: Run the new test to verify it fails first, then passes after Steps 1-5** + +Run: `cd backend && go test ./internal/store/... -run TestUsersAndOwnershipSchema_AppliesCleanly -v` +Expected: PASS (the migrations from Steps 1-5 are already in place, so this confirms them; if you're following TDD strictly, temporarily comment out Steps 1-5's files first to see it fail, then restore them). + +- [ ] **Step 9: Run the full store test suite and `gofmt`** + +Run: `cd backend && gofmt -l . && go vet ./... && go test ./internal/store/... -v` +Expected: `gofmt -l .` prints nothing; all tests PASS (existing store tests still work unchanged since no store method signatures have changed yet — that's Tasks 4-8). + +- [ ] **Step 10: Commit** + +```bash +git add backend/internal/store/migrations/0021_users_table.sql backend/internal/store/migrations/0022_activities_and_syncruns_user_id.sql backend/internal/store/migrations/0023_profile_user_scoped.sql backend/internal/store/migrations/0024_workout_kinds_user_scoped.sql backend/internal/store/migrations/0025_sync_state_user_scoped.sql backend/internal/store/store_test.go +git commit -m "$(cat <<'EOF' +store: add users table and user_id scoping to migrations + +Schema-only step toward per-user profile isolation: profile/workout_kinds/ +sync_state are rebuilt to drop their singleton constraints, activities/ +sync_runs gain a nullable user_id column. Store methods are scoped in +later tasks. +EOF +)" +``` + +--- + +## Task 2: `store.User` + `ProvisionUser` + +**Files:** +- Create: `backend/internal/store/users.go` +- Test: `backend/internal/store/users_test.go` + +**Interfaces:** +- Consumes: the `users`/`profile`/`workout_kinds`/`workout_type_paces`/`sync_state` schema from Task 1. +- Produces: `type User struct { ID int64; OIDCSub string; DisplayName string; CreatedAt string }`; `func (db *DB) CreateUser(ctx, oidcSub, displayName string) (int64, error)`; `func (db *DB) GetUserBySub(ctx, oidcSub string) (User, bool, error)`; `func (db *DB) ListUsers(ctx) ([]User, error)`; `func (db *DB) ProvisionUser(ctx, oidcSub, displayName string) (int64, error)`. Task 11's middleware and Task 3's legacy-claim bootstrap both depend on these exact signatures. + +- [ ] **Step 1: Write the failing test** + +```go +package store + +import ( + "context" + "testing" +) + +func TestGetUserBySub_NotFoundReturnsFalseNotError(t *testing.T) { + db := openTestDB(t) + _, found, err := db.GetUserBySub(context.Background(), "no-such-sub") + if err != nil { + t.Fatalf("GetUserBySub: %v", err) + } + if found { + t.Fatal("expected found=false for an unknown sub") + } +} + +func TestProvisionUser_SeedsProfileTaxonomyAndSyncState(t *testing.T) { + db := openTestDB(t) + ctx := context.Background() + + userID, err := db.ProvisionUser(ctx, "sub-123", "Lucie") + if err != nil { + t.Fatalf("ProvisionUser: %v", err) + } + + u, found, err := db.GetUserBySub(ctx, "sub-123") + if err != nil || !found { + t.Fatalf("GetUserBySub: found=%v err=%v", found, err) + } + if u.ID != userID || u.DisplayName != "Lucie" { + t.Fatalf("got %+v, want ID=%d DisplayName=Lucie", u, userID) + } + + profile, err := db.GetProfile(ctx, userID) + if err != nil { + t.Fatalf("GetProfile: %v", err) + } + if profile.Name != "Lucie" { + t.Errorf("profile.Name = %q, want %q", profile.Name, "Lucie") + } + if profile.RollingWindowDays != 90 { + t.Errorf("profile.RollingWindowDays = %d, want 90 (default)", profile.RollingWindowDays) + } + + kinds, err := db.ListWorkoutKinds(ctx, userID, false) + if err != nil { + t.Fatalf("ListWorkoutKinds: %v", err) + } + if len(kinds) != 8 { + t.Fatalf("expected 8 seeded workout kinds, got %d", len(kinds)) + } + + state, err := db.GetSyncState(ctx, userID) + if err != nil { + t.Fatalf("GetSyncState: %v", err) + } + if state.EarliestSyncedDate != nil || state.BackfillComplete { + t.Errorf("expected fresh sync state, got %+v", state) + } +} + +func TestProvisionUser_TwoUsersGetIndependentTaxonomies(t *testing.T) { + db := openTestDB(t) + ctx := context.Background() + + userA, err := db.ProvisionUser(ctx, "sub-a", "A") + if err != nil { + t.Fatalf("ProvisionUser(a): %v", err) + } + userB, err := db.ProvisionUser(ctx, "sub-b", "B") + if err != nil { + t.Fatalf("ProvisionUser(b): %v", err) + } + + kindsA, err := db.ListWorkoutKinds(ctx, userA, false) + if err != nil { + t.Fatalf("ListWorkoutKinds(a): %v", err) + } + kindsB, err := db.ListWorkoutKinds(ctx, userB, false) + if err != nil { + t.Fatalf("ListWorkoutKinds(b): %v", err) + } + if len(kindsA) != 8 || len(kindsB) != 8 { + t.Fatalf("expected 8 kinds each, got a=%d b=%d", len(kindsA), len(kindsB)) + } + if kindsA[0].ID == kindsB[0].ID { + t.Fatal("expected each user's seeded kinds to be distinct rows") + } +} +``` + +Note: this test file depends on `GetProfile`/`ListWorkoutKinds`/`GetSyncState` already taking a `userID` parameter — write it now (it won't compile until Tasks 4/5/8 land), or write it as part of Task 8 instead if executing tasks strictly in order. Since this plan is meant to be executed top-to-bottom, add this test file now but expect it to fail to *compile* until Task 8 is done — track that as expected, not a regression, when running Step 2 below. + +- [ ] **Step 2: Confirm the test doesn't compile yet (expected)** + +Run: `cd backend && go vet ./internal/store/...` +Expected: FAIL to compile — `GetProfile`/`ListWorkoutKinds`/`GetSyncState` don't take a `userID` argument yet. This is expected; the test will compile and pass once Tasks 4/5/8 are done. Continue with Step 3 below regardless. + +- [ ] **Step 3: Write `backend/internal/store/users.go`** + +```go +package store + +import ( + "context" + "database/sql" + "fmt" +) + +// User is one geniusrun account, bound 1:1 to an OIDC subject. Every +// synced/classified dataset (profile, workout kinds, activities, sync +// state) is scoped to exactly one User -- see +// docs/superpowers/specs/2026-07-25-per-user-profile-design.md. +type User struct { + ID int64 + OIDCSub string + DisplayName string + CreatedAt string +} + +// CreateUser inserts a bare users row. Most callers want ProvisionUser +// instead, which also seeds the profile/taxonomy/sync-state a fresh account +// needs; CreateUser alone exists for ClaimLegacyOwner (Task 3), which +// attaches an *existing* profile/taxonomy rather than seeding new ones. +func (db *DB) CreateUser(ctx context.Context, oidcSub, displayName string) (int64, error) { + res, err := db.ExecContext(ctx, `INSERT INTO users (oidc_sub, display_name) VALUES (?, ?)`, oidcSub, displayName) + if err != nil { + return 0, fmt.Errorf("create user %q: %w", oidcSub, err) + } + return res.LastInsertId() +} + +// GetUserBySub looks up a user by their OIDC subject -- the only lookup key +// the session-resolution middleware (Task 11) ever uses. +func (db *DB) GetUserBySub(ctx context.Context, oidcSub string) (User, bool, error) { + var u User + err := db.QueryRowContext(ctx, `SELECT id, oidc_sub, display_name, created_at FROM users WHERE oidc_sub = ?`, oidcSub). + Scan(&u.ID, &u.OIDCSub, &u.DisplayName, &u.CreatedAt) + if err == sql.ErrNoRows { + return User{}, false, nil + } + if err != nil { + return User{}, false, fmt.Errorf("get user by sub: %w", err) + } + return u, true, nil +} + +// ListUsers returns every provisioned user, for the background incremental +// sync loop (Task 13) to iterate. +func (db *DB) ListUsers(ctx context.Context) ([]User, error) { + rows, err := db.QueryContext(ctx, `SELECT id, oidc_sub, display_name, created_at FROM users ORDER BY id`) + if err != nil { + return nil, fmt.Errorf("list users: %w", err) + } + defer rows.Close() + + users := []User{} + for rows.Next() { + var u User + if err := rows.Scan(&u.ID, &u.OIDCSub, &u.DisplayName, &u.CreatedAt); err != nil { + return nil, fmt.Errorf("scan user row: %w", err) + } + users = append(users, u) + } + return users, rows.Err() +} + +// neverMatchRule is the same placeholder every fresh install's rule-engine +// kinds start with (migration 0004) -- every activity lands in needs_review +// until the user tunes real rules. +const neverMatchRule = `{"match":"all","conditions":[{"metric":"distance_meters","op":"<","value":0}]}` + +// defaultWorkoutKindSeeds mirrors the 8 kinds migrations 0004/0007 seed for +// a fresh install, so a newly-provisioned user starts with the same +// taxonomy instead of an empty one. +var defaultWorkoutKindSeeds = []WorkoutKind{ + {Name: "Easy", Color: "#22c55e", RuleJSON: neverMatchRule, Priority: 80, IsActive: true}, + {Name: "Long", Color: "#3b82f6", RuleJSON: neverMatchRule, Priority: 70, IsActive: true}, + {Name: "60' Threshold", Color: "#f59e0b", RuleJSON: neverMatchRule, Priority: 60, IsActive: true}, + {Name: "30' Threshold", Color: "#f59e0b", RuleJSON: neverMatchRule, Priority: 50, IsActive: true}, + {Name: "Tempo", Color: "#eab308", RuleJSON: neverMatchRule, Priority: 40, IsActive: true}, + {Name: "Intervals", Color: "#ef4444", RuleJSON: neverMatchRule, Priority: 30, IsActive: true}, + {Name: "MAS Test", Color: "#a855f7", RuleJSON: neverMatchRule, Priority: 20, IsActive: true}, + {Name: "Race", Color: "#dc2626", RuleJSON: `{"match":"all","conditions":[{"metric":"is_race","op":"==","value":true}]}`, Priority: 10, IsActive: true}, +} + +// ProvisionUser creates a brand-new geniusrun account for an OIDC subject: +// the users row, a default profile (name defaulted to displayName), the 8 +// default workout kinds (with their paired workout_type_paces rows), and an +// initial sync_state row -- all in one transaction, so a partially +// provisioned user is never observable. +func (db *DB) ProvisionUser(ctx context.Context, oidcSub, displayName string) (int64, error) { + tx, err := db.BeginTx(ctx, nil) + if err != nil { + return 0, fmt.Errorf("begin provision user tx: %w", err) + } + defer tx.Rollback() + + res, err := tx.ExecContext(ctx, `INSERT INTO users (oidc_sub, display_name) VALUES (?, ?)`, oidcSub, displayName) + if err != nil { + return 0, fmt.Errorf("create user %q: %w", oidcSub, err) + } + userID, err := res.LastInsertId() + if err != nil { + return 0, err + } + + if _, err := tx.ExecContext(ctx, `INSERT INTO profile (user_id, name) VALUES (?, ?)`, userID, displayName); err != nil { + return 0, fmt.Errorf("create profile for user %d: %w", userID, err) + } + + for _, k := range defaultWorkoutKindSeeds { + res, err := tx.ExecContext(ctx, ` + INSERT INTO workout_kinds (user_id, name, description, color, rule_json, priority, is_active) + VALUES (?,?,?,?,?,?,?)`, + userID, k.Name, k.Description, k.Color, k.RuleJSON, k.Priority, k.IsActive) + if err != nil { + return 0, fmt.Errorf("seed workout kind %q for user %d: %w", k.Name, userID, err) + } + kindID, err := res.LastInsertId() + if err != nil { + return 0, err + } + if _, err := tx.ExecContext(ctx, `INSERT INTO workout_type_paces (workout_kind_id) VALUES (?)`, kindID); err != nil { + return 0, fmt.Errorf("seed workout type pace for kind %d: %w", kindID, err) + } + } + + if _, err := tx.ExecContext(ctx, `INSERT INTO sync_state (user_id, earliest_synced_date, backfill_complete) VALUES (?, NULL, 0)`, userID); err != nil { + return 0, fmt.Errorf("create sync state for user %d: %w", userID, err) + } + + return userID, tx.Commit() +} +``` + +- [ ] **Step 4: Run the store package tests** + +Run: `cd backend && go test ./internal/store/... -run 'TestGetUserBySub|TestProvisionUser' -v` +Expected: still FAIL to compile (Tasks 4/5/8 haven't landed) — confirm the *only* compile errors reference `GetProfile`/`ListWorkoutKinds`/`GetSyncState` missing a `userID` argument, not anything in `users.go` itself. If `users.go` itself has an error, fix it now. + +- [ ] **Step 5: Commit** + +```bash +git add backend/internal/store/users.go backend/internal/store/users_test.go +git commit -m "$(cat <<'EOF' +store: add User type and ProvisionUser + +Lays the groundwork for per-user accounts: CreateUser/GetUserBySub/ +ListUsers plus ProvisionUser, which seeds a brand-new user's profile, +default workout-kind taxonomy, and sync state in one transaction. Depends +on later tasks scoping GetProfile/ListWorkoutKinds/GetSyncState to compile +and pass -- expected or committing to a shared branch. +EOF +)" +``` + +--- + +## Task 3: Legacy data claim bootstrap + +**Files:** +- Create: `backend/internal/store/legacy_claim.go` +- Test: `backend/internal/store/legacy_claim_test.go` + +**Interfaces:** +- Consumes: the nullable `user_id` columns from Task 1. +- Produces: `func (db *DB) ClaimLegacyOwner(ctx context.Context, oidcSub string) error`. Task 11's `main.go` wiring calls this once at startup. + +- [ ] **Step 1: Write the failing test** + +```go +package store + +import ( + "context" + "testing" +) + +func TestClaimLegacyOwner_BindsExistingSingletonRowsToOneNewUser(t *testing.T) { + db := openTestDB(t) + ctx := context.Background() + + // Simulate the pre-migration state: a fresh DB already has one + // migration-seeded profile row (id=1, user_id=NULL) and 8 workout_kinds + // rows (user_id=NULL) -- exactly what a real upgraded deployment looks + // like right after Task 1's migrations run, before any user exists. + if _, err := db.ExecContext(ctx, `UPDATE profile SET name = 'Kriss', garmin_email = 'kriss@example.com' WHERE user_id IS NULL`); err != nil { + t.Fatalf("seed legacy profile: %v", err) + } + if _, err := db.ExecContext(ctx, `INSERT INTO activities (user_id, garmin_activity_id, start_time_utc, duration_seconds, distance_meters, raw_json) VALUES (NULL, 1, '2026-01-01 00:00:00', 1800, 5000, '{}')`); err != nil { + t.Fatalf("seed legacy activity: %v", err) + } + + if err := db.ClaimLegacyOwner(ctx, "kriss-sub"); err != nil { + t.Fatalf("ClaimLegacyOwner: %v", err) + } + + u, found, err := db.GetUserBySub(ctx, "kriss-sub") + if err != nil || !found { + t.Fatalf("GetUserBySub: found=%v err=%v", found, err) + } + if u.DisplayName != "Kriss" { + t.Errorf("DisplayName = %q, want %q (from the legacy profile's name column)", u.DisplayName, "Kriss") + } + + profile, err := db.GetProfile(ctx, u.ID) + if err != nil { + t.Fatalf("GetProfile(claimed user): %v", err) + } + if profile.GarminEmail != "kriss@example.com" { + t.Errorf("claimed profile GarminEmail = %q, want kriss@example.com", profile.GarminEmail) + } + + kinds, err := db.ListWorkoutKinds(ctx, u.ID, false) + if err != nil { + t.Fatalf("ListWorkoutKinds(claimed user): %v", err) + } + if len(kinds) != 8 { + t.Fatalf("expected the 8 legacy workout_kinds rows to be claimed, got %d", len(kinds)) + } + + activities, err := db.ListActivities(ctx, u.ID, ActivityFilter{}) + if err != nil { + t.Fatalf("ListActivities(claimed user): %v", err) + } + if len(activities) != 1 { + t.Fatalf("expected the 1 legacy activity to be claimed, got %d", len(activities)) + } + + var remainingNullUserIDRows int + for _, table := range []string{"profile", "workout_kinds", "activities", "sync_state"} { + var n int + if err := db.QueryRowContext(ctx, `SELECT COUNT(*) FROM `+table+` WHERE user_id IS NULL`).Scan(&n); err != nil { + t.Fatalf("count NULL user_id in %s: %v", table, err) + } + remainingNullUserIDRows += n + } + if remainingNullUserIDRows != 0 { + t.Errorf("expected every legacy row to be claimed, %d rows still have NULL user_id", remainingNullUserIDRows) + } +} + +func TestClaimLegacyOwner_NoOpOnceAUserAlreadyExists(t *testing.T) { + db := openTestDB(t) + ctx := context.Background() + + firstUserID, err := db.ProvisionUser(ctx, "already-here", "Someone") + if err != nil { + t.Fatalf("ProvisionUser: %v", err) + } + + // A second call (simulating a later restart with the env var still set) + // must not create a second user or touch anything. + if err := db.ClaimLegacyOwner(ctx, "kriss-sub"); err != nil { + t.Fatalf("ClaimLegacyOwner (should be a no-op): %v", err) + } + + if _, found, err := db.GetUserBySub(ctx, "kriss-sub"); err != nil || found { + t.Fatalf("expected no user created for kriss-sub, found=%v err=%v", found, err) + } + users, err := db.ListUsers(ctx) + if err != nil { + t.Fatalf("ListUsers: %v", err) + } + if len(users) != 1 || users[0].ID != firstUserID { + t.Fatalf("expected exactly the 1 pre-existing user to remain, got %+v", users) + } +} + +func TestClaimLegacyOwner_NoOpOnGenuinelyFreshInstall(t *testing.T) { + db := openTestDB(t) + ctx := context.Background() + + if err := db.ClaimLegacyOwner(ctx, "kriss-sub"); err != nil { + t.Fatalf("ClaimLegacyOwner on fresh install: %v", err) + } + if _, found, err := db.GetUserBySub(ctx, "kriss-sub"); err != nil || found { + t.Fatalf("expected no user created on a fresh install with no legacy profile, found=%v err=%v", found, err) + } +} +``` + +- [ ] **Step 2: Run to verify it fails to compile (no `ClaimLegacyOwner` yet)** + +Run: `cd backend && go vet ./internal/store/...` +Expected: FAIL — `db.ClaimLegacyOwner` undefined. + +- [ ] **Step 3: Write `backend/internal/store/legacy_claim.go`** + +```go +package store + +import ( + "context" + "fmt" +) + +// ClaimLegacyOwner is a one-time upgrade step, not a normal runtime +// operation: it binds every pre-multi-tenancy row (user_id IS NULL, left +// that way by migrations that can't take runtime parameters -- see +// docs/superpowers/specs/2026-07-25-per-user-profile-design.md) to a single +// new user identified by oidcSub. Safe to call on every startup: once the +// users table is non-empty, it's a no-op, so leaving +// GENIUSRUN_LEGACY_OWNER_OIDC_SUB set after the first successful run causes +// no harm. +func (db *DB) ClaimLegacyOwner(ctx context.Context, oidcSub string) error { + var userCount int + if err := db.QueryRowContext(ctx, `SELECT COUNT(*) FROM users`).Scan(&userCount); err != nil { + return fmt.Errorf("count users: %w", err) + } + if userCount > 0 { + return nil // already bootstrapped (either claimed already, or real signups exist) + } + + var displayName string + err := db.QueryRowContext(ctx, `SELECT name FROM profile WHERE user_id IS NULL LIMIT 1`).Scan(&displayName) + if err != nil { + return fmt.Errorf("find legacy profile: %w", err) + } + + tx, err := db.BeginTx(ctx, nil) + if err != nil { + return fmt.Errorf("begin claim legacy owner tx: %w", err) + } + defer tx.Rollback() + + res, err := tx.ExecContext(ctx, `INSERT INTO users (oidc_sub, display_name) VALUES (?, ?)`, oidcSub, displayName) + if err != nil { + return fmt.Errorf("create legacy owner user: %w", err) + } + userID, err := res.LastInsertId() + if err != nil { + return err + } + + // table is always one of the fixed literals below, never user input. + for _, table := range []string{"profile", "workout_kinds", "activities", "sync_state", "sync_runs"} { + if _, err := tx.ExecContext(ctx, `UPDATE `+table+` SET user_id = ? WHERE user_id IS NULL`, userID); err != nil { + return fmt.Errorf("claim legacy %s rows: %w", table, err) + } + } + + return tx.Commit() +} +``` + +- [ ] **Step 4: Run the tests** + +Run: `cd backend && go test ./internal/store/... -run TestClaimLegacyOwner -v` +Expected: still FAIL to compile until Tasks 4/5/6/8 land (`GetProfile`/`ListWorkoutKinds`/`ListActivities` need their `userID` parameter). Confirm the only failures are in those dependencies, not in `legacy_claim.go` itself. + +- [ ] **Step 5: Commit** + +```bash +git add backend/internal/store/legacy_claim.go backend/internal/store/legacy_claim_test.go +git commit -m "$(cat <<'EOF' +store: add ClaimLegacyOwner one-time upgrade bootstrap + +Binds pre-multi-tenancy singleton rows to one named OIDC subject, given at +startup via an env var (wired in Task 11). No-ops once any user exists. +EOF +)" +``` + +--- + +## Task 4: Scope `profile.go` to `user_id` + +**Files:** +- Modify: `backend/internal/store/profile.go` +- Modify: `backend/internal/store/profile_test.go` + +**Interfaces:** +- Produces: `func (db *DB) GetProfile(ctx context.Context, userID int64) (Profile, error)`; `func (db *DB) UpdateProfile(ctx context.Context, userID int64, p Profile) error`. Every other task/caller of these two methods must pass `userID` as the second positional argument from here on. + +- [ ] **Step 1: Update `GetProfile`/`UpdateProfile` in `backend/internal/store/profile.go`** + +Replace the two function bodies (the `Profile` struct and `profileColumns` constant are unchanged): + +```go +// GetProfile returns the profile row for userID. +func (db *DB) GetProfile(ctx context.Context, userID int64) (Profile, error) { + var p Profile + err := db.QueryRowContext(ctx, `SELECT `+profileColumns+` FROM profile WHERE user_id = ?`, userID).Scan( + &p.Name, &p.GarminEmail, &p.GarminPassword, &p.RollingWindowDays, &p.BackfillHorizonDays, &p.MaxHeartRate, &p.RestingHeartRate, + &p.HRZone1MinPct, &p.HRZone1MaxPct, &p.HRZone2MinPct, &p.HRZone2MaxPct, + &p.HRZone3MinPct, &p.HRZone3MaxPct, &p.HRZone4MinPct, &p.HRZone4MaxPct, + &p.HRZone5MinPct, &p.HRZone5MaxPct, + &p.WarmupMinutes, &p.CooldownMinutes, + &p.MinRepresentativePaceSecPerKm, &p.MinRepresentativeTimeSeconds, + &p.PaceColor, &p.HeartRateColor, &p.WarmupColor, &p.EffortColor, &p.RecoveryColor, &p.CooldownColor, + &p.MainLineTintPct, &p.BackgroundDarkenPct, &p.TargetBrightenPct, + &p.CreatedAt, &p.UpdatedAt, + ) + if err != nil { + return Profile{}, fmt.Errorf("get profile for user %d: %w", userID, err) + } + return p, nil +} + +// UpdateProfile overwrites userID's profile row. Callers should read via +// GetProfile first and modify the fields they intend to change, since this +// replaces every column. +func (db *DB) UpdateProfile(ctx context.Context, userID int64, p Profile) error { + _, err := db.ExecContext(ctx, ` + UPDATE profile SET + name=?, garmin_email=?, garmin_password=?, rolling_window_days=?, backfill_horizon_days=?, max_heart_rate=?, resting_heart_rate=?, + hr_zone1_min_pct=?, hr_zone1_max_pct=?, hr_zone2_min_pct=?, hr_zone2_max_pct=?, + hr_zone3_min_pct=?, hr_zone3_max_pct=?, hr_zone4_min_pct=?, hr_zone4_max_pct=?, + hr_zone5_min_pct=?, hr_zone5_max_pct=?, + warmup_minutes=?, cooldown_minutes=?, + min_representative_pace_sec_per_km=?, min_representative_time_seconds=?, + pace_color=?, heart_rate_color=?, warmup_color=?, effort_color=?, recovery_color=?, cooldown_color=?, + main_line_tint_pct=?, background_darken_pct=?, target_brighten_pct=?, + updated_at=datetime('now') + WHERE user_id = ?`, + p.Name, p.GarminEmail, p.GarminPassword, p.RollingWindowDays, p.BackfillHorizonDays, p.MaxHeartRate, p.RestingHeartRate, + p.HRZone1MinPct, p.HRZone1MaxPct, p.HRZone2MinPct, p.HRZone2MaxPct, + p.HRZone3MinPct, p.HRZone3MaxPct, p.HRZone4MinPct, p.HRZone4MaxPct, + p.HRZone5MinPct, p.HRZone5MaxPct, + p.WarmupMinutes, p.CooldownMinutes, + p.MinRepresentativePaceSecPerKm, p.MinRepresentativeTimeSeconds, + p.PaceColor, p.HeartRateColor, p.WarmupColor, p.EffortColor, p.RecoveryColor, p.CooldownColor, + p.MainLineTintPct, p.BackgroundDarkenPct, p.TargetBrightenPct, + userID, + ) + if err != nil { + return fmt.Errorf("update profile for user %d: %w", userID, err) + } + return nil +} +``` + +- [ ] **Step 2: Fix `backend/internal/store/profile_test.go`'s call sites** + +The existing `TestProfile_DefaultsThenUpdate` calls `db.GetProfile(ctx)` and `db.UpdateProfile(ctx, p)` directly against a fresh DB with no provisioned user — since Task 1 made `profile` rows always belong to a user, this test must provision one first. Replace the test's opening two lines: + +```go +func TestProfile_DefaultsThenUpdate(t *testing.T) { + db := openTestDB(t) + ctx := context.Background() + userID, err := db.ProvisionUser(ctx, "test-sub", "Default") + if err != nil { + t.Fatalf("ProvisionUser: %v", err) + } + + p, err := db.GetProfile(ctx, userID) +``` + +Then update every remaining `db.GetProfile(ctx)` → `db.GetProfile(ctx, userID)` and `db.UpdateProfile(ctx, p)` → `db.UpdateProfile(ctx, userID, p)` in the rest of that test function (there are 2 more `GetProfile` calls and 1 `UpdateProfile` call further down). Also change the assertion `if p.Name != "Default"` to expect `"Default"` still (ProvisionUser was called with displayName `"Default"` above, matching the original migration-seeded default, so no assertion values need to change) — but the later `p.Name = "Kriss"` assignment and `got.Name != "Kriss"` assertion stay as-is (that's the test uprating the name via UpdateProfile, unrelated to the account's own display name). + +- [ ] **Step 3: Run the store tests, fix any remaining call sites via compiler errors** + +Run: `cd backend && go build ./... && go vet ./...` +Expected: compile errors pointing at every remaining `GetProfile`/`UpdateProfile` call site missing the new `userID` argument (e.g. in `internal/sync/service.go`, `internal/api/profile.go`, `cmd/seedsample/main.go` — those are fixed in Tasks 10/14/17, so it's expected they still fail here; only fix sites inside `backend/internal/store/` in this task). Fix any remaining `internal/store` call sites the same way, then re-run until `go vet ./internal/store/...` is clean. + +- [ ] **Step 4: Run the profile store tests** + +Run: `cd backend && go test ./internal/store/... -run TestProfile -v` +Expected: PASS. + +- [ ] **Step 5: `gofmt` and commit** + +Run: `cd backend && gofmt -l internal/store/` +Expected: no output. + +```bash +git add backend/internal/store/profile.go backend/internal/store/profile_test.go +git commit -m "$(cat <<'EOF' +store: scope GetProfile/UpdateProfile to a user_id + +Part of per-user profile isolation: profile rows are no longer a global +singleton, so every read/write requires the caller's userID. +EOF +)" +``` + +--- + +## Task 5: Scope `workoutkinds.go` + `workoutpaces.go` to `user_id` + +**Files:** +- Modify: `backend/internal/store/workoutkinds.go` +- Modify: `backend/internal/store/workoutpaces.go` +- Modify: `backend/internal/store/workoutkinds_taxonomy_test.go` +- Modify: `backend/internal/store/workoutpaces_test.go` + +**Interfaces:** +- Produces: `func (db *DB) CreateWorkoutKind(ctx, userID int64, k WorkoutKind) (int64, error)`; `func (db *DB) UpdateWorkoutKind(ctx, userID int64, k WorkoutKind) error`; `func (db *DB) GetWorkoutKind(ctx, userID, id int64) (WorkoutKind, bool, error)`; `func (db *DB) GetWorkoutKindByName(ctx, userID int64, name string) (WorkoutKind, bool, error)`; `func (db *DB) ListWorkoutKinds(ctx, userID int64, activeOnly bool) ([]WorkoutKind, error)`; `func (db *DB) SoftDeleteWorkoutKind(ctx, userID, id int64) error`; `func (db *DB) GetWorkoutTypePace(ctx, userID, workoutKindID int64) (WorkoutTypePace, error)`; `func (db *DB) UpdateWorkoutTypePace(ctx, userID int64, p WorkoutTypePace) error`; `func (db *DB) ListWorkoutTypePaces(ctx, userID int64) ([]WorkoutTypePace, error)`. +- `workout_type_paces` has no `user_id` column of its own (see the design doc) — ownership is checked transitively via a join to `workout_kinds.user_id`. + +- [ ] **Step 1: Rewrite `backend/internal/store/workoutkinds.go`** + +```go +package store + +import ( + "context" + "database/sql" + "fmt" +) + +// WorkoutKind is a user-editable classification rule ("Easy", "Tempo", ...). +// RuleJSON holds the condition tree evaluated by internal/classify. +type WorkoutKind struct { + ID int64 + Name string + Description string + Color string + RuleJSON string + Priority int + IsActive bool + CreatedAt string + UpdatedAt string +} + +func scanWorkoutKind(row interface{ Scan(...any) error }) (WorkoutKind, error) { + var k WorkoutKind + err := row.Scan(&k.ID, &k.Name, &k.Description, &k.Color, &k.RuleJSON, &k.Priority, &k.IsActive, &k.CreatedAt, &k.UpdatedAt) + return k, err +} + +const workoutKindColumns = `id, name, description, color, rule_json, priority, is_active, created_at, updated_at` + +// CreateWorkoutKind inserts a new workout kind for userID and returns its id. +func (db *DB) CreateWorkoutKind(ctx context.Context, userID int64, k WorkoutKind) (int64, error) { + res, err := db.ExecContext(ctx, ` + INSERT INTO workout_kinds (user_id, name, description, color, rule_json, priority, is_active) + VALUES (?,?,?,?,?,?,?)`, + userID, k.Name, k.Description, k.Color, k.RuleJSON, k.Priority, k.IsActive) + if err != nil { + return 0, fmt.Errorf("create workout kind %q for user %d: %w", k.Name, userID, err) + } + return res.LastInsertId() +} + +// UpdateWorkoutKind updates an existing workout kind's editable fields, +// scoped so it can only ever affect a row owned by userID. +func (db *DB) UpdateWorkoutKind(ctx context.Context, userID int64, k WorkoutKind) error { + _, err := db.ExecContext(ctx, ` + UPDATE workout_kinds SET name=?, description=?, color=?, rule_json=?, priority=?, is_active=?, updated_at=datetime('now') + WHERE id=? AND user_id=?`, + k.Name, k.Description, k.Color, k.RuleJSON, k.Priority, k.IsActive, k.ID, userID) + if err != nil { + return fmt.Errorf("update workout kind %d for user %d: %w", k.ID, userID, err) + } + return nil +} + +// GetWorkoutKind fetches one workout kind by id, scoped to userID. +func (db *DB) GetWorkoutKind(ctx context.Context, userID, id int64) (WorkoutKind, bool, error) { + row := db.QueryRowContext(ctx, `SELECT `+workoutKindColumns+` FROM workout_kinds WHERE id = ? AND user_id = ?`, id, userID) + k, err := scanWorkoutKind(row) + if err == sql.ErrNoRows { + return WorkoutKind{}, false, nil + } + if err != nil { + return WorkoutKind{}, false, fmt.Errorf("get workout kind %d for user %d: %w", id, userID, err) + } + return k, true, nil +} + +// GetWorkoutKindByName fetches one workout kind by name, scoped to userID +// (the same name can exist for different users -- see UNIQUE(user_id, name)). +func (db *DB) GetWorkoutKindByName(ctx context.Context, userID int64, name string) (WorkoutKind, bool, error) { + row := db.QueryRowContext(ctx, `SELECT `+workoutKindColumns+` FROM workout_kinds WHERE name = ? AND user_id = ?`, name, userID) + k, err := scanWorkoutKind(row) + if err == sql.ErrNoRows { + return WorkoutKind{}, false, nil + } + if err != nil { + return WorkoutKind{}, false, fmt.Errorf("get workout kind %q for user %d: %w", name, userID, err) + } + return k, true, nil +} + +// ListWorkoutKinds returns userID's workout kinds. If activeOnly, +// soft-deleted (is_active=0) kinds are excluded. +func (db *DB) ListWorkoutKinds(ctx context.Context, userID int64, activeOnly bool) ([]WorkoutKind, error) { + query := `SELECT ` + workoutKindColumns + ` FROM workout_kinds WHERE user_id = ?` + if activeOnly { + query += ` AND is_active = 1` + } + query += ` ORDER BY priority DESC, name` + + rows, err := db.QueryContext(ctx, query, userID) + if err != nil { + return nil, fmt.Errorf("list workout kinds for user %d: %w", userID, err) + } + defer rows.Close() + + kinds := []WorkoutKind{} + for rows.Next() { + k, err := scanWorkoutKind(rows) + if err != nil { + return nil, fmt.Errorf("scan workout kind row: %w", err) + } + kinds = append(kinds, k) + } + return kinds, rows.Err() +} + +// SoftDeleteWorkoutKind sets is_active=0, keeping history (kind_assignments +// referencing it) intact. Scoped to userID. +func (db *DB) SoftDeleteWorkoutKind(ctx context.Context, userID, id int64) error { + _, err := db.ExecContext(ctx, `UPDATE workout_kinds SET is_active=0, updated_at=datetime('now') WHERE id=? AND user_id=?`, id, userID) + if err != nil { + return fmt.Errorf("soft delete workout kind %d for user %d: %w", id, userID, err) + } + return nil +} +``` + +- [ ] **Step 2: Rewrite `backend/internal/store/workoutpaces.go`** + +```go +package store + +import ( + "context" + "database/sql" + "fmt" +) + +// WorkoutTypePace is a workout kind's user-declared target pace range and HR +// range (percent of heart rate reserve). Informational only -- never read by +// the classification rule engine. No history: fields are overwritten in +// place. Has no user_id column of its own -- ownership is checked via a +// join to workout_kinds.user_id, since it's always accessed 1:1 through a +// specific workout kind. +type WorkoutTypePace struct { + WorkoutKindID int64 + PaceMinSecPerKm *float64 + PaceMaxSecPerKm *float64 + HRMinPctHRR *float64 + HRMaxPctHRR *float64 +} + +func scanWorkoutTypePace(row interface{ Scan(...any) error }) (WorkoutTypePace, error) { + var p WorkoutTypePace + err := row.Scan(&p.WorkoutKindID, &p.PaceMinSecPerKm, &p.PaceMaxSecPerKm, &p.HRMinPctHRR, &p.HRMaxPctHRR) + return p, err +} + +const workoutTypePaceColumns = `wtp.workout_kind_id, wtp.pace_min_sec_per_km, wtp.pace_max_sec_per_km, wtp.hr_min_pct_hrr, wtp.hr_max_pct_hrr` + +// GetWorkoutTypePace fetches the pace/zone row for one workout kind, scoped +// to userID via a join to workout_kinds. +func (db *DB) GetWorkoutTypePace(ctx context.Context, userID, workoutKindID int64) (WorkoutTypePace, error) { + row := db.QueryRowContext(ctx, ` + SELECT `+workoutTypePaceColumns+` + FROM workout_type_paces wtp + JOIN workout_kinds wk ON wk.id = wtp.workout_kind_id + WHERE wtp.workout_kind_id = ? AND wk.user_id = ?`, workoutKindID, userID) + p, err := scanWorkoutTypePace(row) + if err == sql.ErrNoRows { + return WorkoutTypePace{WorkoutKindID: workoutKindID}, nil + } + if err != nil { + return WorkoutTypePace{}, fmt.Errorf("get workout type pace for kind %d (user %d): %w", workoutKindID, userID, err) + } + return p, nil +} + +// UpdateWorkoutTypePace overwrites the pace/zone row for one workout kind, +// scoped so it can only ever affect a kind owned by userID. +func (db *DB) UpdateWorkoutTypePace(ctx context.Context, userID int64, p WorkoutTypePace) error { + _, err := db.ExecContext(ctx, ` + UPDATE workout_type_paces SET pace_min_sec_per_km=?, pace_max_sec_per_km=?, hr_min_pct_hrr=?, hr_max_pct_hrr=? + WHERE workout_kind_id=? AND workout_kind_id IN (SELECT id FROM workout_kinds WHERE user_id=?)`, + p.PaceMinSecPerKm, p.PaceMaxSecPerKm, p.HRMinPctHRR, p.HRMaxPctHRR, p.WorkoutKindID, userID) + if err != nil { + return fmt.Errorf("update workout type pace for kind %d (user %d): %w", p.WorkoutKindID, userID, err) + } + return nil +} + +// ListWorkoutTypePaces returns every one of userID's workout kinds' pace/zone rows. +func (db *DB) ListWorkoutTypePaces(ctx context.Context, userID int64) ([]WorkoutTypePace, error) { + rows, err := db.QueryContext(ctx, ` + SELECT `+workoutTypePaceColumns+` + FROM workout_type_paces wtp + JOIN workout_kinds wk ON wk.id = wtp.workout_kind_id + WHERE wk.user_id = ? + ORDER BY wtp.workout_kind_id`, userID) + if err != nil { + return nil, fmt.Errorf("list workout type paces for user %d: %w", userID, err) + } + defer rows.Close() + + paces := []WorkoutTypePace{} + for rows.Next() { + p, err := scanWorkoutTypePace(rows) + if err != nil { + return nil, fmt.Errorf("scan workout type pace row: %w", err) + } + paces = append(paces, p) + } + return paces, rows.Err() +} +``` + +- [ ] **Step 2: Fix `workoutkinds_taxonomy_test.go` and `workoutpaces_test.go`** + +Both files currently call things like `db.ListWorkoutKinds(ctx, false)` or `db.CreateWorkoutKind(ctx, WorkoutKind{...})` against a bare `openTestDB(t)`. For each test function in both files: add `userID, err := db.ProvisionUser(ctx, "test-sub", "Test")` (checking the error) right after `db := openTestDB(t)`, then insert `userID` as the new second argument (right after `ctx`) into every `CreateWorkoutKind`/`UpdateWorkoutKind`/`GetWorkoutKind`/`GetWorkoutKindByName`/`ListWorkoutKinds`/`SoftDeleteWorkoutKind`/`GetWorkoutTypePace`/`UpdateWorkoutTypePace`/`ListWorkoutTypePaces` call in that function. Note `ListWorkoutKinds`/`ListWorkoutTypePaces` will now also return `ProvisionUser`'s 8 seeded kinds in addition to whatever the test itself creates — check whether any test asserts an exact count and adjust that expected count to include the 8 seeded ones (e.g. a test creating 2 more kinds and asserting `len(kinds) == 2` needs to become `len(kinds) == 10`, or better, filter to just the kind(s) the test created by name/id before asserting on count). + +- [ ] **Step 3: Build, fix remaining call sites, run tests** + +Run: `cd backend && go build ./... && go vet ./internal/store/...` +Expected: fix every remaining `internal/store/*_test.go` compile error the same way (only within `internal/store` for this task — `internal/sync`, `internal/api`, `cmd/seedsample` are handled in later tasks). + +Run: `cd backend && go test ./internal/store/... -run 'TestWorkoutKind|TestWorkoutTypePace' -v` +Expected: PASS. + +- [ ] **Step 4: `gofmt` and commit** + +Run: `cd backend && gofmt -l internal/store/` +Expected: no output. + +```bash +git add backend/internal/store/workoutkinds.go backend/internal/store/workoutpaces.go backend/internal/store/workoutkinds_taxonomy_test.go backend/internal/store/workoutpaces_test.go +git commit -m "$(cat <<'EOF' +store: scope workout kinds and their pace/HR ranges to a user_id + +workout_kinds gains a real user_id column (Task 1); workout_type_paces has +none of its own and is scoped via a join to workout_kinds instead, since +it's always accessed through a specific kind. +EOF +)" +``` + +--- + +## Task 6: Scope `activities.go` to `user_id` + +**Files:** +- Modify: `backend/internal/store/activities.go` +- Modify: `backend/internal/store/store_test.go` (fixes `TestUpsertActivity_InsertThenUpdateIsIdempotent`; other tests in this file are fixed in Task 7) + +**Interfaces:** +- Produces: `func (db *DB) UpsertActivity(ctx, userID int64, a Activity) (int64, error)`; `func (db *DB) GetActivity(ctx, userID, id int64) (Activity, bool, error)`; `func (db *DB) ListActivities(ctx, userID int64, f ActivityFilter) ([]Activity, error)`; `func (db *DB) ActivityExists(ctx, userID, garminActivityID int64) (bool, error)`; `func (db *DB) LatestActivityStartTime(ctx, userID int64) (string, bool, error)`; `func (db *DB) SetActivityDetails(ctx, userID, activityID int64, rawJSON string) error`; `func (db *DB) SetActivityWorkout(ctx, userID, activityID int64, rawJSON string) error`; `func (db *DB) SetActivitySplitsFetched(ctx, userID, activityID int64) error`; `func (db *DB) ActivitiesMissingDetails(ctx, userID int64, limit int) ([]Activity, error)`; `func (db *DB) CountActivitiesMissingDetails(ctx, userID int64) (int, error)`. + +- [ ] **Step 1: Rewrite `backend/internal/store/activities.go`** + +Keep the `Activity` struct exactly as-is; replace every function below it: + +```go +// UpsertActivity inserts a new activity for userID or updates the existing +// row for the same (userID, garmin_activity_id) pair (idempotent, safe to +// call on every sync pass), and returns its internal id. +func (db *DB) UpsertActivity(ctx context.Context, userID int64, a Activity) (int64, error) { + _, err := db.ExecContext(ctx, ` + INSERT INTO activities ( + user_id, garmin_activity_id, event_type_key, workout_id, start_time_utc, + duration_seconds, distance_meters, avg_hr, max_hr, + avg_speed_mps, elevation_gain_m, + aerobic_training_effect, anaerobic_training_effect, vo2max_value, + raw_json, updated_at + ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?, datetime('now')) + ON CONFLICT(user_id, garmin_activity_id) DO UPDATE SET + event_type_key=excluded.event_type_key, + workout_id=excluded.workout_id, + start_time_utc=excluded.start_time_utc, + duration_seconds=excluded.duration_seconds, + distance_meters=excluded.distance_meters, + avg_hr=excluded.avg_hr, + max_hr=excluded.max_hr, + avg_speed_mps=excluded.avg_speed_mps, + elevation_gain_m=excluded.elevation_gain_m, + aerobic_training_effect=excluded.aerobic_training_effect, + anaerobic_training_effect=excluded.anaerobic_training_effect, + vo2max_value=excluded.vo2max_value, + raw_json=excluded.raw_json, + updated_at=datetime('now') + `, + userID, a.GarminActivityID, a.EventTypeKey, a.WorkoutID, a.StartTimeUTC, + a.DurationSeconds, a.DistanceMeters, a.AvgHR, a.MaxHR, + a.AvgSpeedMps, a.ElevationGainM, + a.AerobicTrainingEffect, a.AnaerobicTrainingEffect, a.VO2MaxValue, + a.RawJSON, + ) + if err != nil { + return 0, fmt.Errorf("upsert activity %d for user %d: %w", a.GarminActivityID, userID, err) + } + + var id int64 + if err := db.QueryRowContext(ctx, `SELECT id FROM activities WHERE user_id = ? AND garmin_activity_id = ?`, userID, a.GarminActivityID).Scan(&id); err != nil { + return 0, fmt.Errorf("fetch id for activity %d (user %d): %w", a.GarminActivityID, userID, err) + } + return id, nil +} + +func scanActivity(row interface{ Scan(...any) error }) (Activity, error) { + var a Activity + err := row.Scan( + &a.ID, &a.GarminActivityID, &a.EventTypeKey, &a.WorkoutID, &a.StartTimeUTC, + &a.DurationSeconds, &a.DistanceMeters, &a.AvgHR, &a.MaxHR, + &a.AvgSpeedMps, &a.ElevationGainM, + &a.AerobicTrainingEffect, &a.AnaerobicTrainingEffect, &a.VO2MaxValue, + &a.RawJSON, &a.DetailsFetchedAt, &a.DetailsRawJSON, &a.SplitsFetchedAt, &a.WorkoutRawJSON, + &a.CreatedAt, &a.UpdatedAt, + ) + return a, err +} + +const activityColumns = ` + id, garmin_activity_id, event_type_key, workout_id, start_time_utc, + duration_seconds, distance_meters, avg_hr, max_hr, + avg_speed_mps, elevation_gain_m, + aerobic_training_effect, anaerobic_training_effect, vo2max_value, + raw_json, details_fetched_at, details_raw_json, splits_fetched_at, workout_raw_json, + created_at, updated_at +` + +// GetActivity fetches one activity by its internal id, scoped to userID. +func (db *DB) GetActivity(ctx context.Context, userID, id int64) (Activity, bool, error) { + row := db.QueryRowContext(ctx, `SELECT `+activityColumns+` FROM activities WHERE id = ? AND user_id = ?`, id, userID) + a, err := scanActivity(row) + if err == sql.ErrNoRows { + return Activity{}, false, nil + } + if err != nil { + return Activity{}, false, fmt.Errorf("get activity %d for user %d: %w", id, userID, err) + } + return a, true, nil +} + +// ActivityFilter narrows ListActivities results. Zero values mean "no filter". +type ActivityFilter struct { + FromDate string // inclusive, "YYYY-MM-DD" + ToDate string // inclusive, "YYYY-MM-DD" + Limit int + Offset int +} + +// ListActivities returns userID's activities newest-first, optionally +// filtered by date range. +func (db *DB) ListActivities(ctx context.Context, userID int64, f ActivityFilter) ([]Activity, error) { + query := `SELECT ` + activityColumns + ` FROM activities WHERE user_id = ?` + args := []any{userID} + if f.FromDate != "" { + query += ` AND start_time_utc >= ?` + args = append(args, f.FromDate) + } + if f.ToDate != "" { + query += ` AND start_time_utc <= ?` + args = append(args, f.ToDate+" 23:59:59") + } + query += ` ORDER BY start_time_utc DESC` + if f.Limit > 0 { + query += ` LIMIT ? OFFSET ?` + args = append(args, f.Limit, f.Offset) + } + + rows, err := db.QueryContext(ctx, query, args...) + if err != nil { + return nil, fmt.Errorf("list activities for user %d: %w", userID, err) + } + defer rows.Close() + + activities := []Activity{} + for rows.Next() { + a, err := scanActivity(rows) + if err != nil { + return nil, fmt.Errorf("scan activity row: %w", err) + } + activities = append(activities, a) + } + return activities, rows.Err() +} + +// ActivityExists reports whether userID already has an activity with this +// garmin_activity_id stored. +func (db *DB) ActivityExists(ctx context.Context, userID, garminActivityID int64) (bool, error) { + var id int64 + err := db.QueryRowContext(ctx, `SELECT id FROM activities WHERE user_id = ? AND garmin_activity_id = ?`, userID, garminActivityID).Scan(&id) + if err == sql.ErrNoRows { + return false, nil + } + if err != nil { + return false, fmt.Errorf("check activity %d exists for user %d: %w", garminActivityID, userID, err) + } + return true, nil +} + +// LatestActivityStartTime returns userID's most recently started activity's +// start_time_utc, used to compute the incremental sync window. +func (db *DB) LatestActivityStartTime(ctx context.Context, userID int64) (string, bool, error) { + var t string + err := db.QueryRowContext(ctx, `SELECT start_time_utc FROM activities WHERE user_id = ? ORDER BY start_time_utc DESC LIMIT 1`, userID).Scan(&t) + if err == sql.ErrNoRows { + return "", false, nil + } + if err != nil { + return "", false, fmt.Errorf("latest activity start time for user %d: %w", userID, err) + } + return t, true, nil +} + +// SetActivityDetails records that get_activity_details has been fetched for +// this activity, storing the raw response for future reprocessing. +func (db *DB) SetActivityDetails(ctx context.Context, userID, activityID int64, rawJSON string) error { + _, err := db.ExecContext(ctx, ` + UPDATE activities SET details_fetched_at = datetime('now'), details_raw_json = ?, updated_at = datetime('now') + WHERE id = ? AND user_id = ?`, rawJSON, activityID, userID) + if err != nil { + return fmt.Errorf("set activity %d details for user %d: %w", activityID, userID, err) + } + return nil +} + +// SetActivityWorkout stores the raw get_workout_by_id() response used to +// compute this activity's laps' target pace/HR bands. +func (db *DB) SetActivityWorkout(ctx context.Context, userID, activityID int64, rawJSON string) error { + _, err := db.ExecContext(ctx, ` + UPDATE activities SET workout_raw_json = ?, updated_at = datetime('now') + WHERE id = ? AND user_id = ?`, rawJSON, activityID, userID) + if err != nil { + return fmt.Errorf("set activity %d workout for user %d: %w", activityID, userID, err) + } + return nil +} + +// SetActivitySplitsFetched records that get_activity_splits has been fetched +// for this activity. +func (db *DB) SetActivitySplitsFetched(ctx context.Context, userID, activityID int64) error { + _, err := db.ExecContext(ctx, ` + UPDATE activities SET splits_fetched_at = datetime('now'), updated_at = datetime('now') + WHERE id = ? AND user_id = ?`, activityID, userID) + if err != nil { + return fmt.Errorf("set activity %d splits fetched for user %d: %w", activityID, userID, err) + } + return nil +} + +// ActivitiesMissingDetails returns userID's activities that haven't had +// get_activity_details/get_activity_splits fetched yet, for the lazy +// background detail-fill pass. +func (db *DB) ActivitiesMissingDetails(ctx context.Context, userID int64, limit int) ([]Activity, error) { + rows, err := db.QueryContext(ctx, `SELECT `+activityColumns+` FROM activities + WHERE user_id = ? AND (details_fetched_at IS NULL OR splits_fetched_at IS NULL) + ORDER BY start_time_utc DESC LIMIT ?`, userID, limit) + if err != nil { + return nil, fmt.Errorf("list activities missing details for user %d: %w", userID, err) + } + defer rows.Close() + + activities := []Activity{} + for rows.Next() { + a, err := scanActivity(rows) + if err != nil { + return nil, fmt.Errorf("scan activity row: %w", err) + } + activities = append(activities, a) + } + return activities, rows.Err() +} + +// CountActivitiesMissingDetails returns how many of userID's activities +// still need get_activity_details/get_activity_splits fetched, regardless +// of any per-call batch limit -- used to report overall remaining work. +func (db *DB) CountActivitiesMissingDetails(ctx context.Context, userID int64) (int, error) { + var n int + err := db.QueryRowContext(ctx, `SELECT COUNT(*) FROM activities + WHERE user_id = ? AND (details_fetched_at IS NULL OR splits_fetched_at IS NULL)`, userID).Scan(&n) + if err != nil { + return 0, fmt.Errorf("count activities missing details for user %d: %w", userID, err) + } + return n, nil +} +``` + +- [ ] **Step 2: Fix `TestUpsertActivity_InsertThenUpdateIsIdempotent` in `store_test.go`** + +```go +func TestUpsertActivity_InsertThenUpdateIsIdempotent(t *testing.T) { + db := openTestDB(t) + ctx := context.Background() + userID, err := db.ProvisionUser(ctx, "test-sub", "Test") + if err != nil { + t.Fatalf("ProvisionUser: %v", err) + } + + a := Activity{ + GarminActivityID: 23554066504, + StartTimeUTC: "2026-07-11 05:02:35", + DurationSeconds: 1800, + DistanceMeters: 6858, + AvgHR: f(148), + RawJSON: `{"activityId":23554066504,"activityName":"Auriol - W2-5-Base Endurance","activityType":{"typeKey":"running"}}`, + } + + id, err := db.UpsertActivity(ctx, userID, a) + if err != nil { + t.Fatalf("UpsertActivity (insert): %v", err) + } + + a.AvgHR = f(150) // simulate a re-sync with a corrected value + id2, err := db.UpsertActivity(ctx, userID, a) + if err != nil { + t.Fatalf("UpsertActivity (update): %v", err) + } + if id != id2 { + t.Fatalf("expected same internal id across upserts, got %d then %d", id, id2) + } + + got, ok, err := db.GetActivity(ctx, userID, id) + if err != nil || !ok { + t.Fatalf("GetActivity: ok=%v err=%v", ok, err) + } + if *got.AvgHR != 150 { + t.Errorf("AvgHR = %v, want 150 (update should have applied)", *got.AvgHR) + } + + all, err := db.ListActivities(ctx, userID, ActivityFilter{}) + if err != nil { + t.Fatalf("ListActivities: %v", err) + } + if len(all) != 1 { + t.Fatalf("expected exactly 1 activity after upsert-update, got %d", len(all)) + } +} +``` + +- [ ] **Step 3: Add a cross-user activity-uniqueness test to `store_test.go`** + +```go +func TestUpsertActivity_SameGarminActivityIDAllowedAcrossDifferentUsers(t *testing.T) { + db := openTestDB(t) + ctx := context.Background() + userA, err := db.ProvisionUser(ctx, "sub-a", "A") + if err != nil { + t.Fatalf("ProvisionUser(a): %v", err) + } + userB, err := db.ProvisionUser(ctx, "sub-b", "B") + if err != nil { + t.Fatalf("ProvisionUser(b): %v", err) + } + + activity := Activity{GarminActivityID: 999, StartTimeUTC: "2026-07-11 05:00:00", RawJSON: "{}"} + if _, err := db.UpsertActivity(ctx, userA, activity); err != nil { + t.Fatalf("UpsertActivity(a): %v", err) + } + if _, err := db.UpsertActivity(ctx, userB, activity); err != nil { + t.Fatalf("expected the same garmin_activity_id to be allowed for a different user, got: %v", err) + } + + aActivities, err := db.ListActivities(ctx, userA, ActivityFilter{}) + if err != nil || len(aActivities) != 1 { + t.Fatalf("ListActivities(a): got %d, err=%v", len(aActivities), err) + } + bActivities, err := db.ListActivities(ctx, userB, ActivityFilter{}) + if err != nil || len(bActivities) != 1 { + t.Fatalf("ListActivities(b): got %d, err=%v", len(bActivities), err) + } +} +``` + +- [ ] **Step 4: Build, fix remaining `internal/store` call sites, run tests** + +Run: `cd backend && go build ./... && go vet ./internal/store/...` +Expected: fix any remaining compile errors within `internal/store/` the same way (Task 7's tests still reference `UpsertActivity`/`GetActivity` without `userID` too — fix those now if `go vet` flags them here, since Task 7 assumes activities.go is already scoped). + +Run: `cd backend && go test ./internal/store/... -run 'TestUpsertActivity' -v` +Expected: PASS. + +- [ ] **Step 5: `gofmt` and commit** + +Run: `cd backend && gofmt -l internal/store/` +Expected: no output. + +```bash +git add backend/internal/store/activities.go backend/internal/store/store_test.go +git commit -m "$(cat <<'EOF' +store: scope activities to a user_id + +garmin_activity_id uniqueness becomes per-user (UNIQUE(user_id, +garmin_activity_id), added in Task 1) so two users' Garmin accounts can +never collide even in the unlikely event their activity ids coincide. +EOF +)" +``` + +--- + +## Task 7: Scope `assignments.go` + `laps.go` + `samples.go` via activity ownership + +**Files:** +- Modify: `backend/internal/store/assignments.go` +- Modify: `backend/internal/store/laps.go` +- Modify: `backend/internal/store/samples.go` +- Modify: `backend/internal/store/store_test.go` (remaining tests: `TestKindAssignment_AppendOnlyHistoryAndCurrentView`, `TestReplaceLapsIsIdempotent`) + +**Interfaces:** +- Consumes: `activities.user_id` from Task 6. +- Produces: `func (db *DB) InsertKindAssignment(ctx, userID int64, a KindAssignment) (int64, error)`; `func (db *DB) CurrentAssignment(ctx, userID, activityID int64) (KindAssignment, bool, error)`; `func (db *DB) ReviewQueue(ctx, userID int64) ([]KindAssignment, error)`; `func (db *DB) AllCurrentAssignments(ctx, userID int64) ([]KindAssignment, error)`; `func (db *DB) AssignmentsForKind(ctx, userID, workoutKindID int64) ([]KindAssignment, error)`; `func (db *DB) ReplaceLaps(ctx, userID, activityID int64, laps []Lap) error`; `func (db *DB) LapsForActivity(ctx, userID, activityID int64) ([]Lap, error)`; `func (db *DB) ReplaceActivitySamples(ctx, userID, activityID int64, samples []Sample) error`; `func (db *DB) SamplesForActivity(ctx, userID, activityID int64) ([]Sample, error)`. +- None of `kind_assignments`/`laps`/`activity_samples` gained a `user_id` column in Task 1 — ownership is always checked via a join/subquery against `activities.user_id`, since these tables are only ever accessed through a specific activity. + +- [ ] **Step 1: Rewrite `backend/internal/store/assignments.go`** + +Keep the `KindAssignment` struct and the `AssignmentSource*`/`AssignmentStatus*` constants exactly as-is; replace everything below them: + +```go +// InsertKindAssignment appends a new assignment row for an activity owned +// by userID. +func (db *DB) InsertKindAssignment(ctx context.Context, userID int64, a KindAssignment) (int64, error) { + var exists int + err := db.QueryRowContext(ctx, `SELECT 1 FROM activities WHERE id = ? AND user_id = ?`, a.ActivityID, userID).Scan(&exists) + if err == sql.ErrNoRows { + return 0, fmt.Errorf("insert kind assignment: activity %d not found for user %d", a.ActivityID, userID) + } + if err != nil { + return 0, fmt.Errorf("insert kind assignment for activity %d (user %d): %w", a.ActivityID, userID, err) + } + + res, err := db.ExecContext(ctx, ` + INSERT INTO kind_assignments (activity_id, workout_kind_id, assignment_source, status, confidence, candidate_kinds_json) + VALUES (?,?,?,?,?,?)`, + a.ActivityID, a.WorkoutKindID, a.AssignmentSource, a.Status, a.Confidence, a.CandidateKindsJSON) + if err != nil { + return 0, fmt.Errorf("insert kind assignment for activity %d: %w", a.ActivityID, err) + } + return res.LastInsertId() +} + +func scanKindAssignment(row interface{ Scan(...any) error }) (KindAssignment, error) { + var a KindAssignment + err := row.Scan(&a.ID, &a.ActivityID, &a.WorkoutKindID, &a.AssignmentSource, &a.Status, &a.Confidence, &a.CandidateKindsJSON, &a.CreatedAt) + return a, err +} + +const kindAssignmentColumns = `a.id, a.activity_id, a.workout_kind_id, a.assignment_source, a.status, a.confidence, a.candidate_kinds_json, a.created_at` + +// CurrentAssignment returns the latest assignment for an activity owned by +// userID, if any. +func (db *DB) CurrentAssignment(ctx context.Context, userID, activityID int64) (KindAssignment, bool, error) { + row := db.QueryRowContext(ctx, ` + SELECT `+kindAssignmentColumns+` + FROM current_kind_assignment a + JOIN activities ON activities.id = a.activity_id + WHERE a.activity_id = ? AND activities.user_id = ?`, activityID, userID) + a, err := scanKindAssignment(row) + if err == sql.ErrNoRows { + return KindAssignment{}, false, nil + } + if err != nil { + return KindAssignment{}, false, fmt.Errorf("current assignment for activity %d (user %d): %w", activityID, userID, err) + } + return a, true, nil +} + +// ReviewQueue returns userID's activities whose current assignment status +// is needs_review, newest first. +func (db *DB) ReviewQueue(ctx context.Context, userID int64) ([]KindAssignment, error) { + rows, err := db.QueryContext(ctx, ` + SELECT `+kindAssignmentColumns+` + FROM current_kind_assignment a + JOIN activities ON activities.id = a.activity_id + WHERE activities.user_id = ? AND a.status = ? + ORDER BY a.created_at DESC`, userID, AssignmentStatusNeedsReview) + if err != nil { + return nil, fmt.Errorf("review queue for user %d: %w", userID, err) + } + defer rows.Close() + + assignments := []KindAssignment{} + for rows.Next() { + a, err := scanKindAssignment(rows) + if err != nil { + return nil, fmt.Errorf("scan kind assignment row: %w", err) + } + assignments = append(assignments, a) + } + return assignments, rows.Err() +} + +// AllCurrentAssignments returns the latest assignment for every one of +// userID's activities that has one, regardless of status or source -- the +// basis for deciding which activities a global reclassify pass may touch. +func (db *DB) AllCurrentAssignments(ctx context.Context, userID int64) ([]KindAssignment, error) { + rows, err := db.QueryContext(ctx, ` + SELECT `+kindAssignmentColumns+` + FROM current_kind_assignment a + JOIN activities ON activities.id = a.activity_id + WHERE activities.user_id = ?`, userID) + if err != nil { + return nil, fmt.Errorf("all current assignments for user %d: %w", userID, err) + } + defer rows.Close() + + assignments := []KindAssignment{} + for rows.Next() { + a, err := scanKindAssignment(rows) + if err != nil { + return nil, fmt.Errorf("scan kind assignment row: %w", err) + } + assignments = append(assignments, a) + } + return assignments, rows.Err() +} + +// AssignmentsForKind returns every historical assignment (for userID's +// activities) where the given workout kind was the resolved kind (regardless +// of source), oldest first -- the basis for progression-over-time charts. +func (db *DB) AssignmentsForKind(ctx context.Context, userID, workoutKindID int64) ([]KindAssignment, error) { + rows, err := db.QueryContext(ctx, ` + SELECT `+kindAssignmentColumns+` + FROM current_kind_assignment a + JOIN activities ON activities.id = a.activity_id + WHERE activities.user_id = ? AND a.workout_kind_id = ? AND a.status = ? + ORDER BY a.created_at ASC`, + userID, workoutKindID, AssignmentStatusAssigned) + if err != nil { + return nil, fmt.Errorf("assignments for kind %d (user %d): %w", workoutKindID, userID, err) + } + defer rows.Close() + + assignments := []KindAssignment{} + for rows.Next() { + a, err := scanKindAssignment(rows) + if err != nil { + return nil, fmt.Errorf("scan kind assignment row: %w", err) + } + assignments = append(assignments, a) + } + return assignments, rows.Err() +} +``` + +- [ ] **Step 2: Rewrite `backend/internal/store/laps.go`** + +Keep the `Lap` struct exactly as-is; replace the two functions below it: + +```go +// ReplaceLaps deletes any existing laps for activityID (owned by userID) +// and inserts the given set, so re-syncing an activity's splits is +// idempotent. +func (db *DB) ReplaceLaps(ctx context.Context, userID, activityID int64, laps []Lap) error { + var exists int + err := db.QueryRowContext(ctx, `SELECT 1 FROM activities WHERE id = ? AND user_id = ?`, activityID, userID).Scan(&exists) + if err == sql.ErrNoRows { + return fmt.Errorf("replace laps: activity %d not found for user %d", activityID, userID) + } + if err != nil { + return fmt.Errorf("replace laps for activity %d (user %d): %w", activityID, userID, err) + } + + tx, err := db.BeginTx(ctx, nil) + if err != nil { + return fmt.Errorf("begin replace laps tx: %w", err) + } + defer tx.Rollback() + + if _, err := tx.ExecContext(ctx, `DELETE FROM laps WHERE activity_id = ?`, activityID); err != nil { + return fmt.Errorf("delete existing laps for activity %d: %w", activityID, err) + } + + for _, l := range laps { + _, err := tx.ExecContext(ctx, ` + INSERT INTO laps ( + activity_id, lap_index, avg_speed_mps, + intensity_type, hr_drift_bpm_per_min, hr_recovery_bpm_per_min, + target_pace_low_mps, target_pace_high_mps, target_hr_low_bpm, target_hr_high_bpm, raw_json + ) VALUES (?,?,?,?,?,?,?,?,?,?,?)`, + activityID, l.LapIndex, l.AvgSpeedMps, + l.IntensityType, l.HRDriftBpmPerMin, l.HRRecoveryBpmPerMin, + l.TargetPaceLowMps, l.TargetPaceHighMps, l.TargetHRLowBpm, l.TargetHRHighBpm, l.RawJSON, + ) + if err != nil { + return fmt.Errorf("insert lap %d for activity %d: %w", l.LapIndex, activityID, err) + } + } + return tx.Commit() +} + +// LapsForActivity returns all laps for an activity owned by userID, +// ordered by lap_index. +func (db *DB) LapsForActivity(ctx context.Context, userID, activityID int64) ([]Lap, error) { + rows, err := db.QueryContext(ctx, ` + SELECT laps.id, laps.activity_id, laps.lap_index, laps.avg_speed_mps, + laps.intensity_type, laps.hr_drift_bpm_per_min, laps.hr_recovery_bpm_per_min, + laps.target_pace_low_mps, laps.target_pace_high_mps, laps.target_hr_low_bpm, laps.target_hr_high_bpm, laps.raw_json + FROM laps + JOIN activities ON activities.id = laps.activity_id + WHERE laps.activity_id = ? AND activities.user_id = ? + ORDER BY laps.lap_index`, activityID, userID) + if err != nil { + return nil, fmt.Errorf("laps for activity %d (user %d): %w", activityID, userID, err) + } + defer rows.Close() + + laps := []Lap{} + for rows.Next() { + var l Lap + if err := rows.Scan( + &l.ID, &l.ActivityID, &l.LapIndex, &l.AvgSpeedMps, + &l.IntensityType, &l.HRDriftBpmPerMin, &l.HRRecoveryBpmPerMin, + &l.TargetPaceLowMps, &l.TargetPaceHighMps, &l.TargetHRLowBpm, &l.TargetHRHighBpm, &l.RawJSON, + ); err != nil { + return nil, fmt.Errorf("scan lap row: %w", err) + } + laps = append(laps, l) + } + return laps, rows.Err() +} +``` + +- [ ] **Step 3: Rewrite `backend/internal/store/samples.go`** + +Keep the `Sample` struct exactly as-is; replace the two functions below it: + +```go +// ReplaceActivitySamples deletes any existing samples for activityID (owned +// by userID) and bulk-inserts the given set, so re-syncing an activity's +// details is idempotent. +func (db *DB) ReplaceActivitySamples(ctx context.Context, userID, activityID int64, samples []Sample) error { + var exists int + err := db.QueryRowContext(ctx, `SELECT 1 FROM activities WHERE id = ? AND user_id = ?`, activityID, userID).Scan(&exists) + if err == sql.ErrNoRows { + return fmt.Errorf("replace activity samples: activity %d not found for user %d", activityID, userID) + } + if err != nil { + return fmt.Errorf("replace activity samples for activity %d (user %d): %w", activityID, userID, err) + } + + tx, err := db.BeginTx(ctx, nil) + if err != nil { + return fmt.Errorf("begin replace samples tx: %w", err) + } + defer tx.Rollback() + + if _, err := tx.ExecContext(ctx, `DELETE FROM activity_samples WHERE activity_id = ?`, activityID); err != nil { + return fmt.Errorf("delete existing samples for activity %d: %w", activityID, err) + } + + stmt, err := tx.PrepareContext(ctx, ` + INSERT INTO activity_samples (activity_id, elapsed_seconds, timestamp_ms, heart_rate, speed_mps, distance_m, elevation_m) + VALUES (?,?,?,?,?,?,?)`) + if err != nil { + return fmt.Errorf("prepare insert sample: %w", err) + } + defer stmt.Close() + + for _, s := range samples { + if _, err := stmt.ExecContext(ctx, activityID, s.ElapsedSeconds, s.TimestampMs, s.HeartRate, s.SpeedMps, s.DistanceM, s.ElevationM); err != nil { + return fmt.Errorf("insert sample for activity %d: %w", activityID, err) + } + } + return tx.Commit() +} + +// SamplesForActivity returns all samples for an activity owned by userID, +// ordered by elapsed_seconds. +func (db *DB) SamplesForActivity(ctx context.Context, userID, activityID int64) ([]Sample, error) { + rows, err := db.QueryContext(ctx, ` + SELECT activity_samples.elapsed_seconds, activity_samples.timestamp_ms, activity_samples.heart_rate, + activity_samples.speed_mps, activity_samples.distance_m, activity_samples.elevation_m + FROM activity_samples + JOIN activities ON activities.id = activity_samples.activity_id + WHERE activity_samples.activity_id = ? AND activities.user_id = ? + ORDER BY activity_samples.elapsed_seconds`, activityID, userID) + if err != nil { + return nil, fmt.Errorf("samples for activity %d (user %d): %w", activityID, userID, err) + } + defer rows.Close() + + samples := []Sample{} + for rows.Next() { + var s Sample + if err := rows.Scan(&s.ElapsedSeconds, &s.TimestampMs, &s.HeartRate, &s.SpeedMps, &s.DistanceM, &s.ElevationM); err != nil { + return nil, fmt.Errorf("scan sample row: %w", err) + } + samples = append(samples, s) + } + return samples, rows.Err() +} +``` + +- [ ] **Step 4: Fix `TestKindAssignment_AppendOnlyHistoryAndCurrentView` and `TestReplaceLapsIsIdempotent` in `store_test.go`** + +For each, add `userID, err := db.ProvisionUser(ctx, "test-sub", "Test")` right after `db := openTestDB(t)` (checking the error), then thread `userID` as the new argument (right after `ctx`) into every `UpsertActivity`, `CreateWorkoutKind`, `InsertKindAssignment`, `ReviewQueue`, `CurrentAssignment`, `AssignmentsForKind`, `ReplaceLaps`, `LapsForActivity` call in those two functions. `CreateWorkoutKind` was already scoped in Task 5, so this task only needs to add `userID` to the assignment/lap-specific calls. + +- [ ] **Step 5: Add a cross-user activity-ownership test to `store_test.go`** + +```go +func TestCurrentAssignment_ScopedToOwningUser(t *testing.T) { + db := openTestDB(t) + ctx := context.Background() + userA, err := db.ProvisionUser(ctx, "sub-a", "A") + if err != nil { + t.Fatalf("ProvisionUser(a): %v", err) + } + userB, err := db.ProvisionUser(ctx, "sub-b", "B") + if err != nil { + t.Fatalf("ProvisionUser(b): %v", err) + } + + activityID, err := db.UpsertActivity(ctx, userA, Activity{GarminActivityID: 1, StartTimeUTC: "2026-07-11 05:00:00", RawJSON: "{}"}) + if err != nil { + t.Fatalf("UpsertActivity: %v", err) + } + if _, err := db.InsertKindAssignment(ctx, userA, KindAssignment{ + ActivityID: activityID, AssignmentSource: AssignmentSourceRuleEngine, Status: AssignmentStatusNeedsReview, CandidateKindsJSON: "[]", + }); err != nil { + t.Fatalf("InsertKindAssignment: %v", err) + } + + if _, found, err := db.CurrentAssignment(ctx, userB, activityID); err != nil || found { + t.Fatalf("expected userB to not see userA's activity assignment, found=%v err=%v", found, err) + } + if _, err := db.InsertKindAssignment(ctx, userB, KindAssignment{ + ActivityID: activityID, AssignmentSource: AssignmentSourceManual, Status: AssignmentStatusAssigned, CandidateKindsJSON: "[]", + }); err == nil { + t.Fatal("expected InsertKindAssignment to reject an activity that doesn't belong to userB") + } +} +``` + +- [ ] **Step 6: Build, fix remaining `internal/store` call sites, run tests** + +Run: `cd backend && go build ./... && go vet ./internal/store/...` +Expected: fix any remaining compile errors within `internal/store/` the same way. + +Run: `cd backend && go test ./internal/store/... -v` +Expected: every test in `internal/store` PASSes (this is the last store-layer scoping task; the whole package should compile and pass cleanly now, aside from anything intentionally deferred to Task 9's cross-user tests, which don't exist as failures — they're new). + +- [ ] **Step 7: `gofmt` and commit** + +Run: `cd backend && gofmt -l internal/store/` +Expected: no output. + +```bash +git add backend/internal/store/assignments.go backend/internal/store/laps.go backend/internal/store/samples.go backend/internal/store/store_test.go +git commit -m "$(cat <<'EOF' +store: scope kind assignments, laps, and samples via activity ownership + +None of these three tables gained their own user_id column -- they're +always accessed through a specific activity, so ownership is checked via a +join/subquery against activities.user_id instead. +EOF +)" +``` + +--- + +## Task 8: Scope `syncstate.go` + `syncruns.go` + `reset.go` to `user_id` + +**Files:** +- Modify: `backend/internal/store/syncstate.go` +- Modify: `backend/internal/store/syncruns.go` +- Modify: `backend/internal/store/reset.go` +- Modify: `backend/internal/store/syncstate_test.go` +- Modify: `backend/internal/store/reset_test.go` + +**Interfaces:** +- Produces: `func (db *DB) GetSyncState(ctx, userID int64) (SyncState, error)`; `func (db *DB) UpdateSyncState(ctx, userID int64, earliestSyncedDate string, complete bool) error`; `func (db *DB) StartSyncRun(ctx, userID int64, kind string) (int64, error)`; `func (db *DB) FinishSyncRun(ctx, userID, id int64, activitiesFetched int, errMsg *string) error`; `func (db *DB) LatestSyncRun(ctx, userID int64) (SyncRun, bool, error)`; `func (db *DB) ListSyncRuns(ctx, userID int64, limit int) ([]SyncRun, error)`; `func (db *DB) ResetAllSyncedData(ctx, userID int64) error`. +- This is the last store-package scoping task — after it, every method Task 10 (`internal/sync`) and Task 14/15 (`internal/api`) depend on already has its final signature. + +- [ ] **Step 1: Rewrite `backend/internal/store/syncstate.go`** + +Keep the `SyncState` struct as-is; replace the two functions: + +```go +// GetSyncState returns userID's current backfill watermark. +func (db *DB) GetSyncState(ctx context.Context, userID int64) (SyncState, error) { + var s SyncState + err := db.QueryRowContext(ctx, `SELECT earliest_synced_date, backfill_complete FROM sync_state WHERE user_id = ?`, userID). + Scan(&s.EarliestSyncedDate, &s.BackfillComplete) + if err != nil { + return SyncState{}, fmt.Errorf("get sync state for user %d: %w", userID, err) + } + return s, nil +} + +// UpdateSyncState records progress of a backfill run for userID. +func (db *DB) UpdateSyncState(ctx context.Context, userID int64, earliestSyncedDate string, complete bool) error { + _, err := db.ExecContext(ctx, ` + UPDATE sync_state SET earliest_synced_date = ?, backfill_complete = ? WHERE user_id = ?`, + earliestSyncedDate, complete, userID) + if err != nil { + return fmt.Errorf("update sync state for user %d: %w", userID, err) + } + return nil +} +``` + +- [ ] **Step 2: Rewrite `backend/internal/store/syncruns.go`** + +Keep the `SyncKind*`/`SyncStatus*` constants and `SyncRun` struct as-is; replace the four functions: + +```go +// StartSyncRun records a new in-progress sync run for userID and returns its id. +func (db *DB) StartSyncRun(ctx context.Context, userID int64, kind string) (int64, error) { + res, err := db.ExecContext(ctx, ` + INSERT INTO sync_runs (user_id, kind, started_at, status) VALUES (?, ?, datetime('now'), ?)`, + userID, kind, SyncStatusRunning) + if err != nil { + return 0, fmt.Errorf("start sync run for user %d: %w", userID, err) + } + return res.LastInsertId() +} + +// FinishSyncRun marks a sync run (owned by userID) as finished, recording +// how many activities were fetched and whether it succeeded. +func (db *DB) FinishSyncRun(ctx context.Context, userID, id int64, activitiesFetched int, errMsg *string) error { + status := SyncStatusSuccess + if errMsg != nil { + status = SyncStatusError + } + _, err := db.ExecContext(ctx, ` + UPDATE sync_runs SET finished_at = datetime('now'), activities_fetched = ?, status = ?, error_message = ? + WHERE id = ? AND user_id = ?`, activitiesFetched, status, errMsg, id, userID) + if err != nil { + return fmt.Errorf("finish sync run %d for user %d: %w", id, userID, err) + } + return nil +} + +// LatestSyncRun returns userID's most recent sync run, if any. +func (db *DB) LatestSyncRun(ctx context.Context, userID int64) (SyncRun, bool, error) { + row := db.QueryRowContext(ctx, ` + SELECT id, kind, started_at, finished_at, activities_fetched, status, error_message + FROM sync_runs WHERE user_id = ? ORDER BY id DESC LIMIT 1`, userID) + var r SyncRun + err := row.Scan(&r.ID, &r.Kind, &r.StartedAt, &r.FinishedAt, &r.ActivitiesFetched, &r.Status, &r.ErrorMessage) + if err == sql.ErrNoRows { + return SyncRun{}, false, nil + } + if err != nil { + return SyncRun{}, false, fmt.Errorf("latest sync run for user %d: %w", userID, err) + } + return r, true, nil +} + +// ListSyncRuns returns userID's recent sync runs, newest first. +func (db *DB) ListSyncRuns(ctx context.Context, userID int64, limit int) ([]SyncRun, error) { + rows, err := db.QueryContext(ctx, ` + SELECT id, kind, started_at, finished_at, activities_fetched, status, error_message + FROM sync_runs WHERE user_id = ? ORDER BY id DESC LIMIT ?`, userID, limit) + if err != nil { + return nil, fmt.Errorf("list sync runs for user %d: %w", userID, err) + } + defer rows.Close() + + runs := []SyncRun{} + for rows.Next() { + var r SyncRun + if err := rows.Scan(&r.ID, &r.Kind, &r.StartedAt, &r.FinishedAt, &r.ActivitiesFetched, &r.Status, &r.ErrorMessage); err != nil { + return nil, fmt.Errorf("scan sync run row: %w", err) + } + runs = append(runs, r) + } + return runs, rows.Err() +} +``` + +- [ ] **Step 3: Rewrite `backend/internal/store/reset.go`** + +```go +package store + +import ( + "context" + "fmt" +) + +// ResetAllSyncedData deletes every synced activity belonging to userID +// (cascading to its laps, activity_samples, and kind_assignments via ON +// DELETE CASCADE) and rewinds userID's backfill watermark, so a subsequent +// Backfill starts a genuinely fresh pull instead of thinking history is +// already covered. userID's workout kinds (their taxonomy) are left +// untouched. +func (db *DB) ResetAllSyncedData(ctx context.Context, userID int64) error { + tx, err := db.BeginTx(ctx, nil) + if err != nil { + return fmt.Errorf("begin reset tx: %w", err) + } + defer tx.Rollback() + + if _, err := tx.ExecContext(ctx, `DELETE FROM activities WHERE user_id = ?`, userID); err != nil { + return fmt.Errorf("delete activities for user %d: %w", userID, err) + } + if _, err := tx.ExecContext(ctx, `UPDATE sync_state SET earliest_synced_date = NULL, backfill_complete = 0 WHERE user_id = ?`, userID); err != nil { + return fmt.Errorf("reset sync state for user %d: %w", userID, err) + } + return tx.Commit() +} +``` + +- [ ] **Step 4: Fix `syncstate_test.go` and `reset_test.go`** + +Both files call `db.GetSyncState(ctx)`/`db.UpdateSyncState(ctx, ...)`/`db.ResetAllSyncedData(ctx)` against a bare `openTestDB(t)`. In each test function, add `userID, err := db.ProvisionUser(ctx, "test-sub", "Test")` right after `db := openTestDB(t)` (checking the error), then add `userID` as the new argument (right after `ctx`) to every call. + +- [ ] **Step 5: Build, fix any remaining `internal/store` call sites, run the full store suite** + +Run: `cd backend && go build ./internal/store/... && go vet ./internal/store/...` +Expected: clean (this is the last store-scoping task, so `internal/store` itself should now fully compile — remaining errors elsewhere in `go build ./...`, e.g. `internal/sync`, `internal/api`, `cmd/seedsample`, are expected and fixed in Tasks 10/14/15/17). + +Run: `cd backend && go test ./internal/store/... -v` +Expected: every test in the package PASSes. + +- [ ] **Step 6: `gofmt` and commit** + +Run: `cd backend && gofmt -l internal/store/` +Expected: no output. + +```bash +git add backend/internal/store/syncstate.go backend/internal/store/syncruns.go backend/internal/store/reset.go backend/internal/store/syncstate_test.go backend/internal/store/reset_test.go +git commit -m "$(cat <<'EOF' +store: scope sync state, sync runs, and reset to a user_id + +Completes the store-layer scoping pass -- every store method touching +per-user data now requires an explicit userID. +EOF +)" +``` + +--- + +## Task 9: Store-layer cross-user isolation tests + +**Files:** +- Create: `backend/internal/store/isolation_test.go` + +**Interfaces:** +- Consumes: every scoped store method from Tasks 4-8. This task adds no new production code — it's a dedicated adversarial verification pass for the core security property (Tasks 6/7 already added a couple of these inline; this task rounds out coverage for `profile`, `workout_kinds`/`workout_type_paces`, and `sync_state`/`sync_runs`, which didn't get one yet). + +- [ ] **Step 1: Write `backend/internal/store/isolation_test.go`** + +```go +package store + +import ( + "context" + "testing" +) + +// TestIsolation_ProfileNeverLeaksAcrossUsers confirms GetProfile only ever +// returns the row matching the given userID, and that two users' profiles +// can diverge independently. +func TestIsolation_ProfileNeverLeaksAcrossUsers(t *testing.T) { + db := openTestDB(t) + ctx := context.Background() + + userA, err := db.ProvisionUser(ctx, "sub-a", "A") + if err != nil { + t.Fatalf("ProvisionUser(a): %v", err) + } + userB, err := db.ProvisionUser(ctx, "sub-b", "B") + if err != nil { + t.Fatalf("ProvisionUser(b): %v", err) + } + + profileA, err := db.GetProfile(ctx, userA) + if err != nil { + t.Fatalf("GetProfile(a): %v", err) + } + profileA.GarminEmail = "a@example.com" + if err := db.UpdateProfile(ctx, userA, profileA); err != nil { + t.Fatalf("UpdateProfile(a): %v", err) + } + + profileB, err := db.GetProfile(ctx, userB) + if err != nil { + t.Fatalf("GetProfile(b): %v", err) + } + if profileB.GarminEmail == "a@example.com" { + t.Fatal("userB's profile picked up userA's GarminEmail update") + } + + // Attempting to update B's profile "as A" (i.e. calling UpdateProfile + // with userA but a struct that happens to describe B's desired state) + // only ever touches the row WHERE user_id = userA -- confirm B is + // unaffected by any such call. + if err := db.UpdateProfile(ctx, userA, Profile{GarminEmail: "still-a-only@example.com"}); err != nil { + t.Fatalf("UpdateProfile(a) second call: %v", err) + } + profileB2, err := db.GetProfile(ctx, userB) + if err != nil { + t.Fatalf("GetProfile(b) after A's update: %v", err) + } + if profileB2.GarminEmail == "still-a-only@example.com" { + t.Fatal("userA's UpdateProfile call leaked into userB's row") + } +} + +// TestIsolation_WorkoutKindsAndPacesNeverLeakAcrossUsers confirms +// GetWorkoutKind/GetWorkoutTypePace return not-found for a kind id that +// exists but belongs to a different user, and that UpdateWorkoutKind / +// UpdateWorkoutTypePace can never mutate another user's row even if handed +// that row's real id. +func TestIsolation_WorkoutKindsAndPacesNeverLeakAcrossUsers(t *testing.T) { + db := openTestDB(t) + ctx := context.Background() + + userA, err := db.ProvisionUser(ctx, "sub-a", "A") + if err != nil { + t.Fatalf("ProvisionUser(a): %v", err) + } + userB, err := db.ProvisionUser(ctx, "sub-b", "B") + if err != nil { + t.Fatalf("ProvisionUser(b): %v", err) + } + + kindsA, err := db.ListWorkoutKinds(ctx, userA, false) + if err != nil || len(kindsA) == 0 { + t.Fatalf("ListWorkoutKinds(a): len=%d err=%v", len(kindsA), err) + } + targetKindID := kindsA[0].ID + + if _, found, err := db.GetWorkoutKind(ctx, userB, targetKindID); err != nil || found { + t.Fatalf("expected userB not to see userA's kind %d, found=%v err=%v", targetKindID, found, err) + } + + // Attempt to update A's kind "as B" -- must silently affect zero rows, + // not A's real row. + if err := db.UpdateWorkoutKind(ctx, userB, WorkoutKind{ID: targetKindID, Name: "Hijacked", RuleJSON: "{}"}); err != nil { + t.Fatalf("UpdateWorkoutKind(as b): %v", err) + } + stillA, found, err := db.GetWorkoutKind(ctx, userA, targetKindID) + if err != nil || !found { + t.Fatalf("GetWorkoutKind(a) after B's attempted update: found=%v err=%v", found, err) + } + if stillA.Name == "Hijacked" { + t.Fatal("userB's UpdateWorkoutKind call was able to mutate userA's kind") + } + + if _, err := db.GetWorkoutTypePace(ctx, userB, targetKindID); err != nil { + t.Fatalf("GetWorkoutTypePace(as b) should return a zero-value pace, not an error, got: %v", err) + } + minPace := 300.0 + if err := db.UpdateWorkoutTypePace(ctx, userB, WorkoutTypePace{WorkoutKindID: targetKindID, PaceMinSecPerKm: &minPace}); err != nil { + t.Fatalf("UpdateWorkoutTypePace(as b): %v", err) + } + paceA, err := db.GetWorkoutTypePace(ctx, userA, targetKindID) + if err != nil { + t.Fatalf("GetWorkoutTypePace(a): %v", err) + } + if paceA.PaceMinSecPerKm != nil { + t.Fatal("userB's UpdateWorkoutTypePace call was able to mutate userA's pace row") + } +} + +// TestIsolation_SyncStateAndRunsNeverLeakAcrossUsers confirms each user's +// backfill watermark and sync run history are independent. +func TestIsolation_SyncStateAndRunsNeverLeakAcrossUsers(t *testing.T) { + db := openTestDB(t) + ctx := context.Background() + + userA, err := db.ProvisionUser(ctx, "sub-a", "A") + if err != nil { + t.Fatalf("ProvisionUser(a): %v", err) + } + userB, err := db.ProvisionUser(ctx, "sub-b", "B") + if err != nil { + t.Fatalf("ProvisionUser(b): %v", err) + } + + if err := db.UpdateSyncState(ctx, userA, "2020-01-01", true); err != nil { + t.Fatalf("UpdateSyncState(a): %v", err) + } + stateB, err := db.GetSyncState(ctx, userB) + if err != nil { + t.Fatalf("GetSyncState(b): %v", err) + } + if stateB.BackfillComplete || stateB.EarliestSyncedDate != nil { + t.Fatalf("userA's UpdateSyncState leaked into userB's sync_state: %+v", stateB) + } + + runID, err := db.StartSyncRun(ctx, userA, SyncKindBackfill) + if err != nil { + t.Fatalf("StartSyncRun(a): %v", err) + } + runsB, err := db.ListSyncRuns(ctx, userB, 10) + if err != nil { + t.Fatalf("ListSyncRuns(b): %v", err) + } + if len(runsB) != 0 { + t.Fatalf("expected userB to have 0 sync runs, got %d (userA's run id=%d)", len(runsB), runID) + } + + // Finishing A's run "as B" must not succeed against A's row. + if err := db.FinishSyncRun(ctx, userB, runID, 5, nil); err != nil { + t.Fatalf("FinishSyncRun(as b): %v", err) + } + latestA, found, err := db.LatestSyncRun(ctx, userA) + if err != nil || !found { + t.Fatalf("LatestSyncRun(a): found=%v err=%v", found, err) + } + if latestA.Status == SyncStatusSuccess { + t.Fatal("userB's FinishSyncRun call was able to mutate userA's sync run") + } +} +``` + +- [ ] **Step 2: Run the new isolation tests** + +Run: `cd backend && go test ./internal/store/... -run TestIsolation -v` +Expected: PASS. If any test fails, it points at a real cross-user data leak in a Task 4-8 query — fix the query (it's missing a `WHERE user_id = ?` or the join condition is wrong), not the test. + +- [ ] **Step 3: Run the full store suite one more time** + +Run: `cd backend && gofmt -l internal/store/ && go vet ./internal/store/... && go test ./internal/store/... -v` +Expected: `gofmt` prints nothing; all PASS. + +- [ ] **Step 4: Commit** + +```bash +git add backend/internal/store/isolation_test.go +git commit -m "$(cat <<'EOF' +store: add cross-user isolation tests + +Dedicated adversarial coverage for the core security property: no store +method can read or mutate another user's profile, workout kinds/paces, or +sync state/runs, even when handed that other user's real row id. +EOF +)" +``` + +--- + +## Task 10: `internal/sync.Service` becomes per-user + +**Files:** +- Modify: `backend/internal/sync/service.go` +- Modify: `backend/internal/sync/service_test.go` + +**Interfaces:** +- Consumes: every scoped store method from Tasks 4-8. +- Produces: `func NewService(g garmin.Client, db *store.DB, userID int64, cfg Config, now func() time.Time) *Service`. Every other `Service` method (`Backfill`, `IncrementalSync`, `FullSync`, `ResetAll`, `FillPendingDetails`, `ClassifyActivity`, `Progress`) keeps its exact current signature — the userID is baked into the `Service` instance at construction (one `Service` per logged-in user, per the design), so callers in `internal/api` (Tasks 13/15) never pass a userID to these methods directly, only to `NewService` itself. + +- [ ] **Step 1: Add the `userID` field and thread it through every store call in `backend/internal/sync/service.go`** + +Change the `Service` struct and `NewService`: + +```go +// Service is the sync orchestrator, scoped to one user -- every store call +// it makes is for userID's data only. +type Service struct { + garmin garmin.Client + db *store.DB + userID int64 + cfg Config + now func() time.Time + + progressMu sync.Mutex + progress Progress +} + +// NewService builds a Service scoped to userID. now defaults to time.Now if +// nil (tests can override it for deterministic date windows). +func NewService(g garmin.Client, db *store.DB, userID int64, cfg Config, now func() time.Time) *Service { + if now == nil { + now = time.Now + } + return &Service{garmin: g, db: db, userID: userID, cfg: cfg.withDefaults(), now: now} +} +``` + +Then, in every remaining method, prefix `s.userID` as the new argument to every `s.db.*` call. The full list of call sites to update (unchanged method signatures, only their bodies change): + +- `backfillCore`: `s.db.GetProfile(ctx)` → `s.db.GetProfile(ctx, s.userID)`; `s.db.GetSyncState(ctx)` → `s.db.GetSyncState(ctx, s.userID)`; both `s.db.UpdateSyncState(ctx, dateStr(...), ...)` calls → `s.db.UpdateSyncState(ctx, s.userID, dateStr(...), ...)`. +- `Backfill`/`IncrementalSync`/`FullSync`: every `s.db.StartSyncRun(ctx, store.SyncKind...)` → `s.db.StartSyncRun(ctx, s.userID, store.SyncKind...)`; every `s.db.FinishSyncRun(ctx, runID, ...)` → `s.db.FinishSyncRun(ctx, s.userID, runID, ...)`. +- `incrementalSyncCore`: `s.db.LatestActivityStartTime(ctx)` → `s.db.LatestActivityStartTime(ctx, s.userID)`. +- `ResetAll`: `s.db.ResetAllSyncedData(ctx)` → `s.db.ResetAllSyncedData(ctx, s.userID)`. +- `fetchAndStoreWindow`: `s.db.ActivityExists(ctx, a.ActivityID)` → `s.db.ActivityExists(ctx, s.userID, a.ActivityID)`; `s.db.UpsertActivity(ctx, toActivityRow(a))` → `s.db.UpsertActivity(ctx, s.userID, toActivityRow(a))`. +- `FillPendingDetails`: `s.db.ActivitiesMissingDetails(ctx, limit)` → `s.db.ActivitiesMissingDetails(ctx, s.userID, limit)`; `s.db.GetProfile(ctx)` → `s.db.GetProfile(ctx, s.userID)`. +- `fillActivityDetails`: `s.db.SetActivityWorkout(ctx, a.ID, ...)` → `s.db.SetActivityWorkout(ctx, s.userID, a.ID, ...)`; `s.db.ReplaceActivitySamples(ctx, a.ID, ...)` → `s.db.ReplaceActivitySamples(ctx, s.userID, a.ID, ...)`; `s.db.ReplaceLaps(ctx, a.ID, ...)` → `s.db.ReplaceLaps(ctx, s.userID, a.ID, ...)`; `s.db.SetActivityDetails(ctx, a.ID, ...)` → `s.db.SetActivityDetails(ctx, s.userID, a.ID, ...)`; `s.db.SetActivitySplitsFetched(ctx, a.ID)` → `s.db.SetActivitySplitsFetched(ctx, s.userID, a.ID)`. +- `ClassifyActivity`: `s.db.GetActivity(ctx, activityID)` → `s.db.GetActivity(ctx, s.userID, activityID)`; `s.db.LapsForActivity(ctx, activityID)` → `s.db.LapsForActivity(ctx, s.userID, activityID)`; `s.db.ListWorkoutKinds(ctx, true)` → `s.db.ListWorkoutKinds(ctx, s.userID, true)`; `s.db.GetProfile(ctx)` → `s.db.GetProfile(ctx, s.userID)`; `s.db.InsertKindAssignment(ctx, store.KindAssignment{...})` → `s.db.InsertKindAssignment(ctx, s.userID, store.KindAssignment{...})`. + +- [ ] **Step 2: Fix `backend/internal/sync/service_test.go`'s `NewService` calls** + +Every `NewService(m, db, Config{...}, fixedNow(...))` call in this file needs a `userID` inserted as the third argument. Add a shared helper near the top of the file (next to `openTestDB`/`fixedNow`): + +```go +func provisionTestUser(t *testing.T, db *store.DB) int64 { + t.Helper() + userID, err := db.ProvisionUser(context.Background(), "test-sub", "Test") + if err != nil { + t.Fatalf("ProvisionUser: %v", err) + } + return userID +} +``` + +Then in each test, right after `db := openTestDB(t)`, add `userID := provisionTestUser(t, db)`, and change `NewService(m, db, Config{}, fixedNow(...))` → `NewService(m, db, userID, Config{}, fixedNow(...))` (and similarly wherever `setBackfillHorizon(t, db, days)` is called, since that helper calls `db.GetProfile`/`db.UpdateProfile` directly — update its signature to `setBackfillHorizon(t *testing.T, db *store.DB, userID int64, days int)` and thread `userID` into its two calls, then update every call site to pass the test's `userID`). + +- [ ] **Step 3: Build and fix remaining call sites** + +Run: `cd backend && go build ./internal/sync/... && go vet ./internal/sync/...` +Expected: fix any remaining compile errors in `internal/sync/service_test.go` the same way (there may be more `NewService`/`setBackfillHorizon` calls further down the file than the ones shown above — use the compiler to find every one). + +- [ ] **Step 4: Run the sync package tests** + +Run: `cd backend && go test ./internal/sync/... -v` +Expected: PASS. + +- [ ] **Step 5: Add a cross-user sync isolation test** + +```go +func TestService_TwoUsersSyncIndependently(t *testing.T) { + db := openTestDB(t) + ctx := context.Background() + userA, err := db.ProvisionUser(ctx, "sub-a", "A") + if err != nil { + t.Fatalf("ProvisionUser(a): %v", err) + } + userB, err := db.ProvisionUser(ctx, "sub-b", "B") + if err != nil { + t.Fatalf("ProvisionUser(b): %v", err) + } + + mA := &mock.Client{Activities: []garmin.Activity{ + {ActivityID: 1, ActivityType: garmin.ActivityType{TypeKey: "running"}, StartTimeGMT: "2026-07-01 06:00:00", Distance: 5000, Duration: 1500}, + }} + mB := &mock.Client{Activities: []garmin.Activity{ + {ActivityID: 2, ActivityType: garmin.ActivityType{TypeKey: "running"}, StartTimeGMT: "2026-07-01 06:00:00", Distance: 8000, Duration: 2400}, + }} + svcA := NewService(mA, db, userA, Config{}, fixedNow(time.Date(2026, 7, 11, 0, 0, 0, 0, time.UTC))) + svcB := NewService(mB, db, userB, Config{}, fixedNow(time.Date(2026, 7, 11, 0, 0, 0, 0, time.UTC))) + + if err := svcA.Backfill(ctx); err != nil { + t.Fatalf("Backfill(a): %v", err) + } + if err := svcB.Backfill(ctx); err != nil { + t.Fatalf("Backfill(b): %v", err) + } + + activitiesA, err := db.ListActivities(ctx, userA, store.ActivityFilter{}) + if err != nil { + t.Fatalf("ListActivities(a): %v", err) + } + activitiesB, err := db.ListActivities(ctx, userB, store.ActivityFilter{}) + if err != nil { + t.Fatalf("ListActivities(b): %v", err) + } + if len(activitiesA) != 1 || activitiesA[0].GarminActivityID != 1 { + t.Fatalf("userA's activities = %+v, want exactly garmin id 1", activitiesA) + } + if len(activitiesB) != 1 || activitiesB[0].GarminActivityID != 2 { + t.Fatalf("userB's activities = %+v, want exactly garmin id 2", activitiesB) + } +} +``` + +- [ ] **Step 6: Run it** + +Run: `cd backend && go test ./internal/sync/... -run TestService_TwoUsersSyncIndependently -v` +Expected: PASS. + +- [ ] **Step 7: `gofmt` and commit** + +Run: `cd backend && gofmt -l internal/sync/` +Expected: no output. + +```bash +git add backend/internal/sync/service.go backend/internal/sync/service_test.go +git commit -m "$(cat <<'EOF' +sync: scope Service to one user per instance + +NewService now takes a userID, baked into the instance rather than passed +per-call -- matches internal/api's one-Service-per-logged-in-user model +(Task 13), so ClassifyActivity/Backfill/etc. keep their existing call +signatures unchanged everywhere they're already used. +EOF +)" +``` + +--- + +## Task 11: `internal/config` additions + +**Files:** +- Modify: `backend/internal/config/config.go` + +**Interfaces:** +- Produces: `Config.GarminTokenStoreRoot` (renamed from `GarminTokenStore`, same env var `GARMIN_TOKENSTORE`, now holds a root directory rather than a single path); `Config.LegacyOwnerOIDCSub` (new, optional, env var `GENIUSRUN_LEGACY_OWNER_OIDC_SUB`). Task 13's `main.go` wiring consumes both. + +- [ ] **Step 1: Rename `GarminTokenStore` → `GarminTokenStoreRoot` and add `LegacyOwnerOIDCSub`** + +In the `Config` struct, change: + +```go + // GarminTokenStoreRoot, if set, is the root directory under which each + // user's mcp-garmin session cache lives (one subdirectory per user id, + // e.g. "/3"), overriding mcp-garmin's default ~/.garth. Read from + // the same GARMIN_TOKENSTORE env var as before Task 1's per-user + // scoping -- only its meaning changed (a root directory rather than a + // single path). + GarminTokenStoreRoot string +``` + +And in `Load()`, change the field name in the struct literal: + +```go + GarminTokenStoreRoot: os.Getenv("GARMIN_TOKENSTORE"), +``` + +Add, in the `Config` struct near the OIDC fields: + +```go + // LegacyOwnerOIDCSub, if set, is used exactly once at startup (via + // store.ClaimLegacyOwner) to bind this deployment's pre-existing + // single-tenant data to one named OIDC subject after upgrading to + // per-user profiles. Safe to leave set indefinitely -- ClaimLegacyOwner + // no-ops once any user already exists. + LegacyOwnerOIDCSub string +``` + +And in `Load()`'s struct literal: + +```go + LegacyOwnerOIDCSub: os.Getenv("GENIUSRUN_LEGACY_OWNER_OIDC_SUB"), +``` + +This field is optional (no required-var check added) — a fresh install or an already-claimed deployment simply never uses it. + +- [ ] **Step 2: Build** + +Run: `cd backend && go build ./internal/config/...` +Expected: compiles clean (nothing else references `cfg.GarminTokenStore` yet inside this package). Other packages referencing the old field name (`cmd/geniusrund/main.go`) are fixed in Task 13. + +- [ ] **Step 3: `gofmt` and commit** + +Run: `cd backend && gofmt -l internal/config/` +Expected: no output. + +```bash +git add backend/internal/config/config.go +git commit -m "$(cat <<'EOF' +config: add per-user Garmin token store root and legacy-owner bootstrap var + +GarminTokenStore is renamed GarminTokenStoreRoot to reflect that it now +roots one subdirectory per user rather than a single session cache path. +EOF +)" +``` + +--- + +## Task 12: User-resolution middleware + `POST /api/setup` + +**Files:** +- Create: `backend/internal/api/usercontext.go` +- Create: `backend/internal/api/setup.go` +- Modify: `backend/internal/api/session.go` (extend `sessionMeResponse`, `handleSessionMe`) +- Create: `backend/internal/api/usercontext_test.go` +- Create: `backend/internal/api/setup_test.go` + +**Interfaces:** +- Consumes: `store.GetUserBySub`/`store.ProvisionUser` (Task 2), `auth.ClaimsFromContext` (existing). +- Produces: `func (s *Server) resolveUser(next http.Handler) http.Handler` (middleware); `func requireProvisionedUser(next http.Handler) http.Handler` (middleware); `func userFromContext(ctx context.Context) (resolvedUser, bool)`; `func userIDFromContext(ctx context.Context) int64`; `func (s *Server) handleSetup(w http.ResponseWriter, r *http.Request)`. Task 13 wires all of these into the router; every handler task (14/15) calls `userIDFromContext`. + +- [ ] **Step 1: Write the failing tests in `backend/internal/api/usercontext_test.go`** + +Since `auth`'s session context key is unexported, this test drives the real chain (`auth.RequireSession` then `s.resolveUser`) via a minted session cookie — the same `doJSON` helper `api_test.go` already uses for every other handler test — rather than trying to poke the context directly. + +```go +package api + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + + "geniusrun/backend/internal/auth" +) + +func TestResolveUser_AttachesResolvedUserWhenProvisioned(t *testing.T) { + s, db := newTestServer(t) + userID, err := db.ProvisionUser(context.Background(), "test-user", "Test User") + if err != nil { + t.Fatalf("ProvisionUser: %v", err) + } + + var gotUserID int64 + var gotOK bool + handler := auth.RequireSession(testSessionConfig.Secret)(s.resolveUser(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + u, ok := userFromContext(r.Context()) + gotUserID, gotOK = u.ID, ok + }))) + + rec := doJSON(t, handler, http.MethodGet, "/", nil) // doJSON's test cookie carries Sub: "test-user" + _ = rec + if !gotOK || gotUserID != userID { + t.Fatalf("resolveUser: ok=%v userID=%d, want ok=true userID=%d", gotOK, gotUserID, userID) + } +} + +func TestResolveUser_LeavesContextEmptyWhenNotProvisioned(t *testing.T) { + s, _ := newTestServer(t) + + var gotOK bool + handler := auth.RequireSession(testSessionConfig.Secret)(s.resolveUser(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, gotOK = userFromContext(r.Context()) + }))) + + doJSON(t, handler, http.MethodGet, "/", nil) // "test-user" sub, never provisioned in this test + if gotOK { + t.Fatal("expected userFromContext to report not-found for an unprovisioned sub") + } +} + +func TestRequireProvisionedUser_RejectsWhenNoUserResolved(t *testing.T) { + handler := requireProvisionedUser(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Fatal("handler should not be reached") + })) + req := httptest.NewRequest(http.MethodGet, "/", nil) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + if rec.Code != http.StatusForbidden { + t.Fatalf("status = %d, want 403", rec.Code) + } +} +``` + +- [ ] **Step 2: Run to verify these fail to compile (expected)** + +Run: `cd backend && go vet ./internal/api/...` +Expected: FAIL — `s.resolveUser`/`userFromContext`/`requireProvisionedUser` undefined. + +- [ ] **Step 3: Write `backend/internal/api/usercontext.go`** + +```go +package api + +import ( + "context" + "net/http" + + "geniusrun/backend/internal/auth" +) + +type userContextKey int + +const resolvedUserContextKey userContextKey = iota + +// resolvedUser is the geniusrun account (if any) bound to the current +// session's OIDC subject. +type resolvedUser struct { + ID int64 + DisplayName string +} + +// resolveUser runs after auth.RequireSession on every request and looks up +// whether the session's OIDC subject has a provisioned geniusrun user. It +// never blocks the request itself -- it only attaches the result (found or +// not) to context -- since a couple of routes (session/me, setup) must stay +// reachable for an authorized-but-not-yet-provisioned session. Routes that +// require a provisioned user are wrapped in requireProvisionedUser as well. +func (s *Server) resolveUser(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + claims, ok := auth.ClaimsFromContext(r.Context()) + if !ok { + // Unreachable in practice -- RequireSession already 401s before + // this middleware runs -- but fail closed rather than panic. + next.ServeHTTP(w, r) + return + } + u, found, err := s.DB.GetUserBySub(r.Context(), claims.Sub) + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + ctx := r.Context() + if found { + ctx = context.WithValue(ctx, resolvedUserContextKey, resolvedUser{ID: u.ID, DisplayName: u.DisplayName}) + } + next.ServeHTTP(w, r.WithContext(ctx)) + }) +} + +// requireProvisionedUser wraps routes that operate on a user's data: it +// 403s if the session's OIDC identity has no provisioned geniusrun user yet +// (resolveUser must run earlier in the chain). This -- not any +// client-supplied id -- is the only source of truth for "which user's data" +// a request may touch. +func requireProvisionedUser(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if _, ok := userFromContext(r.Context()); !ok { + writeError(w, http.StatusForbidden, "no profile provisioned for this account yet") + return + } + next.ServeHTTP(w, r) + }) +} + +// userFromContext returns the resolved user for the current session, as +// populated by resolveUser. +func userFromContext(ctx context.Context) (resolvedUser, bool) { + u, ok := ctx.Value(resolvedUserContextKey).(resolvedUser) + return u, ok +} + +// userIDFromContext is a convenience for the overwhelming majority of +// handlers, which only need the id. Panics if called somewhere +// requireProvisionedUser didn't already guarantee a resolved user -- that +// would be a routing bug, not a runtime condition to handle gracefully. +func userIDFromContext(ctx context.Context) int64 { + u, ok := userFromContext(ctx) + if !ok { + panic("api: userIDFromContext called without requireProvisionedUser in the middleware chain") + } + return u.ID +} +``` + +- [ ] **Step 4: Run the usercontext tests** + +Run: `cd backend && go test ./internal/api/... -run 'TestResolveUser|TestRequireProvisionedUser' -v` +Expected: PASS. + +- [ ] **Step 5: Extend `sessionMeResponse`/`handleSessionMe` in `backend/internal/api/session.go`** + +```go +type sessionMeResponse struct { + Name string `json:"name"` + Email string `json:"email"` + HasProfile bool `json:"has_profile"` + DisplayName string `json:"display_name,omitempty"` +} +``` + +```go +func (s *Server) handleSessionMe(w http.ResponseWriter, r *http.Request) { + claims, ok := auth.ClaimsFromContext(r.Context()) + if !ok { + // Unreachable in practice -- RequireSession already 401s before this + // handler runs -- but fail closed rather than panic if that ever changes. + writeError(w, http.StatusUnauthorized, "not authenticated") + return + } + resp := sessionMeResponse{Name: claims.Name, Email: claims.Email} + if u, found := userFromContext(r.Context()); found { + resp.HasProfile = true + resp.DisplayName = u.DisplayName + } + writeJSON(w, http.StatusOK, resp) +} +``` + +- [ ] **Step 6: Write the failing test for setup, then `backend/internal/api/setup.go`** + +`backend/internal/api/setup_test.go`: + +```go +package api + +import ( + "net/http" + "testing" +) + +func TestSetup_ProvisionsNewUserWithDisplayName(t *testing.T) { + s, db := newTestServer(t) + router := s.Router() + + rec := doJSON(t, router, http.MethodGet, "/api/session/me", nil) + var me sessionMeResponse + unmarshalBody(t, rec, &me) + if me.HasProfile { + t.Fatal("expected a brand-new session to have no profile yet") + } + + rec = doJSON(t, router, http.MethodPost, "/api/setup", map[string]any{"display_name": "Lucie"}) + if rec.Code != http.StatusOK { + t.Fatalf("setup status = %d, body = %s", rec.Code, rec.Body.String()) + } + + rec = doJSON(t, router, http.MethodGet, "/api/session/me", nil) + unmarshalBody(t, rec, &me) + if !me.HasProfile || me.DisplayName != "Lucie" { + t.Fatalf("session/me after setup = %+v, want HasProfile=true DisplayName=Lucie", me) + } + + u, found, err := db.GetUserBySub(newCtx(), "test-user") // doJSON's test cookie's Sub + if err != nil || !found { + t.Fatalf("GetUserBySub: found=%v err=%v", found, err) + } + if u.DisplayName != "Lucie" { + t.Errorf("stored DisplayName = %q, want Lucie", u.DisplayName) + } +} + +func TestSetup_RejectsEmptyDisplayName(t *testing.T) { + s, _ := newTestServer(t) + 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) + } +} + +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) + } + 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()) + } +} + +// unmarshalBody is a small shared helper -- add it once here; later handler +// test tasks may reuse it instead of repeating json.Unmarshal(rec.Body.Bytes(), ...) inline. +func unmarshalBody(t *testing.T, rec interface{ Result() *http.Response }, v any) { + t.Helper() + _ = rec // placeholder signature note removed below; see actual implementation +} +``` + +Replace that last placeholder helper with a real, correctly-typed one (it takes `*httptest.ResponseRecorder`, not an interface — the interface above was illustrative only, not real Go): + +```go +func unmarshalBody(t *testing.T, rec *httptest.ResponseRecorder, v any) { + t.Helper() + if err := json.Unmarshal(rec.Body.Bytes(), v); err != nil { + t.Fatalf("unmarshal response body %q: %v", rec.Body.String(), err) + } +} +``` + +(Add `"encoding/json"` and `"net/http/httptest"` to this file's imports.) + +Now write `backend/internal/api/setup.go`: + +```go +package api + +import ( + "encoding/json" + "net/http" + + "geniusrun/backend/internal/auth" +) + +func (s *Server) handleSetup(w http.ResponseWriter, r *http.Request) { + claims, ok := auth.ClaimsFromContext(r.Context()) + if !ok { + writeError(w, http.StatusUnauthorized, "not authenticated") + return + } + if _, found := userFromContext(r.Context()); found { + writeError(w, http.StatusConflict, "profile already exists for this account") + return + } + + var body struct { + DisplayName string `json:"display_name"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + writeError(w, http.StatusBadRequest, "invalid request body") + return + } + if body.DisplayName == "" { + writeError(w, http.StatusBadRequest, "display_name is required") + return + } + + userID, err := s.DB.ProvisionUser(r.Context(), claims.Sub, body.DisplayName) + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + writeJSON(w, http.StatusOK, map[string]any{"user_id": userID, "display_name": body.DisplayName}) +} +``` + +- [ ] **Step 7: Wire `/api/setup` into the router (temporary, minimal — Task 13 does the full router rewrite)** + +In `backend/internal/api/server.go`'s `Router()`, inside the existing `r.Group` that has `auth.RequireSession`, add right after the `r.Get("/session/me", ...)`/`r.Post("/session/logout", ...)` lines: + +```go + r.Use(s.resolveUser) + r.Post("/setup", s.handleSetup) +``` + +(`r.Use(s.resolveUser)` must come before any route registration in that group to apply to all of them, including the pre-existing profile/auth/sync/etc. routes below it — this is intentionally a minimal, functional wiring; Task 13 restructures this same group into the final two-tier shape with `requireProvisionedUser` around the data routes specifically.) + +- [ ] **Step 8: Run the full `internal/api` test suite** + +Run: `cd backend && go build ./internal/api/... && go test ./internal/api/... -v` +Expected: PASS for the new tests; other tests may now fail if they hit `userIDFromContext` panics somewhere — that's expected and addressed by Task 13's full router restructure plus Tasks 14/15's handler updates. If `go build` fails elsewhere (e.g. `cmd/geniusrund`), that's expected too (Task 13). + +- [ ] **Step 9: `gofmt` and commit** + +Run: `cd backend && gofmt -l internal/api/` +Expected: no output. + +```bash +git add backend/internal/api/usercontext.go backend/internal/api/usercontext_test.go backend/internal/api/setup.go backend/internal/api/setup_test.go backend/internal/api/session.go backend/internal/api/server.go +git commit -m "$(cat <<'EOF' +api: add user-resolution middleware and POST /api/setup + +resolveUser attaches the session's provisioned geniusrun user (if any) to +request context without blocking; requireProvisionedUser (wired fully in +Task 13) 403s routes that need one. GET /api/session/me now reports +has_profile/display_name so the frontend can show the setup screen. +EOF +)" +``` + +--- + +## Task 13: Per-user `garmin.Client`/`sync.Service` wiring in `api.Server` + +**Files:** +- Modify: `backend/internal/api/server.go` +- Modify: `backend/cmd/geniusrund/main.go` +- Modify: `backend/internal/api/api_test.go` (`newTestServer` helper) + +**Interfaces:** +- Consumes: `sync.NewService(g, db, userID, cfg, now)` (Task 10), `garmin.NewClient(cfg)` (unchanged), `requireProvisionedUser`/`userIDFromContext` (Task 12). +- Produces: `func NewServer(db *store.DB, garminFactory func(garmin.Config) garmin.Client, garminBase garmin.Config, syncConfig appsync.Config, authVerifier auth.Verifier, session SessionConfig) *Server`; `func (s *Server) garminFor(ctx context.Context, userID int64) (garmin.Client, error)`; `func (s *Server) syncFor(ctx context.Context, userID int64) (*appsync.Service, error)`; `func (s *Server) backgroundSync(userID int64, fn func(ctx context.Context) error) bool` (replaces the old unscoped version); `func (s *Server) RunIncrementalSyncForAllUsers(ctx context.Context)`. Tasks 14/15 call `s.garminFor`/`s.syncFor`/`s.backgroundSync(userID, ...)` from handlers instead of touching fixed `s.Garmin`/`s.Sync` fields (which no longer exist). + +- [ ] **Step 1: Rewrite the `Server` struct, `NewServer`, and add the per-user accessors in `backend/internal/api/server.go`** + +```go +// Package api is geniusrun's HTTP layer: REST handlers over internal/store, +// internal/garmin, and internal/sync. +package api + +import ( + "context" + "encoding/json" + "fmt" + "log" + "net/http" + "path/filepath" + "strconv" + "sync" + + "github.com/go-chi/chi/v5" + + "geniusrun/backend/internal/auth" + "geniusrun/backend/internal/garmin" + "geniusrun/backend/internal/store" + appsync "geniusrun/backend/internal/sync" +) + +// 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 + Auth auth.Verifier + Session SessionConfig + + // 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, 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. +func (s *Server) Router() http.Handler { + r := chi.NewRouter() + r.Use(corsMiddleware) + r.Route("/api", func(r chi.Router) { + r.Get("/health", s.handleHealth) + + // Unprotected: these two ARE the login flow, so they can't require + // a session yet. + r.Get("/session/login", s.handleSessionLogin) + r.Get("/session/callback", s.handleSessionCallback) + + r.Group(func(r chi.Router) { + r.Use(auth.RequireSession(s.Session.Secret)) + r.Use(s.resolveUser) + + r.Get("/session/me", s.handleSessionMe) + r.Post("/session/logout", s.handleSessionLogout) + r.Post("/setup", s.handleSetup) + + 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) + }) + }) + }) + return r +} + +// corsMiddleware allows the frontend dev server (a different port) to call +// this API. Reflecting any origin back is safe even with credentials +// enabled: this remains a single-operator app whose real access control is +// the OIDC login gate (internal/auth), not origin-based CSRF defense. +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") + w.Header().Set("Access-Control-Allow-Credentials", "true") + } + 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 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.userSyncRunning[userID] { + s.mu.Unlock() + return false + } + s.userSyncRunning[userID] = true + s.mu.Unlock() + + go func() { + defer func() { + s.mu.Lock() + s.userSyncRunning[userID] = false + s.mu.Unlock() + }() + if err := fn(context.Background()); err != nil { + log.Printf("api: background sync error (user %d): %v", userID, err) + } + }() + return true +} +``` + +- [ ] **Step 2: Update `backend/cmd/geniusrund/main.go`** + +```go +// Command geniusrund is geniusrun's backend server: syncs runs from Garmin, +// classifies them into workout kinds, and serves the REST API the frontend +// talks to. +package main + +import ( + "context" + "log" + "net/http" + "os/signal" + "syscall" + "time" + + "geniusrun/backend/internal/api" + "geniusrun/backend/internal/auth" + "geniusrun/backend/internal/config" + "geniusrun/backend/internal/garmin" + "geniusrun/backend/internal/store" + appsync "geniusrun/backend/internal/sync" +) + +func main() { + cfg, err := config.Load() + if err != nil { + log.Fatalf("config: %v", err) + } + + db, err := store.Open(cfg.DBPath) + if err != nil { + log.Fatalf("open database: %v", err) + } + defer db.Close() + + if cfg.LegacyOwnerOIDCSub != "" { + if err := db.ClaimLegacyOwner(context.Background(), cfg.LegacyOwnerOIDCSub); err != nil { + log.Fatalf("claim legacy owner: %v", err) + } + } + + authVerifier, err := auth.NewOIDCVerifier(context.Background(), auth.OIDCConfig{ + IssuerURL: cfg.OIDCIssuerURL, + ClientID: cfg.OIDCClientID, + ClientSecret: cfg.OIDCClientSecret, + RedirectURL: cfg.OIDCRedirectURL, + RequiredRole: cfg.OIDCRequiredRole, + }) + if err != nil { + log.Fatalf("oidc: %v", err) + } + + 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, + PublicBaseURL: cfg.PublicBaseURL, + }) + + ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) + defer stop() + + go runIncrementalSyncLoop(ctx, server, cfg.IncrementalSyncEvery) + + httpServer := &http.Server{Addr: cfg.Addr, Handler: server.Router()} + go func() { + log.Printf("geniusrund listening on %s", cfg.Addr) + if err := httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed { + log.Fatalf("http server: %v", err) + } + }() + + <-ctx.Done() + log.Println("shutting down...") + shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + if err := httpServer.Shutdown(shutdownCtx); err != nil { + log.Printf("http server shutdown: %v", err) + } +} + +// 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 { + select { + case <-ctx.Done(): + return + case <-ticker.C: + server.RunIncrementalSyncForAllUsers(ctx) + } + } +} +``` + +- [ ] **Step 3: Update `newTestServer` in `backend/internal/api/api_test.go`** + +```go +func newTestServer(t *testing.T) (*Server, *store.DB) { + t.Helper() + 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{} + garminFactory := func(garmin.Config) garmin.Client { return m } + s := NewServer(db, garminFactory, garmin.Config{}, appsync.Config{}, &authmock.Verifier{}, testSessionConfig) + return s, db +} +``` + +(Add `"geniusrun/backend/internal/garmin"` to this file's imports if not already present — it likely isn't, since the old test only imported `garmin/mock`.) + +Every existing test in `api_test.go` that constructs a server via `newTestServer` and then does its own OIDC-authenticated request now needs a provisioned user, since Task 13's router puts every data route behind `requireProvisionedUser`. Since `doJSON`'s test cookie always carries `Sub: "test-user"`, add one line near the top of `newTestServer` (after opening `db`, before returning) that provisions that exact user, so every existing test keeps working with zero further changes to individual test bodies: + +```go +func newTestServer(t *testing.T) (*Server, *store.DB) { + t.Helper() + 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() }) + + if _, err := db.ProvisionUser(context.Background(), "test-user", "Test User"); err != nil { + t.Fatalf("ProvisionUser: %v", err) + } + + m := &mock.Client{} + garminFactory := func(garmin.Config) garmin.Client { return m } + s := NewServer(db, garminFactory, garmin.Config{}, appsync.Config{}, &authmock.Verifier{}, testSessionConfig) + return s, db +} +``` + +This makes `newTestServer`'s pre-provisioned "test-user" the norm going forward — Task 12's `TestSetup_*` tests, which specifically need an *unprovisioned* session, already call `db.ProvisionUser`/expect no profile using their own `newTestServer(t)` call before setup runs, so double check those still make sense: `TestSetup_ProvisionsNewUserWithDisplayName` and `TestSetup_RejectsEmptyDisplayName` assumed a fresh, unprovisioned session — since `newTestServer` now *always* provisions `"test-user"`, those two tests need updating. Fix them now: + +```go +func TestSetup_ProvisionsNewUserWithDisplayName(t *testing.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) + var me sessionMeResponse + unmarshalBody(t, rec, &me) + if me.HasProfile { + t.Fatal("expected a brand-new session to have no profile yet") + } + + rec = doJSON(t, router, http.MethodPost, "/api/setup", map[string]any{"display_name": "Lucie"}) + if rec.Code != http.StatusOK { + t.Fatalf("setup status = %d, body = %s", rec.Code, rec.Body.String()) + } + + rec = doJSON(t, router, http.MethodGet, "/api/session/me", nil) + unmarshalBody(t, rec, &me) + if !me.HasProfile || me.DisplayName != "Lucie" { + t.Fatalf("session/me after setup = %+v, want HasProfile=true DisplayName=Lucie", me) + } + + u, found, err := db.GetUserBySub(newCtx(), "test-user") + if err != nil || !found { + t.Fatalf("GetUserBySub: found=%v err=%v", found, err) + } + if u.DisplayName != "Lucie" { + t.Errorf("stored DisplayName = %q, want Lucie", u.DisplayName) + } +} + +func TestSetup_RejectsEmptyDisplayName(t *testing.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) + } +} +``` + +`TestSetup_RejectsWhenAlreadyProvisioned` (Task 12) already calls `db.ProvisionUser(newCtx(), "test-user", "Already Here")` explicitly on top of a `newTestServer(t)` call — since `newTestServer` now provisions `"test-user"` itself, that explicit call becomes redundant and will fail with a UNIQUE constraint error (`users.oidc_sub`). Remove that now-redundant `db.ProvisionUser` call from that test — `newTestServer` alone already provisions it. + +- [ ] **Step 4: Build everything and fix remaining call sites** + +Run: `cd backend && go build ./... 2>&1 | head -50` +Expected: remaining errors are all inside `internal/api/*.go` handler files (`profile.go`, `activities.go`, `sync.go`, `kinds.go`, `review.go`, `reclassify.go`, `progression.go`, `auth.go`) and `cmd/seedsample/main.go` — referencing `s.Garmin`/`s.Sync` (now removed) or store methods missing `userID`. These are fixed in Tasks 14/15/17; confirm no errors remain in `server.go`, `main.go`, or `api_test.go` itself. + +- [ ] **Step 5: `gofmt` and commit** + +Run: `cd backend && gofmt -l internal/api/ cmd/geniusrund/` +Expected: no output. + +```bash +git add backend/internal/api/server.go backend/cmd/geniusrund/main.go backend/internal/api/api_test.go backend/internal/api/setup_test.go +git commit -m "$(cat <<'EOF' +api: build per-user garmin.Client/sync.Service via lazy caching + +Server no longer holds one fixed Garmin/Sync pair -- garminFor/syncFor +build and cache one instance per user, keyed off their own profile's +Garmin credentials and a per-user token-store subdirectory. The background +incremental sync loop now iterates every provisioned user each tick +instead of syncing one global account. +EOF +)" +``` + +--- + +## Task 14: Thread `userID` into `profile.go`, `kinds.go`, `auth.go`, `progression.go` + +**Files:** +- Modify: `backend/internal/api/profile.go` +- Modify: `backend/internal/api/kinds.go` +- Modify: `backend/internal/api/auth.go` +- Modify: `backend/internal/api/progression.go` +- Modify: `backend/internal/api/api_test.go` (no new call-site changes expected beyond what Task 13 already made — this task only touches handler bodies, not test bodies, since the HTTP-level test assertions are unchanged) + +**Interfaces:** +- Consumes: `userIDFromContext(ctx)` (Task 12), `s.garminFor(ctx, userID)` (Task 13), every scoped store method (Tasks 4-8). + +- [ ] **Step 1: Update `backend/internal/api/profile.go`** + +Keep `validateProfile` unchanged; replace the two handlers: + +```go +func (s *Server) handleGetProfile(w http.ResponseWriter, r *http.Request) { + userID := userIDFromContext(r.Context()) + p, err := s.DB.GetProfile(r.Context(), userID) + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + writeJSON(w, http.StatusOK, p) +} + +func (s *Server) handleUpdateProfile(w http.ResponseWriter, r *http.Request) { + userID := userIDFromContext(r.Context()) + var p store.Profile + if err := json.NewDecoder(r.Body).Decode(&p); err != nil { + writeError(w, http.StatusBadRequest, "invalid request body") + return + } + if err := validateProfile(p); err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + + if err := s.DB.UpdateProfile(r.Context(), userID, p); err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + client, err := s.garminFor(r.Context(), userID) + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + client.UpdateCredentials(p.GarminEmail, p.GarminPassword) + + updated, err := s.DB.GetProfile(r.Context(), userID) + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + writeJSON(w, http.StatusOK, updated) +} +``` + +- [ ] **Step 2: Update `backend/internal/api/kinds.go`** + +Keep `workoutKindResponse`, `workoutKindRequest`, and `(req workoutKindRequest) validate()` unchanged; replace `toWorkoutKindResponse` and the three handlers: + +```go +func (s *Server) toWorkoutKindResponse(r *http.Request, k store.WorkoutKind) (workoutKindResponse, error) { + userID := userIDFromContext(r.Context()) + pace, err := s.DB.GetWorkoutTypePace(r.Context(), userID, k.ID) + if err != nil { + return workoutKindResponse{}, err + } + return workoutKindResponse{ + WorkoutKind: k, + PaceMinSecPerKm: pace.PaceMinSecPerKm, + PaceMaxSecPerKm: pace.PaceMaxSecPerKm, + HRMinPctHRR: pace.HRMinPctHRR, + HRMaxPctHRR: pace.HRMaxPctHRR, + }, nil +} + +func (s *Server) handleListWorkoutKinds(w http.ResponseWriter, r *http.Request) { + userID := userIDFromContext(r.Context()) + activeOnly := r.URL.Query().Get("include_inactive") != "true" + kinds, err := s.DB.ListWorkoutKinds(r.Context(), userID, activeOnly) + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + resp := make([]workoutKindResponse, 0, len(kinds)) + for _, k := range kinds { + wr, err := s.toWorkoutKindResponse(r, k) + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + resp = append(resp, wr) + } + writeJSON(w, http.StatusOK, resp) +} + +func (s *Server) handleGetWorkoutKind(w http.ResponseWriter, r *http.Request) { + userID := userIDFromContext(r.Context()) + id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid workout kind id") + return + } + kind, ok, err := s.DB.GetWorkoutKind(r.Context(), userID, id) + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + if !ok { + writeError(w, http.StatusNotFound, "workout kind not found") + return + } + resp, err := s.toWorkoutKindResponse(r, kind) + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + writeJSON(w, http.StatusOK, resp) +} + +func (s *Server) handleUpdateWorkoutKind(w http.ResponseWriter, r *http.Request) { + userID := userIDFromContext(r.Context()) + id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid workout kind id") + return + } + existing, ok, err := s.DB.GetWorkoutKind(r.Context(), userID, id) + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + if !ok { + writeError(w, http.StatusNotFound, "workout kind not found") + return + } + + var req workoutKindRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "invalid request body") + return + } + if _, err := req.validate(); err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + + isActive := existing.IsActive + if req.IsActive != nil { + isActive = *req.IsActive + } + + if err := s.DB.UpdateWorkoutKind(r.Context(), userID, store.WorkoutKind{ + ID: id, Name: req.Name, Description: req.Description, Color: req.Color, + RuleJSON: string(req.Rule), Priority: req.Priority, IsActive: isActive, + }); err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + if err := s.DB.UpdateWorkoutTypePace(r.Context(), userID, store.WorkoutTypePace{ + WorkoutKindID: id, + PaceMinSecPerKm: req.PaceMinSecPerKm, + PaceMaxSecPerKm: req.PaceMaxSecPerKm, + HRMinPctHRR: req.HRMinPctHRR, + HRMaxPctHRR: req.HRMaxPctHRR, + }); err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + + kind, _, _ := s.DB.GetWorkoutKind(r.Context(), userID, id) + resp, err := s.toWorkoutKindResponse(r, kind) + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + writeJSON(w, http.StatusOK, resp) +} +``` + +- [ ] **Step 3: Update `backend/internal/api/auth.go`** + +Keep `authResponse`, `authStatusString` unchanged; replace `recordAuthResult` and the three handlers: + +```go +func (s *Server) recordAuthResult(userID int64, res garmin.AuthResult) { + s.mu.Lock() + s.userAuthStatus[userID] = res.Status + s.userAuthMessage[userID] = res.Message + s.mu.Unlock() +} + +func (s *Server) handleAuthLogin(w http.ResponseWriter, r *http.Request) { + userID := userIDFromContext(r.Context()) + client, err := s.garminFor(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(userID, res) + writeJSON(w, http.StatusOK, authResponse{Status: authStatusString(res.Status), Message: res.Message}) +} + +func (s *Server) handleAuthMFA(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.garminFor(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(userID, res) + writeJSON(w, http.StatusOK, authResponse{Status: authStatusString(res.Status), Message: res.Message}) +} + +func (s *Server) handleAuthStatus(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}) +} +``` + +- [ ] **Step 4: Update `backend/internal/api/progression.go`** + +Keep `progressionPoint` and `metricValue` unchanged; replace `handleProgression`: + +```go +func (s *Server) handleProgression(w http.ResponseWriter, r *http.Request) { + userID := userIDFromContext(r.Context()) + kindID, err := strconv.ParseInt(chi.URLParam(r, "kindID"), 10, 64) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid workout kind id") + return + } + metric := r.URL.Query().Get("metric") + if metric == "" { + metric = "pace" + } + from, to := r.URL.Query().Get("from"), r.URL.Query().Get("to") + + assignments, err := s.DB.AssignmentsForKind(r.Context(), userID, kindID) + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + + points := []progressionPoint{} + for _, a := range assignments { + activity, ok, err := s.DB.GetActivity(r.Context(), userID, a.ActivityID) + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + if !ok { + continue + } + if from != "" && activity.StartTimeUTC < from { + continue + } + if to != "" && activity.StartTimeUTC > to+" 23:59:59" { + continue + } + value, ok := metricValue(metric, activity) + if !ok { + continue + } + points = append(points, progressionPoint{Date: activity.StartTimeUTC, ActivityID: activity.ID, Value: value}) + } + + sort.Slice(points, func(i, j int) bool { return points[i].Date < points[j].Date }) + writeJSON(w, http.StatusOK, points) +} +``` + +- [ ] **Step 5: Build and run the whole `internal/api` suite** + +Run: `cd backend && go build ./internal/api/... && go test ./internal/api/... -v 2>&1 | tail -80` +Expected: tests touching profile/workout-kinds/auth/progression endpoints (`TestWorkoutKindList_*`, `TestWorkoutKindUpdate_*`, `TestProfile_*`, `TestProgression_*`, `TestMetricValue_*`) PASS. Tests touching activities/sync/review/reclassify endpoints still FAIL (Task 15) — confirm the failures are only in those, not in anything this task touched. + +- [ ] **Step 6: `gofmt` and commit** + +Run: `cd backend && gofmt -l internal/api/` +Expected: no output. + +```bash +git add backend/internal/api/profile.go backend/internal/api/kinds.go backend/internal/api/auth.go backend/internal/api/progression.go +git commit -m "$(cat <<'EOF' +api: scope profile, workout-kind, Garmin auth, and progression handlers to userID + +Pulled from userIDFromContext (never a URL/body parameter) and threaded +into every store call plus the per-user garmin.Client accessor. +EOF +)" +``` + +--- + +## Task 15: Thread `userID` into `activities.go`, `sync.go`, `review.go`, `reclassify.go` + +**Files:** +- Modify: `backend/internal/api/activities.go` +- Modify: `backend/internal/api/sync.go` +- Modify: `backend/internal/api/review.go` +- Modify: `backend/internal/api/reclassify.go` + +**Interfaces:** +- Consumes: `userIDFromContext(ctx)` (Task 12), `s.syncFor(ctx, userID)`/`s.backgroundSync(userID, fn)` (Task 13), every scoped store method (Tasks 4-8). + +- [ ] **Step 1: Update `backend/internal/api/activities.go`** + +Keep `activityListItem` unchanged; replace the two handlers: + +```go +func (s *Server) handleListActivities(w http.ResponseWriter, r *http.Request) { + userID := userIDFromContext(r.Context()) + q := r.URL.Query() + filter := store.ActivityFilter{ + FromDate: q.Get("from"), + ToDate: q.Get("to"), + } + if limit, err := strconv.Atoi(q.Get("limit")); err == nil { + filter.Limit = limit + } + if offset, err := strconv.Atoi(q.Get("offset")); err == nil { + filter.Offset = offset + } + + activities, err := s.DB.ListActivities(r.Context(), userID, filter) + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + + kinds, err := s.DB.ListWorkoutKinds(r.Context(), userID, false) + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + kindNames := make(map[int64]string, len(kinds)) + for _, k := range kinds { + kindNames[k.ID] = k.Name + } + + resp := make([]activityListItem, 0, len(activities)) + for _, a := range activities { + item := activityListItem{activityResponse: toActivityResponse(a)} + assignment, ok, err := s.DB.CurrentAssignment(r.Context(), userID, a.ID) + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + if ok { + source, status := assignment.AssignmentSource, assignment.Status + item.AssignmentSource, item.AssignmentStatus = &source, &status + if assignment.WorkoutKindID != nil { + item.WorkoutKindID = assignment.WorkoutKindID + if name, found := kindNames[*assignment.WorkoutKindID]; found { + item.WorkoutKindName = &name + } + } + item.Locked = source == store.AssignmentSourceManual || + (item.WorkoutKindName != nil && *item.WorkoutKindName == "Race") + } + resp = append(resp, item) + } + writeJSON(w, http.StatusOK, resp) +} + +func (s *Server) handleGetActivity(w http.ResponseWriter, r *http.Request) { + userID := userIDFromContext(r.Context()) + id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid activity id") + return + } + + activity, ok, err := s.DB.GetActivity(r.Context(), userID, id) + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + if !ok { + writeError(w, http.StatusNotFound, "activity not found") + return + } + + laps, err := s.DB.LapsForActivity(r.Context(), userID, id) + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + + assignment, hasAssignment, err := s.DB.CurrentAssignment(r.Context(), userID, id) + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + + resp := map[string]any{"activity": toActivityResponse(activity), "laps": toLapResponses(laps)} + if hasAssignment { + resp["assignment"] = assignment + } + writeJSON(w, http.StatusOK, resp) +} +``` + +- [ ] **Step 2: Update `backend/internal/api/sync.go`** + +Keep `detailFillBatchSize` unchanged; replace all four handlers: + +```go +func (s *Server) handleSyncRun(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"}) +} + +func (s *Server) handleSyncReset(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) handleSyncRuns(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) handleSyncStatus(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 + } + remaining, err := s.DB.CountActivitiesMissingDetails(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, + "detail_fill_progress": progress, + "activities_pending_details": remaining, + } + if ok { + resp["last_run"] = run + } + writeJSON(w, http.StatusOK, resp) +} +``` + +- [ ] **Step 3: Update `backend/internal/api/review.go`** + +Keep `defaultReviewQueuePageSize` and `reviewQueueItem` unchanged; replace all four handlers: + +```go +func (s *Server) handleReviewQueue(w http.ResponseWriter, r *http.Request) { + userID := userIDFromContext(r.Context()) + limit := defaultReviewQueuePageSize + if v := r.URL.Query().Get("limit"); v != "" { + if n, err := strconv.Atoi(v); err == nil && n > 0 { + limit = n + } + } + cursor := r.URL.Query().Get("before") + + var kindIDFilter *int64 + if v := r.URL.Query().Get("kind_id"); v != "" { + if n, err := strconv.ParseInt(v, 10, 64); err == nil { + kindIDFilter = &n + } + } + unclassifiedOnly := r.URL.Query().Get("unclassified") == "true" + + queue, err := s.DB.AllCurrentAssignments(r.Context(), userID) + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + + type withActivity struct { + assignment store.KindAssignment + activity store.Activity + } + all := make([]withActivity, 0, len(queue)) + for _, a := range queue { + if kindIDFilter != nil && (a.WorkoutKindID == nil || *a.WorkoutKindID != *kindIDFilter) { + continue + } + if unclassifiedOnly && a.WorkoutKindID != nil { + continue + } + activity, ok, err := s.DB.GetActivity(r.Context(), userID, a.ActivityID) + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + if !ok { + continue + } + all = append(all, withActivity{assignment: a, activity: activity}) + } + + sort.Slice(all, func(i, j int) bool { + return all[i].activity.StartTimeUTC > all[j].activity.StartTimeUTC + }) + + total := len(all) + + if cursor != "" { + idx := 0 + for idx < len(all) && all[idx].activity.StartTimeUTC >= cursor { + idx++ + } + all = all[idx:] + } + hasMore := len(all) > limit + if len(all) > limit { + all = all[:limit] + } + + items := make([]reviewQueueItem, 0, len(all)) + for _, wa := range all { + laps, err := s.DB.LapsForActivity(r.Context(), userID, wa.assignment.ActivityID) + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + samples, err := s.DB.SamplesForActivity(r.Context(), userID, wa.assignment.ActivityID) + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + items = append(items, reviewQueueItem{ + KindAssignment: wa.assignment, + Activity: toActivityResponse(wa.activity), + Laps: toLapResponses(laps), + Samples: samples, + }) + } + + var nextCursor *string + if hasMore && len(items) > 0 { + c := items[len(items)-1].Activity.StartTimeUTC + nextCursor = &c + } + + writeJSON(w, http.StatusOK, map[string]any{ + "items": items, + "next_cursor": nextCursor, + "total": total, + }) +} + +func (s *Server) handleResolveReview(w http.ResponseWriter, r *http.Request) { + userID := userIDFromContext(r.Context()) + activityID, err := strconv.ParseInt(chi.URLParam(r, "activityID"), 10, 64) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid activity id") + return + } + + var body struct { + WorkoutKindID int64 `json:"workout_kind_id"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + writeError(w, http.StatusBadRequest, "invalid request body") + return + } + if body.WorkoutKindID == 0 { + writeError(w, http.StatusBadRequest, "workout_kind_id is required") + return + } + + kind, ok, err := s.DB.GetWorkoutKind(r.Context(), userID, body.WorkoutKindID) + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } else if !ok { + writeError(w, http.StatusBadRequest, "workout kind not found") + return + } + if kind.Name == "Race" { + writeError(w, http.StatusBadRequest, "Race is assigned automatically from Garmin metadata and cannot be set manually") + return + } + + kindID := body.WorkoutKindID + if _, err := s.DB.InsertKindAssignment(r.Context(), userID, store.KindAssignment{ + ActivityID: activityID, + WorkoutKindID: &kindID, + AssignmentSource: store.AssignmentSourceManual, + Status: store.AssignmentStatusAssigned, + CandidateKindsJSON: "[]", + }); err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + writeJSON(w, http.StatusOK, map[string]string{"status": "resolved"}) +} + +func (s *Server) handleUnassignReview(w http.ResponseWriter, r *http.Request) { + userID := userIDFromContext(r.Context()) + activityID, err := strconv.ParseInt(chi.URLParam(r, "activityID"), 10, 64) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid activity id") + return + } + + if _, err := s.DB.InsertKindAssignment(r.Context(), userID, store.KindAssignment{ + ActivityID: activityID, + WorkoutKindID: nil, + AssignmentSource: store.AssignmentSourceManual, + Status: store.AssignmentStatusNeedsReview, + CandidateKindsJSON: "[]", + }); err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + writeJSON(w, http.StatusOK, map[string]string{"status": "unassigned"}) +} + +func (s *Server) handleUnlockReview(w http.ResponseWriter, r *http.Request) { + userID := userIDFromContext(r.Context()) + activityID, err := strconv.ParseInt(chi.URLParam(r, "activityID"), 10, 64) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid activity id") + return + } + + current, ok, err := s.DB.CurrentAssignment(r.Context(), userID, activityID) + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + if !ok { + writeError(w, http.StatusNotFound, "activity has no assignment yet") + return + } + if current.AssignmentSource != store.AssignmentSourceManual { + writeError(w, http.StatusBadRequest, "activity is not manually locked") + return + } + + status := store.AssignmentStatusAssigned + if current.WorkoutKindID == nil { + status = store.AssignmentStatusNeedsReview + } + if _, err := s.DB.InsertKindAssignment(r.Context(), userID, store.KindAssignment{ + ActivityID: activityID, + WorkoutKindID: current.WorkoutKindID, + AssignmentSource: store.AssignmentSourceRuleEngine, + Status: status, + CandidateKindsJSON: "[]", + }); err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + writeJSON(w, http.StatusOK, map[string]string{"status": "unlocked"}) +} +``` + +- [ ] **Step 4: Update `backend/internal/api/reclassify.go`** + +```go +func (s *Server) handleReclassifyAll(w http.ResponseWriter, r *http.Request) { + userID := userIDFromContext(r.Context()) + raceKind, hasRaceKind, err := s.DB.GetWorkoutKindByName(r.Context(), userID, "Race") + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + + assignments, err := s.DB.AllCurrentAssignments(r.Context(), userID) + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + + var activityIDs []int64 + for _, a := range assignments { + if a.AssignmentSource == store.AssignmentSourceManual { + continue + } + if hasRaceKind && a.WorkoutKindID != nil && *a.WorkoutKindID == raceKind.ID { + continue + } + activityIDs = append(activityIDs, a.ActivityID) + } + + svc, err := s.syncFor(r.Context(), userID) + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + for _, activityID := range activityIDs { + if err := svc.ClassifyActivity(r.Context(), activityID); err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + } + writeJSON(w, http.StatusOK, map[string]int{"reclassified": len(activityIDs)}) +} +``` + +- [ ] **Step 5: Build and run the full `internal/api` suite** + +Run: `cd backend && go build ./... 2>&1 | head -50` +Expected: `internal/api` now compiles cleanly. Only `cmd/seedsample/main.go` should still fail (Task 17). + +Run: `cd backend && go test ./internal/api/... -v` +Expected: every existing test PASSes. + +- [ ] **Step 6: `gofmt` and commit** + +Run: `cd backend && gofmt -l internal/api/` +Expected: no output. + +```bash +git add backend/internal/api/activities.go backend/internal/api/sync.go backend/internal/api/review.go backend/internal/api/reclassify.go +git commit -m "$(cat <<'EOF' +api: scope activities, sync, review-queue, and reclassify handlers to userID + +Completes the internal/api scoping pass -- the whole package now compiles +against the per-user store/sync/garmin signatures from Tasks 4-13. +EOF +)" +``` + +--- + +## Task 16: API-level adversarial cross-user isolation tests + +**Files:** +- Create: `backend/internal/api/isolation_test.go` + +**Interfaces:** +- Consumes: `newTestServer` (Task 13), `doJSON` (existing), `auth.MintSessionCookie` (existing). This task adds no new production code — it's the HTTP-level counterpart to Task 9's store-level isolation tests, proving the whole request path (middleware + handlers) enforces the boundary end-to-end, not just the store queries in isolation. + +- [ ] **Step 1: Write `backend/internal/api/isolation_test.go`** + +```go +package api + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "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" +) + +// doJSONAs is doJSON but for an explicit session Sub, for tests that need +// two distinct logged-in users against the same server (doJSON itself +// always mints a cookie for Sub: "test-user", the identity newTestServer +// pre-provisions). +func doJSONAs(t *testing.T, handler http.Handler, sub, method, path string, body any) *httptest.ResponseRecorder { + t.Helper() + var reader *bytes.Reader + if body != nil { + b, err := json.Marshal(body) + if err != nil { + t.Fatalf("marshal request body: %v", err) + } + reader = bytes.NewReader(b) + } else { + reader = bytes.NewReader(nil) + } + req := httptest.NewRequest(method, path, reader) + req.Header.Set("Content-Type", "application/json") + cookie, err := auth.MintSessionCookie(auth.Claims{Sub: sub, Name: sub, Email: sub + "@example.com"}, testSessionConfig.Secret, testSessionConfig.Duration, testSessionConfig.Secure) + if err != nil { + t.Fatalf("mint test session cookie: %v", err) + } + req.AddCookie(cookie) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + return rec +} + +func TestIsolation_ActivityDetailNotVisibleToOtherUser(t *testing.T) { + s, db := newTestServer(t) // provisions "test-user" (userA) + userB, err := db.ProvisionUser(newCtx(), "user-b", "B") + if err != nil { + t.Fatalf("ProvisionUser(b): %v", err) + } + + userA, found, err := db.GetUserBySub(newCtx(), "test-user") + if err != nil || !found { + t.Fatalf("GetUserBySub(test-user): found=%v err=%v", found, err) + } + activityID, err := db.UpsertActivity(newCtx(), userA.ID, store.Activity{GarminActivityID: 1, StartTimeUTC: "2026-07-11 06:00:00", RawJSON: "{}"}) + if err != nil { + t.Fatalf("UpsertActivity: %v", err) + } + _ = userB + + router := s.Router() + + // userA (the default doJSON identity) can see it. + rec := doJSON(t, router, http.MethodGet, "/api/activities/"+itoa(activityID), nil) + if rec.Code != http.StatusOK { + t.Fatalf("userA GetActivity status = %d, body = %s", rec.Code, rec.Body.String()) + } + + // userB, given the exact same activity id, gets 404 -- not another + // user's data, and not a 500 that would leak existence either way. + rec = doJSONAs(t, router, "user-b", http.MethodGet, "/api/activities/"+itoa(activityID), nil) + if rec.Code != http.StatusNotFound { + t.Fatalf("userB GetActivity(userA's id) status = %d, want 404, body = %s", rec.Code, rec.Body.String()) + } +} + +func TestIsolation_WorkoutKindUpdateCannotTargetOtherUsersKind(t *testing.T) { + s, db := newTestServer(t) // provisions "test-user" (userA) + if _, err := db.ProvisionUser(newCtx(), "user-b", "B"); err != nil { + t.Fatalf("ProvisionUser(b): %v", err) + } + userA, _, _ := db.GetUserBySub(newCtx(), "test-user") + kindsA, err := db.ListWorkoutKinds(newCtx(), userA.ID, false) + if err != nil || len(kindsA) == 0 { + t.Fatalf("ListWorkoutKinds(a): len=%d err=%v", len(kindsA), err) + } + targetID := kindsA[0].ID + originalName := kindsA[0].Name + + router := s.Router() + rec := doJSONAs(t, router, "user-b", http.MethodPut, "/api/workout-kinds/"+itoa(targetID), map[string]any{ + "name": "Hijacked", + "rule": json.RawMessage(`{"match":"all","conditions":[]}`), + }) + if rec.Code != http.StatusNotFound { + t.Fatalf("userB updating userA's kind status = %d, want 404, body = %s", rec.Code, rec.Body.String()) + } + + rec = doJSON(t, router, http.MethodGet, "/api/workout-kinds/"+itoa(targetID), nil) + var got workoutKindResponse + json.Unmarshal(rec.Body.Bytes(), &got) + if got.Name != originalName { + t.Fatalf("userA's kind name changed to %q despite userB's update being rejected", got.Name) + } +} + +func TestIsolation_ReviewQueueOnlyShowsOwnActivities(t *testing.T) { + s, db := newTestServer(t) // provisions "test-user" (userA) + userB, err := db.ProvisionUser(newCtx(), "user-b", "B") + if err != nil { + t.Fatalf("ProvisionUser(b): %v", err) + } + if _, err := db.UpsertActivity(newCtx(), userB, store.Activity{GarminActivityID: 42, StartTimeUTC: "2026-07-11 06:00:00", RawJSON: "{}"}); err != nil { + t.Fatalf("UpsertActivity(b): %v", err) + } + if _, err := db.InsertKindAssignment(newCtx(), userB, store.KindAssignment{ + ActivityID: func() int64 { + acts, _ := db.ListActivities(newCtx(), userB, store.ActivityFilter{}) + return acts[0].ID + }(), + AssignmentSource: store.AssignmentSourceRuleEngine, Status: store.AssignmentStatusNeedsReview, CandidateKindsJSON: "[]", + }); err != nil { + t.Fatalf("InsertKindAssignment(b): %v", err) + } + + rec := doJSON(t, s.Router(), http.MethodGet, "/api/review-queue/", nil) // as userA + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String()) + } + var page struct { + Items []map[string]any `json:"items"` + Total int `json:"total"` + } + json.Unmarshal(rec.Body.Bytes(), &page) + if page.Total != 0 || len(page.Items) != 0 { + t.Fatalf("expected userA's review queue to be empty (the only assignment belongs to userB), got total=%d items=%d", page.Total, len(page.Items)) + } +} + +func TestIsolation_RequireProvisionedUser_BlocksDataRoutesUntilSetup(t *testing.T) { + db, err := store.Open(t.TempDir() + "/isolation_test.db") + if err != nil { + t.Fatalf("store.Open: %v", err) + } + defer 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.MethodGet, "/api/activities/", nil) // "test-user" sub, never provisioned + if rec.Code != http.StatusForbidden { + t.Fatalf("status = %d, want 403 (no profile provisioned yet), body = %s", rec.Code, rec.Body.String()) + } +} +``` + +- [ ] **Step 2: Run the isolation tests** + +Run: `cd backend && go test ./internal/api/... -run TestIsolation -v` +Expected: PASS. If any test fails, it points at a real cross-user leak in a Task 14/15 handler (missing `userIDFromContext` scoping, or a route not wrapped in `requireProvisionedUser`) — fix the handler/router, not the test. + +- [ ] **Step 3: Run the full `internal/api` suite one more time** + +Run: `cd backend && gofmt -l internal/api/ && go vet ./internal/api/... && go test ./internal/api/... -v` +Expected: `gofmt` prints nothing; all PASS. + +- [ ] **Step 4: Commit** + +```bash +git add backend/internal/api/isolation_test.go +git commit -m "$(cat <<'EOF' +api: add end-to-end cross-user isolation tests + +HTTP-level counterpart to the store-layer isolation tests: proves the full +middleware+handler chain rejects/hides another user's activities, workout +kinds, and review queue even when given that user's real row ids, and that +an unprovisioned session is blocked from every data route. +EOF +)" +``` + +--- + +## Task 17: Update `cmd/seedsample/main.go` for per-user provisioning + +**Files:** +- Modify: `backend/cmd/seedsample/main.go` + +**Interfaces:** +- Consumes: `db.ProvisionUser` (Task 2), every scoped store method (Tasks 4-8), `appsync.NewService(m, db, userID, cfg, nil)` (Task 10). + +- [ ] **Step 1: Provision a user at the top of `main()` and thread `userID` through every call** + +Replace the profile-loading block near the top: + +```go + ctx := context.Background() + db, err := store.Open(*dbPath) + if err != nil { + log.Fatalf("open db: %v", err) + } + defer db.Close() + + userID, err := db.ProvisionUser(ctx, "seedsample-user", "Sample") + must(err) + + // Set a max heart rate on the profile so avg_hr_pct_max is computed + // during classification below (it's nil/unset by default). + profile, err := db.GetProfile(ctx, userID) + must(err) + maxHR := 190.0 + profile.MaxHeartRate = &maxHR + must(db.UpdateProfile(ctx, userID, profile)) +``` + +Then update every remaining call in `main()` and its helper functions to pass `userID`: + +- `mustFindKindID(ctx, db, "Easy")` → `mustFindKindID(ctx, db, userID, "Easy")` (and its two other calls, `"Tempo"`/`"Intervals"`) +- Each `db.UpdateWorkoutKind(ctx, store.WorkoutKind{...})` → `db.UpdateWorkoutKind(ctx, userID, store.WorkoutKind{...})` +- `appsync.NewService(m, db, appsync.Config{MinConfidence: 0.6}, nil)` → `appsync.NewService(m, db, userID, appsync.Config{MinConfidence: 0.6}, nil)` +- Every `seedActivity(ctx, db, seedParams{...})` call needs `userID` threaded through — update `seedActivity`'s signature and body: + +```go +func seedActivity(ctx context.Context, db *store.DB, userID int64, p seedParams) int64 { + speed := p.speedMps + hr := p.avgHR + aerobic := p.aerobicTE + anaerobic := p.anaerobicTE + id, err := db.UpsertActivity(ctx, userID, store.Activity{ + GarminActivityID: p.garminID, + StartTimeUTC: p.start.Format("2006-01-02 15:04:05"), + DurationSeconds: p.duration, + DistanceMeters: p.distance, + AvgSpeedMps: &speed, + AvgHR: &hr, + AerobicTrainingEffect: &aerobic, + AnaerobicTrainingEffect: &anaerobic, + RawJSON: fmt.Sprintf(`{"activityName":%q,"activityType":{"typeKey":"running"}}`, p.name), + }) + must(err) + return id +} +``` + + and every call site `seedActivity(ctx, db, seedParams{...})` → `seedActivity(ctx, db, userID, seedParams{...})`. +- `seedIntervalLapsAndSamples(ctx, db, id)` → update its signature to take `userID` and pass it through to its own `db.ReplaceActivitySamples`/`db.ReplaceLaps` calls: + +```go +func seedIntervalLapsAndSamples(ctx context.Context, db *store.DB, userID, activityID int64) { + // ... body unchanged except the final two calls: + must(db.ReplaceActivitySamples(ctx, userID, activityID, samples)) + must(db.ReplaceLaps(ctx, userID, activityID, laps)) +} +``` + + and its one call site: `seedIntervalLapsAndSamples(ctx, db, id)` → `seedIntervalLapsAndSamples(ctx, db, userID, id)`. +- `svc.ClassifyActivity(ctx, id)` in the final loop is unchanged (`Service.ClassifyActivity` still just takes `activityID` — `userID` is baked into `svc` per Task 10). +- `mustFindKindID`'s own body: update its signature and internal call: + +```go +func mustFindKindID(ctx context.Context, db *store.DB, userID int64, name string) int64 { + kinds, err := db.ListWorkoutKinds(ctx, userID, false) + must(err) + for _, k := range kinds { + if k.Name == name { + return k.ID + } + } + log.Fatalf("seedsample: no workout kind named %q found for the seeded user (did ProvisionUser seed the taxonomy?)", name) + return 0 +} +``` + +- [ ] **Step 2: Build** + +Run: `cd backend && go build ./cmd/seedsample/...` +Expected: compiles clean. If any call site was missed, the compiler error names the exact line — fix it the same way as above. + +- [ ] **Step 3: Smoke-test it actually runs and produces the expected output** + +Run: `cd backend && go run ./cmd/seedsample -db /tmp/geniusrun_seedsample_test.db && rm /tmp/geniusrun_seedsample_test.db` +Expected: prints `Seeded 10 activities (kinds: Easy=, Tempo=) into /tmp/geniusrun_seedsample_test.db` (or similar, matching the existing `fmt.Printf` in `main()`) with no errors. + +- [ ] **Step 4: `gofmt` and commit** + +Run: `cd backend && gofmt -l cmd/seedsample/` +Expected: no output. + +```bash +git add backend/cmd/seedsample/main.go +git commit -m "$(cat <<'EOF' +cmd/seedsample: provision a sample user before seeding + +Every store call now needs a userID -- seedsample provisions one fixed +"seedsample-user" account up front and threads it through the rest of the +seeding logic, unchanged in what it actually seeds. +EOF +)" +``` + +--- + +## Task 18: Frontend — Create-profile setup screen + API client wiring + +**Files:** +- Modify: `frontend/src/types/api.ts` (extend `SessionInfo`) +- Modify: `frontend/src/api/client.ts` (add `setup()`) +- Create: `frontend/src/CreateProfile.tsx` +- Create: `frontend/src/CreateProfile.css` +- Modify: `frontend/src/LoginGate.tsx` + +**Interfaces:** +- Consumes: `GET /api/session/me` (now returns `has_profile`/`display_name`, Task 12), `POST /api/setup` (Task 12). +- Produces: `api.setup(displayName: string): Promise<{user_id: number; display_name: string}>`; ` void} />` component, rendered by `LoginGate` in place of `` when the session is authenticated but `has_profile` is false. + +- [ ] **Step 1: Extend `SessionInfo` in `frontend/src/types/api.ts`** + +```typescript +export interface SessionInfo { + name: string; + email: string; + has_profile: boolean; + display_name?: string; +} +``` + +- [ ] **Step 2: Add `api.setup` in `frontend/src/api/client.ts`** + +Add near the `getSessionInfo` entry (same "Session" comment block): + +```typescript + setup: (displayName: string) => + request<{ user_id: number; display_name: string }>("/api/setup", { + method: "POST", + body: JSON.stringify({ display_name: displayName }), + }), +``` + +- [ ] **Step 3: Write `frontend/src/CreateProfile.css`** + +```css +.create-profile { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + height: 100vh; + gap: 1rem; + text-align: center; +} + +.create-profile form { + display: flex; + flex-direction: column; + gap: 0.75rem; + width: 100%; + max-width: 320px; +} + +.create-profile input { + padding: 0.6rem 0.8rem; + font-size: 1rem; + border-radius: 0.5rem; + border: 1px solid #ccc; +} + +.create-profile button { + padding: 0.6rem 0.8rem; + font-size: 1rem; + border-radius: 0.5rem; + border: none; + background: #3b82f6; + color: white; + cursor: pointer; +} + +.create-profile button:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +.create-profile-error { + color: #ef4444; +} +``` + +- [ ] **Step 4: Write `frontend/src/CreateProfile.tsx`** + +```tsx +import { useState } from "react"; +import { api } from "./api/client"; +import "./CreateProfile.css"; + +// Shown once, right after a brand-new OIDC login, before the account has a +// geniusrun profile at all. The only thing asked for is a display name -- +// Garmin credentials and every other tunable are filled in afterward via +// the existing Profile screen, same as a fresh single-user install today. +export function CreateProfile({ onCreated }: { onCreated: (displayName: string) => void }) { + const [displayName, setDisplayName] = useState(""); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(null); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + const trimmed = displayName.trim(); + if (!trimmed) { + setError("Please enter a display name."); + return; + } + setSubmitting(true); + setError(null); + try { + const result = await api.setup(trimmed); + onCreated(result.display_name); + } catch (err) { + setError(err instanceof Error ? err.message : "Something went wrong, please try again."); + setSubmitting(false); + } + }; + + return ( +
+

🧞‍♀️ Welcome to geniusrun

+

Let's set up your profile. You can add your Garmin account afterward.

+
+ setDisplayName(e.target.value)} + disabled={submitting} + autoFocus + /> + {error &&

{error}

} + +
+
+ ); +} +``` + +- [ ] **Step 5: Wire it into `frontend/src/LoginGate.tsx`** + +Replace the whole file: + +```tsx +import { useEffect, useState } from "react"; +import { api, BASE_URL } from "./api/client"; +import "./LoginGate.css"; +import App from "./App"; +import { CreateProfile } from "./CreateProfile"; +import type { SessionInfo } from "./types/api"; + +type Status = "loading" | "authenticated" | "unauthenticated"; + +const AUTH_ERROR_MESSAGES: Record = { + forbidden: "Your account isn't authorized for geniusrun.", + failed: "Login failed, please try again.", +}; + +// Wraps App: on mount, asks the backend whether this browser already has a +// valid session (GET /api/session/me). geniusrun has no anonymous view, so +// this is the first fork -- "show the login screen" vs "show the app." A +// second fork, once authenticated, is whether the session's account has a +// provisioned profile yet (session.has_profile) -- a brand-new OIDC login +// sees CreateProfile instead of App until it submits one. +export function LoginGate() { + const [status, setStatus] = useState("loading"); + const [session, setSession] = useState(null); + + useEffect(() => { + api + .getSessionInfo() + .then((s) => { + setSession(s); + setStatus("authenticated"); + }) + .catch(() => setStatus("unauthenticated")); + }, []); + + if (status === "loading") { + return
Loading…
; + } + + if (status === "unauthenticated") { + const authError = new URLSearchParams(window.location.search).get("auth_error"); + return ( +
+

🧞‍♀️ geniusrun

+ {authError &&

{AUTH_ERROR_MESSAGES[authError] ?? "Login failed, please try again."}

} + + Log in + +
+ ); + } + + if (!session!.has_profile) { + return ( + setSession((s) => (s ? { ...s, has_profile: true, display_name: displayName } : s))} + /> + ); + } + + return ; +} +``` + +- [ ] **Step 6: Build the frontend and confirm no type errors** + +Run: `cd frontend && npm run build` +Expected: `tsc -b && vite build` completes with no type errors. + +Run: `cd frontend && npm run lint` +Expected: no new lint errors from the changed/added files. + +- [ ] **Step 7: Manual smoke test** + +Run the backend against a fresh (or freshly-reset) DB and the frontend dev server (`./start.sh` in each of `backend/`/`frontend/`, per this repo's existing dev workflow), then in a browser: +1. Log in as a user with no prior `users` row (a fresh DB has none) — confirm the "Welcome to geniusrun" screen appears instead of the normal app. +2. Submit a display name — confirm it lands on the normal app (Activities tab) afterward, and the profile-name button in the header shows that display name. +3. Reload the page — confirm it goes straight to the normal app now (not back to the create-profile screen), since `has_profile` is now `true` for that session. + +Since this can't be automated in this plan (it needs a live browser + OIDC-authenticated session), explicitly note the result of this manual check when reporting the task done — don't claim success without having actually clicked through it. + +- [ ] **Step 8: Commit** + +```bash +git add frontend/src/types/api.ts frontend/src/api/client.ts frontend/src/CreateProfile.tsx frontend/src/CreateProfile.css frontend/src/LoginGate.tsx +git commit -m "$(cat <<'EOF' +frontend: add Create-profile setup screen for brand-new accounts + +LoginGate now shows CreateProfile (display name only) instead of App when +an authenticated session has no provisioned geniusrun profile yet, backed +by the new POST /api/setup endpoint and session/me's has_profile flag. +EOF +)" +``` + +--- + +## Final verification + +After all 18 tasks are complete, run the full test suite and a clean build one more time before considering this feature done: + +```bash +cd backend && gofmt -l . && go build ./... && go vet ./... && go test ./... +cd ../frontend && npm run build && npm run lint +``` + +Expected: `gofmt -l .` prints nothing; every Go package builds and vets clean; every Go test passes; the frontend builds and lints clean. Then walk through the manual smoke test in Task 18's Step 7 one more time end-to-end (fresh login → create profile → normal app → reload persists), plus a second browser session (or incognito window) logged in as a *different* OIDC identity to confirm it gets its own empty create-profile flow and never sees the first account's data anywhere in the UI. + diff --git a/frontend/src/CreateProfile.css b/frontend/src/CreateProfile.css new file mode 100644 index 0000000..01bda25 --- /dev/null +++ b/frontend/src/CreateProfile.css @@ -0,0 +1,43 @@ +.create-profile { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + height: 100vh; + gap: 1rem; + text-align: center; +} + +.create-profile form { + display: flex; + flex-direction: column; + gap: 0.75rem; + width: 100%; + max-width: 320px; +} + +.create-profile input { + padding: 0.6rem 0.8rem; + font-size: 1rem; + border-radius: 0.5rem; + border: 1px solid #ccc; +} + +.create-profile button { + padding: 0.6rem 0.8rem; + font-size: 1rem; + border-radius: 0.5rem; + border: none; + background: #3b82f6; + color: white; + cursor: pointer; +} + +.create-profile button:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +.create-profile-error { + color: #ef4444; +} diff --git a/frontend/src/CreateProfile.tsx b/frontend/src/CreateProfile.tsx new file mode 100644 index 0000000..ae621b3 --- /dev/null +++ b/frontend/src/CreateProfile.tsx @@ -0,0 +1,52 @@ +import { useState } from "react"; +import { api } from "./api/client"; +import "./CreateProfile.css"; + +// Shown once, right after a brand-new OIDC login, before the account has a +// geniusrun profile at all. The only thing asked for is a display name -- +// Garmin credentials and every other tunable are filled in afterward via +// the existing Profile screen, same as a fresh single-user install today. +export function CreateProfile({ onCreated }: { onCreated: (displayName: string) => void }) { + const [displayName, setDisplayName] = useState(""); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(null); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + const trimmed = displayName.trim(); + if (!trimmed) { + setError("Please enter a display name."); + return; + } + setSubmitting(true); + setError(null); + try { + const result = await api.setup(trimmed); + onCreated(result.display_name); + } catch (err) { + setError(err instanceof Error ? err.message : "Something went wrong, please try again."); + setSubmitting(false); + } + }; + + return ( +
+

🧞‍♀️ Welcome to geniusrun

+

Let's set up your profile. You can add your Garmin account afterward.

+
+ setDisplayName(e.target.value)} + disabled={submitting} + autoFocus + /> + {error &&

{error}

} + +
+
+ ); +} diff --git a/frontend/src/LoginGate.tsx b/frontend/src/LoginGate.tsx index 96a3558..73e63f1 100644 --- a/frontend/src/LoginGate.tsx +++ b/frontend/src/LoginGate.tsx @@ -2,6 +2,7 @@ import { useEffect, useState } from "react"; import { api, BASE_URL } from "./api/client"; import "./LoginGate.css"; import App from "./App"; +import { CreateProfile } from "./CreateProfile"; import type { SessionInfo } from "./types/api"; type Status = "loading" | "authenticated" | "unauthenticated"; @@ -13,8 +14,10 @@ const AUTH_ERROR_MESSAGES: Record = { // Wraps App: on mount, asks the backend whether this browser already has a // valid session (GET /api/session/me). geniusrun has no anonymous view, so -// this is the only fork in the whole frontend between "show the login -// screen" and "show the app." +// this is the first fork -- "show the login screen" vs "show the app." A +// second fork, once authenticated, is whether the session's account has a +// provisioned profile yet (session.has_profile) -- a brand-new OIDC login +// sees CreateProfile instead of App until it submits one. export function LoginGate() { const [status, setStatus] = useState("loading"); const [session, setSession] = useState(null); @@ -46,5 +49,13 @@ export function LoginGate() { ); } + if (!session!.has_profile) { + return ( + setSession((s) => (s ? { ...s, has_profile: true, display_name: displayName } : s))} + /> + ); + } + return ; } diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 1d16f26..e21cdb7 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -46,6 +46,11 @@ export const api = { // browser navigation. Logout must POST (see server.go's route), which an // can't do, hence the form. getSessionInfo: () => request("/api/session/me"), + setup: (displayName: string) => + request<{ user_id: number; display_name: string }>("/api/setup", { + method: "POST", + body: JSON.stringify({ display_name: displayName }), + }), // Auth login: () => request("/api/auth/login", { method: "POST" }), diff --git a/frontend/src/types/api.ts b/frontend/src/types/api.ts index 348bfa0..0c2359d 100644 --- a/frontend/src/types/api.ts +++ b/frontend/src/types/api.ts @@ -189,6 +189,8 @@ export interface AuthResponse { export interface SessionInfo { name: string; email: string; + has_profile: boolean; + display_name?: string; } export interface DetailFillProgress {