refactor(config): mandatory app-config rows seeded in DB; rename to session.setup_timeout
All registry keys must exist as rows in the config table: main seeds missing keys with their defaults at startup, LoadApp fails fast on a missing key, and the code-side fallback const/helper for the onboarding setup timeout is gone -- the value rides in SessionConfig.SetupTimeout. The key is renamed session.idle_timeout -> session.setup_timeout, and the /config page's 'overridden' now means 'differs from the default'. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -45,11 +45,22 @@ func main() {
|
|||||||
}
|
}
|
||||||
defer db.Close()
|
defer db.Close()
|
||||||
|
|
||||||
overrides, err := db.ConfigValues(context.Background())
|
values, err := db.ConfigValues(context.Background())
|
||||||
if err != nil {
|
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 {
|
if err != nil {
|
||||||
fatal("load app config", err)
|
fatal("load app config", err)
|
||||||
}
|
}
|
||||||
@@ -75,15 +86,14 @@ func main() {
|
|||||||
garmin.SyncConfig{},
|
garmin.SyncConfig{},
|
||||||
authVerifier,
|
authVerifier,
|
||||||
api.SessionConfig{
|
api.SessionConfig{
|
||||||
Secret: envCfg.SessionSecret,
|
Secret: envCfg.SessionSecret,
|
||||||
Duration: appCfg.SessionDuration,
|
Duration: appCfg.SessionDuration,
|
||||||
Secure: envCfg.SessionSecure,
|
SetupTimeout: appCfg.SetupTimeout,
|
||||||
BackendURL: envCfg.BackendURL,
|
Secure: envCfg.SessionSecure,
|
||||||
FrontendURL: envCfg.FrontendURL,
|
BackendURL: envCfg.BackendURL,
|
||||||
|
FrontendURL: envCfg.FrontendURL,
|
||||||
})
|
})
|
||||||
|
|
||||||
server.SetupSessionIdleTimeout = appCfg.SetupSessionIdleTimeout
|
|
||||||
|
|
||||||
for _, e := range envCfg.DisplayEnv() {
|
for _, e := range envCfg.DisplayEnv() {
|
||||||
server.EnvVars = append(server.EnvVars, api.EnvVar{Name: e.Name, Value: e.Value})
|
server.EnvVars = append(server.EnvVars, api.EnvVar{Name: e.Name, Value: e.Value})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,11 +25,12 @@ import (
|
|||||||
func newCtx() context.Context { return context.Background() }
|
func newCtx() context.Context { return context.Background() }
|
||||||
|
|
||||||
var testSessionConfig = SessionConfig{
|
var testSessionConfig = SessionConfig{
|
||||||
Secret: []byte("test-session-secret-at-least-32-bytes-long"),
|
Secret: []byte("test-session-secret-at-least-32-bytes-long"),
|
||||||
Duration: time.Hour,
|
Duration: time.Hour,
|
||||||
Secure: false,
|
SetupTimeout: 15 * time.Minute,
|
||||||
BackendURL: "https://geniusrun.example.com",
|
Secure: false,
|
||||||
FrontendURL: "https://app.geniusrun.example.com",
|
BackendURL: "https://geniusrun.example.com",
|
||||||
|
FrontendURL: "https://app.geniusrun.example.com",
|
||||||
}
|
}
|
||||||
|
|
||||||
func newTestServer(t *testing.T) (*Server, *store.DB, int64) {
|
func newTestServer(t *testing.T) (*Server, *store.DB, int64) {
|
||||||
|
|||||||
@@ -22,23 +22,27 @@ type configEntry struct {
|
|||||||
Description string `json:"description"`
|
Description string `json:"description"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// configView assembles the GET/PUT response: registry defaults overlaid
|
// configView assembles the GET/PUT response: every registry key with its
|
||||||
// with DB overrides, plus the env snapshot.
|
// 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) {
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
registry := config.AppRegistry()
|
registry := config.AppRegistry()
|
||||||
app := make([]configEntry, 0, len(registry))
|
app := make([]configEntry, 0, len(registry))
|
||||||
for _, k := range registry {
|
for _, k := range registry {
|
||||||
value, overridden := overrides[k.Key]
|
value, ok := values[k.Key]
|
||||||
if !overridden {
|
if !ok {
|
||||||
value = k.Default
|
value = k.Default
|
||||||
}
|
}
|
||||||
app = append(app, configEntry{
|
app = append(app, configEntry{
|
||||||
Key: k.Key, Value: value, Default: k.Default,
|
Key: k.Key, Value: value, Default: k.Default,
|
||||||
Overridden: overridden, Description: k.Description,
|
Overridden: value != k.Default, Description: k.Description,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
envVars := s.EnvVars
|
envVars := s.EnvVars
|
||||||
|
|||||||
@@ -55,8 +55,8 @@ func TestConfig_GetDefaultsAndEnvSnapshot(t *testing.T) {
|
|||||||
if e := byKey["session.duration"]; e.value != "720" || e.def != "720" || e.overridden {
|
if e := byKey["session.duration"]; e.value != "720" || e.def != "720" || e.overridden {
|
||||||
t.Fatalf("session.duration default entry = %+v", e)
|
t.Fatalf("session.duration default entry = %+v", e)
|
||||||
}
|
}
|
||||||
if e := byKey["session.idle_timeout"]; e.value != "15" || e.def != "15" || e.overridden {
|
if e := byKey["session.setup_timeout"]; e.value != "15" || e.def != "15" || e.overridden {
|
||||||
t.Fatalf("session.idle_timeout default entry = %+v", e)
|
t.Fatalf("session.setup_timeout default entry = %+v", e)
|
||||||
}
|
}
|
||||||
if len(resp.Environment) != 1 || resp.Environment[0].Value != "•••• (set)" {
|
if len(resp.Environment) != 1 || resp.Environment[0].Value != "•••• (set)" {
|
||||||
t.Fatalf("env snapshot not passed through: %+v", resp.Environment)
|
t.Fatalf("env snapshot not passed through: %+v", resp.Environment)
|
||||||
|
|||||||
@@ -53,11 +53,6 @@ type Server struct {
|
|||||||
// never call os.Getenv.
|
// never call os.Getenv.
|
||||||
EnvVars []EnvVar
|
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
|
mu sync.Mutex
|
||||||
userClient map[int64]garmin.Client
|
userClient map[int64]garmin.Client
|
||||||
userSync map[int64]*garmin.Sync
|
userSync map[int64]*garmin.Sync
|
||||||
|
|||||||
@@ -15,7 +15,13 @@ import (
|
|||||||
type SessionConfig struct {
|
type SessionConfig struct {
|
||||||
Secret []byte
|
Secret []byte
|
||||||
Duration time.Duration
|
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.
|
// BackendURL is this app's own externally reachable origin (e.g.
|
||||||
// "https://geniusrun.example.com", no trailing slash) -- derives
|
// "https://geniusrun.example.com", no trailing slash) -- derives
|
||||||
// OIDCRedirectURL (config.Config), the only thing that must stay pointed
|
// OIDCRedirectURL (config.Config), the only thing that must stay pointed
|
||||||
|
|||||||
@@ -13,24 +13,6 @@ import (
|
|||||||
applog "geniusrun/backend/internal/log"
|
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
|
// setupSession is a temporary, not-yet-persisted Garmin authentication
|
||||||
// attempt made during onboarding, before any users/profile row exists --
|
// attempt made during onboarding, before any users/profile row exists --
|
||||||
// keyed by OIDC subject (the only stable identifier available pre-account)
|
// 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
|
// the next time anything closes and restarts its subprocess; a later
|
||||||
// garminFor(ctx, userID) call builds a fresh client with the correct path
|
// garminFor(ctx, userID) call builds a fresh client with the correct path
|
||||||
// instead. Otherwise evicted lazily (the next setup-endpoint touch for that
|
// 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 {
|
type setupSession struct {
|
||||||
Client garmin.Client
|
Client garmin.Client
|
||||||
Email, Password string
|
Email, Password string
|
||||||
@@ -225,7 +207,7 @@ func (s *Server) setupSessionFor(sub string) (*setupSession, bool) {
|
|||||||
if !ok {
|
if !ok {
|
||||||
return nil, false
|
return nil, false
|
||||||
}
|
}
|
||||||
if time.Since(sess.LastUsed) > s.setupIdleTimeout() {
|
if time.Since(sess.LastUsed) > s.SessionConfig.SetupTimeout {
|
||||||
sess.Client.Close()
|
sess.Client.Close()
|
||||||
delete(s.setupSession, sub)
|
delete(s.setupSession, sub)
|
||||||
if s.ClientConfig.TokenStorePath != "" {
|
if s.ClientConfig.TokenStorePath != "" {
|
||||||
|
|||||||
@@ -19,13 +19,13 @@ type AppKey struct {
|
|||||||
// KeySessionDuration is the session cookie lifetime, in integer hours.
|
// KeySessionDuration is the session cookie lifetime, in integer hours.
|
||||||
const (
|
const (
|
||||||
KeySessionDuration = "session.duration"
|
KeySessionDuration = "session.duration"
|
||||||
// KeySessionIdleTimeout bounds an *onboarding Garmin session* (the
|
// KeySessionSetupTimeout bounds an *onboarding Garmin setup session*
|
||||||
// ephemeral, pre-account login attempt), not the login cookie --
|
// (the ephemeral, pre-account login attempt), not the login cookie --
|
||||||
// session.duration is how long a signed-in user stays signed in
|
// 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
|
// attempt survives untouched before its subprocess is torn down
|
||||||
// (minutes).
|
// (minutes).
|
||||||
KeySessionIdleTimeout = "session.idle_timeout"
|
KeySessionSetupTimeout = "session.setup_timeout"
|
||||||
)
|
)
|
||||||
|
|
||||||
var appRegistry = []AppKey{
|
var appRegistry = []AppKey{
|
||||||
@@ -36,9 +36,9 @@ var appRegistry = []AppKey{
|
|||||||
Validate: validatePositiveInt,
|
Validate: validatePositiveInt,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Key: KeySessionIdleTimeout,
|
Key: KeySessionSetupTimeout,
|
||||||
Default: "15",
|
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,
|
Validate: validatePositiveInt,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -69,32 +69,35 @@ func ValidateAppValue(key, value string) error {
|
|||||||
return fmt.Errorf("unknown configuration key %q", key)
|
return fmt.Errorf("unknown configuration key %q", key)
|
||||||
}
|
}
|
||||||
|
|
||||||
// AppConfig is the typed result of merging DB overrides over registry
|
// AppConfig is the typed application configuration, built from the DB's
|
||||||
// defaults.
|
// config rows.
|
||||||
type AppConfig struct {
|
type AppConfig struct {
|
||||||
SessionDuration time.Duration
|
SessionDuration time.Duration
|
||||||
SetupSessionIdleTimeout time.Duration
|
SetupTimeout time.Duration
|
||||||
}
|
}
|
||||||
|
|
||||||
// LoadApp merges overrides (store.ConfigValues' map) over defaults. Pure
|
// LoadApp builds the typed AppConfig from the config table's rows. Every
|
||||||
// -- it takes the raw map instead of a *store.DB so this package needs no
|
// registry key is mandatory: main seeds missing keys with their defaults
|
||||||
// store dependency and the merge logic tests without a database. A bad
|
// at startup (see cmd/geniusrund), so a missing key here means that
|
||||||
// stored value fails fast, same posture as LoadEnv() for env vars.
|
// seeding didn't run -- fail fast, same posture as LoadEnv() for env
|
||||||
func LoadApp(overrides map[string]string) (AppConfig, error) {
|
// vars, and likewise for an unknown or invalid stored value. Pure -- it
|
||||||
merged := map[string]string{}
|
// takes the raw map instead of a *store.DB so this package needs no store
|
||||||
for _, k := range appRegistry {
|
// dependency and the logic tests without a database.
|
||||||
merged[k.Key] = k.Default
|
func LoadApp(values map[string]string) (AppConfig, error) {
|
||||||
}
|
for key, value := range values {
|
||||||
for key, value := range overrides {
|
|
||||||
if err := ValidateAppValue(key, value); err != nil {
|
if err := ValidateAppValue(key, value); err != nil {
|
||||||
return AppConfig{}, fmt.Errorf("app config: %w", err)
|
return AppConfig{}, fmt.Errorf("app config: %w", err)
|
||||||
}
|
}
|
||||||
merged[key] = value
|
|
||||||
}
|
}
|
||||||
hours, _ := strconv.Atoi(merged[KeySessionDuration])
|
for _, k := range appRegistry {
|
||||||
idleMinutes, _ := strconv.Atoi(merged[KeySessionIdleTimeout])
|
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{
|
return AppConfig{
|
||||||
SessionDuration: time.Duration(hours) * time.Hour,
|
SessionDuration: time.Duration(hours) * time.Hour,
|
||||||
SetupSessionIdleTimeout: time.Duration(idleMinutes) * time.Minute,
|
SetupTimeout: time.Duration(setupMinutes) * time.Minute,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,22 +8,25 @@ import (
|
|||||||
|
|
||||||
func TestLoadApp(t *testing.T) {
|
func TestLoadApp(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
overrides map[string]string
|
values map[string]string
|
||||||
want time.Duration
|
want time.Duration
|
||||||
wantIdle time.Duration
|
wantIdle time.Duration
|
||||||
wantErr string
|
wantErr string
|
||||||
}{
|
}{
|
||||||
{name: "defaults when no overrides", overrides: nil, want: 720 * time.Hour, wantIdle: 15 * time.Minute},
|
// Every registry key is mandatory: main seeds the DB with defaults
|
||||||
{name: "override applied", overrides: map[string]string{"session.duration": "168"}, want: 168 * time.Hour, wantIdle: 15 * time.Minute},
|
// at startup, so LoadApp always receives a complete map.
|
||||||
{name: "idle timeout override applied", overrides: map[string]string{"session.idle_timeout": "30"}, want: 720 * time.Hour, wantIdle: 30 * time.Minute},
|
{name: "seeded defaults", values: map[string]string{"session.duration": "720", "session.setup_timeout": "15"}, want: 720 * time.Hour, wantIdle: 15 * time.Minute},
|
||||||
{name: "invalid stored value", overrides: map[string]string{"session.duration": "zero"}, wantErr: "session.duration"},
|
{name: "custom values", values: map[string]string{"session.duration": "168", "session.setup_timeout": "30"}, want: 168 * time.Hour, wantIdle: 30 * time.Minute},
|
||||||
{name: "invalid idle timeout", overrides: map[string]string{"session.idle_timeout": "0"}, wantErr: "session.idle_timeout"},
|
{name: "missing key fails fast", values: map[string]string{"session.duration": "720"}, wantErr: "missing key"},
|
||||||
{name: "unknown stored key", overrides: map[string]string{"bogus.key": "1"}, wantErr: "unknown configuration 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 {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
got, err := LoadApp(tt.overrides)
|
got, err := LoadApp(tt.values)
|
||||||
if tt.wantErr != "" {
|
if tt.wantErr != "" {
|
||||||
if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
|
if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
|
||||||
t.Fatalf("err = %v, want containing %q", err, 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 {
|
if got.SessionDuration != tt.want {
|
||||||
t.Fatalf("SessionDuration = %v, want %v", got.SessionDuration, tt.want)
|
t.Fatalf("SessionDuration = %v, want %v", got.SessionDuration, tt.want)
|
||||||
}
|
}
|
||||||
if got.SetupSessionIdleTimeout != tt.wantIdle {
|
if got.SetupTimeout != tt.wantIdle {
|
||||||
t.Fatalf("SetupSessionIdleTimeout = %v, want %v", got.SetupSessionIdleTimeout, tt.wantIdle)
|
t.Fatalf("SetupSessionIdleTimeout = %v, want %v", got.SetupTimeout, tt.wantIdle)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,9 +5,10 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ConfigValues returns every application-configuration override row as
|
// ConfigValues returns every application-configuration row as key ->
|
||||||
// key -> value. An absent key means its default (from internal/config's
|
// value. The table holds a row for every registry key: main seeds missing
|
||||||
// app-key registry) is in effect -- the table stores overrides only.
|
// 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) {
|
func (db *DB) ConfigValues(ctx context.Context) (map[string]string, error) {
|
||||||
rows, err := db.QueryContext(ctx, `SELECT key, value FROM config`)
|
rows, err := db.QueryContext(ctx, `SELECT key, value FROM config`)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -252,11 +252,13 @@ CREATE TABLE sync_runs (
|
|||||||
error_message TEXT
|
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
|
-- by every user -- deliberately the one table with no user_id, because
|
||||||
-- application configuration is common to all users by definition. Stores
|
-- application configuration is common to all users by definition. Every
|
||||||
-- overrides only; defaults live in internal/config's app-key registry.
|
-- key in internal/config's app-key registry is mandatory here: missing
|
||||||
-- Cold: read once at startup, a change applies on the next backend restart.
|
-- 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 (
|
CREATE TABLE config (
|
||||||
key TEXT PRIMARY KEY,
|
key TEXT PRIMARY KEY,
|
||||||
value TEXT NOT NULL,
|
value TEXT NOT NULL,
|
||||||
|
|||||||
@@ -22,8 +22,8 @@
|
|||||||
|
|
||||||
**Files:**
|
**Files:**
|
||||||
- Create: `backend/internal/store/migrations/0003_profile.sql`
|
- Create: `backend/internal/store/migrations/0003_profile.sql`
|
||||||
- Create: `backend/internal/store/profile.go`
|
- Create: `../../../backend/internal/store/profiles.go`
|
||||||
- Test: `backend/internal/store/profile_test.go`
|
- Test: `../../../backend/internal/store/profiles_test.go`
|
||||||
|
|
||||||
**Interfaces:**
|
**Interfaces:**
|
||||||
- Produces: `store.Profile` struct, `(db *DB) GetProfile(ctx context.Context) (Profile, error)`, `(db *DB) UpdateProfile(ctx context.Context, p Profile) error`.
|
- 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**
|
- [ ] **Step 2: Write the failing test**
|
||||||
|
|
||||||
Create `backend/internal/store/profile_test.go`:
|
Create `../../../backend/internal/store/profiles_test.go`:
|
||||||
|
|
||||||
```go
|
```go
|
||||||
package store
|
package store
|
||||||
@@ -130,9 +130,9 @@ func TestProfile_DefaultsThenUpdate(t *testing.T) {
|
|||||||
Run: `cd backend && go test ./internal/store/... -run TestProfile -v`
|
Run: `cd backend && go test ./internal/store/... -run TestProfile -v`
|
||||||
Expected: FAIL — `db.GetProfile undefined` (compile error, `Profile` type/methods don't exist yet).
|
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
|
```go
|
||||||
package store
|
package store
|
||||||
@@ -253,7 +253,7 @@ Expected: PASS
|
|||||||
- [ ] **Step 6: Commit**
|
- [ ] **Step 6: Commit**
|
||||||
|
|
||||||
```bash
|
```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"
|
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.)
|
(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**
|
- [ ] **Step 7: Commit**
|
||||||
|
|
||||||
```bash
|
```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"
|
git commit -m "feat: add profile REST endpoints with HR zone validation"
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -765,16 +765,16 @@ EOF
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Task 4: Scope `profile.go` to `user_id`
|
## Task 4: Scope `profiles.go` to `user_id`
|
||||||
|
|
||||||
**Files:**
|
**Files:**
|
||||||
- Modify: `backend/internal/store/profile.go`
|
- Modify: `../../../backend/internal/store/profiles.go`
|
||||||
- Modify: `backend/internal/store/profile_test.go`
|
- Modify: `../../../backend/internal/store/profiles_test.go`
|
||||||
|
|
||||||
**Interfaces:**
|
**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.
|
- 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):
|
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:
|
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.
|
Expected: no output.
|
||||||
|
|
||||||
```bash
|
```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'
|
git commit -m "$(cat <<'EOF'
|
||||||
store: scope GetProfile/UpdateProfile to a user_id
|
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**
|
- [ ] **Step 4: Build everything and fix remaining call sites**
|
||||||
|
|
||||||
Run: `cd backend && go build ./... 2>&1 | head -50`
|
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**
|
- [ ] **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:**
|
**Files:**
|
||||||
- Modify: `backend/internal/api/profile.go`
|
- Modify: `backend/internal/api/profile.go`
|
||||||
@@ -3636,7 +3636,7 @@ Run: `cd backend && gofmt -l internal/api/`
|
|||||||
Expected: no output.
|
Expected: no output.
|
||||||
|
|
||||||
```bash
|
```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'
|
git commit -m "$(cat <<'EOF'
|
||||||
api: scope profile, workout-kind, Garmin auth, and progression handlers to userID
|
api: scope profile, workout-kind, Garmin auth, and progression handlers to userID
|
||||||
|
|
||||||
|
|||||||
@@ -24,8 +24,8 @@
|
|||||||
|
|
||||||
**Files:**
|
**Files:**
|
||||||
- Modify: `backend/internal/store/schema.sql` (add column to the `profile` table)
|
- 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/profiles.go` (`Profile` struct, `profileColumns`, `GetProfile`, new `MarkGarminConnected`)
|
||||||
- Modify: `backend/internal/store/profile_test.go` (new tests)
|
- Modify: `../../../backend/internal/store/profiles_test.go` (new tests)
|
||||||
- Modify: `backend/internal/store/isolation_test.go` (new adversarial test)
|
- Modify: `backend/internal/store/isolation_test.go` (new adversarial test)
|
||||||
- Modify: `docs/DATABASE.md` (regenerated)
|
- Modify: `docs/DATABASE.md` (regenerated)
|
||||||
- Modify (real DB, not version-controlled): `backend/geniusrun.db` — additive `ALTER TABLE`
|
- Modify (real DB, not version-controlled): `backend/geniusrun.db` — additive `ALTER TABLE`
|
||||||
@@ -35,7 +35,7 @@
|
|||||||
|
|
||||||
- [ ] **Step 1: Write the failing tests**
|
- [ ] **Step 1: Write the failing tests**
|
||||||
|
|
||||||
Add to `backend/internal/store/profile_test.go`:
|
Add to `../../../backend/internal/store/profiles_test.go`:
|
||||||
|
|
||||||
```go
|
```go
|
||||||
func TestMarkGarminConnected_SetsTimestampOnceOnFirstCall(t *testing.T) {
|
func TestMarkGarminConnected_SetsTimestampOnceOnFirstCall(t *testing.T) {
|
||||||
@@ -139,7 +139,7 @@ to:
|
|||||||
rolling_window_days INTEGER NOT NULL DEFAULT 90,
|
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`):
|
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`**
|
- [ ] **Step 5: Add `MarkGarminConnected`**
|
||||||
|
|
||||||
Append to `backend/internal/store/profile.go`:
|
Append to `../../../backend/internal/store/profiles.go`:
|
||||||
|
|
||||||
```go
|
```go
|
||||||
|
|
||||||
@@ -237,7 +237,7 @@ Confirm the backend isn't running before touching the file (`ps aux | grep geniu
|
|||||||
- [ ] **Step 10: Commit**
|
- [ ] **Step 10: Commit**
|
||||||
|
|
||||||
```bash
|
```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"
|
git commit -m "feat(store): persist garmin_connected_at, set once on first successful auth"
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -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`:
|
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**
|
- [ ] **Step 8: Commit**
|
||||||
|
|
||||||
```bash
|
```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"
|
git commit -m "feat(api): add DELETE /api/profile with Garmin client/tokenstore teardown"
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ hatch back to the login screen.
|
|||||||
nullable-TEXT-pointer pattern as `sync_state.EarliestSyncedDate`).
|
nullable-TEXT-pointer pattern as `sync_state.EarliestSyncedDate`).
|
||||||
|
|
||||||
New store method (`internal/store/users.go` or a new small file, e.g.
|
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
|
```go
|
||||||
// MarkGarminConnected records the first time userID successfully
|
// MarkGarminConnected records the first time userID successfully
|
||||||
|
|||||||
Reference in New Issue
Block a user