From 36bc651d6632caa48779f99b9f2c1c69850bd269 Mon Sep 17 00:00:00 2001 From: Christophe Vila Date: Sat, 25 Jul 2026 12:59:56 +0200 Subject: [PATCH] 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. --- backend/internal/store/users.go | 133 +++++++++++++++++++++++++++ backend/internal/store/users_test.go | 91 ++++++++++++++++++ 2 files changed, 224 insertions(+) create mode 100644 backend/internal/store/users.go create mode 100644 backend/internal/store/users_test.go 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") + } +}