diff --git a/backend/cmd/geniusrund/main.go b/backend/cmd/geniusrund/main.go index 345e0e7..79e5efd 100644 --- a/backend/cmd/geniusrund/main.go +++ b/backend/cmd/geniusrund/main.go @@ -45,11 +45,22 @@ func main() { } defer db.Close() - overrides, err := db.ConfigValues(context.Background()) + values, err := db.ConfigValues(context.Background()) if err != nil { - fatal("read app config overrides", err) + fatal("read app config", err) } - appCfg, err := config.LoadApp(overrides) + // Every registry key is mandatory in the DB: seed missing ones with + // their defaults so the config table is always fully populated (no + // code-side fallbacks anywhere downstream). + for _, k := range config.AppRegistry() { + if _, ok := values[k.Key]; !ok { + if err := db.SetConfigValue(context.Background(), k.Key, k.Default); err != nil { + fatal("seed app config default", err) + } + values[k.Key] = k.Default + } + } + appCfg, err := config.LoadApp(values) if err != nil { fatal("load app config", err) } @@ -75,15 +86,14 @@ func main() { garmin.SyncConfig{}, authVerifier, api.SessionConfig{ - Secret: envCfg.SessionSecret, - Duration: appCfg.SessionDuration, - Secure: envCfg.SessionSecure, - BackendURL: envCfg.BackendURL, - FrontendURL: envCfg.FrontendURL, + Secret: envCfg.SessionSecret, + Duration: appCfg.SessionDuration, + SetupTimeout: appCfg.SetupTimeout, + Secure: envCfg.SessionSecure, + BackendURL: envCfg.BackendURL, + FrontendURL: envCfg.FrontendURL, }) - server.SetupSessionIdleTimeout = appCfg.SetupSessionIdleTimeout - for _, e := range envCfg.DisplayEnv() { server.EnvVars = append(server.EnvVars, api.EnvVar{Name: e.Name, Value: e.Value}) } diff --git a/backend/internal/api/api_test.go b/backend/internal/api/api_test.go index b5d72cc..68185b4 100644 --- a/backend/internal/api/api_test.go +++ b/backend/internal/api/api_test.go @@ -25,11 +25,12 @@ import ( func newCtx() context.Context { return context.Background() } var testSessionConfig = SessionConfig{ - Secret: []byte("test-session-secret-at-least-32-bytes-long"), - Duration: time.Hour, - Secure: false, - BackendURL: "https://geniusrun.example.com", - FrontendURL: "https://app.geniusrun.example.com", + Secret: []byte("test-session-secret-at-least-32-bytes-long"), + Duration: time.Hour, + SetupTimeout: 15 * time.Minute, + Secure: false, + BackendURL: "https://geniusrun.example.com", + FrontendURL: "https://app.geniusrun.example.com", } func newTestServer(t *testing.T) (*Server, *store.DB, int64) { diff --git a/backend/internal/api/config.go b/backend/internal/api/config.go index 85c3579..1a4c286 100644 --- a/backend/internal/api/config.go +++ b/backend/internal/api/config.go @@ -22,23 +22,27 @@ type configEntry struct { Description string `json:"description"` } -// configView assembles the GET/PUT response: registry defaults overlaid -// with DB overrides, plus the env snapshot. +// configView assembles the GET/PUT response: every registry key with its +// stored value (the DB is fully seeded with defaults at startup; the +// registry default only fills in here for a key added since the last +// boot, e.g. under httptest where main's seeding never ran), plus the env +// snapshot. Overridden means "differs from the default", since every key +// always has a row. func (s *Server) configView(r *http.Request) (map[string]any, error) { - overrides, err := s.DB.ConfigValues(r.Context()) + values, err := s.DB.ConfigValues(r.Context()) if err != nil { return nil, err } registry := config.AppRegistry() app := make([]configEntry, 0, len(registry)) for _, k := range registry { - value, overridden := overrides[k.Key] - if !overridden { + value, ok := values[k.Key] + if !ok { value = k.Default } app = append(app, configEntry{ Key: k.Key, Value: value, Default: k.Default, - Overridden: overridden, Description: k.Description, + Overridden: value != k.Default, Description: k.Description, }) } envVars := s.EnvVars diff --git a/backend/internal/api/config_test.go b/backend/internal/api/config_test.go index 2985110..388ac58 100644 --- a/backend/internal/api/config_test.go +++ b/backend/internal/api/config_test.go @@ -55,8 +55,8 @@ func TestConfig_GetDefaultsAndEnvSnapshot(t *testing.T) { if e := byKey["session.duration"]; e.value != "720" || e.def != "720" || e.overridden { t.Fatalf("session.duration default entry = %+v", e) } - if e := byKey["session.idle_timeout"]; e.value != "15" || e.def != "15" || e.overridden { - t.Fatalf("session.idle_timeout default entry = %+v", e) + if e := byKey["session.setup_timeout"]; e.value != "15" || e.def != "15" || e.overridden { + t.Fatalf("session.setup_timeout default entry = %+v", e) } if len(resp.Environment) != 1 || resp.Environment[0].Value != "•••• (set)" { t.Fatalf("env snapshot not passed through: %+v", resp.Environment) diff --git a/backend/internal/api/server.go b/backend/internal/api/server.go index 4e6f43e..a05ece1 100644 --- a/backend/internal/api/server.go +++ b/backend/internal/api/server.go @@ -53,11 +53,6 @@ type Server struct { // never call os.Getenv. EnvVars []EnvVar - // SetupSessionIdleTimeout is the app-config session.idle_timeout value - // (see internal/config); zero falls back to - // defaultSetupSessionIdleTimeout. - SetupSessionIdleTimeout time.Duration - mu sync.Mutex userClient map[int64]garmin.Client userSync map[int64]*garmin.Sync diff --git a/backend/internal/api/session.go b/backend/internal/api/session.go index 6b9035a..a434fae 100644 --- a/backend/internal/api/session.go +++ b/backend/internal/api/session.go @@ -15,7 +15,13 @@ import ( type SessionConfig struct { Secret []byte Duration time.Duration - Secure bool + // SetupTimeout evicts an unfinished onboarding Garmin setup session + // once idle this long (app-config key session.setup_timeout, minutes; + // distinct from Duration, the login cookie lifetime). Mandatory -- + // there is no code fallback; the default lives in the DB, seeded at + // startup. + SetupTimeout time.Duration + Secure bool // BackendURL is this app's own externally reachable origin (e.g. // "https://geniusrun.example.com", no trailing slash) -- derives // OIDCRedirectURL (config.Config), the only thing that must stay pointed diff --git a/backend/internal/api/setup.go b/backend/internal/api/setup.go index 1fd80c8..6458fa9 100644 --- a/backend/internal/api/setup.go +++ b/backend/internal/api/setup.go @@ -13,24 +13,6 @@ import ( applog "geniusrun/backend/internal/log" ) -// defaultSetupSessionIdleTimeout bounds how long an onboarding Garmin -// session survives without being touched (login, MFA, or complete) before -// it's evicted -- long enough to check email for an MFA code, short enough -// that an abandoned attempt doesn't leave a subprocess running -// indefinitely. Tunable via the session.idle_timeout application-config -// key (minutes -- distinct from session.duration, the login cookie -// lifetime); this constant is the fallback when the Server field was never -// wired (tests building a bare NewServer). -const defaultSetupSessionIdleTimeout = 15 * time.Minute - -// setupIdleTimeout returns the configured onboarding-session idle timeout. -func (s *Server) setupIdleTimeout() time.Duration { - if s.SetupSessionIdleTimeout > 0 { - return s.SetupSessionIdleTimeout - } - return defaultSetupSessionIdleTimeout -} - // setupSession is a temporary, not-yet-persisted Garmin authentication // attempt made during onboarding, before any users/profile row exists -- // keyed by OIDC subject (the only stable identifier available pre-account) @@ -42,7 +24,7 @@ func (s *Server) setupIdleTimeout() time.Duration { // the next time anything closes and restarts its subprocess; a later // garminFor(ctx, userID) call builds a fresh client with the correct path // instead. Otherwise evicted lazily (the next setup-endpoint touch for that -// subject checks staleness first) once idle past setupIdleTimeout(). +// subject checks staleness first) once idle past SessionConfig.SetupTimeout. type setupSession struct { Client garmin.Client Email, Password string @@ -225,7 +207,7 @@ func (s *Server) setupSessionFor(sub string) (*setupSession, bool) { if !ok { return nil, false } - if time.Since(sess.LastUsed) > s.setupIdleTimeout() { + if time.Since(sess.LastUsed) > s.SessionConfig.SetupTimeout { sess.Client.Close() delete(s.setupSession, sub) if s.ClientConfig.TokenStorePath != "" { diff --git a/backend/internal/config/appconfig.go b/backend/internal/config/appconfig.go index 0cc63db..d8c4830 100644 --- a/backend/internal/config/appconfig.go +++ b/backend/internal/config/appconfig.go @@ -19,13 +19,13 @@ type AppKey struct { // KeySessionDuration is the session cookie lifetime, in integer hours. const ( KeySessionDuration = "session.duration" - // KeySessionIdleTimeout bounds an *onboarding Garmin session* (the - // ephemeral, pre-account login attempt), not the login cookie -- + // KeySessionSetupTimeout bounds an *onboarding Garmin setup session* + // (the ephemeral, pre-account login attempt), not the login cookie -- // session.duration is how long a signed-in user stays signed in - // (hours); session.idle_timeout is how long an unfinished setup + // (hours); session.setup_timeout is how long an unfinished setup // attempt survives untouched before its subprocess is torn down // (minutes). - KeySessionIdleTimeout = "session.idle_timeout" + KeySessionSetupTimeout = "session.setup_timeout" ) var appRegistry = []AppKey{ @@ -36,9 +36,9 @@ var appRegistry = []AppKey{ Validate: validatePositiveInt, }, { - Key: KeySessionIdleTimeout, + Key: KeySessionSetupTimeout, Default: "15", - Description: "Idle timeout in minutes for an unfinished onboarding Garmin session", + Description: "Idle timeout in minutes for an unfinished onboarding Garmin setup session", Validate: validatePositiveInt, }, } @@ -69,32 +69,35 @@ func ValidateAppValue(key, value string) error { return fmt.Errorf("unknown configuration key %q", key) } -// AppConfig is the typed result of merging DB overrides over registry -// defaults. +// AppConfig is the typed application configuration, built from the DB's +// config rows. type AppConfig struct { - SessionDuration time.Duration - SetupSessionIdleTimeout time.Duration + SessionDuration time.Duration + SetupTimeout time.Duration } -// LoadApp merges overrides (store.ConfigValues' map) over defaults. Pure -// -- it takes the raw map instead of a *store.DB so this package needs no -// store dependency and the merge logic tests without a database. A bad -// stored value fails fast, same posture as LoadEnv() for env vars. -func LoadApp(overrides map[string]string) (AppConfig, error) { - merged := map[string]string{} - for _, k := range appRegistry { - merged[k.Key] = k.Default - } - for key, value := range overrides { +// LoadApp builds the typed AppConfig from the config table's rows. Every +// registry key is mandatory: main seeds missing keys with their defaults +// at startup (see cmd/geniusrund), so a missing key here means that +// seeding didn't run -- fail fast, same posture as LoadEnv() for env +// vars, and likewise for an unknown or invalid stored value. Pure -- it +// takes the raw map instead of a *store.DB so this package needs no store +// dependency and the logic tests without a database. +func LoadApp(values map[string]string) (AppConfig, error) { + for key, value := range values { if err := ValidateAppValue(key, value); err != nil { return AppConfig{}, fmt.Errorf("app config: %w", err) } - merged[key] = value } - hours, _ := strconv.Atoi(merged[KeySessionDuration]) - idleMinutes, _ := strconv.Atoi(merged[KeySessionIdleTimeout]) + for _, k := range appRegistry { + if _, ok := values[k.Key]; !ok { + return AppConfig{}, fmt.Errorf("app config: missing key %q (defaults are seeded into the DB at startup)", k.Key) + } + } + hours, _ := strconv.Atoi(values[KeySessionDuration]) + setupMinutes, _ := strconv.Atoi(values[KeySessionSetupTimeout]) return AppConfig{ - SessionDuration: time.Duration(hours) * time.Hour, - SetupSessionIdleTimeout: time.Duration(idleMinutes) * time.Minute, + SessionDuration: time.Duration(hours) * time.Hour, + SetupTimeout: time.Duration(setupMinutes) * time.Minute, }, nil } diff --git a/backend/internal/config/appconfig_test.go b/backend/internal/config/appconfig_test.go index a19f24e..14ffe11 100644 --- a/backend/internal/config/appconfig_test.go +++ b/backend/internal/config/appconfig_test.go @@ -8,22 +8,25 @@ import ( func TestLoadApp(t *testing.T) { tests := []struct { - name string - overrides map[string]string - want time.Duration - wantIdle time.Duration - wantErr string + name string + values map[string]string + want time.Duration + wantIdle time.Duration + wantErr string }{ - {name: "defaults when no overrides", overrides: nil, want: 720 * time.Hour, wantIdle: 15 * time.Minute}, - {name: "override applied", overrides: map[string]string{"session.duration": "168"}, want: 168 * time.Hour, wantIdle: 15 * time.Minute}, - {name: "idle timeout override applied", overrides: map[string]string{"session.idle_timeout": "30"}, want: 720 * time.Hour, wantIdle: 30 * time.Minute}, - {name: "invalid stored value", overrides: map[string]string{"session.duration": "zero"}, wantErr: "session.duration"}, - {name: "invalid idle timeout", overrides: map[string]string{"session.idle_timeout": "0"}, wantErr: "session.idle_timeout"}, - {name: "unknown stored key", overrides: map[string]string{"bogus.key": "1"}, wantErr: "unknown configuration key"}, + // Every registry key is mandatory: main seeds the DB with defaults + // at startup, so LoadApp always receives a complete map. + {name: "seeded defaults", values: map[string]string{"session.duration": "720", "session.setup_timeout": "15"}, want: 720 * time.Hour, wantIdle: 15 * time.Minute}, + {name: "custom values", values: map[string]string{"session.duration": "168", "session.setup_timeout": "30"}, want: 168 * time.Hour, wantIdle: 30 * time.Minute}, + {name: "missing key fails fast", values: map[string]string{"session.duration": "720"}, wantErr: "missing key"}, + {name: "nil map fails fast", values: nil, wantErr: "missing key"}, + {name: "invalid stored value", values: map[string]string{"session.duration": "zero", "session.setup_timeout": "15"}, wantErr: "session.duration"}, + {name: "invalid setup timeout", values: map[string]string{"session.duration": "720", "session.setup_timeout": "0"}, wantErr: "session.setup_timeout"}, + {name: "unknown stored key", values: map[string]string{"session.duration": "720", "session.setup_timeout": "15", "bogus.key": "1"}, wantErr: "unknown configuration key"}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got, err := LoadApp(tt.overrides) + got, err := LoadApp(tt.values) if tt.wantErr != "" { if err == nil || !strings.Contains(err.Error(), tt.wantErr) { t.Fatalf("err = %v, want containing %q", err, tt.wantErr) @@ -36,8 +39,8 @@ func TestLoadApp(t *testing.T) { if got.SessionDuration != tt.want { t.Fatalf("SessionDuration = %v, want %v", got.SessionDuration, tt.want) } - if got.SetupSessionIdleTimeout != tt.wantIdle { - t.Fatalf("SetupSessionIdleTimeout = %v, want %v", got.SetupSessionIdleTimeout, tt.wantIdle) + if got.SetupTimeout != tt.wantIdle { + t.Fatalf("SetupSessionIdleTimeout = %v, want %v", got.SetupTimeout, tt.wantIdle) } }) } diff --git a/backend/internal/store/config.go b/backend/internal/store/config.go index 86b80f6..c6bf72c 100644 --- a/backend/internal/store/config.go +++ b/backend/internal/store/config.go @@ -5,9 +5,10 @@ import ( "fmt" ) -// ConfigValues returns every application-configuration override row as -// key -> value. An absent key means its default (from internal/config's -// app-key registry) is in effect -- the table stores overrides only. +// ConfigValues returns every application-configuration row as key -> +// value. The table holds a row for every registry key: main seeds missing +// keys with their defaults at startup, so downstream code never needs a +// code-side fallback. func (db *DB) ConfigValues(ctx context.Context) (map[string]string, error) { rows, err := db.QueryContext(ctx, `SELECT key, value FROM config`) if err != nil { diff --git a/backend/internal/store/schema.sql b/backend/internal/store/schema.sql index 1646696..edbd9cd 100644 --- a/backend/internal/store/schema.sql +++ b/backend/internal/store/schema.sql @@ -252,11 +252,13 @@ CREATE TABLE sync_runs ( error_message TEXT ); --- Application configuration: instance-global key/value overrides, shared +-- Application configuration: instance-global key/value settings, shared -- by every user -- deliberately the one table with no user_id, because --- application configuration is common to all users by definition. Stores --- overrides only; defaults live in internal/config's app-key registry. --- Cold: read once at startup, a change applies on the next backend restart. +-- application configuration is common to all users by definition. Every +-- key in internal/config's app-key registry is mandatory here: missing +-- rows are seeded with their defaults at startup (cmd/geniusrund), so +-- there are no code-side fallbacks. Cold: read once at startup, a change +-- applies on the next backend restart. CREATE TABLE config ( key TEXT PRIMARY KEY, value TEXT NOT NULL, diff --git a/docs/superpowers/plans/2026-07-17-profile-and-taxonomy.md b/docs/superpowers/plans/2026-07-17-profile-and-taxonomy.md index 94f6f3a..f4146bc 100644 --- a/docs/superpowers/plans/2026-07-17-profile-and-taxonomy.md +++ b/docs/superpowers/plans/2026-07-17-profile-and-taxonomy.md @@ -22,8 +22,8 @@ **Files:** - Create: `backend/internal/store/migrations/0003_profile.sql` -- Create: `backend/internal/store/profile.go` -- Test: `backend/internal/store/profile_test.go` +- Create: `../../../backend/internal/store/profiles.go` +- Test: `../../../backend/internal/store/profiles_test.go` **Interfaces:** - Produces: `store.Profile` struct, `(db *DB) GetProfile(ctx context.Context) (Profile, error)`, `(db *DB) UpdateProfile(ctx context.Context, p Profile) error`. @@ -72,7 +72,7 @@ INSERT INTO profile (id) VALUES (1); - [ ] **Step 2: Write the failing test** -Create `backend/internal/store/profile_test.go`: +Create `../../../backend/internal/store/profiles_test.go`: ```go package store @@ -130,9 +130,9 @@ func TestProfile_DefaultsThenUpdate(t *testing.T) { Run: `cd backend && go test ./internal/store/... -run TestProfile -v` Expected: FAIL — `db.GetProfile undefined` (compile error, `Profile` type/methods don't exist yet). -- [ ] **Step 4: Write `profile.go`** +- [ ] **Step 4: Write `profiles.go`** -Create `backend/internal/store/profile.go`: +Create `../../../backend/internal/store/profiles.go`: ```go package store @@ -253,7 +253,7 @@ Expected: PASS - [ ] **Step 6: Commit** ```bash -cd backend && git add internal/store/migrations/0003_profile.sql internal/store/profile.go internal/store/profile_test.go +cd backend && git add internal/store/migrations/0003_profile.sql internal/store/profiles.go internal/store/profiles_test.go git commit -m "feat: add single-profile settings table and store layer" ``` (If this is the first commit in the repo, run `git init` first and skip any `git add` of files outside `backend/` — later tasks add frontend files separately.) @@ -876,7 +876,7 @@ Expected: all PASS. - [ ] **Step 7: Commit** ```bash -cd backend && git add internal/api/profile.go internal/api/server.go internal/api/api_test.go +cd backend && git add internal/api/profiles.go internal/api/server.go internal/api/api_test.go git commit -m "feat: add profile REST endpoints with HR zone validation" ``` diff --git a/docs/superpowers/plans/2026-07-25-per-user-profile.md b/docs/superpowers/plans/2026-07-25-per-user-profile.md index 180b0aa..445ecb8 100644 --- a/docs/superpowers/plans/2026-07-25-per-user-profile.md +++ b/docs/superpowers/plans/2026-07-25-per-user-profile.md @@ -765,16 +765,16 @@ EOF --- -## Task 4: Scope `profile.go` to `user_id` +## Task 4: Scope `profiles.go` to `user_id` **Files:** -- Modify: `backend/internal/store/profile.go` -- Modify: `backend/internal/store/profile_test.go` +- Modify: `../../../backend/internal/store/profiles.go` +- Modify: `../../../backend/internal/store/profiles_test.go` **Interfaces:** - Produces: `func (db *DB) GetProfile(ctx context.Context, userID int64) (Profile, error)`; `func (db *DB) UpdateProfile(ctx context.Context, userID int64, p Profile) error`. Every other task/caller of these two methods must pass `userID` as the second positional argument from here on. -- [ ] **Step 1: Update `GetProfile`/`UpdateProfile` in `backend/internal/store/profile.go`** +- [ ] **Step 1: Update `GetProfile`/`UpdateProfile` in `../../../backend/internal/store/profiles.go`** Replace the two function bodies (the `Profile` struct and `profileColumns` constant are unchanged): @@ -832,7 +832,7 @@ func (db *DB) UpdateProfile(ctx context.Context, userID int64, p Profile) error } ``` -- [ ] **Step 2: Fix `backend/internal/store/profile_test.go`'s call sites** +- [ ] **Step 2: Fix `../../../backend/internal/store/profiles_test.go`'s call sites** The existing `TestProfile_DefaultsThenUpdate` calls `db.GetProfile(ctx)` and `db.UpdateProfile(ctx, p)` directly against a fresh DB with no provisioned user — since Task 1 made `profile` rows always belong to a user, this test must provision one first. Replace the test's opening two lines: @@ -866,7 +866,7 @@ Run: `cd backend && gofmt -l internal/store/` Expected: no output. ```bash -git add backend/internal/store/profile.go backend/internal/store/profile_test.go +git add backend/internal/store/profiles.go backend/internal/store/profiles_test.go git commit -m "$(cat <<'EOF' store: scope GetProfile/UpdateProfile to a user_id @@ -3302,7 +3302,7 @@ func TestSetup_RejectsEmptyDisplayName(t *testing.T) { - [ ] **Step 4: Build everything and fix remaining call sites** Run: `cd backend && go build ./... 2>&1 | head -50` -Expected: remaining errors are all inside `internal/api/*.go` handler files (`profile.go`, `activities.go`, `sync.go`, `kinds.go`, `review.go`, `reclassify.go`, `progression.go`, `garmin.go`) and `cmd/seedsample/main.go` — referencing `s.Garmin`/`s.Sync` (now removed) or store methods missing `userID`. These are fixed in Tasks 14/15/17; confirm no errors remain in `server.go`, `main.go`, or `api_test.go` itself. +Expected: remaining errors are all inside `internal/api/*.go` handler files (`profiles.go`, `activities.go`, `sync.go`, `kinds.go`, `review.go`, `reclassify.go`, `progression.go`, `garmin.go`) and `cmd/seedsample/main.go` — referencing `s.Garmin`/`s.Sync` (now removed) or store methods missing `userID`. These are fixed in Tasks 14/15/17; confirm no errors remain in `server.go`, `main.go`, or `api_test.go` itself. - [ ] **Step 5: `gofmt` and commit** @@ -3325,7 +3325,7 @@ EOF --- -## Task 14: Thread `userID` into `profile.go`, `kinds.go`, `garmin.go`, `progression.go` +## Task 14: Thread `userID` into `profiles.go`, `kinds.go`, `garmin.go`, `progression.go` **Files:** - Modify: `backend/internal/api/profile.go` @@ -3636,7 +3636,7 @@ Run: `cd backend && gofmt -l internal/api/` Expected: no output. ```bash -git add backend/internal/api/profile.go backend/internal/api/kinds.go backend/internal/api/garmin.go backend/internal/api/progression.go +git add backend/internal/api/profiles.go backend/internal/api/kinds.go backend/internal/api/garmin.go backend/internal/api/progression.go git commit -m "$(cat <<'EOF' api: scope profile, workout-kind, Garmin auth, and progression handlers to userID diff --git a/docs/superpowers/plans/2026-07-26-improve-first-connection.md b/docs/superpowers/plans/2026-07-26-improve-first-connection.md index 0a116d3..eaf5aec 100644 --- a/docs/superpowers/plans/2026-07-26-improve-first-connection.md +++ b/docs/superpowers/plans/2026-07-26-improve-first-connection.md @@ -24,8 +24,8 @@ **Files:** - Modify: `backend/internal/store/schema.sql` (add column to the `profile` table) -- Modify: `backend/internal/store/profile.go` (`Profile` struct, `profileColumns`, `GetProfile`, new `MarkGarminConnected`) -- Modify: `backend/internal/store/profile_test.go` (new tests) +- Modify: `../../../backend/internal/store/profiles.go` (`Profile` struct, `profileColumns`, `GetProfile`, new `MarkGarminConnected`) +- Modify: `../../../backend/internal/store/profiles_test.go` (new tests) - Modify: `backend/internal/store/isolation_test.go` (new adversarial test) - Modify: `docs/DATABASE.md` (regenerated) - Modify (real DB, not version-controlled): `backend/geniusrun.db` — additive `ALTER TABLE` @@ -35,7 +35,7 @@ - [ ] **Step 1: Write the failing tests** -Add to `backend/internal/store/profile_test.go`: +Add to `../../../backend/internal/store/profiles_test.go`: ```go func TestMarkGarminConnected_SetsTimestampOnceOnFirstCall(t *testing.T) { @@ -139,7 +139,7 @@ to: rolling_window_days INTEGER NOT NULL DEFAULT 90, ``` -- [ ] **Step 4: Update `Profile`, `profileColumns`, and `GetProfile` in `profile.go`** +- [ ] **Step 4: Update `Profile`, `profileColumns`, and `GetProfile` in `profiles.go`** Change the struct (add the field right after `GarminPassword`): @@ -192,7 +192,7 @@ Do **not** touch `UpdateProfile` — `garmin_connected_at` must stay out of its - [ ] **Step 5: Add `MarkGarminConnected`** -Append to `backend/internal/store/profile.go`: +Append to `../../../backend/internal/store/profiles.go`: ```go @@ -237,7 +237,7 @@ Confirm the backend isn't running before touching the file (`ps aux | grep geniu - [ ] **Step 10: Commit** ```bash -git add backend/internal/store/schema.sql backend/internal/store/profile.go backend/internal/store/profile_test.go backend/internal/store/isolation_test.go docs/DATABASE.md +git add backend/internal/store/schema.sql backend/internal/store/profiles.go backend/internal/store/profiles_test.go backend/internal/store/isolation_test.go docs/DATABASE.md git commit -m "feat(store): persist garmin_connected_at, set once on first successful auth" ``` diff --git a/docs/superpowers/plans/2026-07-26-profile-deletion.md b/docs/superpowers/plans/2026-07-26-profile-deletion.md index a420c24..b636a41 100644 --- a/docs/superpowers/plans/2026-07-26-profile-deletion.md +++ b/docs/superpowers/plans/2026-07-26-profile-deletion.md @@ -508,7 +508,7 @@ to: }) ``` -- [ ] **Step 5: Add `handleDeleteProfile` to `profile.go`** +- [ ] **Step 5: Add `handleDeleteProfile` to `profiles.go`** Append to `backend/internal/api/profile.go`: @@ -554,7 +554,7 @@ Expected: `gofmt -l .` prints nothing; `go build`/`go vet`/`go test` all succeed - [ ] **Step 8: Commit** ```bash -git add backend/internal/api/server.go backend/internal/api/profile.go backend/internal/api/api_test.go backend/internal/api/isolation_test.go +git add backend/internal/api/server.go backend/internal/api/profiles.go backend/internal/api/api_test.go backend/internal/api/isolation_test.go git commit -m "feat(api): add DELETE /api/profile with Garmin client/tokenstore teardown" ``` diff --git a/docs/superpowers/specs/2026-07-26-improve-first-connection-design.md b/docs/superpowers/specs/2026-07-26-improve-first-connection-design.md index 0b56832..de308de 100644 --- a/docs/superpowers/specs/2026-07-26-improve-first-connection-design.md +++ b/docs/superpowers/specs/2026-07-26-improve-first-connection-design.md @@ -52,7 +52,7 @@ hatch back to the login screen. nullable-TEXT-pointer pattern as `sync_state.EarliestSyncedDate`). New store method (`internal/store/users.go` or a new small file, e.g. -`profile.go` wherever `UpdateProfile`/`GetProfile` already live): +`profiles.go` wherever `UpdateProfile`/`GetProfile` already live): ```go // MarkGarminConnected records the first time userID successfully