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. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -65,7 +65,7 @@ func TestResetAllSyncedData_DeletesActivitiesCascadesAndRewindsWatermark(t *test
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Workout kinds (the user's taxonomy) must survive a reset.
|
// 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 {
|
if err != nil || !ok {
|
||||||
t.Fatalf("GetWorkoutKind after reset: ok=%v err=%v", ok, err)
|
t.Fatalf("GetWorkoutKind after reset: ok=%v err=%v", ok, err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,40 +40,42 @@ func (db *DB) CreateWorkoutKind(ctx context.Context, userID int64, k WorkoutKind
|
|||||||
return res.LastInsertId()
|
return res.LastInsertId()
|
||||||
}
|
}
|
||||||
|
|
||||||
// UpdateWorkoutKind updates an existing workout kind's editable fields.
|
// UpdateWorkoutKind updates an existing workout kind's editable fields,
|
||||||
func (db *DB) UpdateWorkoutKind(ctx context.Context, k WorkoutKind) error {
|
// 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, `
|
_, err := db.ExecContext(ctx, `
|
||||||
UPDATE workout_kinds SET name=?, description=?, color=?, rule_json=?, priority=?, is_active=?, updated_at=datetime('now')
|
UPDATE workout_kinds SET name=?, description=?, color=?, rule_json=?, priority=?, is_active=?, updated_at=datetime('now')
|
||||||
WHERE id=?`,
|
WHERE id=? AND user_id=?`,
|
||||||
k.Name, k.Description, k.Color, k.RuleJSON, k.Priority, k.IsActive, k.ID)
|
k.Name, k.Description, k.Color, k.RuleJSON, k.Priority, k.IsActive, k.ID, userID)
|
||||||
if err != nil {
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetWorkoutKind fetches one workout kind by id.
|
// GetWorkoutKind fetches one workout kind by id, scoped to userID.
|
||||||
func (db *DB) GetWorkoutKind(ctx context.Context, id int64) (WorkoutKind, bool, error) {
|
func (db *DB) GetWorkoutKind(ctx context.Context, userID, id int64) (WorkoutKind, bool, error) {
|
||||||
row := db.QueryRowContext(ctx, `SELECT `+workoutKindColumns+` FROM workout_kinds WHERE id = ?`, id)
|
row := db.QueryRowContext(ctx, `SELECT `+workoutKindColumns+` FROM workout_kinds WHERE id = ? AND user_id = ?`, id, userID)
|
||||||
k, err := scanWorkoutKind(row)
|
k, err := scanWorkoutKind(row)
|
||||||
if err == sql.ErrNoRows {
|
if err == sql.ErrNoRows {
|
||||||
return WorkoutKind{}, false, nil
|
return WorkoutKind{}, false, nil
|
||||||
}
|
}
|
||||||
if err != 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
|
return k, true, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetWorkoutKindByName fetches one workout kind by its unique name.
|
// GetWorkoutKindByName fetches one workout kind by name, scoped to userID
|
||||||
func (db *DB) GetWorkoutKindByName(ctx context.Context, name string) (WorkoutKind, bool, error) {
|
// (the same name can exist for different users -- see UNIQUE(user_id, name)).
|
||||||
row := db.QueryRowContext(ctx, `SELECT `+workoutKindColumns+` FROM workout_kinds WHERE name = ?`, 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)
|
k, err := scanWorkoutKind(row)
|
||||||
if err == sql.ErrNoRows {
|
if err == sql.ErrNoRows {
|
||||||
return WorkoutKind{}, false, nil
|
return WorkoutKind{}, false, nil
|
||||||
}
|
}
|
||||||
if err != 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
|
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
|
// SoftDeleteWorkoutKind sets is_active=0, keeping history (kind_assignments
|
||||||
// referencing it) intact.
|
// referencing it) intact. Scoped to userID.
|
||||||
func (db *DB) SoftDeleteWorkoutKind(ctx context.Context, id int64) error {
|
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=?`, id)
|
_, 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 {
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,9 @@ import (
|
|||||||
// WorkoutTypePace is a workout kind's user-declared target pace range and HR
|
// WorkoutTypePace is a workout kind's user-declared target pace range and HR
|
||||||
// range (percent of heart rate reserve). Informational only -- never read by
|
// range (percent of heart rate reserve). Informational only -- never read by
|
||||||
// the classification rule engine. No history: fields are overwritten in
|
// 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 {
|
type WorkoutTypePace struct {
|
||||||
WorkoutKindID int64
|
WorkoutKindID int64
|
||||||
PaceMinSecPerKm *float64
|
PaceMinSecPerKm *float64
|
||||||
@@ -24,38 +26,49 @@ func scanWorkoutTypePace(row interface{ Scan(...any) error }) (WorkoutTypePace,
|
|||||||
return p, err
|
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.
|
// GetWorkoutTypePace fetches the pace/zone row for one workout kind, scoped
|
||||||
func (db *DB) GetWorkoutTypePace(ctx context.Context, workoutKindID int64) (WorkoutTypePace, error) {
|
// to userID via a join to workout_kinds.
|
||||||
row := db.QueryRowContext(ctx, `SELECT `+workoutTypePaceColumns+` FROM workout_type_paces WHERE workout_kind_id = ?`, workoutKindID)
|
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)
|
p, err := scanWorkoutTypePace(row)
|
||||||
if err == sql.ErrNoRows {
|
if err == sql.ErrNoRows {
|
||||||
return WorkoutTypePace{WorkoutKindID: workoutKindID}, nil
|
return WorkoutTypePace{WorkoutKindID: workoutKindID}, nil
|
||||||
}
|
}
|
||||||
if err != 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
|
return p, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// UpdateWorkoutTypePace overwrites the pace/zone row for one workout kind.
|
// UpdateWorkoutTypePace overwrites the pace/zone row for one workout kind,
|
||||||
func (db *DB) UpdateWorkoutTypePace(ctx context.Context, p WorkoutTypePace) error {
|
// 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, `
|
_, 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=?
|
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=?`,
|
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)
|
p.PaceMinSecPerKm, p.PaceMaxSecPerKm, p.HRMinPctHRR, p.HRMaxPctHRR, p.WorkoutKindID, userID)
|
||||||
if err != nil {
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ListWorkoutTypePaces returns every workout kind's pace/zone row.
|
// ListWorkoutTypePaces returns every one of userID's workout kinds' pace/zone rows.
|
||||||
func (db *DB) ListWorkoutTypePaces(ctx context.Context) ([]WorkoutTypePace, error) {
|
func (db *DB) ListWorkoutTypePaces(ctx context.Context, userID int64) ([]WorkoutTypePace, error) {
|
||||||
rows, err := db.QueryContext(ctx, `SELECT `+workoutTypePaceColumns+` FROM workout_type_paces ORDER BY workout_kind_id`)
|
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 {
|
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()
|
defer rows.Close()
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,12 @@ func TestWorkoutTypePaces_SeededOnePerKindThenUpdate(t *testing.T) {
|
|||||||
db := openTestDB(t)
|
db := openTestDB(t)
|
||||||
ctx := context.Background()
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("ListWorkoutTypePaces: %v", err)
|
t.Fatalf("ListWorkoutTypePaces: %v", err)
|
||||||
}
|
}
|
||||||
@@ -29,11 +34,11 @@ func TestWorkoutTypePaces_SeededOnePerKindThenUpdate(t *testing.T) {
|
|||||||
target.HRMinPctHRR = &hrMin
|
target.HRMinPctHRR = &hrMin
|
||||||
target.HRMaxPctHRR = &hrMax
|
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)
|
t.Fatalf("UpdateWorkoutTypePace: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
got, err := db.GetWorkoutTypePace(ctx, target.WorkoutKindID)
|
got, err := db.GetWorkoutTypePace(ctx, userID, target.WorkoutKindID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("GetWorkoutTypePace: %v", err)
|
t.Fatalf("GetWorkoutTypePace: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user