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. This also
requires scoping ListActivities, ListWorkoutKinds, UpsertActivity,
CreateWorkoutKind, GetSyncState, UpdateSyncState, and ResetAllSyncedData
to userID, plus updating all related tests in the store package.
This commit is contained in:
2026-07-25 13:17:17 +02:00
parent b0a7462ebe
commit ef410863c6
10 changed files with 104 additions and 67 deletions

View File

@@ -52,15 +52,15 @@ type Activity struct {
// UpsertActivity inserts a new activity or updates the existing row for the // 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 // same garmin_activity_id (idempotent, safe to call on every sync pass), and
// returns its internal id. // returns its internal id.
func (db *DB) UpsertActivity(ctx context.Context, a Activity) (int64, error) { func (db *DB) UpsertActivity(ctx context.Context, userID int64, a Activity) (int64, error) {
_, err := db.ExecContext(ctx, ` _, err := db.ExecContext(ctx, `
INSERT INTO activities ( 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, duration_seconds, distance_meters, avg_hr, max_hr,
avg_speed_mps, elevation_gain_m, avg_speed_mps, elevation_gain_m,
aerobic_training_effect, anaerobic_training_effect, vo2max_value, aerobic_training_effect, anaerobic_training_effect, vo2max_value,
raw_json, updated_at raw_json, updated_at
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?, datetime('now')) ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?, datetime('now'))
ON CONFLICT(garmin_activity_id) DO UPDATE SET ON CONFLICT(garmin_activity_id) DO UPDATE SET
event_type_key=excluded.event_type_key, event_type_key=excluded.event_type_key,
workout_id=excluded.workout_id, workout_id=excluded.workout_id,
@@ -77,18 +77,18 @@ func (db *DB) UpsertActivity(ctx context.Context, a Activity) (int64, error) {
raw_json=excluded.raw_json, raw_json=excluded.raw_json,
updated_at=datetime('now') 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.DurationSeconds, a.DistanceMeters, a.AvgHR, a.MaxHR,
a.AvgSpeedMps, a.ElevationGainM, a.AvgSpeedMps, a.ElevationGainM,
a.AerobicTrainingEffect, a.AnaerobicTrainingEffect, a.VO2MaxValue, a.AerobicTrainingEffect, a.AnaerobicTrainingEffect, a.VO2MaxValue,
a.RawJSON, a.RawJSON,
) )
if err != nil { 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 var id int64
if err := db.QueryRowContext(ctx, `SELECT id FROM activities WHERE garmin_activity_id = ?`, a.GarminActivityID).Scan(&id); err != nil { if err := db.QueryRowContext(ctx, `SELECT id FROM activities WHERE garmin_activity_id = ? AND user_id = ?`, a.GarminActivityID, userID).Scan(&id); err != nil {
return 0, fmt.Errorf("fetch id for activity %d: %w", a.GarminActivityID, err) return 0, fmt.Errorf("fetch id for activity %d: %w", a.GarminActivityID, err)
} }
return id, nil return id, nil
@@ -137,10 +137,10 @@ type ActivityFilter struct {
Offset int Offset int
} }
// ListActivities returns activities newest-first, optionally filtered by date range. // ListActivities returns activities for userID newest-first, optionally filtered by date range.
func (db *DB) ListActivities(ctx context.Context, f ActivityFilter) ([]Activity, error) { func (db *DB) ListActivities(ctx context.Context, userID int64, f ActivityFilter) ([]Activity, error) {
query := `SELECT ` + activityColumns + ` FROM activities WHERE 1=1` query := `SELECT ` + activityColumns + ` FROM activities WHERE user_id = ?`
var args []any args := []any{userID}
if f.FromDate != "" { if f.FromDate != "" {
query += ` AND start_time_utc >= ?` query += ` AND start_time_utc >= ?`
args = append(args, f.FromDate) args = append(args, f.FromDate)
@@ -157,7 +157,7 @@ func (db *DB) ListActivities(ctx context.Context, f ActivityFilter) ([]Activity,
rows, err := db.QueryContext(ctx, query, args...) rows, err := db.QueryContext(ctx, query, args...)
if err != nil { 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() defer rows.Close()

View File

@@ -81,10 +81,10 @@ const profileColumns = `
created_at, updated_at created_at, updated_at
` `
// GetProfile returns the single profile row. // GetProfile returns the profile row for userID.
func (db *DB) GetProfile(ctx context.Context) (Profile, error) { func (db *DB) GetProfile(ctx context.Context, userID int64) (Profile, error) {
var p Profile 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.Name, &p.GarminEmail, &p.GarminPassword, &p.RollingWindowDays, &p.BackfillHorizonDays, &p.MaxHeartRate, &p.RestingHeartRate,
&p.HRZone1MinPct, &p.HRZone1MaxPct, &p.HRZone2MinPct, &p.HRZone2MaxPct, &p.HRZone1MinPct, &p.HRZone1MaxPct, &p.HRZone2MinPct, &p.HRZone2MaxPct,
&p.HRZone3MinPct, &p.HRZone3MaxPct, &p.HRZone4MinPct, &p.HRZone4MaxPct, &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, &p.CreatedAt, &p.UpdatedAt,
) )
if err != nil { 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 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 // GetProfile first and modify the fields they intend to change, since this
// replaces every column. // 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, ` _, err := db.ExecContext(ctx, `
UPDATE profile SET UPDATE profile SET
name=?, garmin_email=?, garmin_password=?, rolling_window_days=?, backfill_horizon_days=?, max_heart_rate=?, resting_heart_rate=?, 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=?, pace_color=?, heart_rate_color=?, warmup_color=?, effort_color=?, recovery_color=?, cooldown_color=?,
main_line_tint_pct=?, background_darken_pct=?, target_brighten_pct=?, main_line_tint_pct=?, background_darken_pct=?, target_brighten_pct=?,
updated_at=datetime('now') 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.Name, p.GarminEmail, p.GarminPassword, p.RollingWindowDays, p.BackfillHorizonDays, p.MaxHeartRate, p.RestingHeartRate,
p.HRZone1MinPct, p.HRZone1MaxPct, p.HRZone2MinPct, p.HRZone2MaxPct, p.HRZone1MinPct, p.HRZone1MaxPct, p.HRZone2MinPct, p.HRZone2MaxPct,
p.HRZone3MinPct, p.HRZone3MaxPct, p.HRZone4MinPct, p.HRZone4MaxPct, 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.MinRepresentativePaceSecPerKm, p.MinRepresentativeTimeSeconds,
p.PaceColor, p.HeartRateColor, p.WarmupColor, p.EffortColor, p.RecoveryColor, p.CooldownColor, p.PaceColor, p.HeartRateColor, p.WarmupColor, p.EffortColor, p.RecoveryColor, p.CooldownColor,
p.MainLineTintPct, p.BackgroundDarkenPct, p.TargetBrightenPct, p.MainLineTintPct, p.BackgroundDarkenPct, p.TargetBrightenPct,
userID,
) )
if err != nil { if err != nil {
return fmt.Errorf("update profile: %w", err) return fmt.Errorf("update profile for user %d: %w", userID, err)
} }
return nil return nil
} }

View File

@@ -8,8 +8,12 @@ import (
func TestProfile_DefaultsThenUpdate(t *testing.T) { func TestProfile_DefaultsThenUpdate(t *testing.T) {
db := openTestDB(t) db := openTestDB(t)
ctx := context.Background() 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 { if err != nil {
t.Fatalf("GetProfile: %v", err) t.Fatalf("GetProfile: %v", err)
} }
@@ -64,11 +68,11 @@ func TestProfile_DefaultsThenUpdate(t *testing.T) {
p.MinRepresentativePaceSecPerKm = 600 p.MinRepresentativePaceSecPerKm = 600
p.MinRepresentativeTimeSeconds = 5 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) t.Fatalf("UpdateProfile: %v", err)
} }
got, err := db.GetProfile(ctx) got, err := db.GetProfile(ctx, userID)
if err != nil { if err != nil {
t.Fatalf("GetProfile after update: %v", err) t.Fatalf("GetProfile after update: %v", err)
} }

View File

@@ -5,23 +5,23 @@ import (
"fmt" "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 // activity_samples, and kind_assignments via ON DELETE CASCADE) and rewinds
// the backfill watermark to its initial state, so a subsequent Backfill // the backfill watermark to its initial state, so a subsequent Backfill
// starts a genuinely fresh pull instead of thinking history is already // starts a genuinely fresh pull instead of thinking history is already
// covered. Workout kinds (the user's taxonomy) are left untouched. // 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) tx, err := db.BeginTx(ctx, nil)
if err != nil { if err != nil {
return fmt.Errorf("begin reset tx: %w", err) return fmt.Errorf("begin reset tx: %w", err)
} }
defer tx.Rollback() defer tx.Rollback()
if _, err := tx.ExecContext(ctx, `DELETE FROM activities`); err != nil { if _, err := tx.ExecContext(ctx, `DELETE FROM activities WHERE user_id = ?`, userID); err != nil {
return fmt.Errorf("delete activities: %w", err) 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 { 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: %w", err) return fmt.Errorf("reset sync state for user %d: %w", userID, err)
} }
return tx.Commit() return tx.Commit()
} }

View File

@@ -8,12 +8,16 @@ import (
func TestResetAllSyncedData_DeletesActivitiesCascadesAndRewindsWatermark(t *testing.T) { func TestResetAllSyncedData_DeletesActivitiesCascadesAndRewindsWatermark(t *testing.T) {
db := openTestDB(t) db := openTestDB(t)
ctx := context.Background() 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 { if err != nil {
t.Fatalf("UpsertActivity: %v", err) 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 { if err != nil {
t.Fatalf("CreateWorkoutKind: %v", err) t.Fatalf("CreateWorkoutKind: %v", err)
} }
@@ -26,15 +30,15 @@ func TestResetAllSyncedData_DeletesActivitiesCascadesAndRewindsWatermark(t *test
}); err != nil { }); err != nil {
t.Fatalf("InsertKindAssignment: %v", err) 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) 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) t.Fatalf("ResetAllSyncedData: %v", err)
} }
activities, err := db.ListActivities(ctx, ActivityFilter{}) activities, err := db.ListActivities(ctx, userID, ActivityFilter{})
if err != nil { if err != nil {
t.Fatalf("ListActivities: %v", err) t.Fatalf("ListActivities: %v", err)
} }
@@ -52,7 +56,7 @@ func TestResetAllSyncedData_DeletesActivitiesCascadesAndRewindsWatermark(t *test
t.Errorf("expected kind assignment to cascade-delete, ok=%v err=%v", ok, err) 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 { if err != nil {
t.Fatalf("GetSyncState: %v", err) t.Fatalf("GetSyncState: %v", err)
} }

View File

@@ -41,6 +41,10 @@ func TestMigrateIsIdempotent(t *testing.T) {
func TestUpsertActivity_InsertThenUpdateIsIdempotent(t *testing.T) { func TestUpsertActivity_InsertThenUpdateIsIdempotent(t *testing.T) {
db := openTestDB(t) db := openTestDB(t)
ctx := context.Background() ctx := context.Background()
userID, err := db.ProvisionUser(ctx, "test-sub", "Test User")
if err != nil {
t.Fatalf("ProvisionUser: %v", err)
}
a := Activity{ a := Activity{
GarminActivityID: 23554066504, GarminActivityID: 23554066504,
@@ -51,13 +55,13 @@ func TestUpsertActivity_InsertThenUpdateIsIdempotent(t *testing.T) {
RawJSON: `{"activityId":23554066504,"activityName":"Auriol - W2-5-Base Endurance","activityType":{"typeKey":"running"}}`, 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 { if err != nil {
t.Fatalf("UpsertActivity (insert): %v", err) t.Fatalf("UpsertActivity (insert): %v", err)
} }
a.AvgHR = f(150) // simulate a re-sync with a corrected value 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 { if err != nil {
t.Fatalf("UpsertActivity (update): %v", err) t.Fatalf("UpsertActivity (update): %v", err)
} }
@@ -73,7 +77,7 @@ func TestUpsertActivity_InsertThenUpdateIsIdempotent(t *testing.T) {
t.Errorf("AvgHR = %v, want 150 (update should have applied)", *got.AvgHR) 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 { if err != nil {
t.Fatalf("ListActivities: %v", err) t.Fatalf("ListActivities: %v", err)
} }
@@ -85,8 +89,12 @@ func TestUpsertActivity_InsertThenUpdateIsIdempotent(t *testing.T) {
func TestKindAssignment_AppendOnlyHistoryAndCurrentView(t *testing.T) { func TestKindAssignment_AppendOnlyHistoryAndCurrentView(t *testing.T) {
db := openTestDB(t) db := openTestDB(t)
ctx := context.Background() 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{ activityID, err := db.UpsertActivity(ctx, userID, Activity{
GarminActivityID: 1, GarminActivityID: 1,
StartTimeUTC: "2026-07-11 05:00:00", StartTimeUTC: "2026-07-11 05:00:00",
RawJSON: "{}", RawJSON: "{}",
@@ -95,7 +103,7 @@ func TestKindAssignment_AppendOnlyHistoryAndCurrentView(t *testing.T) {
t.Fatalf("UpsertActivity: %v", err) t.Fatalf("UpsertActivity: %v", err)
} }
kindID, err := db.CreateWorkoutKind(ctx, WorkoutKind{ kindID, err := db.CreateWorkoutKind(ctx, userID, WorkoutKind{
Name: "Test Assignment Custom", Name: "Test Assignment Custom",
RuleJSON: `{"match":"all","conditions":[]}`, RuleJSON: `{"match":"all","conditions":[]}`,
IsActive: true, IsActive: true,
@@ -169,8 +177,12 @@ func TestKindAssignment_AppendOnlyHistoryAndCurrentView(t *testing.T) {
func TestReplaceLapsIsIdempotent(t *testing.T) { func TestReplaceLapsIsIdempotent(t *testing.T) {
db := openTestDB(t) db := openTestDB(t)
ctx := context.Background() 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{ activityID, err := db.UpsertActivity(ctx, userID, Activity{
GarminActivityID: 2, GarminActivityID: 2,
StartTimeUTC: "2026-07-11 05:00:00", StartTimeUTC: "2026-07-11 05:00:00",
RawJSON: "{}", RawJSON: "{}",
@@ -245,8 +257,13 @@ func TestForeignKeyEnforcementPostMigration(t *testing.T) {
// migrations 0023/0024/0025) is properly scoped and re-enabled after each // migrations 0023/0024/0025) is properly scoped and re-enabled after each
// table rebuild. // table rebuild.
userID, err := db.ProvisionUser(ctx, "test-sub", "Test User")
if err != nil {
t.Fatalf("ProvisionUser: %v", err)
}
// Insert an activity that we can reference. // Insert an activity that we can reference.
activityID, err := db.UpsertActivity(ctx, Activity{ activityID, err := db.UpsertActivity(ctx, userID, Activity{
GarminActivityID: 1001, GarminActivityID: 1001,
StartTimeUTC: "2026-07-11 05:00:00", StartTimeUTC: "2026-07-11 05:00:00",
RawJSON: "{}", RawJSON: "{}",

View File

@@ -13,25 +13,24 @@ type SyncState struct {
BackfillComplete bool // true once backfill has reached the configured horizon (or Garmin's own history start) 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, // GetSyncState returns the current backfill watermark for userID.
// created by migration 0002). func (db *DB) GetSyncState(ctx context.Context, userID int64) (SyncState, error) {
func (db *DB) GetSyncState(ctx context.Context) (SyncState, error) {
var s SyncState 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) Scan(&s.EarliestSyncedDate, &s.BackfillComplete)
if err != nil { 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 return s, nil
} }
// UpdateSyncState records progress of a backfill run. // UpdateSyncState records progress of a backfill run for userID.
func (db *DB) UpdateSyncState(ctx context.Context, earliestSyncedDate string, complete bool) error { func (db *DB) UpdateSyncState(ctx context.Context, userID int64, earliestSyncedDate string, complete bool) error {
_, err := db.ExecContext(ctx, ` _, err := db.ExecContext(ctx, `
UPDATE sync_state SET earliest_synced_date = ?, backfill_complete = ? WHERE id = 1`, UPDATE sync_state SET earliest_synced_date = ?, backfill_complete = ? WHERE user_id = ?`,
earliestSyncedDate, complete) earliestSyncedDate, complete, userID)
if err != nil { 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 return nil
} }

View File

@@ -8,8 +8,12 @@ import (
func TestSyncState_DefaultsAndUpdate(t *testing.T) { func TestSyncState_DefaultsAndUpdate(t *testing.T) {
db := openTestDB(t) db := openTestDB(t)
ctx := context.Background() 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 { if err != nil {
t.Fatalf("GetSyncState: %v", err) 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) 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) t.Fatalf("UpdateSyncState: %v", err)
} }
state, err = db.GetSyncState(ctx) state, err = db.GetSyncState(ctx, userID)
if err != nil { if err != nil {
t.Fatalf("GetSyncState after update: %v", err) t.Fatalf("GetSyncState after update: %v", err)
} }

View File

@@ -29,13 +29,13 @@ func scanWorkoutKind(row interface{ Scan(...any) error }) (WorkoutKind, error) {
const workoutKindColumns = `id, name, description, color, rule_json, priority, is_active, created_at, updated_at` 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. // 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, ` res, err := db.ExecContext(ctx, `
INSERT INTO workout_kinds (name, description, color, rule_json, priority, is_active) INSERT INTO workout_kinds (user_id, name, description, color, rule_json, priority, is_active)
VALUES (?,?,?,?,?,?)`, VALUES (?,?,?,?,?,?,?)`,
k.Name, k.Description, k.Color, k.RuleJSON, k.Priority, k.IsActive) userID, k.Name, k.Description, k.Color, k.RuleJSON, k.Priority, k.IsActive)
if err != nil { 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() return res.LastInsertId()
} }
@@ -78,18 +78,18 @@ func (db *DB) GetWorkoutKindByName(ctx context.Context, name string) (WorkoutKin
return k, true, nil 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. // (is_active=0) kinds are excluded.
func (db *DB) ListWorkoutKinds(ctx context.Context, activeOnly bool) ([]WorkoutKind, error) { func (db *DB) ListWorkoutKinds(ctx context.Context, userID int64, activeOnly bool) ([]WorkoutKind, error) {
query := `SELECT ` + workoutKindColumns + ` FROM workout_kinds` query := `SELECT ` + workoutKindColumns + ` FROM workout_kinds WHERE user_id = ?`
if activeOnly { if activeOnly {
query += ` WHERE is_active = 1` query += ` AND is_active = 1`
} }
query += ` ORDER BY priority DESC, name` query += ` ORDER BY priority DESC, name`
rows, err := db.QueryContext(ctx, query) rows, err := db.QueryContext(ctx, query, userID)
if err != nil { 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() defer rows.Close()

View File

@@ -8,8 +8,12 @@ import (
func TestWorkoutTaxonomy_SeededWithEightFixedTypes(t *testing.T) { func TestWorkoutTaxonomy_SeededWithEightFixedTypes(t *testing.T) {
db := openTestDB(t) db := openTestDB(t)
ctx := context.Background() 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 { if err != nil {
t.Fatalf("ListWorkoutKinds: %v", err) t.Fatalf("ListWorkoutKinds: %v", err)
} }
@@ -42,8 +46,12 @@ func TestWorkoutTaxonomy_SeededWithEightFixedTypes(t *testing.T) {
func TestWorkoutTaxonomy_ListedInFixedDisplayOrder(t *testing.T) { func TestWorkoutTaxonomy_ListedInFixedDisplayOrder(t *testing.T) {
db := openTestDB(t) db := openTestDB(t)
ctx := context.Background() 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 { if err != nil {
t.Fatalf("ListWorkoutKinds: %v", err) t.Fatalf("ListWorkoutKinds: %v", err)
} }