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() }