diff --git a/backend/internal/store/reset_test.go b/backend/internal/store/reset_test.go index 1257459..ab9d47b 100644 --- a/backend/internal/store/reset_test.go +++ b/backend/internal/store/reset_test.go @@ -65,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/workoutkinds.go b/backend/internal/store/workoutkinds.go index 4883da8..0d40e43 100644 --- a/backend/internal/store/workoutkinds.go +++ b/backend/internal/store/workoutkinds.go @@ -40,40 +40,42 @@ func (db *DB) CreateWorkoutKind(ctx context.Context, userID int64, k WorkoutKind 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 } @@ -105,11 +107,11 @@ func (db *DB) ListWorkoutKinds(ctx context.Context, userID int64, activeOnly boo } // 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/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) }