refactor(store): single account name on users; rename profile/laps tables
users.display_name becomes users.name and is now the only human-facing name -- profiles.name is dropped (it duplicated the account name; the Profile page's Name field now edits users.name through PUT /api/profile, which carries Name alongside the profiles columns). The profile table becomes profiles and laps becomes activity_laps, homogeneous with activity_samples. Onboarding pre-fills the display name from the OIDC claim's name. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -188,12 +188,12 @@ func TestIsolation_DeleteUserLeavesOtherUsersDataIntact(t *testing.T) {
|
||||
if _, found, err := db.GetUserBySub(ctx, "sub-b"); err != nil || !found {
|
||||
t.Fatalf("expected userB to survive userA's deletion, found=%v err=%v", found, err)
|
||||
}
|
||||
profileB, err := db.GetProfile(ctx, userB)
|
||||
if err != nil {
|
||||
if _, err := db.GetProfile(ctx, userB); err != nil {
|
||||
t.Fatalf("GetProfile(b) after deleting a: %v", err)
|
||||
}
|
||||
if profileB.Name != "B" {
|
||||
t.Errorf("userB's profile changed after deleting userA: %+v", profileB)
|
||||
userBRow, found, err := db.GetUserBySub(ctx, "sub-b")
|
||||
if err != nil || !found || userBRow.Name != "B" {
|
||||
t.Errorf("userB's account changed after deleting userA: %+v (found=%v err=%v)", userBRow, found, err)
|
||||
}
|
||||
kindsB, err := db.ListWorkoutKinds(ctx, userB, false)
|
||||
if err != nil || len(kindsB) != 8 {
|
||||
|
||||
@@ -55,13 +55,13 @@ func (db *DB) ReplaceLaps(ctx context.Context, userID, activityID int64, laps []
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
if _, err := tx.ExecContext(ctx, `DELETE FROM laps WHERE activity_id = ?`, activityID); err != nil {
|
||||
if _, err := tx.ExecContext(ctx, `DELETE FROM activity_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 (
|
||||
INSERT INTO activity_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
|
||||
@@ -84,7 +84,7 @@ func (db *DB) LapsForActivity(ctx context.Context, userID, activityID int64) ([]
|
||||
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
|
||||
FROM activity_laps AS laps
|
||||
JOIN activities ON activities.id = laps.activity_id
|
||||
WHERE laps.activity_id = ? AND activities.user_id = ?
|
||||
ORDER BY laps.lap_index`, activityID, userID)
|
||||
|
||||
@@ -8,9 +8,6 @@ import (
|
||||
// Profile is the single active user's Garmin credentials plus every
|
||||
// tunable analysis-engine parameter. Always exactly one row (id=1).
|
||||
type Profile struct {
|
||||
// Name labels this profile so a future multi-profile setup can show
|
||||
// which one is active. Only one profile row exists today (id=1).
|
||||
Name string
|
||||
GarminEmail string
|
||||
GarminPassword string
|
||||
// GarminConnectedAt is nil until this user's first successful Garmin
|
||||
@@ -76,7 +73,7 @@ type Profile struct {
|
||||
}
|
||||
|
||||
const profileColumns = `
|
||||
name, garmin_email, garmin_password, garmin_connected_at, rolling_window_days, backfill_horizon_days, max_heart_rate, resting_heart_rate,
|
||||
garmin_email, garmin_password, garmin_connected_at, 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,
|
||||
@@ -90,8 +87,8 @@ const profileColumns = `
|
||||
// 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.GarminConnectedAt, &p.RollingWindowDays, &p.BackfillHorizonDays, &p.MaxHeartRate, &p.RestingHeartRate,
|
||||
err := db.QueryRowContext(ctx, `SELECT `+profileColumns+` FROM profiles WHERE user_id = ?`, userID).Scan(
|
||||
&p.GarminEmail, &p.GarminPassword, &p.GarminConnectedAt, &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,
|
||||
@@ -112,8 +109,8 @@ func (db *DB) GetProfile(ctx context.Context, userID int64) (Profile, error) {
|
||||
// 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=?,
|
||||
UPDATE profiles SET
|
||||
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=?,
|
||||
@@ -123,7 +120,7 @@ func (db *DB) UpdateProfile(ctx context.Context, userID int64, p Profile) error
|
||||
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.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,
|
||||
@@ -143,7 +140,7 @@ func (db *DB) UpdateProfile(ctx context.Context, userID int64, p Profile) error
|
||||
// authenticates with Garmin. A no-op if already set, so it always reflects
|
||||
// the first connection, not the most recent one.
|
||||
func (db *DB) MarkGarminConnected(ctx context.Context, userID int64) error {
|
||||
_, err := db.ExecContext(ctx, `UPDATE profile SET garmin_connected_at = datetime('now') WHERE user_id = ? AND garmin_connected_at IS NULL`, userID)
|
||||
_, err := db.ExecContext(ctx, `UPDATE profiles SET garmin_connected_at = datetime('now') WHERE user_id = ? AND garmin_connected_at IS NULL`, userID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("mark garmin connected for user %d: %w", userID, err)
|
||||
}
|
||||
|
||||
@@ -32,9 +32,6 @@ func TestProfile_DefaultsThenUpdate(t *testing.T) {
|
||||
if p.MinRepresentativePaceSecPerKm != 720 || p.MinRepresentativeTimeSeconds != 3 {
|
||||
t.Errorf("pace artifact filter defaults = %+v, want pace=720, time=3", p)
|
||||
}
|
||||
if p.Name != "Default" {
|
||||
t.Errorf("Name = %q, want %q (migration default)", p.Name, "Default")
|
||||
}
|
||||
if p.PaceColor != "#3b82f6" || p.HeartRateColor != "#ef4444" {
|
||||
t.Errorf("main line color defaults = %+v, want pace=#3b82f6, heartRate=#ef4444", p)
|
||||
}
|
||||
@@ -52,7 +49,6 @@ func TestProfile_DefaultsThenUpdate(t *testing.T) {
|
||||
}
|
||||
|
||||
maxHR, restingHR := 190.0, 50.0
|
||||
p.Name = "Kriss"
|
||||
p.PaceColor = "#111111"
|
||||
p.EffortColor = "#222222"
|
||||
p.MainLineTintPct = 45
|
||||
@@ -79,9 +75,6 @@ func TestProfile_DefaultsThenUpdate(t *testing.T) {
|
||||
if got.GarminEmail != "runner@example.com" || got.RollingWindowDays != 120 {
|
||||
t.Errorf("got = %+v, want updated email/window", got)
|
||||
}
|
||||
if got.Name != "Kriss" {
|
||||
t.Errorf("Name = %q, want %q after update", got.Name, "Kriss")
|
||||
}
|
||||
if got.MaxHeartRate == nil || *got.MaxHeartRate != 190 {
|
||||
t.Errorf("MaxHeartRate = %v, want 190", got.MaxHeartRate)
|
||||
}
|
||||
|
||||
@@ -8,26 +8,26 @@
|
||||
-- here rather than appending an ALTER TABLE migration.
|
||||
|
||||
-- One geniusrun account per OIDC subject. Every other table below is scoped
|
||||
-- to a user_id, directly (profile/workout_kinds/activities/sync_state/
|
||||
-- sync_runs) or transitively through a JOIN to the owning row (laps/
|
||||
-- to a user_id, directly (profiles/workout_kinds/activities/sync_state/
|
||||
-- sync_runs) or transitively through a JOIN to the owning row (activity_laps/
|
||||
-- activity_samples/kind_assignments/workout_type_paces, which have no
|
||||
-- user_id column of their own since they're never queried except through a
|
||||
-- specific activity or workout kind).
|
||||
-- specific activity or workout kind). name is the account's single
|
||||
-- human-facing name: set at onboarding, editable from the Profile page.
|
||||
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'))
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
oidc_sub TEXT NOT NULL UNIQUE,
|
||||
name TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
-- One row per user: Garmin credentials plus every tunable analysis-engine
|
||||
-- parameter (HR zones, phase-detection minutes, pace-artifact filtering,
|
||||
-- chart colors). store.ProvisionUser creates this (and everything else
|
||||
-- below) in one transaction when a new account signs up.
|
||||
CREATE TABLE profile (
|
||||
CREATE TABLE profiles (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL DEFAULT 'Default',
|
||||
garmin_email TEXT NOT NULL DEFAULT '',
|
||||
garmin_password TEXT NOT NULL DEFAULT '',
|
||||
-- Set once, the first time this user successfully authenticates with
|
||||
@@ -168,7 +168,7 @@ CREATE INDEX idx_activities_start_time ON activities(start_time_utc);
|
||||
-- One row per lap/split (from get_activity_splits), plus HR drift/recovery
|
||||
-- derived from activity_samples. No user_id column -- always accessed
|
||||
-- through a specific owning activity.
|
||||
CREATE TABLE laps (
|
||||
CREATE TABLE activity_laps (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
activity_id INTEGER NOT NULL REFERENCES activities(id) ON DELETE CASCADE,
|
||||
lap_index INTEGER NOT NULL,
|
||||
|
||||
@@ -236,7 +236,7 @@ func TestSchema_PerUserUniqueConstraints(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
// 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 {
|
||||
if _, err := db.ExecContext(ctx, `INSERT INTO users (oidc_sub, name) VALUES ('sub-a', 'A'), ('sub-b', 'B')`); err != nil {
|
||||
t.Fatalf("insert users: %v", err)
|
||||
}
|
||||
if _, err := db.ExecContext(ctx, `
|
||||
@@ -247,10 +247,10 @@ func TestSchema_PerUserUniqueConstraints(t *testing.T) {
|
||||
}
|
||||
|
||||
// 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 {
|
||||
if _, err := db.ExecContext(ctx, `INSERT INTO profiles (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 {
|
||||
if _, err := db.ExecContext(ctx, `INSERT INTO profiles (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)")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,18 +11,18 @@ import (
|
||||
// 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
|
||||
ID int64
|
||||
OIDCSub string
|
||||
Name string
|
||||
CreatedAt string
|
||||
}
|
||||
|
||||
// GetUserBySub looks up a user by their OIDC subject -- the only lookup key
|
||||
// the session-resolution middleware 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)
|
||||
err := db.QueryRowContext(ctx, `SELECT id, oidc_sub, name, created_at FROM users WHERE oidc_sub = ?`, oidcSub).
|
||||
Scan(&u.ID, &u.OIDCSub, &u.Name, &u.CreatedAt)
|
||||
if err == sql.ErrNoRows {
|
||||
return User{}, false, nil
|
||||
}
|
||||
@@ -63,7 +63,7 @@ func (db *DB) ProvisionUser(ctx context.Context, oidcSub, displayName string) (i
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
res, err := tx.ExecContext(ctx, `INSERT INTO users (oidc_sub, display_name) VALUES (?, ?)`, oidcSub, displayName)
|
||||
res, err := tx.ExecContext(ctx, `INSERT INTO users (oidc_sub, name) VALUES (?, ?)`, oidcSub, displayName)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("create user %q: %w", oidcSub, err)
|
||||
}
|
||||
@@ -72,7 +72,7 @@ func (db *DB) ProvisionUser(ctx context.Context, oidcSub, displayName string) (i
|
||||
return 0, err
|
||||
}
|
||||
|
||||
if _, err := tx.ExecContext(ctx, `INSERT INTO profile (user_id, name) VALUES (?, ?)`, userID, displayName); err != nil {
|
||||
if _, err := tx.ExecContext(ctx, `INSERT INTO profiles (user_id) VALUES (?)`, userID); err != nil {
|
||||
return 0, fmt.Errorf("create profile for user %d: %w", userID, err)
|
||||
}
|
||||
|
||||
@@ -113,3 +113,12 @@ func (db *DB) DeleteUser(ctx context.Context, userID int64) error {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpdateUserName renames userID's account -- the single human-facing name
|
||||
// (shown in the header and session info), editable from the Profile page.
|
||||
func (db *DB) UpdateUserName(ctx context.Context, userID int64, name string) error {
|
||||
if _, err := db.ExecContext(ctx, `UPDATE users SET name = ? WHERE id = ?`, name, userID); err != nil {
|
||||
return fmt.Errorf("update name for user %d: %w", userID, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -29,17 +29,14 @@ func TestProvisionUser_SeedsProfileTaxonomyAndSyncState(t *testing.T) {
|
||||
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)
|
||||
if u.ID != userID || u.Name != "Lucie" {
|
||||
t.Fatalf("got %+v, want ID=%d Name=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)
|
||||
}
|
||||
@@ -142,11 +139,11 @@ func TestDeleteUser_RemovesUserAndCascadesEverything(t *testing.T) {
|
||||
query string
|
||||
arg int64
|
||||
}{
|
||||
{`SELECT COUNT(*) FROM profile WHERE user_id = ?`, userID},
|
||||
{`SELECT COUNT(*) FROM profiles WHERE user_id = ?`, userID},
|
||||
{`SELECT COUNT(*) FROM workout_kinds WHERE user_id = ?`, userID},
|
||||
{`SELECT COUNT(*) FROM workout_type_paces WHERE workout_kind_id = ?`, kindID},
|
||||
{`SELECT COUNT(*) FROM activities WHERE user_id = ?`, userID},
|
||||
{`SELECT COUNT(*) FROM laps WHERE activity_id = ?`, activityID},
|
||||
{`SELECT COUNT(*) FROM activity_laps WHERE activity_id = ?`, activityID},
|
||||
{`SELECT COUNT(*) FROM activity_samples WHERE activity_id = ?`, activityID},
|
||||
{`SELECT COUNT(*) FROM kind_assignments WHERE activity_id = ?`, activityID},
|
||||
{`SELECT COUNT(*) FROM sync_state WHERE user_id = ?`, userID},
|
||||
|
||||
Reference in New Issue
Block a user