Compare commits
84 Commits
d1d2d612a5
...
5b238b3f54
| Author | SHA1 | Date | |
|---|---|---|---|
| 5b238b3f54 | |||
| 5ebb0f756d | |||
| 49ebd289a4 | |||
| 10cdc4176a | |||
| 6effb79097 | |||
| 78c494affb | |||
| 0e6cf00dba | |||
| 8c9285f33c | |||
| dcbb1d8bb0 | |||
| 431315bac3 | |||
| 042579a396 | |||
| e2b2bf9611 | |||
| 19c9aecdeb | |||
| bee3d7e493 | |||
| d45489714d | |||
| e6cb21c65a | |||
| f6712497ba | |||
| 8508e89ad7 | |||
| 27af77418e | |||
| a2f24273ce | |||
| a394bbb770 | |||
| eb24dcd89d | |||
| c40f6634f2 | |||
| 6f6d1ea5e0 | |||
| 7d371219b7 | |||
| c6e2c5c00e | |||
| 1d878e0f46 | |||
| b5cd47c219 | |||
| 67f4ec27ab | |||
| fe344867c0 | |||
| 0e3295b746 | |||
| f9b1d454bc | |||
| 83a59d0fdd | |||
| 3cf5d4b747 | |||
| 273731442f | |||
| 7e960e3ce1 | |||
| 687dc2bca2 | |||
| 668fb9efb8 | |||
| 940295510e | |||
| a5fffff737 | |||
| d48bbfd43e | |||
| a147f5cffa | |||
| aef58fd529 | |||
| 3bf11ae6bf | |||
| b2c705ce29 | |||
| 6e01037832 | |||
| f7e7b68078 | |||
| 3a7f305221 | |||
| 0a7aa3a5e0 | |||
| 1046e9f7a0 | |||
| 101ef639ab | |||
| 1fb9271aaa | |||
| 39aa121420 | |||
| 35d9933d1b | |||
| 32fdfc1c72 | |||
| 7204a48c1c | |||
| 9d65c50068 | |||
| aac9359b02 | |||
| a828055c30 | |||
| 7f5618ce49 | |||
| d3b26d7d41 | |||
| 0211dffa1e | |||
| 12cbfdbbee | |||
| 92284d3b96 | |||
| 45f2921971 | |||
| 77f9588c2d | |||
| a12fe3e810 | |||
| 4d2cbe4883 | |||
| 7346e2eadd | |||
| c9e089a2b2 | |||
| 1d4b1cccfd | |||
| 379e7cc990 | |||
| 501d951e5b | |||
| 2fbe762290 | |||
| 84a7417781 | |||
| 7d68af5b3c | |||
| a1eaa42d42 | |||
| 35eff03509 | |||
| a622af1bfa | |||
| 7f4f6d3493 | |||
| 049ed16b69 | |||
| 002858c1cb | |||
| 6a3f0201af | |||
| 50d65b40c2 |
@@ -58,8 +58,8 @@ Each `workout_kinds.rule_json` is a recursive AND/OR condition tree (`internal/c
|
|||||||
`internal/garmin` spawns a small Python wrapper script (`internal/garmin/pyscript/wrapper.py`, embedded into the Go binary via `go:embed`) that imports `garminconnect` directly and speaks a minimal newline-delimited-JSON protocol over stdin/stdout: `{"id","cmd","params"}` in, `{"id","result"}` or `{"id","error"}` out. `cmd` is `authenticate`, `complete_mfa`, or a fully generic `call` (`{"method": "<any garminconnect.Garmin method>", "args": {...}}`) — there's no MCP layer, and the separate `mcp-garmin` repo this used to depend on is retired. Key things learned the hard way, that would otherwise get rediscovered:
|
`internal/garmin` spawns a small Python wrapper script (`internal/garmin/pyscript/wrapper.py`, embedded into the Go binary via `go:embed`) that imports `garminconnect` directly and speaks a minimal newline-delimited-JSON protocol over stdin/stdout: `{"id","cmd","params"}` in, `{"id","result"}` or `{"id","error"}` out. `cmd` is `authenticate`, `complete_mfa`, or a fully generic `call` (`{"method": "<any garminconnect.Garmin method>", "args": {...}}`) — there's no MCP layer, and the separate `mcp-garmin` repo this used to depend on is retired. Key things learned the hard way, that would otherwise get rediscovered:
|
||||||
|
|
||||||
- **`authenticate`/`complete_mfa` return structured JSON** (`{"status": "success"|"mfa_required"|"failed", "message": "..."}`), not plain strings — `internal/garmin/client.go` maps `status` directly to `AuthStatus`, no string pattern-matching.
|
- **`authenticate`/`complete_mfa` return structured JSON** (`{"status": "success"|"mfa_required"|"failed", "message": "..."}`), not plain strings — `internal/garmin/client.go` maps `status` directly to `AuthStatus`, no string pattern-matching.
|
||||||
- **The 10s "MFA required" timeout in `wrapper.py`'s `authenticate` handler is a false-positive trap.** A login that's merely slow (e.g. Garmin/Cloudflare rate-limiting) looks identical to a real MFA challenge unless you check whether `prompt_mfa()` was actually invoked (`wrapper.py` logs this to stderr for exactly this reason).
|
- **The 10s "MFA required" timeout in `wrapper.py`'s `authenticate` handler is a false-positive trap.** A login that's merely slow (e.g. Garmin/Cloudflare rate-limiting) looks identical to a real MFA challenge unless you check whether `prompt_mfa()` was actually invoked (`wrapper.py` emits a JSON log line for exactly this reason, forwarded into the Go log stream by `forwardWrapperStderr`).
|
||||||
- **`wrapper.py` persists Garmin sessions** via a `GARMIN_TOKENSTORE` env var passed to `Garmin.login(tokenstore=...)` — without this, every process start does a full SSO login, which is what trips Garmin's rate limiting under repeated testing.
|
- **`wrapper.py` persists Garmin sessions** via a `GARMIN_TOKENSTORE` env var (the Go→Python subprocess protocol variable — the process-level config var is `GENIUSRUN_TOKENSTORE_PATH`) passed to `Garmin.login(tokenstore=...)` — without this, every process start does a full SSO login, which is what trips Garmin's rate limiting under repeated testing.
|
||||||
- **`activityId` is a large int64** — never round-trip it through `float64`/generic `map[string]any` JSON decoding, or it corrupts into scientific notation. Decode into typed structs (`garmin.Activity`, not `map[string]any`).
|
- **`activityId` is a large int64** — never round-trip it through `float64`/generic `map[string]any` JSON decoding, or it corrupts into scientific notation. Decode into typed structs (`garmin.Activity`, not `map[string]any`).
|
||||||
- **`get_activity_details` returns raw per-second telemetry** (`activityDetailMetrics` + `metricDescriptors`), not lap/split summaries. Its `metricDescriptors` index-to-field mapping is **not stable across activities/devices** — `garmin.ExtractSamples` always resolves fields by descriptor key, never by fixed array position.
|
- **`get_activity_details` returns raw per-second telemetry** (`activityDetailMetrics` + `metricDescriptors`), not lap/split summaries. Its `metricDescriptors` index-to-field mapping is **not stable across activities/devices** — `garmin.ExtractSamples` always resolves fields by descriptor key, never by fixed array position.
|
||||||
- **`get_activity_splits`** returns the actual lap/split summaries (`lapDTOs`).
|
- **`get_activity_splits`** returns the actual lap/split summaries (`lapDTOs`).
|
||||||
@@ -74,7 +74,7 @@ Each `workout_kinds.rule_json` is a recursive AND/OR condition tree (`internal/c
|
|||||||
|
|
||||||
## Dev workflow
|
## Dev workflow
|
||||||
|
|
||||||
- Backend: `cd backend && go run ./cmd/geniusrund`. The Garmin wrapper's Python interpreter defaults to `python3` on `PATH`; set `GARMIN_WRAPPER_PYTHON` if you need a specific one (e.g. a venv with `garminconnect` installed) — see `internal/config/config.go` for all knobs. Garmin credentials are **not** env vars — they live in the `profile` singleton row (`internal/store.Profile`), set via the frontend's Profile page or directly through `PUT /api/profile`.
|
- Backend: `cd backend && go run ./cmd/geniusrund`. The Garmin wrapper's Python interpreter defaults to `python3` on `PATH`; set `GENIUSRUN_PYTHON_PATH` if you need a specific one (e.g. a venv with `garminconnect` installed) — see `internal/config/config.go` for all knobs. Garmin credentials are **not** env vars — they live in the `profile` singleton row (`internal/store.Profile`), set via the frontend's Profile page or directly through `PUT /api/profile`.
|
||||||
- Frontend: `cd frontend && npm run dev` (set `VITE_API_BASE_URL` if the backend isn't on `localhost:8080`).
|
- Frontend: `cd frontend && npm run dev` (set `VITE_API_BASE_URL` if the backend isn't on `localhost:8080`).
|
||||||
- No live Garmin account needed for frontend/UI work: `go run ./cmd/seedsample -db /tmp/sample.db` seeds realistic activities/laps/kinds and runs them through the real classification engine, then point `geniusrund` at that DB.
|
- No live Garmin account needed for frontend/UI work: `go run ./cmd/seedsample -db /tmp/sample.db` seeds realistic activities/laps/kinds and runs them through the real classification engine, then point `geniusrund` at that DB.
|
||||||
- `internal/garmin/mock` provides a fake `Client` for tests that need to exercise `internal/sync`/`internal/api` without a live subprocess.
|
- `internal/garmin/mock` provides a fake `Client` for tests that need to exercise `internal/sync`/`internal/api` without a live subprocess.
|
||||||
|
|||||||
9
.gitignore
vendored
9
.gitignore
vendored
@@ -20,6 +20,15 @@ frontend/dist/
|
|||||||
backend/mcpspike
|
backend/mcpspike
|
||||||
backend/cmd/mcpspike/mcpspike
|
backend/cmd/mcpspike/mcpspike
|
||||||
.superpowers/
|
.superpowers/
|
||||||
|
.worktrees/
|
||||||
|
|
||||||
# Claude Code session-local runtime state
|
# Claude Code session-local runtime state
|
||||||
.claude/*.lock
|
.claude/*.lock
|
||||||
|
|
||||||
|
# Garmin token stores hold live OAuth tokens -- never track them.
|
||||||
|
backend/.garmin/
|
||||||
|
# SQLite sidecar files
|
||||||
|
*.db-shm
|
||||||
|
*.db-wal
|
||||||
|
# IDE state beyond the shared bits already ignored above
|
||||||
|
.idea/
|
||||||
|
|||||||
14
CLAUDE.md
14
CLAUDE.md
@@ -13,7 +13,7 @@ The "Training plan" tab (`frontend/src/pages/Plan.tsx`) is an intentional empty
|
|||||||
## Commands
|
## Commands
|
||||||
|
|
||||||
Backend (from `backend/`):
|
Backend (from `backend/`):
|
||||||
- Run the server: `./start.sh` (wraps `go run ./cmd/geniusrund` with the DB path already set) or `go run ./cmd/geniusrund` directly. The Garmin wrapper's Python interpreter defaults to `python3` on `PATH`; set `GARMIN_WRAPPER_PYTHON` if you need a specific one (e.g. a venv with `garminconnect` installed).
|
- Run the server: `./start.sh` (wraps `go run ./cmd/geniusrund` with the DB path already set) or `go run ./cmd/geniusrund` directly. The Garmin wrapper's Python interpreter defaults to `python3` on `PATH`; set `GENIUSRUN_PYTHON_PATH` if you need a specific one (e.g. a venv with `garminconnect` installed).
|
||||||
- Build/vet: `go build ./...` && `go vet ./...`
|
- Build/vet: `go build ./...` && `go vet ./...`
|
||||||
- Format: `gofmt -l .` must report nothing before committing.
|
- Format: `gofmt -l .` must report nothing before committing.
|
||||||
- All tests: `go test ./...`
|
- All tests: `go test ./...`
|
||||||
@@ -21,6 +21,7 @@ Backend (from `backend/`):
|
|||||||
- Single test: `go test ./internal/store/... -run TestProfile -v`
|
- Single test: `go test ./internal/store/... -run TestProfile -v`
|
||||||
- Seed sample data (no live Garmin account needed): `go run ./cmd/seedsample -db /tmp/sample.db`, then point `geniusrund` at that DB via `GENIUSRUN_DB_PATH`.
|
- Seed sample data (no live Garmin account needed): `go run ./cmd/seedsample -db /tmp/sample.db`, then point `geniusrund` at that DB via `GENIUSRUN_DB_PATH`.
|
||||||
- The schema lives in one file, `internal/store/schema.sql`, applied in full on every `Open()` (idempotent -- skipped if the `users` table already exists). There is no migration history: this is a pre-production app with no compatibility obligation to older database files, so edit `schema.sql` directly rather than appending a migration. After changing it, regenerate the schema doc: `go run ./cmd/dumpschema` (writes `docs/DATABASE.md` from the live schema, so it can't drift out of sync).
|
- The schema lives in one file, `internal/store/schema.sql`, applied in full on every `Open()` (idempotent -- skipped if the `users` table already exists). There is no migration history: this is a pre-production app with no compatibility obligation to older database files, so edit `schema.sql` directly rather than appending a migration. After changing it, regenerate the schema doc: `go run ./cmd/dumpschema` (writes `docs/DATABASE.md` from the live schema, so it can't drift out of sync).
|
||||||
|
- **This app is still under active development, not yet in production.** A breaking schema change (new non-nullable column, renamed/removed column, changed constraint, etc.) is expected to require deleting the existing local DB file and letting `Open()` recreate it fresh from the current `schema.sql` -- this is the normal, accepted remedy during this phase, not a workaround to avoid. Do not add `ALTER TABLE` migration logic, backfill scripts, or any other backward-compatibility shim for an existing DB file to accommodate a schema change. Real migration tooling (Flyway) is planned once the first version ships to production; until then, every schema change is free to be breaking.
|
||||||
|
|
||||||
Frontend (from `frontend/`):
|
Frontend (from `frontend/`):
|
||||||
- Run dev server: `./start.sh` (puts Homebrew's `node@22` on `PATH` and runs `npm install` if `node_modules` is missing) or `npm run dev` directly. Set `VITE_API_BASE_URL` if the backend isn't on `localhost:8080`.
|
- Run dev server: `./start.sh` (puts Homebrew's `node@22` on `PATH` and runs `npm install` if `node_modules` is missing) or `npm run dev` directly. Set `VITE_API_BASE_URL` if the backend isn't on `localhost:8080`.
|
||||||
@@ -36,7 +37,7 @@ Beyond the login gate, every authenticated+authorized OIDC subject maps 1:1 to i
|
|||||||
|
|
||||||
A brand-new OIDC subject with no `users` row yet is routed to a "Create your profile" screen (`frontend/src/CreateProfile.tsx`) instead of the app. `POST /api/setup` (display name only) provisions it — a `users` row, a default `profile` row, the 8-kind workout taxonomy, and an initial `sync_state` row, all in one transaction (`store.ProvisionUser`). Garmin credentials, HR zones, etc. are filled in afterward via the normal Profile screen, same as any fresh install.
|
A brand-new OIDC subject with no `users` row yet is routed to a "Create your profile" screen (`frontend/src/CreateProfile.tsx`) instead of the app. `POST /api/setup` (display name only) provisions it — a `users` row, a default `profile` row, the 8-kind workout taxonomy, and an initial `sync_state` row, all in one transaction (`store.ProvisionUser`). Garmin credentials, HR zones, etc. are filled in afterward via the normal Profile screen, same as any fresh install.
|
||||||
|
|
||||||
Required env vars (`config.Load()` fails fast if any are unset, same pattern as the Garmin paths above): `GENIUSRUN_BACKEND_URL`, `GENIUSRUN_OIDC_ISSUER_URL`, `GENIUSRUN_OIDC_CLIENT_ID`, `GENIUSRUN_OIDC_CLIENT_SECRET`, `GENIUSRUN_SESSION_SECRET` (>=32 characters). Optional: `GENIUSRUN_FRONTEND_URL` (defaults to `GENIUSRUN_BACKEND_URL` -- set this when the frontend and backend are different origins, e.g. local dev), `GENIUSRUN_OIDC_REQUIRED_ROLE`, `GENIUSRUN_SESSION_DURATION` (default `720h`). See `docs/superpowers/specs/2026-07-24-oidc-authentication-design.md` for the original login-gate design and `internal/auth` for its implementation; the per-user data model on top of it lives in `internal/store` (schema/queries) and `internal/api/usercontext.go`+`setup.go` (HTTP layer).
|
Required env vars (`config.Load()` fails fast if any are unset, same pattern as the Garmin paths above): `GENIUSRUN_BACKEND_URL`, `GENIUSRUN_OIDC_ISSUER_URL`, `GENIUSRUN_OIDC_CLIENT_ID`, `GENIUSRUN_OIDC_CLIENT_SECRET`, `GENIUSRUN_SESSION_SECRET` (>=32 characters). Optional: `GENIUSRUN_FRONTEND_URL` (defaults to `GENIUSRUN_BACKEND_URL` -- set this when the frontend and backend are different origins, e.g. local dev), `GENIUSRUN_OIDC_REQUIRED_ROLE`. Session duration is application configuration, not an env var: `session.duration` (hours, default `720`), editable on the `/config` page and applied on the next backend restart. See `docs/superpowers/specs/2026-07-24-oidc-authentication-design.md` for the original login-gate design and `internal/auth` for its implementation; the per-user data model on top of it lives in `internal/store` (schema/queries) and `internal/api/usercontext.go`+`setup.go` (HTTP layer).
|
||||||
|
|
||||||
## Repo layout
|
## Repo layout
|
||||||
|
|
||||||
@@ -79,7 +80,7 @@ Each `workout_kinds.rule_json` is a recursive AND/OR condition tree (`internal/c
|
|||||||
- Leaf conditions: `{metric, op, value}`. `op` is `==`, `!=`, `>`, `>=`, `<`, `<=`, or `between` (value is a 2-element array).
|
- Leaf conditions: `{metric, op, value}`. `op` is `==`, `!=`, `>`, `>=`, `<`, `<=`, or `between` (value is a 2-element array).
|
||||||
- Supported metrics (see `internal/sync/mapping.go`'s `buildMetricContext`): `avg_pace_sec_per_km`, `avg_hr`, `avg_hr_pct_max`, `max_hr`, `duration_seconds`, `distance_meters`, `elevation_gain_m`, `aerobic_training_effect`, `anaerobic_training_effect`, `vo2max_value`, `is_race`, `lap_interval_pattern` (0/1), `lap_pace_stddev`, `lap_hr_drift_bpm_per_min`, `lap_hr_recovery_bpm_per_min`.
|
- Supported metrics (see `internal/sync/mapping.go`'s `buildMetricContext`): `avg_pace_sec_per_km`, `avg_hr`, `avg_hr_pct_max`, `max_hr`, `duration_seconds`, `distance_meters`, `elevation_gain_m`, `aerobic_training_effect`, `anaerobic_training_effect`, `vo2max_value`, `is_race`, `lap_interval_pattern` (0/1), `lap_pace_stddev`, `lap_hr_drift_bpm_per_min`, `lap_hr_recovery_bpm_per_min`.
|
||||||
- **Scoring**: each leaf gets a margin-based confidence in ~[0,1] (`between` = distance from center; comparisons = logistic squash of margin past the threshold). Branches aggregate via `min` (AND) / `max` (OR) — no ML, fully explainable.
|
- **Scoring**: each leaf gets a margin-based confidence in ~[0,1] (`between` = distance from center; comparisons = logistic squash of margin past the threshold). Branches aggregate via `min` (AND) / `max` (OR) — no ML, fully explainable.
|
||||||
- **`needs_review` triggers** (in `classify.Classify`): zero kinds matched, 2+ kinds matched, or exactly one matched below `min_confidence` (default from `GENIUSRUN_MIN_CONFIDENCE`, 0.6). All three populate `Candidates` for the review UI.
|
- **`needs_review` triggers** (in `classify.Classify`): zero kinds matched, 2+ kinds matched, or exactly one matched below `min_confidence` (hard-coded `classify.DefaultMinConfidence`, 0.6). All three populate `Candidates` for the review UI.
|
||||||
- **Interval detection** (`classify.DetectIntervalPattern`) trusts Garmin's own per-lap `IntensityType` tagging (ACTIVE vs REST/RECOVERY/WARMUP/COOLDOWN) rather than inferring it from pace variance.
|
- **Interval detection** (`classify.DetectIntervalPattern`) trusts Garmin's own per-lap `IntensityType` tagging (ACTIVE vs REST/RECOVERY/WARMUP/COOLDOWN) rather than inferring it from pace variance.
|
||||||
- **HR drift/recovery** (`classify.HRDrift`/`HRRecovery`): linear regression of heart rate vs elapsed time within a lap's sample window. Drift = rising HR during an active lap. Recovery = HR decay rate during a rest lap, sign-flipped so positive = good recovery.
|
- **HR drift/recovery** (`classify.HRDrift`/`HRRecovery`): linear regression of heart rate vs elapsed time within a lap's sample window. Drift = rising HR during an active lap. Recovery = HR decay rate during a rest lap, sign-flipped so positive = good recovery.
|
||||||
- Max HR for `avg_hr_pct_max` comes from `profile.max_heart_rate`, not a static config value — nil/unset omits that metric from the context rather than erroring.
|
- Max HR for `avg_hr_pct_max` comes from `profile.max_heart_rate`, not a static config value — nil/unset omits that metric from the context rather than erroring.
|
||||||
@@ -87,11 +88,11 @@ Each `workout_kinds.rule_json` is a recursive AND/OR condition tree (`internal/c
|
|||||||
|
|
||||||
## Garmin integration (direct wrapper, no MCP)
|
## Garmin integration (direct wrapper, no MCP)
|
||||||
|
|
||||||
`internal/garmin` spawns a small Python wrapper script (`internal/garmin/pyscript/wrapper.py`, embedded into the Go binary via `go:embed` — see `docs/superpowers/specs/2026-07-25-garmin-direct-wrapper-design.md`) that imports `garminconnect` directly and speaks a minimal newline-delimited-JSON protocol over stdin/stdout: `{"id","cmd","params"}` in, `{"id","result"}` or `{"id","error"}` out. `cmd` is `authenticate`, `complete_mfa`, or a fully generic `call` (`{"method": "<any garminconnect.Garmin method>", "args": {...}}`) — there's no MCP layer, and the separate `mcp-garmin` repo this used to depend on is retired. Key things learned the hard way, that would otherwise get rediscovered:
|
`internal/garmin` spawns a small Python wrapper script (`internal/garmin/pyscript/wrapper.py`, embedded into the Go binary via `go:embed` — see `docs/superpowers/specs/2026-07-25-garmin-direct-wrapper-design.md`) that imports `garminconnect` directly and speaks a minimal newline-delimited-JSON protocol over stdin/stdout (stderr carries JSON log lines, re-emitted through the Go application logger): `{"id","cmd","params"}` in, `{"id","result"}` or `{"id","error"}` out. `cmd` is `authenticate`, `complete_mfa`, or a fully generic `call` (`{"method": "<any garminconnect.Garmin method>", "args": {...}}`) — there's no MCP layer, and the separate `mcp-garmin` repo this used to depend on is retired. Key things learned the hard way, that would otherwise get rediscovered:
|
||||||
|
|
||||||
- **`authenticate`/`complete_mfa` return structured JSON** (`{"status": "success"|"mfa_required"|"failed", "message": "..."}`), not plain strings — `internal/garmin/client.go` maps `status` directly to `AuthStatus`, no string pattern-matching.
|
- **`authenticate`/`complete_mfa` return structured JSON** (`{"status": "success"|"mfa_required"|"failed", "message": "..."}`), not plain strings — `internal/garmin/client.go` maps `status` directly to `AuthStatus`, no string pattern-matching.
|
||||||
- **The 10s "MFA required" timeout in `wrapper.py`'s `authenticate` handler is a false-positive trap.** A login that's merely slow (e.g. Garmin/Cloudflare rate-limiting) looks identical to a real MFA challenge unless you check whether `prompt_mfa()` was actually invoked (`wrapper.py` logs this to stderr for exactly this reason).
|
- **The 10s "MFA required" timeout in `wrapper.py`'s `authenticate` handler is a false-positive trap.** A login that's merely slow (e.g. Garmin/Cloudflare rate-limiting) looks identical to a real MFA challenge unless you check whether `prompt_mfa()` was actually invoked (`wrapper.py` emits a JSON log line for exactly this reason, forwarded into the Go log stream by `forwardWrapperStderr`).
|
||||||
- **`wrapper.py` persists Garmin sessions** via a `GARMIN_TOKENSTORE` env var passed to `Garmin.login(tokenstore=...)` — without this, every process start does a full SSO login, which is what trips Garmin's rate limiting under repeated testing. Since geniusrun is multi-tenant, `GARMIN_TOKENSTORE` (`config.GarminTokenStoreRoot`) is a **root directory**, not a single session path: `api.Server.garminFor` namespaces each user's subprocess under `{root}/{userID}` so concurrent users never share a Garmin session. If unset, `config.Load()` defaults it to a `garmin-tokenstores` directory next to the DB file (logged at startup) rather than leaving per-user namespacing silently skipped.
|
- **`wrapper.py` persists Garmin sessions** via a `GARMIN_TOKENSTORE` env var passed to `Garmin.login(tokenstore=...)` — without this, every process start does a full SSO login, which is what trips Garmin's rate limiting under repeated testing. Since geniusrun is multi-tenant, this token-store root (`config.GarminTokenStoreRoot`, read from `GENIUSRUN_TOKENSTORE_PATH`) is a **root directory**, not a single session path: `api.Server.garminFor` namespaces each user's subprocess under `{root}/{userID}` so concurrent users never share a Garmin session. If unset, `config.Load()` defaults it to a `.garmin` directory relative to the working directory -- deliberately independent of `GENIUSRUN_DB_PATH`, so the DB file and the token-store root can each be pointed at their own directory -- rather than leaving per-user namespacing silently skipped.
|
||||||
- **`activityId` is a large int64** — never round-trip it through `float64`/generic `map[string]any` JSON decoding, or it corrupts into scientific notation. Decode into typed structs (`garmin.Activity`, not `map[string]any`).
|
- **`activityId` is a large int64** — never round-trip it through `float64`/generic `map[string]any` JSON decoding, or it corrupts into scientific notation. Decode into typed structs (`garmin.Activity`, not `map[string]any`).
|
||||||
- **`get_activity_details` returns raw per-second telemetry** (`activityDetailMetrics` + `metricDescriptors`), not lap/split summaries. Its `metricDescriptors` index-to-field mapping is **not stable across activities/devices** — `garmin.ExtractSamples` always resolves fields by descriptor key, never by fixed array position.
|
- **`get_activity_details` returns raw per-second telemetry** (`activityDetailMetrics` + `metricDescriptors`), not lap/split summaries. Its `metricDescriptors` index-to-field mapping is **not stable across activities/devices** — `garmin.ExtractSamples` always resolves fields by descriptor key, never by fixed array position.
|
||||||
- **`get_activity_splits`** returns the actual lap/split summaries (`lapDTOs`).
|
- **`get_activity_splits`** returns the actual lap/split summaries (`lapDTOs`).
|
||||||
@@ -116,6 +117,7 @@ See `docs/DATABASE.md` for the full, always-current schema (every table/column/i
|
|||||||
|
|
||||||
- `internal/garmin/mock` provides a fake `Client` for tests that need to exercise `internal/sync`/`internal/api` without a live subprocess.
|
- `internal/garmin/mock` provides a fake `Client` for tests that need to exercise `internal/sync`/`internal/api` without a live subprocess.
|
||||||
- No live Garmin account needed for frontend/UI work: `cmd/seedsample` provisions one fixed `"seedsample-user"` account (via `store.ProvisionUser`) then seeds realistic activities/laps/kinds under it through the real classification engine.
|
- No live Garmin account needed for frontend/UI work: `cmd/seedsample` provisions one fixed `"seedsample-user"` account (via `store.ProvisionUser`) then seeds realistic activities/laps/kinds under it through the real classification engine.
|
||||||
|
- **Verify UI/frontend changes with a live click-through against the actual running dev servers (real backend + `npm run dev`), never by mocking network requests (e.g. Playwright route interception) to fake a logged-in session.** This repo's OIDC login gate makes a fully-mocked session tempting, but it's fragile in exactly the way that matters: a real backend is often already running (e.g. started from an IDE) on the same port a mocked test assumes is free, so any request the mock doesn't cover falls through to that real, live backend instead of erroring cleanly -- discovered when an incomplete route-mock's unmocked calls 401'd against a real GoLand-launched `geniusrund` and triggered `client.ts`'s 401-redirect-reload loop. If a live click-through isn't possible in the current environment (no real credentials, no browser tool available), say so explicitly rather than substituting a mocked simulation.
|
||||||
|
|
||||||
## Testing conventions
|
## Testing conventions
|
||||||
|
|
||||||
|
|||||||
@@ -10,11 +10,12 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log/slog"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
applog "geniusrun/backend/internal/log"
|
||||||
"geniusrun/backend/internal/store"
|
"geniusrun/backend/internal/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -26,6 +27,7 @@ type schemaEntry struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
|
slog.SetDefault(applog.NewLogger("info", os.Stdout))
|
||||||
tmpDir, err := os.MkdirTemp("", "geniusrun-dumpschema")
|
tmpDir, err := os.MkdirTemp("", "geniusrun-dumpschema")
|
||||||
must(err)
|
must(err)
|
||||||
defer os.RemoveAll(tmpDir)
|
defer os.RemoveAll(tmpDir)
|
||||||
@@ -91,11 +93,12 @@ func main() {
|
|||||||
|
|
||||||
outPath := "../docs/DATABASE.md"
|
outPath := "../docs/DATABASE.md"
|
||||||
must(os.WriteFile(outPath, []byte(b.String()), 0644))
|
must(os.WriteFile(outPath, []byte(b.String()), 0644))
|
||||||
log.Printf("wrote %s", outPath)
|
applog.App().Info("wrote schema doc", "path", outPath)
|
||||||
}
|
}
|
||||||
|
|
||||||
func must(err error) {
|
func must(err error) {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatal(err)
|
applog.App().Error("dumpschema failed", "error", err)
|
||||||
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,8 +5,9 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"log"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"os"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
@@ -15,80 +16,104 @@ import (
|
|||||||
"geniusrun/backend/internal/auth"
|
"geniusrun/backend/internal/auth"
|
||||||
"geniusrun/backend/internal/config"
|
"geniusrun/backend/internal/config"
|
||||||
"geniusrun/backend/internal/garmin"
|
"geniusrun/backend/internal/garmin"
|
||||||
|
applog "geniusrun/backend/internal/log"
|
||||||
"geniusrun/backend/internal/store"
|
"geniusrun/backend/internal/store"
|
||||||
appsync "geniusrun/backend/internal/sync"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
// fatal logs through the application JSON logger and exits -- the
|
||||||
cfg, err := config.Load()
|
// structured replacement for log.Fatalf, so even startup failures come
|
||||||
if err != nil {
|
// out as JSON lines.
|
||||||
log.Fatalf("config: %v", err)
|
func fatal(msg string, err error) {
|
||||||
}
|
applog.App().Error(msg, "error", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
db, err := store.Open(cfg.DBPath)
|
func main() {
|
||||||
|
// Bootstrap logger at info so failures loading the env config itself
|
||||||
|
// (which carries the real log level) are still emitted as JSON.
|
||||||
|
slog.SetDefault(applog.NewLogger("warn", os.Stdout))
|
||||||
|
|
||||||
|
envCfg, err := config.LoadEnv()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("open database: %v", err)
|
fatal("load env config", err)
|
||||||
|
}
|
||||||
|
slog.SetDefault(applog.NewLogger(envCfg.LogLevel, os.Stdout))
|
||||||
|
|
||||||
|
db, err := store.Open(envCfg.DBPath)
|
||||||
|
if err != nil {
|
||||||
|
fatal("open database", err)
|
||||||
}
|
}
|
||||||
defer db.Close()
|
defer db.Close()
|
||||||
|
|
||||||
authVerifier, err := auth.NewOIDCVerifier(context.Background(), auth.OIDCConfig{
|
values, err := db.ConfigValues(context.Background())
|
||||||
IssuerURL: cfg.OIDCIssuerURL,
|
|
||||||
ClientID: cfg.OIDCClientID,
|
|
||||||
ClientSecret: cfg.OIDCClientSecret,
|
|
||||||
RedirectURL: cfg.OIDCRedirectURL,
|
|
||||||
RequiredRole: cfg.OIDCRequiredRole,
|
|
||||||
})
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("oidc: %v", err)
|
fatal("read app config", err)
|
||||||
|
}
|
||||||
|
// 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)
|
||||||
}
|
}
|
||||||
|
|
||||||
server := api.NewServer(db, garmin.NewClient, garmin.Config{
|
authVerifier, err := auth.NewOIDCVerifier(context.Background(), auth.OIDCConfig{
|
||||||
PythonPath: cfg.GarminPythonPath,
|
IssuerURL: envCfg.OIDCIssuerURL,
|
||||||
TokenStorePath: cfg.GarminTokenStoreRoot,
|
ClientID: envCfg.OIDCClientID,
|
||||||
}, appsync.Config{
|
ClientSecret: envCfg.OIDCClientSecret,
|
||||||
MinConfidence: cfg.MinConfidence,
|
RedirectURL: envCfg.OIDCRedirectURL,
|
||||||
}, authVerifier, api.SessionConfig{
|
RequiredRole: envCfg.OIDCRequiredRole,
|
||||||
Secret: cfg.SessionSecret,
|
|
||||||
Duration: cfg.SessionDuration,
|
|
||||||
Secure: cfg.SessionSecure,
|
|
||||||
BackendURL: cfg.BackendURL,
|
|
||||||
FrontendURL: cfg.FrontendURL,
|
|
||||||
})
|
})
|
||||||
|
if err != nil {
|
||||||
|
fatal("oidc verifier setup", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
server := api.NewServer(
|
||||||
|
db,
|
||||||
|
garmin.NewClient,
|
||||||
|
garmin.ClientConfig{
|
||||||
|
PythonPath: envCfg.PythonPath,
|
||||||
|
TokenStorePath: envCfg.TokenStoreRoot,
|
||||||
|
},
|
||||||
|
garmin.SyncConfig{},
|
||||||
|
authVerifier,
|
||||||
|
api.SessionConfig{
|
||||||
|
Secret: envCfg.SessionSecret,
|
||||||
|
Duration: appCfg.SessionDuration,
|
||||||
|
SetupTimeout: appCfg.SetupTimeout,
|
||||||
|
Secure: envCfg.SessionSecure,
|
||||||
|
BackendURL: envCfg.BackendURL,
|
||||||
|
FrontendURL: envCfg.FrontendURL,
|
||||||
|
})
|
||||||
|
|
||||||
|
for _, e := range envCfg.DisplayEnv() {
|
||||||
|
server.EnvVars = append(server.EnvVars, api.EnvVar{Name: e.Name, Value: e.Value})
|
||||||
|
}
|
||||||
|
|
||||||
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
||||||
defer stop()
|
defer stop()
|
||||||
|
|
||||||
go runIncrementalSyncLoop(ctx, server, cfg.IncrementalSyncEvery)
|
httpServer := &http.Server{Addr: envCfg.BackendAddr, Handler: server.Router()}
|
||||||
|
|
||||||
httpServer := &http.Server{Addr: cfg.Addr, Handler: server.Router()}
|
|
||||||
go func() {
|
go func() {
|
||||||
log.Printf("geniusrund listening on %s", cfg.Addr)
|
applog.App().Info("geniusrund listening", "addr", envCfg.BackendAddr)
|
||||||
if err := httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
if err := httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||||
log.Fatalf("http server: %v", err)
|
fatal("http server", err)
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
<-ctx.Done()
|
<-ctx.Done()
|
||||||
log.Println("shutting down...")
|
applog.App().Info("shutting down")
|
||||||
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
if err := httpServer.Shutdown(shutdownCtx); err != nil {
|
if err := httpServer.Shutdown(shutdownCtx); err != nil {
|
||||||
log.Printf("http server shutdown: %v", err)
|
applog.App().Error("http server shutdown", "error", err)
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// runIncrementalSyncLoop periodically syncs new activities for every
|
|
||||||
// provisioned user in the background so the frontend doesn't need to
|
|
||||||
// trigger every sync manually.
|
|
||||||
func runIncrementalSyncLoop(ctx context.Context, server *api.Server, every time.Duration) {
|
|
||||||
ticker := time.NewTicker(every)
|
|
||||||
defer ticker.Stop()
|
|
||||||
for {
|
|
||||||
select {
|
|
||||||
case <-ctx.Done():
|
|
||||||
return
|
|
||||||
case <-ticker.C:
|
|
||||||
server.RunIncrementalSyncForAllUsers(ctx)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,23 +9,25 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"flag"
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log/slog"
|
||||||
|
"os"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"geniusrun/backend/internal/classify"
|
"geniusrun/backend/internal/classify"
|
||||||
"geniusrun/backend/internal/garmin/mock"
|
"geniusrun/backend/internal/garmin"
|
||||||
|
applog "geniusrun/backend/internal/log"
|
||||||
"geniusrun/backend/internal/store"
|
"geniusrun/backend/internal/store"
|
||||||
appsync "geniusrun/backend/internal/sync"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
|
slog.SetDefault(applog.NewLogger("info", os.Stdout))
|
||||||
dbPath := flag.String("db", "geniusrun_sample.db", "path to the SQLite database to seed")
|
dbPath := flag.String("db", "geniusrun_sample.db", "path to the SQLite database to seed")
|
||||||
flag.Parse()
|
flag.Parse()
|
||||||
|
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
db, err := store.Open(*dbPath)
|
db, err := store.Open(*dbPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("open db: %v", err)
|
fatal("open db", err)
|
||||||
}
|
}
|
||||||
defer db.Close()
|
defer db.Close()
|
||||||
|
|
||||||
@@ -73,8 +75,8 @@ func main() {
|
|||||||
IsActive: true,
|
IsActive: true,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
m := &mock.Client{}
|
m := &garmin.MockClient{}
|
||||||
svc := appsync.NewService(m, db, userID, appsync.Config{MinConfidence: 0.6}, nil)
|
svc := garmin.NewSync(m, db, userID, garmin.SyncConfig{MinConfidence: 0.6}, nil)
|
||||||
|
|
||||||
today := time.Now()
|
today := time.Now()
|
||||||
activityIDs := []int64{}
|
activityIDs := []int64{}
|
||||||
@@ -234,10 +236,17 @@ func seedIntervalLapsAndSamples(ctx context.Context, db *store.DB, userID, activ
|
|||||||
|
|
||||||
func must(err error) {
|
func must(err error) {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatal(err)
|
fatal("seedsample", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// fatal logs through the application JSON logger and exits -- the
|
||||||
|
// structured replacement for log.Fatal.
|
||||||
|
func fatal(msg string, err error) {
|
||||||
|
applog.App().Error(msg, "error", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
func mustFindKindID(ctx context.Context, db *store.DB, userID int64, name string) int64 {
|
func mustFindKindID(ctx context.Context, db *store.DB, userID int64, name string) int64 {
|
||||||
kinds, err := db.ListWorkoutKinds(ctx, userID, false)
|
kinds, err := db.ListWorkoutKinds(ctx, userID, false)
|
||||||
must(err)
|
must(err)
|
||||||
@@ -246,6 +255,7 @@ func mustFindKindID(ctx context.Context, db *store.DB, userID int64, name string
|
|||||||
return k.ID
|
return k.ID
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
log.Fatalf("seedsample: no workout kind named %q found for the seeded user (did ProvisionUser seed the taxonomy?)", name)
|
applog.App().Error("no workout kind with that name for the seeded user (did ProvisionUser seed the taxonomy?)", "kind", name)
|
||||||
|
os.Exit(1)
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
package api
|
package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"sort"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
@@ -9,109 +11,248 @@ import (
|
|||||||
"geniusrun/backend/internal/store"
|
"geniusrun/backend/internal/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
// activityListItem is one row of the activities list, enriched with its
|
// defaultActivitiesPageSize matches the frontend's initial/incremental
|
||||||
// current classification so the frontend can show what kind it's assigned
|
// page size for its infinite-scroll list.
|
||||||
// to and whether that assignment is locked (see Locked's doc comment).
|
const defaultActivitiesPageSize = 10
|
||||||
type activityListItem struct {
|
|
||||||
activityResponse
|
type activityItem struct {
|
||||||
WorkoutKindID *int64 `json:"workout_kind_id"`
|
store.KindAssignment
|
||||||
WorkoutKindName *string `json:"workout_kind_name"`
|
Activity activityResponse `json:"activity"`
|
||||||
AssignmentSource *string `json:"assignment_source"`
|
Laps []lapResponse `json:"laps"`
|
||||||
AssignmentStatus *string `json:"assignment_status"`
|
Samples []store.Sample `json:"samples"`
|
||||||
// Locked is true when a global reclassify pass will never touch this
|
|
||||||
// activity again: a manual assignment is the user's definitive word, and
|
|
||||||
// a Race assignment comes from a hard Garmin fact, not a retunable rule.
|
|
||||||
Locked bool `json:"locked"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// handleListActivities backs the Activities page: every activity that has been
|
||||||
|
// classified at least once, not just ones still needing review, so the page
|
||||||
|
// can show each activity's current kind (or "Unclassified") and let the user
|
||||||
|
// lock/unlock it. It's cursor-paginated: fetching every activity's laps and
|
||||||
|
// (especially) per-second samples is expensive once there are many of them,
|
||||||
|
// so that work only happens for the requested page, not the full backlog.
|
||||||
|
// Sorting/cursor filtering still needs each activity's summary row (a cheap
|
||||||
|
// indexed lookup, no laps/samples), which happens for the whole backlog --
|
||||||
|
// only the heavy per-item fetches are deferred to the page actually being
|
||||||
|
// returned. The optional kind_id/unclassified filters are applied before
|
||||||
|
// that cursor slicing too, so a filtered view still only loads (and
|
||||||
|
// chart-renders) one page at a time instead of the whole matching backlog.
|
||||||
func (s *Server) handleListActivities(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleListActivities(w http.ResponseWriter, r *http.Request) {
|
||||||
userID := userIDFromContext(r.Context())
|
userID := userIDFromContext(r.Context())
|
||||||
q := r.URL.Query()
|
limit := defaultActivitiesPageSize
|
||||||
filter := store.ActivityFilter{
|
if v := r.URL.Query().Get("limit"); v != "" {
|
||||||
FromDate: q.Get("from"),
|
if n, err := strconv.Atoi(v); err == nil && n > 0 {
|
||||||
ToDate: q.Get("to"),
|
limit = n
|
||||||
}
|
}
|
||||||
if limit, err := strconv.Atoi(q.Get("limit")); err == nil {
|
|
||||||
filter.Limit = limit
|
|
||||||
}
|
|
||||||
if offset, err := strconv.Atoi(q.Get("offset")); err == nil {
|
|
||||||
filter.Offset = offset
|
|
||||||
}
|
}
|
||||||
|
cursor := r.URL.Query().Get("before") // activity StartTimeUTC of the last item on the previous page
|
||||||
|
|
||||||
activities, err := s.DB.ListActivities(r.Context(), userID, filter)
|
var kindIDFilter *int64
|
||||||
|
if v := r.URL.Query().Get("kind_id"); v != "" {
|
||||||
|
if n, err := strconv.ParseInt(v, 10, 64); err == nil {
|
||||||
|
kindIDFilter = &n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
unclassifiedOnly := r.URL.Query().Get("unclassified") == "true"
|
||||||
|
|
||||||
|
queue, err := s.DB.AllCurrentAssignments(r.Context(), userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, http.StatusInternalServerError, err.Error())
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
kinds, err := s.DB.ListWorkoutKinds(r.Context(), userID, false)
|
type withActivity struct {
|
||||||
if err != nil {
|
assignment store.KindAssignment
|
||||||
writeError(w, http.StatusInternalServerError, err.Error())
|
activity store.Activity
|
||||||
return
|
|
||||||
}
|
}
|
||||||
kindNames := make(map[int64]string, len(kinds))
|
all := make([]withActivity, 0, len(queue))
|
||||||
for _, k := range kinds {
|
for _, a := range queue {
|
||||||
kindNames[k.ID] = k.Name
|
if kindIDFilter != nil && (a.WorkoutKindID == nil || *a.WorkoutKindID != *kindIDFilter) {
|
||||||
|
continue
|
||||||
}
|
}
|
||||||
|
if unclassifiedOnly && a.WorkoutKindID != nil {
|
||||||
resp := make([]activityListItem, 0, len(activities))
|
continue
|
||||||
for _, a := range activities {
|
|
||||||
item := activityListItem{activityResponse: toActivityResponse(a)}
|
|
||||||
assignment, ok, err := s.DB.CurrentAssignment(r.Context(), userID, a.ID)
|
|
||||||
if err != nil {
|
|
||||||
writeError(w, http.StatusInternalServerError, err.Error())
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
if ok {
|
activity, ok, err := s.DB.GetActivity(r.Context(), userID, a.ActivityID)
|
||||||
source, status := assignment.AssignmentSource, assignment.Status
|
|
||||||
item.AssignmentSource, item.AssignmentStatus = &source, &status
|
|
||||||
if assignment.WorkoutKindID != nil {
|
|
||||||
item.WorkoutKindID = assignment.WorkoutKindID
|
|
||||||
if name, found := kindNames[*assignment.WorkoutKindID]; found {
|
|
||||||
item.WorkoutKindName = &name
|
|
||||||
}
|
|
||||||
}
|
|
||||||
item.Locked = source == store.AssignmentSourceManual ||
|
|
||||||
(item.WorkoutKindName != nil && *item.WorkoutKindName == "Race")
|
|
||||||
}
|
|
||||||
resp = append(resp, item)
|
|
||||||
}
|
|
||||||
writeJSON(w, http.StatusOK, resp)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Server) handleGetActivity(w http.ResponseWriter, r *http.Request) {
|
|
||||||
userID := userIDFromContext(r.Context())
|
|
||||||
id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
|
|
||||||
if err != nil {
|
|
||||||
writeError(w, http.StatusBadRequest, "invalid activity id")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
activity, ok, err := s.DB.GetActivity(r.Context(), userID, id)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, http.StatusInternalServerError, err.Error())
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if !ok {
|
if !ok {
|
||||||
writeError(w, http.StatusNotFound, "activity not found")
|
continue
|
||||||
return
|
}
|
||||||
|
all = append(all, withActivity{assignment: a, activity: activity})
|
||||||
}
|
}
|
||||||
|
|
||||||
laps, err := s.DB.LapsForActivity(r.Context(), userID, id)
|
// Most recent run first, by when the activity actually happened (not
|
||||||
|
// when the rule engine flagged it), so the queue reads like a log.
|
||||||
|
sort.Slice(all, func(i, j int) bool {
|
||||||
|
return all[i].activity.StartTimeUTC > all[j].activity.StartTimeUTC
|
||||||
|
})
|
||||||
|
|
||||||
|
total := len(all)
|
||||||
|
|
||||||
|
if cursor != "" {
|
||||||
|
idx := 0
|
||||||
|
for idx < len(all) && all[idx].activity.StartTimeUTC >= cursor {
|
||||||
|
idx++
|
||||||
|
}
|
||||||
|
all = all[idx:]
|
||||||
|
}
|
||||||
|
hasMore := len(all) > limit
|
||||||
|
if len(all) > limit {
|
||||||
|
all = all[:limit]
|
||||||
|
}
|
||||||
|
|
||||||
|
items := make([]activityItem, 0, len(all))
|
||||||
|
for _, wa := range all {
|
||||||
|
laps, err := s.DB.LapsForActivity(r.Context(), userID, wa.assignment.ActivityID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, http.StatusInternalServerError, err.Error())
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// Per-second telemetry, not just per-lap averages, so the chart can
|
||||||
assignment, hasAssignment, err := s.DB.CurrentAssignment(r.Context(), userID, id)
|
// show real within-lap variation instead of one flat segment per lap
|
||||||
|
// (most activities only have a handful of laps).
|
||||||
|
samples, err := s.DB.SamplesForActivity(r.Context(), userID, wa.assignment.ActivityID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, http.StatusInternalServerError, err.Error())
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
items = append(items, activityItem{
|
||||||
resp := map[string]any{"activity": toActivityResponse(activity), "laps": toLapResponses(laps)}
|
KindAssignment: wa.assignment,
|
||||||
if hasAssignment {
|
Activity: toActivityResponse(wa.activity),
|
||||||
resp["assignment"] = assignment
|
Laps: toLapResponses(laps),
|
||||||
|
Samples: samples,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, resp)
|
|
||||||
|
var nextCursor *string
|
||||||
|
if hasMore && len(items) > 0 {
|
||||||
|
c := items[len(items)-1].Activity.StartTimeUTC
|
||||||
|
nextCursor = &c
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{
|
||||||
|
"items": items,
|
||||||
|
"next_cursor": nextCursor,
|
||||||
|
"total": total,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleAssignActivity(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userID := userIDFromContext(r.Context())
|
||||||
|
activityID, err := strconv.ParseInt(chi.URLParam(r, "activityID"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid activity id")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var body struct {
|
||||||
|
WorkoutKindID int64 `json:"workout_kind_id"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid request body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if body.WorkoutKindID == 0 {
|
||||||
|
writeError(w, http.StatusBadRequest, "workout_kind_id is required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
kind, ok, err := s.DB.GetWorkoutKind(r.Context(), userID, body.WorkoutKindID)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
} else if !ok {
|
||||||
|
writeError(w, http.StatusBadRequest, "workout kind not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if kind.Name == "Race" {
|
||||||
|
writeError(w, http.StatusBadRequest, "Race is assigned automatically from Garmin metadata and cannot be set manually")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
kindID := body.WorkoutKindID
|
||||||
|
if _, err := s.DB.InsertKindAssignment(r.Context(), userID, store.KindAssignment{
|
||||||
|
ActivityID: activityID,
|
||||||
|
WorkoutKindID: &kindID,
|
||||||
|
AssignmentSource: store.AssignmentSourceManual,
|
||||||
|
Status: store.AssignmentStatusAssigned,
|
||||||
|
CandidateKindsJSON: "[]",
|
||||||
|
}); err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]string{"status": "assigned"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleUnassignActivity manually clears an activity's kind back to
|
||||||
|
// Unclassified. Like handleAssignActivity, it's a deliberate manual choice --
|
||||||
|
// recorded as source=manual (with no kind) so the rule engine leaves it alone
|
||||||
|
// on a later reclassify pass, exactly as it would for a manual kind
|
||||||
|
// assignment. The frontend only offers this while the activity is unlocked
|
||||||
|
// (locked activities must be unlocked first, same precondition as picking a
|
||||||
|
// different kind), but the backend doesn't re-enforce that here, matching
|
||||||
|
// handleAssignActivity's own lack of a lock precondition check.
|
||||||
|
func (s *Server) handleUnassignActivity(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userID := userIDFromContext(r.Context())
|
||||||
|
activityID, err := strconv.ParseInt(chi.URLParam(r, "activityID"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid activity id")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := s.DB.InsertKindAssignment(r.Context(), userID, store.KindAssignment{
|
||||||
|
ActivityID: activityID,
|
||||||
|
WorkoutKindID: nil,
|
||||||
|
AssignmentSource: store.AssignmentSourceManual,
|
||||||
|
Status: store.AssignmentStatusNeedsReview,
|
||||||
|
CandidateKindsJSON: "[]",
|
||||||
|
}); err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]string{"status": "unassigned"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleUnlockActivity reverts a manual assignment back to rule_engine
|
||||||
|
// sourcing, keeping the same kind, so a later reclassify pass (which always
|
||||||
|
// skips manual assignments) is free to change it again. It's the inverse of
|
||||||
|
// handleAssignActivity, not a delete: the kind stays visible as-is until
|
||||||
|
// something actually reclassifies it.
|
||||||
|
func (s *Server) handleUnlockActivity(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userID := userIDFromContext(r.Context())
|
||||||
|
activityID, err := strconv.ParseInt(chi.URLParam(r, "activityID"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid activity id")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
current, ok, err := s.DB.CurrentAssignment(r.Context(), userID, activityID)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
writeError(w, http.StatusNotFound, "activity has no assignment yet")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if current.AssignmentSource != store.AssignmentSourceManual {
|
||||||
|
writeError(w, http.StatusBadRequest, "activity is not manually locked")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
status := store.AssignmentStatusAssigned
|
||||||
|
if current.WorkoutKindID == nil {
|
||||||
|
status = store.AssignmentStatusNeedsReview
|
||||||
|
}
|
||||||
|
if _, err := s.DB.InsertKindAssignment(r.Context(), userID, store.KindAssignment{
|
||||||
|
ActivityID: activityID,
|
||||||
|
WorkoutKindID: current.WorkoutKindID,
|
||||||
|
AssignmentSource: store.AssignmentSourceRuleEngine,
|
||||||
|
Status: status,
|
||||||
|
CandidateKindsJSON: "[]",
|
||||||
|
}); err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]string{"status": "unlocked"})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"net/url"
|
"net/url"
|
||||||
@@ -17,9 +18,8 @@ import (
|
|||||||
"geniusrun/backend/internal/auth"
|
"geniusrun/backend/internal/auth"
|
||||||
authmock "geniusrun/backend/internal/auth/mock"
|
authmock "geniusrun/backend/internal/auth/mock"
|
||||||
"geniusrun/backend/internal/garmin"
|
"geniusrun/backend/internal/garmin"
|
||||||
"geniusrun/backend/internal/garmin/mock"
|
"geniusrun/backend/internal/log"
|
||||||
"geniusrun/backend/internal/store"
|
"geniusrun/backend/internal/store"
|
||||||
appsync "geniusrun/backend/internal/sync"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func newCtx() context.Context { return context.Background() }
|
func newCtx() context.Context { return context.Background() }
|
||||||
@@ -27,6 +27,7 @@ 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,
|
||||||
|
SetupTimeout: 15 * time.Minute,
|
||||||
Secure: false,
|
Secure: false,
|
||||||
BackendURL: "https://geniusrun.example.com",
|
BackendURL: "https://geniusrun.example.com",
|
||||||
FrontendURL: "https://app.geniusrun.example.com",
|
FrontendURL: "https://app.geniusrun.example.com",
|
||||||
@@ -45,9 +46,9 @@ func newTestServer(t *testing.T) (*Server, *store.DB, int64) {
|
|||||||
t.Fatalf("ProvisionUser: %v", err)
|
t.Fatalf("ProvisionUser: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
m := &mock.Client{}
|
m := &garmin.MockClient{}
|
||||||
garminFactory := func(garmin.Config) garmin.Client { return m }
|
garminFactory := func(garmin.ClientConfig) garmin.Client { return m }
|
||||||
s := NewServer(db, garminFactory, garmin.Config{}, appsync.Config{}, &authmock.Verifier{}, testSessionConfig)
|
s := NewServer(db, garminFactory, garmin.ClientConfig{}, garmin.SyncConfig{}, &authmock.Verifier{}, testSessionConfig)
|
||||||
return s, db, userID
|
return s, db, userID
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -65,7 +66,7 @@ func doJSON(t *testing.T, handler http.Handler, method, path string, body any) *
|
|||||||
}
|
}
|
||||||
req := httptest.NewRequest(method, path, reader)
|
req := httptest.NewRequest(method, path, reader)
|
||||||
req.Header.Set("Content-Type", "application/json")
|
req.Header.Set("Content-Type", "application/json")
|
||||||
cookie, err := auth.MintSessionCookie(auth.Claims{Sub: "test-user", Name: "Test User", Email: "test@example.com"}, testSessionConfig.Secret, testSessionConfig.Duration, testSessionConfig.Secure)
|
cookie, err := auth.MintSessionCookie(auth.Claims{Sub: "test-user", Name: "Test User", Email: "test@example.com"}, "", testSessionConfig.Secret, testSessionConfig.Duration, testSessionConfig.Secure)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("mint test session cookie: %v", err)
|
t.Fatalf("mint test session cookie: %v", err)
|
||||||
}
|
}
|
||||||
@@ -195,7 +196,7 @@ func TestWorkoutKindUpdate_RejectsInvertedPaceRange(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestReviewQueueResolve(t *testing.T) {
|
func TestActivitiesAssign(t *testing.T) {
|
||||||
s, db, userID := newTestServer(t)
|
s, db, userID := newTestServer(t)
|
||||||
ctx := newCtx()
|
ctx := newCtx()
|
||||||
|
|
||||||
@@ -227,9 +228,9 @@ func TestReviewQueueResolve(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
router := s.Router()
|
router := s.Router()
|
||||||
rec := doJSON(t, router, http.MethodGet, "/api/review-queue/", nil)
|
rec := doJSON(t, router, http.MethodGet, "/api/activities/", nil)
|
||||||
if rec.Code != http.StatusOK {
|
if rec.Code != http.StatusOK {
|
||||||
t.Fatalf("review queue status = %d", rec.Code)
|
t.Fatalf("activities list status = %d", rec.Code)
|
||||||
}
|
}
|
||||||
var page struct {
|
var page struct {
|
||||||
Items []map[string]any `json:"items"`
|
Items []map[string]any `json:"items"`
|
||||||
@@ -238,43 +239,43 @@ func TestReviewQueueResolve(t *testing.T) {
|
|||||||
}
|
}
|
||||||
json.Unmarshal(rec.Body.Bytes(), &page)
|
json.Unmarshal(rec.Body.Bytes(), &page)
|
||||||
if len(page.Items) != 1 || page.Total != 1 {
|
if len(page.Items) != 1 || page.Total != 1 {
|
||||||
t.Fatalf("expected 1 item in review queue (total=1), got %d items, total=%d: %s", len(page.Items), page.Total, rec.Body.String())
|
t.Fatalf("expected 1 item in activities list (total=1), got %d items, total=%d: %s", len(page.Items), page.Total, rec.Body.String())
|
||||||
}
|
}
|
||||||
laps, _ := page.Items[0]["laps"].([]any)
|
laps, _ := page.Items[0]["laps"].([]any)
|
||||||
if len(laps) != 1 {
|
if len(laps) != 1 {
|
||||||
t.Fatalf("expected 1 lap in review queue item, got %d: %s", len(laps), rec.Body.String())
|
t.Fatalf("expected 1 lap in activities list item, got %d: %s", len(laps), rec.Body.String())
|
||||||
}
|
}
|
||||||
samples, _ := page.Items[0]["samples"].([]any)
|
samples, _ := page.Items[0]["samples"].([]any)
|
||||||
if len(samples) != 1 {
|
if len(samples) != 1 {
|
||||||
t.Fatalf("expected 1 sample in review queue item, got %d: %s", len(samples), rec.Body.String())
|
t.Fatalf("expected 1 sample in activities list item, got %d: %s", len(samples), rec.Body.String())
|
||||||
}
|
}
|
||||||
|
|
||||||
rec = doJSON(t, router, http.MethodPost, "/api/review-queue/"+itoa(activityID)+"/resolve", map[string]any{"workout_kind_id": kindID})
|
rec = doJSON(t, router, http.MethodPost, "/api/activities/"+itoa(activityID)+"/assign", map[string]any{"workout_kind_id": kindID})
|
||||||
if rec.Code != http.StatusOK {
|
if rec.Code != http.StatusOK {
|
||||||
t.Fatalf("resolve status = %d, body = %s", rec.Code, rec.Body.String())
|
t.Fatalf("assign status = %d, body = %s", rec.Code, rec.Body.String())
|
||||||
}
|
}
|
||||||
|
|
||||||
// The activity stays listed after resolving -- the Activities page shows
|
// The activity stays listed after resolving -- the Activities page shows
|
||||||
// every activity, locked or not, not just ones still needing review.
|
// every activity, locked or not, not just ones still needing review.
|
||||||
rec = doJSON(t, router, http.MethodGet, "/api/review-queue/", nil)
|
rec = doJSON(t, router, http.MethodGet, "/api/activities/", nil)
|
||||||
json.Unmarshal(rec.Body.Bytes(), &page)
|
json.Unmarshal(rec.Body.Bytes(), &page)
|
||||||
if len(page.Items) != 1 || page.Total != 1 {
|
if len(page.Items) != 1 || page.Total != 1 {
|
||||||
t.Fatalf("expected activity to remain listed after resolve, got %d items, total=%d", len(page.Items), page.Total)
|
t.Fatalf("expected activity to remain listed after assign, got %d items, total=%d", len(page.Items), page.Total)
|
||||||
}
|
}
|
||||||
if page.Items[0]["AssignmentSource"] != store.AssignmentSourceManual {
|
if page.Items[0]["AssignmentSource"] != store.AssignmentSourceManual {
|
||||||
t.Fatalf("expected AssignmentSource=manual after resolve, got %v", page.Items[0]["AssignmentSource"])
|
t.Fatalf("expected AssignmentSource=manual after assign, got %v", page.Items[0]["AssignmentSource"])
|
||||||
}
|
}
|
||||||
if got := page.Items[0]["WorkoutKindID"]; got != float64(kindID) {
|
if got := page.Items[0]["WorkoutKindID"]; got != float64(kindID) {
|
||||||
t.Fatalf("expected WorkoutKindID=%d after resolve, got %v", kindID, got)
|
t.Fatalf("expected WorkoutKindID=%d after assign, got %v", kindID, got)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Unlocking reverts the source to rule_engine but keeps the same kind,
|
// Unlocking reverts the source to rule_engine but keeps the same kind,
|
||||||
// so a later reclassify pass is free to change it again.
|
// so a later reclassify pass is free to change it again.
|
||||||
rec = doJSON(t, router, http.MethodPost, "/api/review-queue/"+itoa(activityID)+"/unlock", nil)
|
rec = doJSON(t, router, http.MethodPost, "/api/activities/"+itoa(activityID)+"/unlock", nil)
|
||||||
if rec.Code != http.StatusOK {
|
if rec.Code != http.StatusOK {
|
||||||
t.Fatalf("unlock status = %d, body = %s", rec.Code, rec.Body.String())
|
t.Fatalf("unlock status = %d, body = %s", rec.Code, rec.Body.String())
|
||||||
}
|
}
|
||||||
rec = doJSON(t, router, http.MethodGet, "/api/review-queue/", nil)
|
rec = doJSON(t, router, http.MethodGet, "/api/activities/", nil)
|
||||||
json.Unmarshal(rec.Body.Bytes(), &page)
|
json.Unmarshal(rec.Body.Bytes(), &page)
|
||||||
if page.Items[0]["AssignmentSource"] != store.AssignmentSourceRuleEngine {
|
if page.Items[0]["AssignmentSource"] != store.AssignmentSourceRuleEngine {
|
||||||
t.Fatalf("expected AssignmentSource=rule_engine after unlock, got %v", page.Items[0]["AssignmentSource"])
|
t.Fatalf("expected AssignmentSource=rule_engine after unlock, got %v", page.Items[0]["AssignmentSource"])
|
||||||
@@ -284,18 +285,18 @@ func TestReviewQueueResolve(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Unlocking an already-unlocked activity is rejected.
|
// Unlocking an already-unlocked activity is rejected.
|
||||||
rec = doJSON(t, router, http.MethodPost, "/api/review-queue/"+itoa(activityID)+"/unlock", nil)
|
rec = doJSON(t, router, http.MethodPost, "/api/activities/"+itoa(activityID)+"/unlock", nil)
|
||||||
if rec.Code != http.StatusBadRequest {
|
if rec.Code != http.StatusBadRequest {
|
||||||
t.Fatalf("expected 400 unlocking a non-manual assignment, got %d", rec.Code)
|
t.Fatalf("expected 400 unlocking a non-manual assignment, got %d", rec.Code)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Manually unassigning clears the kind back to Unclassified and locks
|
// Manually unassigning clears the kind back to Unclassified and locks
|
||||||
// that decision (source=manual), same as resolving to a specific kind.
|
// that decision (source=manual), same as resolving to a specific kind.
|
||||||
rec = doJSON(t, router, http.MethodPost, "/api/review-queue/"+itoa(activityID)+"/unassign", nil)
|
rec = doJSON(t, router, http.MethodPost, "/api/activities/"+itoa(activityID)+"/unassign", nil)
|
||||||
if rec.Code != http.StatusOK {
|
if rec.Code != http.StatusOK {
|
||||||
t.Fatalf("unassign status = %d, body = %s", rec.Code, rec.Body.String())
|
t.Fatalf("unassign status = %d, body = %s", rec.Code, rec.Body.String())
|
||||||
}
|
}
|
||||||
rec = doJSON(t, router, http.MethodGet, "/api/review-queue/", nil)
|
rec = doJSON(t, router, http.MethodGet, "/api/activities/", nil)
|
||||||
json.Unmarshal(rec.Body.Bytes(), &page)
|
json.Unmarshal(rec.Body.Bytes(), &page)
|
||||||
if page.Items[0]["AssignmentSource"] != store.AssignmentSourceManual {
|
if page.Items[0]["AssignmentSource"] != store.AssignmentSourceManual {
|
||||||
t.Fatalf("expected AssignmentSource=manual after unassign, got %v", page.Items[0]["AssignmentSource"])
|
t.Fatalf("expected AssignmentSource=manual after unassign, got %v", page.Items[0]["AssignmentSource"])
|
||||||
@@ -305,7 +306,7 @@ func TestReviewQueueResolve(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// It also shows up under the Unclassified filter now.
|
// It also shows up under the Unclassified filter now.
|
||||||
rec = doJSON(t, router, http.MethodGet, "/api/review-queue/?unclassified=true", nil)
|
rec = doJSON(t, router, http.MethodGet, "/api/activities/?unclassified=true", nil)
|
||||||
json.Unmarshal(rec.Body.Bytes(), &page)
|
json.Unmarshal(rec.Body.Bytes(), &page)
|
||||||
if len(page.Items) != 1 || page.Total != 1 {
|
if len(page.Items) != 1 || page.Total != 1 {
|
||||||
t.Fatalf("expected unassigned activity to show up as unclassified, got %d items, total=%d", len(page.Items), page.Total)
|
t.Fatalf("expected unassigned activity to show up as unclassified, got %d items, total=%d", len(page.Items), page.Total)
|
||||||
@@ -313,18 +314,18 @@ func TestReviewQueueResolve(t *testing.T) {
|
|||||||
|
|
||||||
// Unassigning locks it, so unlocking works again (reverting to
|
// Unassigning locks it, so unlocking works again (reverting to
|
||||||
// rule_engine sourcing with no kind, i.e. needs_review).
|
// rule_engine sourcing with no kind, i.e. needs_review).
|
||||||
rec = doJSON(t, router, http.MethodPost, "/api/review-queue/"+itoa(activityID)+"/unlock", nil)
|
rec = doJSON(t, router, http.MethodPost, "/api/activities/"+itoa(activityID)+"/unlock", nil)
|
||||||
if rec.Code != http.StatusOK {
|
if rec.Code != http.StatusOK {
|
||||||
t.Fatalf("unlock after unassign status = %d, body = %s", rec.Code, rec.Body.String())
|
t.Fatalf("unlock after unassign status = %d, body = %s", rec.Code, rec.Body.String())
|
||||||
}
|
}
|
||||||
rec = doJSON(t, router, http.MethodGet, "/api/review-queue/", nil)
|
rec = doJSON(t, router, http.MethodGet, "/api/activities/", nil)
|
||||||
json.Unmarshal(rec.Body.Bytes(), &page)
|
json.Unmarshal(rec.Body.Bytes(), &page)
|
||||||
if page.Items[0]["AssignmentSource"] != store.AssignmentSourceRuleEngine {
|
if page.Items[0]["AssignmentSource"] != store.AssignmentSourceRuleEngine {
|
||||||
t.Fatalf("expected AssignmentSource=rule_engine after unlock, got %v", page.Items[0]["AssignmentSource"])
|
t.Fatalf("expected AssignmentSource=rule_engine after unlock, got %v", page.Items[0]["AssignmentSource"])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestReviewQueue_PaginatesByCursor(t *testing.T) {
|
func TestActivities_PaginatesByCursor(t *testing.T) {
|
||||||
s, db, userID := newTestServer(t)
|
s, db, userID := newTestServer(t)
|
||||||
ctx := newCtx()
|
ctx := newCtx()
|
||||||
|
|
||||||
@@ -354,7 +355,7 @@ func TestReviewQueue_PaginatesByCursor(t *testing.T) {
|
|||||||
}
|
}
|
||||||
getPage := func(query string) page {
|
getPage := func(query string) page {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
rec := doJSON(t, router, http.MethodGet, "/api/review-queue/"+query, nil)
|
rec := doJSON(t, router, http.MethodGet, "/api/activities/"+query, nil)
|
||||||
if rec.Code != http.StatusOK {
|
if rec.Code != http.StatusOK {
|
||||||
t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String())
|
t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String())
|
||||||
}
|
}
|
||||||
@@ -402,7 +403,7 @@ func TestReviewQueue_PaginatesByCursor(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestReviewQueue_FiltersByKindAndUnclassifiedStayPaginated(t *testing.T) {
|
func TestActivities_FiltersByKindAndUnclassifiedStayPaginated(t *testing.T) {
|
||||||
s, db, userID := newTestServer(t)
|
s, db, userID := newTestServer(t)
|
||||||
ctx := newCtx()
|
ctx := newCtx()
|
||||||
|
|
||||||
@@ -446,7 +447,7 @@ func TestReviewQueue_FiltersByKindAndUnclassifiedStayPaginated(t *testing.T) {
|
|||||||
}
|
}
|
||||||
getPage := func(query string) page {
|
getPage := func(query string) page {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
rec := doJSON(t, router, http.MethodGet, "/api/review-queue/"+query, nil)
|
rec := doJSON(t, router, http.MethodGet, "/api/activities/"+query, nil)
|
||||||
if rec.Code != http.StatusOK {
|
if rec.Code != http.StatusOK {
|
||||||
t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String())
|
t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String())
|
||||||
}
|
}
|
||||||
@@ -489,7 +490,7 @@ func TestReviewQueue_FiltersByKindAndUnclassifiedStayPaginated(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestResolveReview_RejectsRaceKind(t *testing.T) {
|
func TestAssignActivity_RejectsRaceKind(t *testing.T) {
|
||||||
s, db, userID := newTestServer(t)
|
s, db, userID := newTestServer(t)
|
||||||
ctx := newCtx()
|
ctx := newCtx()
|
||||||
|
|
||||||
@@ -502,9 +503,9 @@ func TestResolveReview_RejectsRaceKind(t *testing.T) {
|
|||||||
t.Fatalf("GetWorkoutKindByName(Race): ok=%v err=%v", ok, err)
|
t.Fatalf("GetWorkoutKindByName(Race): ok=%v err=%v", ok, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
rec := doJSON(t, s.Router(), http.MethodPost, "/api/review-queue/"+itoa(activityID)+"/resolve", map[string]any{"workout_kind_id": raceKind.ID})
|
rec := doJSON(t, s.Router(), http.MethodPost, "/api/activities/"+itoa(activityID)+"/assign", map[string]any{"workout_kind_id": raceKind.ID})
|
||||||
if rec.Code != http.StatusBadRequest {
|
if rec.Code != http.StatusBadRequest {
|
||||||
t.Fatalf("resolve to Race status = %d, want 400, body = %s", rec.Code, rec.Body.String())
|
t.Fatalf("assign to Race status = %d, want 400, body = %s", rec.Code, rec.Body.String())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -575,57 +576,6 @@ func TestReclassifyAll_SkipsManualAndRaceAssignments(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestListActivities_ReportsLockedForManualAndRaceAssignments(t *testing.T) {
|
|
||||||
s, db, userID := newTestServer(t)
|
|
||||||
ctx := newCtx()
|
|
||||||
|
|
||||||
easyID, err := db.CreateWorkoutKind(ctx, userID, store.WorkoutKind{Name: "Test Easy Locked", RuleJSON: `{"match":"all","conditions":[]}`, IsActive: true})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("CreateWorkoutKind: %v", err)
|
|
||||||
}
|
|
||||||
raceKind, ok, err := db.GetWorkoutKindByName(ctx, userID, "Race")
|
|
||||||
if err != nil || !ok {
|
|
||||||
t.Fatalf("GetWorkoutKindByName(Race): ok=%v err=%v", ok, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
ruleEngineActivity, _ := db.UpsertActivity(ctx, userID, store.Activity{GarminActivityID: 1, StartTimeUTC: "2026-07-01 06:00:00", RawJSON: "{}"})
|
|
||||||
manualActivity, _ := db.UpsertActivity(ctx, userID, store.Activity{GarminActivityID: 2, StartTimeUTC: "2026-07-02 06:00:00", RawJSON: "{}"})
|
|
||||||
raceActivity, _ := db.UpsertActivity(ctx, userID, store.Activity{GarminActivityID: 3, EventTypeKey: "race", StartTimeUTC: "2026-07-03 06:00:00", RawJSON: "{}"})
|
|
||||||
|
|
||||||
db.InsertKindAssignment(ctx, userID, store.KindAssignment{ActivityID: ruleEngineActivity, WorkoutKindID: &easyID, AssignmentSource: store.AssignmentSourceRuleEngine, Status: store.AssignmentStatusAssigned, CandidateKindsJSON: "[]"})
|
|
||||||
db.InsertKindAssignment(ctx, userID, store.KindAssignment{ActivityID: manualActivity, WorkoutKindID: &easyID, AssignmentSource: store.AssignmentSourceManual, Status: store.AssignmentStatusAssigned, CandidateKindsJSON: "[]"})
|
|
||||||
db.InsertKindAssignment(ctx, userID, store.KindAssignment{ActivityID: raceActivity, WorkoutKindID: &raceKind.ID, AssignmentSource: store.AssignmentSourceRuleEngine, Status: store.AssignmentStatusAssigned, CandidateKindsJSON: "[]"})
|
|
||||||
|
|
||||||
rec := doJSON(t, s.Router(), http.MethodGet, "/api/activities/", nil)
|
|
||||||
if rec.Code != http.StatusOK {
|
|
||||||
t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String())
|
|
||||||
}
|
|
||||||
var items []activityListItem
|
|
||||||
if err := json.Unmarshal(rec.Body.Bytes(), &items); err != nil {
|
|
||||||
t.Fatalf("unmarshal: %v", err)
|
|
||||||
}
|
|
||||||
locked := map[int64]bool{}
|
|
||||||
kindName := map[int64]string{}
|
|
||||||
for _, it := range items {
|
|
||||||
locked[it.ID] = it.Locked
|
|
||||||
if it.WorkoutKindName != nil {
|
|
||||||
kindName[it.ID] = *it.WorkoutKindName
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if locked[ruleEngineActivity] {
|
|
||||||
t.Errorf("rule-engine activity should not be locked")
|
|
||||||
}
|
|
||||||
if !locked[manualActivity] {
|
|
||||||
t.Errorf("manual activity should be locked")
|
|
||||||
}
|
|
||||||
if !locked[raceActivity] {
|
|
||||||
t.Errorf("race activity should be locked")
|
|
||||||
}
|
|
||||||
if kindName[raceActivity] != "Race" {
|
|
||||||
t.Errorf("race activity workout_kind_name = %q, want %q", kindName[raceActivity], "Race")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSyncReset_DeletesActivitiesAndBackfillEndpointIsGone(t *testing.T) {
|
func TestSyncReset_DeletesActivitiesAndBackfillEndpointIsGone(t *testing.T) {
|
||||||
s, db, userID := newTestServer(t)
|
s, db, userID := newTestServer(t)
|
||||||
ctx := newCtx()
|
ctx := newCtx()
|
||||||
@@ -635,7 +585,7 @@ func TestSyncReset_DeletesActivitiesAndBackfillEndpointIsGone(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
router := s.Router()
|
router := s.Router()
|
||||||
rec := doJSON(t, router, http.MethodPost, "/api/sync/reset", nil)
|
rec := doJSON(t, router, http.MethodPost, "/api/garmin/sync/reset", nil)
|
||||||
if rec.Code != http.StatusAccepted {
|
if rec.Code != http.StatusAccepted {
|
||||||
t.Fatalf("reset status = %d, body = %s", rec.Code, rec.Body.String())
|
t.Fatalf("reset status = %d, body = %s", rec.Code, rec.Body.String())
|
||||||
}
|
}
|
||||||
@@ -656,9 +606,59 @@ func TestSyncReset_DeletesActivitiesAndBackfillEndpointIsGone(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// "Full backfill" no longer exists as an endpoint -- superseded by reset.
|
// "Full backfill" no longer exists as an endpoint -- superseded by reset.
|
||||||
rec = doJSON(t, router, http.MethodPost, "/api/sync/backfill", nil)
|
rec = doJSON(t, router, http.MethodPost, "/api/garmin/sync/backfill", nil)
|
||||||
if rec.Code != http.StatusNotFound {
|
if rec.Code != http.StatusNotFound {
|
||||||
t.Errorf("/api/sync/backfill status = %d, want 404 (removed)", rec.Code)
|
t.Errorf("/api/garmin/sync/backfill status = %d, want 404 (removed)", rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSyncStatus_ReportsPhaseAwareProgressAndWorkoutsPending(t *testing.T) {
|
||||||
|
s, db, userID := newTestServer(t)
|
||||||
|
ctx := newCtx()
|
||||||
|
|
||||||
|
workoutID := int64(555)
|
||||||
|
activityID, err := db.UpsertActivity(ctx, userID, store.Activity{
|
||||||
|
GarminActivityID: 1, WorkoutID: &workoutID,
|
||||||
|
StartTimeUTC: "2026-07-11 06:00:00", RawJSON: "{}",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("UpsertActivity: %v", err)
|
||||||
|
}
|
||||||
|
// ActivitiesMissingWorkout only surfaces activities whose details have
|
||||||
|
// already been fetched (see store.ActivitiesMissingWorkout) -- an
|
||||||
|
// activity is only eligible for a workout fetch once fillActivityDetails
|
||||||
|
// has given it laps to align target pace/HR bands against.
|
||||||
|
if err := db.SetActivityDetails(ctx, userID, activityID, "{}"); err != nil {
|
||||||
|
t.Fatalf("SetActivityDetails: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
rec := doJSON(t, s.Router(), http.MethodGet, "/api/garmin/sync/status", nil)
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
var resp struct {
|
||||||
|
InProgress bool `json:"in_progress"`
|
||||||
|
Progress struct {
|
||||||
|
Phase string `json:"Phase"`
|
||||||
|
Done int `json:"Done"`
|
||||||
|
Total int `json:"Total"`
|
||||||
|
} `json:"progress"`
|
||||||
|
ActivitiesPendingDetails int `json:"activities_pending_details"`
|
||||||
|
WorkoutsPending int `json:"workouts_pending"`
|
||||||
|
}
|
||||||
|
unmarshalBody(t, rec, &resp)
|
||||||
|
|
||||||
|
if resp.InProgress {
|
||||||
|
t.Error("in_progress = true, want false (nothing running)")
|
||||||
|
}
|
||||||
|
if resp.Progress.Phase != "idle" {
|
||||||
|
t.Errorf("progress.Phase = %q, want %q", resp.Progress.Phase, "idle")
|
||||||
|
}
|
||||||
|
if resp.ActivitiesPendingDetails != 1 {
|
||||||
|
t.Errorf("activities_pending_details = %d, want 1", resp.ActivitiesPendingDetails)
|
||||||
|
}
|
||||||
|
if resp.WorkoutsPending != 1 {
|
||||||
|
t.Errorf("workouts_pending = %d, want 1", resp.WorkoutsPending)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -716,7 +716,7 @@ func itoa(v int64) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestProfile_GetDefaultsThenUpdate(t *testing.T) {
|
func TestProfile_GetDefaultsThenUpdate(t *testing.T) {
|
||||||
s, _, _ := newTestServer(t)
|
s, db, _ := newTestServer(t)
|
||||||
router := s.Router()
|
router := s.Router()
|
||||||
|
|
||||||
rec := doJSON(t, router, http.MethodGet, "/api/profile", nil)
|
rec := doJSON(t, router, http.MethodGet, "/api/profile", nil)
|
||||||
@@ -734,10 +734,18 @@ func TestProfile_GetDefaultsThenUpdate(t *testing.T) {
|
|||||||
got.GarminEmail = "runner@example.com"
|
got.GarminEmail = "runner@example.com"
|
||||||
got.GarminPassword = "hunter2"
|
got.GarminPassword = "hunter2"
|
||||||
got.RollingWindowDays = 120
|
got.RollingWindowDays = 120
|
||||||
rec = doJSON(t, router, http.MethodPut, "/api/profile", got)
|
// Name rides along in the profile payload but lives on the users row.
|
||||||
|
rec = doJSON(t, router, http.MethodPut, "/api/profile", struct {
|
||||||
|
store.Profile
|
||||||
|
Name string
|
||||||
|
}{got, "Renamed Runner"})
|
||||||
if rec.Code != http.StatusOK {
|
if rec.Code != http.StatusOK {
|
||||||
t.Fatalf("put status = %d, body = %s", rec.Code, rec.Body.String())
|
t.Fatalf("put status = %d, body = %s", rec.Code, rec.Body.String())
|
||||||
}
|
}
|
||||||
|
u, found, err := db.GetUserBySub(newCtx(), "test-user")
|
||||||
|
if err != nil || !found || u.Name != "Renamed Runner" {
|
||||||
|
t.Fatalf("users.name after profile PUT = %q (found=%v err=%v), want Renamed Runner", u.Name, found, err)
|
||||||
|
}
|
||||||
|
|
||||||
rec = doJSON(t, router, http.MethodGet, "/api/profile", nil)
|
rec = doJSON(t, router, http.MethodGet, "/api/profile", nil)
|
||||||
var updated store.Profile
|
var updated store.Profile
|
||||||
@@ -765,22 +773,72 @@ func TestProfile_RejectsInvalidHRZones(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestSessionMe_ReportsGarminConnectedAfterSuccessfulAuth(t *testing.T) {
|
||||||
|
s, _, _ := newTestServer(t)
|
||||||
|
router := s.Router()
|
||||||
|
|
||||||
|
rec := doJSON(t, router, http.MethodGet, "/api/session/me", nil)
|
||||||
|
var before sessionMeResponse
|
||||||
|
unmarshalBody(t, rec, &before)
|
||||||
|
if before.GarminConnected {
|
||||||
|
t.Fatal("expected a fresh account to report garmin_connected=false")
|
||||||
|
}
|
||||||
|
|
||||||
|
rec = doJSON(t, router, http.MethodPost, "/api/garmin/auth/login", nil) // garmin.MockClient defaults to AuthSuccess
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("auth/login status = %d, body = %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
rec = doJSON(t, router, http.MethodGet, "/api/session/me", nil)
|
||||||
|
var after sessionMeResponse
|
||||||
|
unmarshalBody(t, rec, &after)
|
||||||
|
if !after.GarminConnected {
|
||||||
|
t.Fatal("expected garmin_connected=true after a successful auth")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSessionMe_GarminConnectedStaysFalseOnMFARequiredOrFailed(t *testing.T) {
|
||||||
|
s, _, userID := newTestServer(t)
|
||||||
|
client, err := s.clientFor(context.Background(), userID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("garminFor: %v", err)
|
||||||
|
}
|
||||||
|
mockClient, ok := client.(*garmin.MockClient)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("expected *garmin.MockClient, got %T", client)
|
||||||
|
}
|
||||||
|
mockClient.AuthResults = []garmin.AuthResult{{Status: garmin.AuthMFARequired, Message: "MFA required"}}
|
||||||
|
|
||||||
|
router := s.Router()
|
||||||
|
rec := doJSON(t, router, http.MethodPost, "/api/garmin/auth/login", nil)
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("auth/login status = %d, body = %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
rec = doJSON(t, router, http.MethodGet, "/api/session/me", nil)
|
||||||
|
var me sessionMeResponse
|
||||||
|
unmarshalBody(t, rec, &me)
|
||||||
|
if me.GarminConnected {
|
||||||
|
t.Fatal("expected garmin_connected=false after mfa_required")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestDeleteProfile_DeletesUserAndTerminatesGarminClient(t *testing.T) {
|
func TestDeleteProfile_DeletesUserAndTerminatesGarminClient(t *testing.T) {
|
||||||
s, db, userID := newTestServer(t)
|
s, db, userID := newTestServer(t)
|
||||||
router := s.Router()
|
router := s.Router()
|
||||||
|
|
||||||
// Force the per-user Garmin client to be built and cached.
|
// Force the per-user Garmin client to be built and cached.
|
||||||
rec := doJSON(t, router, http.MethodPost, "/api/auth/login", nil)
|
rec := doJSON(t, router, http.MethodPost, "/api/garmin/auth/login", nil)
|
||||||
if rec.Code != http.StatusOK {
|
if rec.Code != http.StatusOK {
|
||||||
t.Fatalf("auth/login status = %d, body = %s", rec.Code, rec.Body.String())
|
t.Fatalf("auth/login status = %d, body = %s", rec.Code, rec.Body.String())
|
||||||
}
|
}
|
||||||
client, err := s.garminFor(context.Background(), userID)
|
client, err := s.clientFor(context.Background(), userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("garminFor: %v", err)
|
t.Fatalf("garminFor: %v", err)
|
||||||
}
|
}
|
||||||
mockClient, ok := client.(*mock.Client)
|
mockClient, ok := client.(*garmin.MockClient)
|
||||||
if !ok {
|
if !ok {
|
||||||
t.Fatalf("expected *mock.Client, got %T", client)
|
t.Fatalf("expected *garmin.MockClient, got %T", client)
|
||||||
}
|
}
|
||||||
|
|
||||||
rec = doJSON(t, router, http.MethodDelete, "/api/profile", nil)
|
rec = doJSON(t, router, http.MethodDelete, "/api/profile", nil)
|
||||||
@@ -831,9 +889,9 @@ func TestDeleteProfile_RemovesTokenStoreDirectory(t *testing.T) {
|
|||||||
t.Fatalf("WriteFile: %v", err)
|
t.Fatalf("WriteFile: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
m := &mock.Client{}
|
m := &garmin.MockClient{}
|
||||||
garminFactory := func(garmin.Config) garmin.Client { return m }
|
garminFactory := func(garmin.ClientConfig) garmin.Client { return m }
|
||||||
s := NewServer(db, garminFactory, garmin.Config{TokenStorePath: tokenStoreRoot}, appsync.Config{}, &authmock.Verifier{}, testSessionConfig)
|
s := NewServer(db, garminFactory, garmin.ClientConfig{TokenStorePath: tokenStoreRoot}, garmin.SyncConfig{}, &authmock.Verifier{}, testSessionConfig)
|
||||||
|
|
||||||
rec := doJSON(t, s.Router(), http.MethodDelete, "/api/profile", nil)
|
rec := doJSON(t, s.Router(), http.MethodDelete, "/api/profile", nil)
|
||||||
if rec.Code != http.StatusNoContent {
|
if rec.Code != http.StatusNoContent {
|
||||||
@@ -1024,7 +1082,8 @@ func TestSessionLogout_PassesIDTokenHintFromSessionCookie(t *testing.T) {
|
|||||||
s, _ := newTestServerWithAuth(t, verifier)
|
s, _ := newTestServerWithAuth(t, verifier)
|
||||||
|
|
||||||
cookie, err := auth.MintSessionCookie(
|
cookie, err := auth.MintSessionCookie(
|
||||||
auth.Claims{Sub: "test-user", Name: "Test User", Email: "test@example.com", IDToken: "raw-id-token-jwt"},
|
auth.Claims{Sub: "test-user", Name: "Test User", Email: "test@example.com"},
|
||||||
|
"raw-id-token-jwt", // travels in the cookie apart from Claims
|
||||||
testSessionConfig.Secret, testSessionConfig.Duration, testSessionConfig.Secure,
|
testSessionConfig.Secret, testSessionConfig.Duration, testSessionConfig.Secure,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -1050,8 +1109,9 @@ func TestSessionLogout_PassesIDTokenHintFromSessionCookie(t *testing.T) {
|
|||||||
func TestSessionCallback_MintsSessionCookieCarryingIDToken(t *testing.T) {
|
func TestSessionCallback_MintsSessionCookieCarryingIDToken(t *testing.T) {
|
||||||
verifier := &authmock.Verifier{
|
verifier := &authmock.Verifier{
|
||||||
CallbackResult: auth.LoginResult{
|
CallbackResult: auth.LoginResult{
|
||||||
Claims: auth.Claims{Sub: "u1", Name: "Alice", Email: "alice@example.com", IDToken: "raw-id-token-jwt"},
|
Claims: auth.Claims{Sub: "u1", Name: "Alice", Email: "alice@example.com"},
|
||||||
Authorized: true,
|
Authorized: true,
|
||||||
|
IDToken: "raw-id-token-jwt",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
s, _ := newTestServerWithAuth(t, verifier)
|
s, _ := newTestServerWithAuth(t, verifier)
|
||||||
@@ -1074,11 +1134,73 @@ func TestSessionCallback_MintsSessionCookieCarryingIDToken(t *testing.T) {
|
|||||||
if sessionCookie == nil {
|
if sessionCookie == nil {
|
||||||
t.Fatal("expected a session cookie to be set")
|
t.Fatal("expected a session cookie to be set")
|
||||||
}
|
}
|
||||||
claims, err := auth.ParseSessionCookie(sessionCookie, testSessionConfig.Secret)
|
idToken, err := auth.IDTokenFromSessionCookie(sessionCookie, testSessionConfig.Secret)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("parse session cookie: %v", err)
|
t.Fatalf("read id token from session cookie: %v", err)
|
||||||
}
|
}
|
||||||
if claims.IDToken != "raw-id-token-jwt" {
|
if idToken != "raw-id-token-jwt" {
|
||||||
t.Errorf("claims.IDToken = %q, want %q", claims.IDToken, "raw-id-token-jwt")
|
t.Errorf("cookie id token = %q, want %q", idToken, "raw-id-token-jwt")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRequestLoggingMiddleware_LogsMethodPathStatusDuration(t *testing.T) {
|
||||||
|
s, _, _ := newTestServer(t)
|
||||||
|
var buf bytes.Buffer
|
||||||
|
prev := slog.Default()
|
||||||
|
slog.SetDefault(applog.NewLogger("info", &buf))
|
||||||
|
defer slog.SetDefault(prev)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/health", nil)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
s.Router().ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
var entry map[string]any
|
||||||
|
if err := json.Unmarshal(buf.Bytes(), &entry); err != nil {
|
||||||
|
t.Fatalf("log output is not valid JSON: %v (%q)", err, buf.String())
|
||||||
|
}
|
||||||
|
if entry["type"] != "http" || entry["package"] != "api" || entry["file"] != "server.go" || entry["method"] != "loggingMiddleware" {
|
||||||
|
t.Errorf("schema fields = %v, want type=http package=api file=server.go method=loggingMiddleware", entry)
|
||||||
|
}
|
||||||
|
if _, hasMsg := entry["msg"]; hasMsg {
|
||||||
|
t.Errorf("expected no msg on the http record, got %v", entry["msg"])
|
||||||
|
}
|
||||||
|
if entry["http_method"] != "GET" || entry["path"] != "/api/health" {
|
||||||
|
t.Errorf("http_method/path = %v/%v, want GET//api/health", entry["http_method"], entry["path"])
|
||||||
|
}
|
||||||
|
if entry["status"] != float64(http.StatusOK) {
|
||||||
|
t.Errorf("status = %v, want 200", entry["status"])
|
||||||
|
}
|
||||||
|
if _, ok := entry["duration_ms"]; !ok {
|
||||||
|
t.Error("expected a duration_ms field")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRequestLoggingMiddleware_5xxLogsAtWarnLevel(t *testing.T) {
|
||||||
|
s, db, _ := newTestServer(t)
|
||||||
|
db.Close() // force a downstream DB call to fail with a 500
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
prev := slog.Default()
|
||||||
|
slog.SetDefault(applog.NewLogger("info", &buf))
|
||||||
|
defer slog.SetDefault(prev)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/profile", nil)
|
||||||
|
cookie, err := auth.MintSessionCookie(auth.Claims{Sub: "test-user", Name: "Test User", Email: "test@example.com"}, "", testSessionConfig.Secret, testSessionConfig.Duration, testSessionConfig.Secure)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("mint session cookie: %v", err)
|
||||||
|
}
|
||||||
|
req.AddCookie(cookie)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
s.Router().ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusInternalServerError {
|
||||||
|
t.Fatalf("expected the request itself to 500 after closing the DB, got %d", rec.Code)
|
||||||
|
}
|
||||||
|
var entry map[string]any
|
||||||
|
if err := json.Unmarshal(buf.Bytes(), &entry); err != nil {
|
||||||
|
t.Fatalf("log output is not valid JSON: %v (%q)", err, buf.String())
|
||||||
|
}
|
||||||
|
if entry["level"] != "WARN" {
|
||||||
|
t.Errorf("level = %v, want WARN for a 5xx response", entry["level"])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,85 +0,0 @@
|
|||||||
package api
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"net/http"
|
|
||||||
|
|
||||||
"geniusrun/backend/internal/garmin"
|
|
||||||
)
|
|
||||||
|
|
||||||
type authResponse struct {
|
|
||||||
Status string `json:"status"` // "authenticated" | "mfa_required" | "failed"
|
|
||||||
Message string `json:"message"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func authStatusString(s garmin.AuthStatus) string {
|
|
||||||
switch s {
|
|
||||||
case garmin.AuthSuccess:
|
|
||||||
return "authenticated"
|
|
||||||
case garmin.AuthMFARequired:
|
|
||||||
return "mfa_required"
|
|
||||||
case garmin.AuthFailed:
|
|
||||||
return "failed"
|
|
||||||
default:
|
|
||||||
return "unknown"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Server) recordAuthResult(userID int64, res garmin.AuthResult) {
|
|
||||||
s.mu.Lock()
|
|
||||||
s.userAuthStatus[userID] = res.Status
|
|
||||||
s.userAuthMessage[userID] = res.Message
|
|
||||||
s.mu.Unlock()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Server) handleAuthLogin(w http.ResponseWriter, r *http.Request) {
|
|
||||||
userID := userIDFromContext(r.Context())
|
|
||||||
client, err := s.garminFor(r.Context(), userID)
|
|
||||||
if err != nil {
|
|
||||||
writeError(w, http.StatusInternalServerError, err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
res, err := client.Authenticate(r.Context())
|
|
||||||
if err != nil {
|
|
||||||
writeError(w, http.StatusBadGateway, err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
s.recordAuthResult(userID, res)
|
|
||||||
writeJSON(w, http.StatusOK, authResponse{Status: authStatusString(res.Status), Message: res.Message})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Server) handleAuthMFA(w http.ResponseWriter, r *http.Request) {
|
|
||||||
userID := userIDFromContext(r.Context())
|
|
||||||
var body struct {
|
|
||||||
Code string `json:"code"`
|
|
||||||
}
|
|
||||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
|
||||||
writeError(w, http.StatusBadRequest, "invalid request body")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if body.Code == "" {
|
|
||||||
writeError(w, http.StatusBadRequest, "code is required")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
client, err := s.garminFor(r.Context(), userID)
|
|
||||||
if err != nil {
|
|
||||||
writeError(w, http.StatusInternalServerError, err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
res, err := client.CompleteMFA(r.Context(), body.Code)
|
|
||||||
if err != nil {
|
|
||||||
writeError(w, http.StatusBadGateway, err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
s.recordAuthResult(userID, res)
|
|
||||||
writeJSON(w, http.StatusOK, authResponse{Status: authStatusString(res.Status), Message: res.Message})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Server) handleAuthStatus(w http.ResponseWriter, r *http.Request) {
|
|
||||||
userID := userIDFromContext(r.Context())
|
|
||||||
s.mu.Lock()
|
|
||||||
status, msg := s.userAuthStatus[userID], s.userAuthMessage[userID]
|
|
||||||
s.mu.Unlock()
|
|
||||||
writeJSON(w, http.StatusOK, authResponse{Status: authStatusString(status), Message: msg})
|
|
||||||
}
|
|
||||||
92
backend/internal/api/config.go
Normal file
92
backend/internal/api/config.go
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"geniusrun/backend/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
// EnvVar is one read-only environment-configuration entry, already
|
||||||
|
// display-safe (masking happens in config.Config.DisplayEnv).
|
||||||
|
type EnvVar struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Value string `json:"value"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type configEntry struct {
|
||||||
|
Key string `json:"key"`
|
||||||
|
Value string `json:"value"`
|
||||||
|
Default string `json:"default"`
|
||||||
|
Overridden bool `json:"overridden"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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) {
|
||||||
|
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, ok := values[k.Key]
|
||||||
|
if !ok {
|
||||||
|
value = k.Default
|
||||||
|
}
|
||||||
|
app = append(app, configEntry{
|
||||||
|
Key: k.Key, Value: value, Default: k.Default,
|
||||||
|
Overridden: value != k.Default, Description: k.Description,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
envVars := s.EnvVars
|
||||||
|
if envVars == nil {
|
||||||
|
envVars = []EnvVar{}
|
||||||
|
}
|
||||||
|
return map[string]any{"application": app, "environment": envVars}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleGetConfig(w http.ResponseWriter, r *http.Request) {
|
||||||
|
resp, err := s.configView(r)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handlePutConfig updates application configuration. Cold: saved values
|
||||||
|
// take effect on the next backend restart; the response is just the
|
||||||
|
// refreshed view, same shape as GET.
|
||||||
|
func (s *Server) handlePutConfig(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var body map[string]string
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid request body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Validate every pair before writing anything -- all-or-nothing.
|
||||||
|
for key, value := range body {
|
||||||
|
if err := config.ValidateAppValue(key, value); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for key, value := range body {
|
||||||
|
if err := s.DB.SetConfigValue(r.Context(), key, value); err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
resp, err := s.configView(r)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, resp)
|
||||||
|
}
|
||||||
111
backend/internal/api/config_test.go
Normal file
111
backend/internal/api/config_test.go
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
authmock "geniusrun/backend/internal/auth/mock"
|
||||||
|
"geniusrun/backend/internal/garmin"
|
||||||
|
"geniusrun/backend/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
type configTestResponse struct {
|
||||||
|
Application []struct {
|
||||||
|
Key string `json:"key"`
|
||||||
|
Value string `json:"value"`
|
||||||
|
Default string `json:"default"`
|
||||||
|
Overridden bool `json:"overridden"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
} `json:"application"`
|
||||||
|
Environment []struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Value string `json:"value"`
|
||||||
|
} `json:"environment"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConfig_GetDefaultsAndEnvSnapshot(t *testing.T) {
|
||||||
|
s, _, _ := newTestServer(t)
|
||||||
|
s.EnvVars = []EnvVar{{Name: "GENIUSRUN_OIDC_CLIENT_SECRET", Value: "•••• (set)"}}
|
||||||
|
|
||||||
|
rec := doJSON(t, s.Router(), http.MethodGet, "/api/config", nil)
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
var resp configTestResponse
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||||
|
t.Fatalf("unmarshal: %v", err)
|
||||||
|
}
|
||||||
|
if len(resp.Application) != 2 {
|
||||||
|
t.Fatalf("expected 2 app-config entries, got %d: %+v", len(resp.Application), resp.Application)
|
||||||
|
}
|
||||||
|
byKey := map[string]struct {
|
||||||
|
value, def string
|
||||||
|
overridden bool
|
||||||
|
}{}
|
||||||
|
for _, e := range resp.Application {
|
||||||
|
if e.Description == "" {
|
||||||
|
t.Errorf("entry %q has no description", e.Key)
|
||||||
|
}
|
||||||
|
byKey[e.Key] = struct {
|
||||||
|
value, def string
|
||||||
|
overridden bool
|
||||||
|
}{e.Value, e.Default, e.Overridden}
|
||||||
|
}
|
||||||
|
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.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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConfig_PutPersistsValidatesAllOrNothing(t *testing.T) {
|
||||||
|
s, _, _ := newTestServer(t)
|
||||||
|
router := s.Router()
|
||||||
|
|
||||||
|
rec := doJSON(t, router, http.MethodPut, "/api/config", map[string]string{"session.duration": "168"})
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("valid PUT status = %d, body = %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
var resp configTestResponse
|
||||||
|
json.Unmarshal(rec.Body.Bytes(), &resp)
|
||||||
|
if resp.Application[0].Value != "168" || !resp.Application[0].Overridden {
|
||||||
|
t.Fatalf("override not reflected: %+v", resp.Application[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
if rec = doJSON(t, router, http.MethodPut, "/api/config", map[string]string{"bogus.key": "1"}); rec.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("unknown key status = %d, want 400", rec.Code)
|
||||||
|
}
|
||||||
|
rec = doJSON(t, router, http.MethodPut, "/api/config", map[string]string{"session.duration": "zero"})
|
||||||
|
if rec.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("invalid value status = %d, want 400", rec.Code)
|
||||||
|
}
|
||||||
|
rec = doJSON(t, router, http.MethodGet, "/api/config", nil)
|
||||||
|
json.Unmarshal(rec.Body.Bytes(), &resp)
|
||||||
|
if resp.Application[0].Value != "168" {
|
||||||
|
t.Fatalf("rejected PUT still changed the value: %+v", resp.Application[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Same gate as every data route: an unprovisioned session gets 403, per
|
||||||
|
// the repo's adversarial-isolation testing convention.
|
||||||
|
func TestConfig_RequiresProvisionedUser(t *testing.T) {
|
||||||
|
db, err := store.Open(t.TempDir() + "/config_gate_test.db")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("store.Open: %v", err)
|
||||||
|
}
|
||||||
|
defer db.Close()
|
||||||
|
m := &garmin.MockClient{}
|
||||||
|
s := NewServer(db, func(garmin.ClientConfig) garmin.Client { return m }, garmin.ClientConfig{}, garmin.SyncConfig{}, &authmock.Verifier{}, testSessionConfig)
|
||||||
|
|
||||||
|
if rec := doJSON(t, s.Router(), http.MethodGet, "/api/config", nil); rec.Code != http.StatusForbidden {
|
||||||
|
t.Fatalf("GET status = %d, want 403", rec.Code)
|
||||||
|
}
|
||||||
|
if rec := doJSON(t, s.Router(), http.MethodPut, "/api/config", map[string]string{"session.duration": "1"}); rec.Code != http.StatusForbidden {
|
||||||
|
t.Fatalf("PUT status = %d, want 403", rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
200
backend/internal/api/garmin.go
Normal file
200
backend/internal/api/garmin.go
Normal file
@@ -0,0 +1,200 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"geniusrun/backend/internal/garmin"
|
||||||
|
applog "geniusrun/backend/internal/log"
|
||||||
|
)
|
||||||
|
|
||||||
|
// detailFillBatchSize bounds how many activities' details/splits are fetched
|
||||||
|
// per sync trigger, matching the sequential rate-limited fetch in
|
||||||
|
// internal/sync.Service.FillPendingDetails.
|
||||||
|
const detailFillBatchSize = 50
|
||||||
|
|
||||||
|
type authResponse struct {
|
||||||
|
Status string `json:"status"` // "authenticated" | "mfa_required" | "failed"
|
||||||
|
Message string `json:"message"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func authStatusString(s garmin.AuthStatus) string {
|
||||||
|
switch s {
|
||||||
|
case garmin.AuthSuccess:
|
||||||
|
return "authenticated"
|
||||||
|
case garmin.AuthMFARequired:
|
||||||
|
return "mfa_required"
|
||||||
|
case garmin.AuthFailed:
|
||||||
|
return "failed"
|
||||||
|
default:
|
||||||
|
return "unknown"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// recordAuthResult updates the in-memory auth status/message for userID,
|
||||||
|
// and -- on a successful authentication -- persists that this account has
|
||||||
|
// connected to Garmin at least once (store.MarkGarminConnected), which is
|
||||||
|
// what the login gate actually checks (the in-memory auth status resets on
|
||||||
|
// every backend restart; this doesn't).
|
||||||
|
func (s *Server) recordAuthResult(ctx context.Context, userID int64, res garmin.AuthResult) {
|
||||||
|
s.mu.Lock()
|
||||||
|
s.userAuthStatus[userID] = res.Status
|
||||||
|
s.userAuthMessage[userID] = res.Message
|
||||||
|
s.mu.Unlock()
|
||||||
|
|
||||||
|
if res.Status == garmin.AuthSuccess {
|
||||||
|
if err := s.DB.MarkGarminConnected(ctx, userID); err != nil {
|
||||||
|
applog.App().Error("mark garmin connected", "user_id", userID, "error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleGarminAuthLogin(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userID := userIDFromContext(r.Context())
|
||||||
|
client, err := s.clientFor(r.Context(), userID)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
res, err := client.Authenticate(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusBadGateway, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.recordAuthResult(r.Context(), userID, res)
|
||||||
|
writeJSON(w, http.StatusOK, authResponse{Status: authStatusString(res.Status), Message: res.Message})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleGarminAuthMFA(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userID := userIDFromContext(r.Context())
|
||||||
|
var body struct {
|
||||||
|
Code string `json:"code"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid request body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if body.Code == "" {
|
||||||
|
writeError(w, http.StatusBadRequest, "code is required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
client, err := s.clientFor(r.Context(), userID)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
res, err := client.CompleteMFA(r.Context(), body.Code)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusBadGateway, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.recordAuthResult(r.Context(), userID, res)
|
||||||
|
writeJSON(w, http.StatusOK, authResponse{Status: authStatusString(res.Status), Message: res.Message})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleGarminAuthStatus(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userID := userIDFromContext(r.Context())
|
||||||
|
s.mu.Lock()
|
||||||
|
status, msg := s.userAuthStatus[userID], s.userAuthMessage[userID]
|
||||||
|
s.mu.Unlock()
|
||||||
|
writeJSON(w, http.StatusOK, authResponse{Status: authStatusString(status), Message: msg})
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleGarminSyncRun does a full sync pass: Backfill first (resumes from the
|
||||||
|
// watermark, so widening the configured history horizon between clicks is
|
||||||
|
// picked up automatically), then IncrementalSync (catches anything new since
|
||||||
|
// the latest known activity), then fills in details for whatever's still
|
||||||
|
// missing them. Activities already fully processed are left untouched --
|
||||||
|
// see internal/sync.Service.FillPendingDetails. Recorded as a single
|
||||||
|
// FullSync run so "last sync" reports the combined activity count, not just
|
||||||
|
// whichever of Backfill/IncrementalSync happened to finish last.
|
||||||
|
func (s *Server) handleGarminSyncRun(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userID := userIDFromContext(r.Context())
|
||||||
|
svc, err := s.syncFor(r.Context(), userID)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ok := s.backgroundSync(userID, func(ctx context.Context) error {
|
||||||
|
return svc.FullSync(ctx, detailFillBatchSize)
|
||||||
|
})
|
||||||
|
if !ok {
|
||||||
|
writeError(w, http.StatusConflict, "a sync is already in progress")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusAccepted, map[string]string{"status": "started"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleGarminSyncReset wipes every synced activity (and its laps/samples/kind
|
||||||
|
// assignments) and rewinds the backfill watermark, so the next Sync Now
|
||||||
|
// performs a genuinely fresh pull from Garmin. Destructive -- the frontend
|
||||||
|
// gates this behind a confirmation.
|
||||||
|
func (s *Server) handleGarminSyncReset(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userID := userIDFromContext(r.Context())
|
||||||
|
svc, err := s.syncFor(r.Context(), userID)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ok := s.backgroundSync(userID, func(ctx context.Context) error {
|
||||||
|
return svc.ResetAll(ctx)
|
||||||
|
})
|
||||||
|
if !ok {
|
||||||
|
writeError(w, http.StatusConflict, "a sync is already in progress")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusAccepted, map[string]string{"status": "started"})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleGarminSyncRuns(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userID := userIDFromContext(r.Context())
|
||||||
|
runs, err := s.DB.ListSyncRuns(r.Context(), userID, 20)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, runs)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleGarminSyncStatus(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userID := userIDFromContext(r.Context())
|
||||||
|
run, ok, err := s.DB.LatestSyncRun(r.Context(), userID)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
pendingDetails, err := s.DB.CountActivitiesMissingDetails(r.Context(), userID)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
pendingWorkouts, err := s.DB.CountActivitiesMissingWorkout(r.Context(), userID)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
s.mu.Lock()
|
||||||
|
inProgress := s.userSyncRunning[userID]
|
||||||
|
s.mu.Unlock()
|
||||||
|
|
||||||
|
svc, err := s.syncFor(r.Context(), userID)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
progress := svc.Progress()
|
||||||
|
|
||||||
|
resp := map[string]any{
|
||||||
|
"in_progress": inProgress,
|
||||||
|
"progress": progress,
|
||||||
|
"activities_pending_details": pendingDetails,
|
||||||
|
"workouts_pending": pendingWorkouts,
|
||||||
|
}
|
||||||
|
if ok {
|
||||||
|
resp["last_run"] = run
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, resp)
|
||||||
|
}
|
||||||
@@ -10,9 +10,7 @@ import (
|
|||||||
"geniusrun/backend/internal/auth"
|
"geniusrun/backend/internal/auth"
|
||||||
authmock "geniusrun/backend/internal/auth/mock"
|
authmock "geniusrun/backend/internal/auth/mock"
|
||||||
"geniusrun/backend/internal/garmin"
|
"geniusrun/backend/internal/garmin"
|
||||||
"geniusrun/backend/internal/garmin/mock"
|
|
||||||
"geniusrun/backend/internal/store"
|
"geniusrun/backend/internal/store"
|
||||||
appsync "geniusrun/backend/internal/sync"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// doJSONAs is doJSON but for an explicit session Sub, for tests that need
|
// doJSONAs is doJSON but for an explicit session Sub, for tests that need
|
||||||
@@ -33,7 +31,7 @@ func doJSONAs(t *testing.T, handler http.Handler, sub, method, path string, body
|
|||||||
}
|
}
|
||||||
req := httptest.NewRequest(method, path, reader)
|
req := httptest.NewRequest(method, path, reader)
|
||||||
req.Header.Set("Content-Type", "application/json")
|
req.Header.Set("Content-Type", "application/json")
|
||||||
cookie, err := auth.MintSessionCookie(auth.Claims{Sub: sub, Name: sub, Email: sub + "@example.com"}, testSessionConfig.Secret, testSessionConfig.Duration, testSessionConfig.Secure)
|
cookie, err := auth.MintSessionCookie(auth.Claims{Sub: sub, Name: sub, Email: sub + "@example.com"}, "", testSessionConfig.Secret, testSessionConfig.Duration, testSessionConfig.Secure)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("mint test session cookie: %v", err)
|
t.Fatalf("mint test session cookie: %v", err)
|
||||||
}
|
}
|
||||||
@@ -43,7 +41,7 @@ func doJSONAs(t *testing.T, handler http.Handler, sub, method, path string, body
|
|||||||
return rec
|
return rec
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestIsolation_ActivityDetailNotVisibleToOtherUser(t *testing.T) {
|
func TestIsolation_AssignCannotTargetOtherUsersActivity(t *testing.T) {
|
||||||
s, db, _ := newTestServer(t) // provisions "test-user" (userA)
|
s, db, _ := newTestServer(t) // provisions "test-user" (userA)
|
||||||
userB, err := db.ProvisionUser(newCtx(), "user-b", "B")
|
userB, err := db.ProvisionUser(newCtx(), "user-b", "B")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -58,21 +56,26 @@ func TestIsolation_ActivityDetailNotVisibleToOtherUser(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("UpsertActivity: %v", err)
|
t.Fatalf("UpsertActivity: %v", err)
|
||||||
}
|
}
|
||||||
_ = userB
|
|
||||||
|
|
||||||
router := s.Router()
|
kindsB, err := db.ListWorkoutKinds(newCtx(), userB, false)
|
||||||
|
if err != nil || len(kindsB) == 0 {
|
||||||
// userA (the default doJSON identity) can see it.
|
t.Fatalf("ListWorkoutKinds(b): len=%d err=%v", len(kindsB), err)
|
||||||
rec := doJSON(t, router, http.MethodGet, "/api/activities/"+itoa(activityID), nil)
|
}
|
||||||
if rec.Code != http.StatusOK {
|
kindB := kindsB[0]
|
||||||
t.Fatalf("userA GetActivity status = %d, body = %s", rec.Code, rec.Body.String())
|
if kindB.Name == "Race" { // Race is rejected before the ownership check
|
||||||
|
kindB = kindsB[1]
|
||||||
}
|
}
|
||||||
|
|
||||||
// userB, given the exact same activity id, gets 404 -- not another
|
// userB, given userA's real activity id (and a kind userB legitimately
|
||||||
// user's data, and not a 500 that would leak existence either way.
|
// owns), must not be able to write an assignment onto it.
|
||||||
rec = doJSONAs(t, router, "user-b", http.MethodGet, "/api/activities/"+itoa(activityID), nil)
|
rec := doJSONAs(t, s.Router(), "user-b", http.MethodPost, "/api/activities/"+itoa(activityID)+"/assign", map[string]any{"workout_kind_id": kindB.ID})
|
||||||
if rec.Code != http.StatusNotFound {
|
if rec.Code == http.StatusOK {
|
||||||
t.Fatalf("userB GetActivity(userA's id) status = %d, want 404, body = %s", rec.Code, rec.Body.String())
|
t.Fatalf("userB assigning userA's activity succeeded (status %d), body = %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
if _, ok, err := db.CurrentAssignment(newCtx(), userA.ID, activityID); err != nil {
|
||||||
|
t.Fatalf("CurrentAssignment: %v", err)
|
||||||
|
} else if ok {
|
||||||
|
t.Fatalf("userB's rejected assign still created an assignment on userA's activity")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -106,7 +109,7 @@ func TestIsolation_WorkoutKindUpdateCannotTargetOtherUsersKind(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestIsolation_ReviewQueueOnlyShowsOwnActivities(t *testing.T) {
|
func TestIsolation_ActivitiesListOnlyShowsOwnActivities(t *testing.T) {
|
||||||
s, db, _ := newTestServer(t) // provisions "test-user" (userA)
|
s, db, _ := newTestServer(t) // provisions "test-user" (userA)
|
||||||
userB, err := db.ProvisionUser(newCtx(), "user-b", "B")
|
userB, err := db.ProvisionUser(newCtx(), "user-b", "B")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -125,7 +128,7 @@ func TestIsolation_ReviewQueueOnlyShowsOwnActivities(t *testing.T) {
|
|||||||
t.Fatalf("InsertKindAssignment(b): %v", err)
|
t.Fatalf("InsertKindAssignment(b): %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
rec := doJSON(t, s.Router(), http.MethodGet, "/api/review-queue/", nil) // as userA
|
rec := doJSON(t, s.Router(), http.MethodGet, "/api/activities/", nil) // as userA
|
||||||
if rec.Code != http.StatusOK {
|
if rec.Code != http.StatusOK {
|
||||||
t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String())
|
t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String())
|
||||||
}
|
}
|
||||||
@@ -135,7 +138,7 @@ func TestIsolation_ReviewQueueOnlyShowsOwnActivities(t *testing.T) {
|
|||||||
}
|
}
|
||||||
json.Unmarshal(rec.Body.Bytes(), &page)
|
json.Unmarshal(rec.Body.Bytes(), &page)
|
||||||
if page.Total != 0 || len(page.Items) != 0 {
|
if page.Total != 0 || len(page.Items) != 0 {
|
||||||
t.Fatalf("expected userA's review queue to be empty (the only assignment belongs to userB), got total=%d items=%d", page.Total, len(page.Items))
|
t.Fatalf("expected userA's activities list to be empty (the only assignment belongs to userB), got total=%d items=%d", page.Total, len(page.Items))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -145,8 +148,8 @@ func TestIsolation_RequireProvisionedUser_BlocksDataRoutesUntilSetup(t *testing.
|
|||||||
t.Fatalf("store.Open: %v", err)
|
t.Fatalf("store.Open: %v", err)
|
||||||
}
|
}
|
||||||
defer db.Close()
|
defer db.Close()
|
||||||
m := &mock.Client{}
|
m := &garmin.MockClient{}
|
||||||
s := NewServer(db, func(garmin.Config) garmin.Client { return m }, garmin.Config{}, appsync.Config{}, &authmock.Verifier{}, testSessionConfig)
|
s := NewServer(db, func(garmin.ClientConfig) garmin.Client { return m }, garmin.ClientConfig{}, garmin.SyncConfig{}, &authmock.Verifier{}, testSessionConfig)
|
||||||
|
|
||||||
rec := doJSON(t, s.Router(), http.MethodGet, "/api/activities/", nil) // "test-user" sub, never provisioned
|
rec := doJSON(t, s.Router(), http.MethodGet, "/api/activities/", nil) // "test-user" sub, never provisioned
|
||||||
if rec.Code != http.StatusForbidden {
|
if rec.Code != http.StatusForbidden {
|
||||||
@@ -181,3 +184,46 @@ func TestIsolation_DeleteProfileOnlyDeletesOwnAccount(t *testing.T) {
|
|||||||
t.Fatalf("userA profile status after userB deleted themselves = %d, body = %s", rec.Code, rec.Body.String())
|
t.Fatalf("userA profile status after userB deleted themselves = %d, body = %s", rec.Code, rec.Body.String())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestIsolation_GarminConnectedNeverLeaksAcrossUsers confirms one user's
|
||||||
|
// successful Garmin auth never flips garmin_connected for another user.
|
||||||
|
func TestIsolation_GarminConnectedNeverLeaksAcrossUsers(t *testing.T) {
|
||||||
|
s, db, _ := newTestServer(t) // provisions "test-user" (userA)
|
||||||
|
if _, err := db.ProvisionUser(newCtx(), "user-b", "B"); err != nil {
|
||||||
|
t.Fatalf("ProvisionUser(b): %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
router := s.Router()
|
||||||
|
rec := doJSON(t, router, http.MethodPost, "/api/garmin/auth/login", nil) // as userA
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("auth/login status = %d, body = %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
rec = doJSONAs(t, router, "user-b", http.MethodGet, "/api/session/me", nil)
|
||||||
|
var meB sessionMeResponse
|
||||||
|
unmarshalBody(t, rec, &meB)
|
||||||
|
if meB.GarminConnected {
|
||||||
|
t.Fatal("userA's successful Garmin auth leaked into userB's garmin_connected flag")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestIsolation_SetupSessionsNeverLeakAcrossSubjects confirms one OIDC
|
||||||
|
// subject's pending ephemeral Garmin session is invisible to another
|
||||||
|
// subject -- e.g. subject B completing MFA must not accidentally continue
|
||||||
|
// subject A's in-progress attempt.
|
||||||
|
func TestIsolation_SetupSessionsNeverLeakAcrossSubjects(t *testing.T) {
|
||||||
|
s, _, _ := newUnprovisionedServer(t)
|
||||||
|
router := s.Router()
|
||||||
|
|
||||||
|
rec := doJSON(t, router, http.MethodPost, "/api/setup/garmin/login", map[string]any{
|
||||||
|
"garmin_email": "a@example.com", "garmin_password": "pw-a",
|
||||||
|
})
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("login(a) status = %d, body = %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
rec = doJSONAs(t, router, "user-b", http.MethodPost, "/api/setup/garmin/mfa", map[string]any{"code": "000000"})
|
||||||
|
if rec.Code != http.StatusConflict {
|
||||||
|
t.Fatalf("user-b mfa (no login attempt of their own) status = %d, want 409, body = %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"geniusrun/backend/internal/store"
|
"geniusrun/backend/internal/store"
|
||||||
)
|
)
|
||||||
@@ -35,6 +36,14 @@ func validateProfile(p store.Profile) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// profilePayload is the profile API's request/response shape: the profiles
|
||||||
|
// row plus Name, which lives on the users row (the account's single
|
||||||
|
// human-facing name) but is edited from the same Profile screen.
|
||||||
|
type profilePayload struct {
|
||||||
|
store.Profile
|
||||||
|
Name string
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Server) handleGetProfile(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleGetProfile(w http.ResponseWriter, r *http.Request) {
|
||||||
userID := userIDFromContext(r.Context())
|
userID := userIDFromContext(r.Context())
|
||||||
p, err := s.DB.GetProfile(r.Context(), userID)
|
p, err := s.DB.GetProfile(r.Context(), userID)
|
||||||
@@ -42,26 +51,35 @@ func (s *Server) handleGetProfile(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeError(w, http.StatusInternalServerError, err.Error())
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, p)
|
u, _ := userFromContext(r.Context())
|
||||||
|
writeJSON(w, http.StatusOK, profilePayload{Profile: p, Name: u.Name})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) handleUpdateProfile(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleUpdateProfile(w http.ResponseWriter, r *http.Request) {
|
||||||
userID := userIDFromContext(r.Context())
|
userID := userIDFromContext(r.Context())
|
||||||
var p store.Profile
|
var p profilePayload
|
||||||
if err := json.NewDecoder(r.Body).Decode(&p); err != nil {
|
if err := json.NewDecoder(r.Body).Decode(&p); err != nil {
|
||||||
writeError(w, http.StatusBadRequest, "invalid request body")
|
writeError(w, http.StatusBadRequest, "invalid request body")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := validateProfile(p); err != nil {
|
if strings.TrimSpace(p.Name) == "" {
|
||||||
|
writeError(w, http.StatusBadRequest, "name is required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := validateProfile(p.Profile); err != nil {
|
||||||
writeError(w, http.StatusBadRequest, err.Error())
|
writeError(w, http.StatusBadRequest, err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := s.DB.UpdateProfile(r.Context(), userID, p); err != nil {
|
if err := s.DB.UpdateProfile(r.Context(), userID, p.Profile); err != nil {
|
||||||
writeError(w, http.StatusInternalServerError, err.Error())
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
client, err := s.garminFor(r.Context(), userID)
|
if err := s.DB.UpdateUserName(r.Context(), userID, strings.TrimSpace(p.Name)); err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
client, err := s.clientFor(r.Context(), userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, http.StatusInternalServerError, err.Error())
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
return
|
return
|
||||||
@@ -73,7 +91,7 @@ func (s *Server) handleUpdateProfile(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeError(w, http.StatusInternalServerError, err.Error())
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, updated)
|
writeJSON(w, http.StatusOK, profilePayload{Profile: updated, Name: strings.TrimSpace(p.Name)})
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleDeleteProfile permanently deletes the signed-in user's entire
|
// handleDeleteProfile permanently deletes the signed-in user's entire
|
||||||
@@ -98,6 +116,6 @@ func (s *Server) handleDeleteProfile(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeError(w, http.StatusInternalServerError, err.Error())
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
s.removeGarminClient(userID)
|
s.removeUserClient(userID)
|
||||||
w.WriteHeader(http.StatusNoContent)
|
w.WriteHeader(http.StatusNoContent)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,258 +0,0 @@
|
|||||||
package api
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"net/http"
|
|
||||||
"sort"
|
|
||||||
"strconv"
|
|
||||||
|
|
||||||
"github.com/go-chi/chi/v5"
|
|
||||||
|
|
||||||
"geniusrun/backend/internal/store"
|
|
||||||
)
|
|
||||||
|
|
||||||
// defaultReviewQueuePageSize matches the frontend's initial/incremental
|
|
||||||
// page size for its infinite-scroll list.
|
|
||||||
const defaultReviewQueuePageSize = 10
|
|
||||||
|
|
||||||
type reviewQueueItem struct {
|
|
||||||
store.KindAssignment
|
|
||||||
Activity activityResponse `json:"activity"`
|
|
||||||
Laps []lapResponse `json:"laps"`
|
|
||||||
Samples []store.Sample `json:"samples"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleReviewQueue backs the Activities page: every activity that has been
|
|
||||||
// classified at least once, not just ones still needing review, so the page
|
|
||||||
// can show each activity's current kind (or "Unclassified") and let the user
|
|
||||||
// lock/unlock it. It's cursor-paginated: fetching every activity's laps and
|
|
||||||
// (especially) per-second samples is expensive once there are many of them,
|
|
||||||
// so that work only happens for the requested page, not the full backlog.
|
|
||||||
// Sorting/cursor filtering still needs each activity's summary row (a cheap
|
|
||||||
// indexed lookup, no laps/samples), which happens for the whole backlog --
|
|
||||||
// only the heavy per-item fetches are deferred to the page actually being
|
|
||||||
// returned. The optional kind_id/unclassified filters are applied before
|
|
||||||
// that cursor slicing too, so a filtered view still only loads (and
|
|
||||||
// chart-renders) one page at a time instead of the whole matching backlog.
|
|
||||||
func (s *Server) handleReviewQueue(w http.ResponseWriter, r *http.Request) {
|
|
||||||
userID := userIDFromContext(r.Context())
|
|
||||||
limit := defaultReviewQueuePageSize
|
|
||||||
if v := r.URL.Query().Get("limit"); v != "" {
|
|
||||||
if n, err := strconv.Atoi(v); err == nil && n > 0 {
|
|
||||||
limit = n
|
|
||||||
}
|
|
||||||
}
|
|
||||||
cursor := r.URL.Query().Get("before") // activity StartTimeUTC of the last item on the previous page
|
|
||||||
|
|
||||||
var kindIDFilter *int64
|
|
||||||
if v := r.URL.Query().Get("kind_id"); v != "" {
|
|
||||||
if n, err := strconv.ParseInt(v, 10, 64); err == nil {
|
|
||||||
kindIDFilter = &n
|
|
||||||
}
|
|
||||||
}
|
|
||||||
unclassifiedOnly := r.URL.Query().Get("unclassified") == "true"
|
|
||||||
|
|
||||||
queue, err := s.DB.AllCurrentAssignments(r.Context(), userID)
|
|
||||||
if err != nil {
|
|
||||||
writeError(w, http.StatusInternalServerError, err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
type withActivity struct {
|
|
||||||
assignment store.KindAssignment
|
|
||||||
activity store.Activity
|
|
||||||
}
|
|
||||||
all := make([]withActivity, 0, len(queue))
|
|
||||||
for _, a := range queue {
|
|
||||||
if kindIDFilter != nil && (a.WorkoutKindID == nil || *a.WorkoutKindID != *kindIDFilter) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if unclassifiedOnly && a.WorkoutKindID != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
activity, ok, err := s.DB.GetActivity(r.Context(), userID, a.ActivityID)
|
|
||||||
if err != nil {
|
|
||||||
writeError(w, http.StatusInternalServerError, err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if !ok {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
all = append(all, withActivity{assignment: a, activity: activity})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Most recent run first, by when the activity actually happened (not
|
|
||||||
// when the rule engine flagged it), so the queue reads like a log.
|
|
||||||
sort.Slice(all, func(i, j int) bool {
|
|
||||||
return all[i].activity.StartTimeUTC > all[j].activity.StartTimeUTC
|
|
||||||
})
|
|
||||||
|
|
||||||
total := len(all)
|
|
||||||
|
|
||||||
if cursor != "" {
|
|
||||||
idx := 0
|
|
||||||
for idx < len(all) && all[idx].activity.StartTimeUTC >= cursor {
|
|
||||||
idx++
|
|
||||||
}
|
|
||||||
all = all[idx:]
|
|
||||||
}
|
|
||||||
hasMore := len(all) > limit
|
|
||||||
if len(all) > limit {
|
|
||||||
all = all[:limit]
|
|
||||||
}
|
|
||||||
|
|
||||||
items := make([]reviewQueueItem, 0, len(all))
|
|
||||||
for _, wa := range all {
|
|
||||||
laps, err := s.DB.LapsForActivity(r.Context(), userID, wa.assignment.ActivityID)
|
|
||||||
if err != nil {
|
|
||||||
writeError(w, http.StatusInternalServerError, err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// Per-second telemetry, not just per-lap averages, so the chart can
|
|
||||||
// show real within-lap variation instead of one flat segment per lap
|
|
||||||
// (most activities only have a handful of laps).
|
|
||||||
samples, err := s.DB.SamplesForActivity(r.Context(), userID, wa.assignment.ActivityID)
|
|
||||||
if err != nil {
|
|
||||||
writeError(w, http.StatusInternalServerError, err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
items = append(items, reviewQueueItem{
|
|
||||||
KindAssignment: wa.assignment,
|
|
||||||
Activity: toActivityResponse(wa.activity),
|
|
||||||
Laps: toLapResponses(laps),
|
|
||||||
Samples: samples,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
var nextCursor *string
|
|
||||||
if hasMore && len(items) > 0 {
|
|
||||||
c := items[len(items)-1].Activity.StartTimeUTC
|
|
||||||
nextCursor = &c
|
|
||||||
}
|
|
||||||
|
|
||||||
writeJSON(w, http.StatusOK, map[string]any{
|
|
||||||
"items": items,
|
|
||||||
"next_cursor": nextCursor,
|
|
||||||
"total": total,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Server) handleResolveReview(w http.ResponseWriter, r *http.Request) {
|
|
||||||
userID := userIDFromContext(r.Context())
|
|
||||||
activityID, err := strconv.ParseInt(chi.URLParam(r, "activityID"), 10, 64)
|
|
||||||
if err != nil {
|
|
||||||
writeError(w, http.StatusBadRequest, "invalid activity id")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var body struct {
|
|
||||||
WorkoutKindID int64 `json:"workout_kind_id"`
|
|
||||||
}
|
|
||||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
|
||||||
writeError(w, http.StatusBadRequest, "invalid request body")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if body.WorkoutKindID == 0 {
|
|
||||||
writeError(w, http.StatusBadRequest, "workout_kind_id is required")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
kind, ok, err := s.DB.GetWorkoutKind(r.Context(), userID, body.WorkoutKindID)
|
|
||||||
if err != nil {
|
|
||||||
writeError(w, http.StatusInternalServerError, err.Error())
|
|
||||||
return
|
|
||||||
} else if !ok {
|
|
||||||
writeError(w, http.StatusBadRequest, "workout kind not found")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if kind.Name == "Race" {
|
|
||||||
writeError(w, http.StatusBadRequest, "Race is assigned automatically from Garmin metadata and cannot be set manually")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
kindID := body.WorkoutKindID
|
|
||||||
if _, err := s.DB.InsertKindAssignment(r.Context(), userID, store.KindAssignment{
|
|
||||||
ActivityID: activityID,
|
|
||||||
WorkoutKindID: &kindID,
|
|
||||||
AssignmentSource: store.AssignmentSourceManual,
|
|
||||||
Status: store.AssignmentStatusAssigned,
|
|
||||||
CandidateKindsJSON: "[]",
|
|
||||||
}); err != nil {
|
|
||||||
writeError(w, http.StatusInternalServerError, err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
writeJSON(w, http.StatusOK, map[string]string{"status": "resolved"})
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleUnassignReview manually clears an activity's kind back to
|
|
||||||
// Unclassified. Like handleResolveReview, it's a deliberate manual choice --
|
|
||||||
// recorded as source=manual (with no kind) so the rule engine leaves it alone
|
|
||||||
// on a later reclassify pass, exactly as it would for a manual kind
|
|
||||||
// assignment. The frontend only offers this while the activity is unlocked
|
|
||||||
// (locked activities must be unlocked first, same precondition as picking a
|
|
||||||
// different kind), but the backend doesn't re-enforce that here, matching
|
|
||||||
// handleResolveReview's own lack of a lock precondition check.
|
|
||||||
func (s *Server) handleUnassignReview(w http.ResponseWriter, r *http.Request) {
|
|
||||||
userID := userIDFromContext(r.Context())
|
|
||||||
activityID, err := strconv.ParseInt(chi.URLParam(r, "activityID"), 10, 64)
|
|
||||||
if err != nil {
|
|
||||||
writeError(w, http.StatusBadRequest, "invalid activity id")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, err := s.DB.InsertKindAssignment(r.Context(), userID, store.KindAssignment{
|
|
||||||
ActivityID: activityID,
|
|
||||||
WorkoutKindID: nil,
|
|
||||||
AssignmentSource: store.AssignmentSourceManual,
|
|
||||||
Status: store.AssignmentStatusNeedsReview,
|
|
||||||
CandidateKindsJSON: "[]",
|
|
||||||
}); err != nil {
|
|
||||||
writeError(w, http.StatusInternalServerError, err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
writeJSON(w, http.StatusOK, map[string]string{"status": "unassigned"})
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleUnlockReview reverts a manual assignment back to rule_engine
|
|
||||||
// sourcing, keeping the same kind, so a later reclassify pass (which always
|
|
||||||
// skips manual assignments) is free to change it again. It's the inverse of
|
|
||||||
// handleResolveReview, not a delete: the kind stays visible as-is until
|
|
||||||
// something actually reclassifies it.
|
|
||||||
func (s *Server) handleUnlockReview(w http.ResponseWriter, r *http.Request) {
|
|
||||||
userID := userIDFromContext(r.Context())
|
|
||||||
activityID, err := strconv.ParseInt(chi.URLParam(r, "activityID"), 10, 64)
|
|
||||||
if err != nil {
|
|
||||||
writeError(w, http.StatusBadRequest, "invalid activity id")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
current, ok, err := s.DB.CurrentAssignment(r.Context(), userID, activityID)
|
|
||||||
if err != nil {
|
|
||||||
writeError(w, http.StatusInternalServerError, err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if !ok {
|
|
||||||
writeError(w, http.StatusNotFound, "activity has no assignment yet")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if current.AssignmentSource != store.AssignmentSourceManual {
|
|
||||||
writeError(w, http.StatusBadRequest, "activity is not manually locked")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
status := store.AssignmentStatusAssigned
|
|
||||||
if current.WorkoutKindID == nil {
|
|
||||||
status = store.AssignmentStatusNeedsReview
|
|
||||||
}
|
|
||||||
if _, err := s.DB.InsertKindAssignment(r.Context(), userID, store.KindAssignment{
|
|
||||||
ActivityID: activityID,
|
|
||||||
WorkoutKindID: current.WorkoutKindID,
|
|
||||||
AssignmentSource: store.AssignmentSourceRuleEngine,
|
|
||||||
Status: status,
|
|
||||||
CandidateKindsJSON: "[]",
|
|
||||||
}); err != nil {
|
|
||||||
writeError(w, http.StatusInternalServerError, err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
writeJSON(w, http.StatusOK, map[string]string{"status": "unlocked"})
|
|
||||||
}
|
|
||||||
@@ -4,21 +4,25 @@ package api
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
applog "geniusrun/backend/internal/log"
|
||||||
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strconv"
|
"strconv"
|
||||||
"sync"
|
"sync"
|
||||||
|
"time"
|
||||||
"github.com/go-chi/chi/v5"
|
|
||||||
|
|
||||||
"geniusrun/backend/internal/auth"
|
"geniusrun/backend/internal/auth"
|
||||||
"geniusrun/backend/internal/garmin"
|
"geniusrun/backend/internal/garmin"
|
||||||
"geniusrun/backend/internal/store"
|
"geniusrun/backend/internal/store"
|
||||||
appsync "geniusrun/backend/internal/sync"
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
"github.com/go-chi/chi/v5/middleware"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Server wires the HTTP handlers to the app's dependencies. garmin.Client
|
// Server wires the HTTP handlers to the app's dependencies. garmin.Client
|
||||||
@@ -27,154 +31,58 @@ import (
|
|||||||
type Server struct {
|
type Server struct {
|
||||||
DB *store.DB
|
DB *store.DB
|
||||||
Auth auth.Verifier
|
Auth auth.Verifier
|
||||||
Session SessionConfig
|
SessionConfig SessionConfig
|
||||||
|
|
||||||
// GarminFactory builds a real (or fake, in tests) garmin.Client from a
|
// GarminFactory builds a real (or fake, in tests) garmin.Client from a
|
||||||
// fully-resolved per-user Config. Production wiring passes
|
// fully-resolved per-user Config. Production wiring passes
|
||||||
// garmin.NewClient; tests inject a factory returning a shared
|
// garmin.NewClient; tests inject a factory returning a shared
|
||||||
// *mock.Client (see newTestServer in api_test.go).
|
// *mock.Client (see newTestServer in api_test.go).
|
||||||
GarminFactory func(garmin.Config) garmin.Client
|
GarminFactory func(garmin.ClientConfig) garmin.Client
|
||||||
// GarminBase holds the plumbing shared by every user's garmin.Config
|
|
||||||
|
// ClientConfig holds the plumbing shared by every user's garmin.Config
|
||||||
// (the python interpreter path + the token-store root directory); only
|
// (the python interpreter path + the token-store root directory); only
|
||||||
// GarminEmail/GarminPassword/TokenStorePath vary per user, filled in by
|
// GarminEmail/GarminPassword/TokenStorePath vary per user, filled in by
|
||||||
// garminFor.
|
// garminFor.
|
||||||
GarminBase garmin.Config
|
ClientConfig garmin.ClientConfig
|
||||||
SyncConfig appsync.Config
|
SyncConfig garmin.SyncConfig
|
||||||
|
|
||||||
|
// EnvVars is the read-only, display-safe environment-configuration
|
||||||
|
// snapshot served by GET /api/config -- built once in main.go from
|
||||||
|
// config.Config.DisplayEnv() (secrets already masked there); handlers
|
||||||
|
// never call os.Getenv.
|
||||||
|
EnvVars []EnvVar
|
||||||
|
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
userGarmin map[int64]garmin.Client
|
userClient map[int64]garmin.Client
|
||||||
userSync map[int64]*appsync.Service
|
userSync map[int64]*garmin.Sync
|
||||||
userAuthStatus map[int64]garmin.AuthStatus
|
userAuthStatus map[int64]garmin.AuthStatus
|
||||||
userAuthMessage map[int64]string
|
userAuthMessage map[int64]string
|
||||||
userSyncRunning map[int64]bool
|
userSyncRunning map[int64]bool
|
||||||
|
setupSession map[string]*setupSession
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewServer builds a Server.
|
// NewServer builds a Server.
|
||||||
func NewServer(db *store.DB, garminFactory func(garmin.Config) garmin.Client, garminBase garmin.Config, syncConfig appsync.Config, authVerifier auth.Verifier, session SessionConfig) *Server {
|
func NewServer(db *store.DB, garminFactory func(garmin.ClientConfig) garmin.Client, garminBase garmin.ClientConfig, syncConfig garmin.SyncConfig, authVerifier auth.Verifier, sessionConfig SessionConfig) *Server {
|
||||||
return &Server{
|
return &Server{
|
||||||
DB: db, GarminFactory: garminFactory, GarminBase: garminBase, SyncConfig: syncConfig,
|
DB: db,
|
||||||
Auth: authVerifier, Session: session,
|
GarminFactory: garminFactory,
|
||||||
userGarmin: map[int64]garmin.Client{},
|
ClientConfig: garminBase,
|
||||||
userSync: map[int64]*appsync.Service{},
|
SyncConfig: syncConfig,
|
||||||
|
Auth: authVerifier,
|
||||||
|
SessionConfig: sessionConfig,
|
||||||
|
userClient: map[int64]garmin.Client{},
|
||||||
|
userSync: map[int64]*garmin.Sync{},
|
||||||
userAuthStatus: map[int64]garmin.AuthStatus{},
|
userAuthStatus: map[int64]garmin.AuthStatus{},
|
||||||
userAuthMessage: map[int64]string{},
|
userAuthMessage: map[int64]string{},
|
||||||
userSyncRunning: map[int64]bool{},
|
userSyncRunning: map[int64]bool{},
|
||||||
}
|
setupSession: map[string]*setupSession{},
|
||||||
}
|
|
||||||
|
|
||||||
// garminFor returns userID's garmin.Client, building and caching it (from
|
|
||||||
// userID's own profile row) on first use.
|
|
||||||
func (s *Server) garminFor(ctx context.Context, userID int64) (garmin.Client, error) {
|
|
||||||
s.mu.Lock()
|
|
||||||
if c, ok := s.userGarmin[userID]; ok {
|
|
||||||
s.mu.Unlock()
|
|
||||||
return c, nil
|
|
||||||
}
|
|
||||||
s.mu.Unlock()
|
|
||||||
|
|
||||||
profile, err := s.DB.GetProfile(ctx, userID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("load profile for garmin client (user %d): %w", userID, err)
|
|
||||||
}
|
|
||||||
cfg := s.GarminBase
|
|
||||||
cfg.GarminEmail = profile.GarminEmail
|
|
||||||
cfg.GarminPassword = profile.GarminPassword
|
|
||||||
if cfg.TokenStorePath != "" {
|
|
||||||
cfg.TokenStorePath = filepath.Join(cfg.TokenStorePath, strconv.FormatInt(userID, 10))
|
|
||||||
}
|
|
||||||
|
|
||||||
s.mu.Lock()
|
|
||||||
defer s.mu.Unlock()
|
|
||||||
if c, ok := s.userGarmin[userID]; ok {
|
|
||||||
return c, nil // built concurrently by another request between our unlock and re-lock
|
|
||||||
}
|
|
||||||
client := s.GarminFactory(cfg)
|
|
||||||
s.userGarmin[userID] = client
|
|
||||||
return client, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// syncFor returns userID's sync.Service, building and caching it on first use.
|
|
||||||
func (s *Server) syncFor(ctx context.Context, userID int64) (*appsync.Service, error) {
|
|
||||||
s.mu.Lock()
|
|
||||||
if svc, ok := s.userSync[userID]; ok {
|
|
||||||
s.mu.Unlock()
|
|
||||||
return svc, nil
|
|
||||||
}
|
|
||||||
s.mu.Unlock()
|
|
||||||
|
|
||||||
client, err := s.garminFor(ctx, userID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
s.mu.Lock()
|
|
||||||
defer s.mu.Unlock()
|
|
||||||
if svc, ok := s.userSync[userID]; ok {
|
|
||||||
return svc, nil
|
|
||||||
}
|
|
||||||
svc := appsync.NewService(client, s.DB, userID, s.SyncConfig, nil)
|
|
||||||
s.userSync[userID] = svc
|
|
||||||
return svc, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// removeGarminClient drops userID's cached garmin.Client/sync.Service (if
|
|
||||||
// any) and every other per-user in-memory entry for userID, terminating the
|
|
||||||
// client's subprocess and best-effort removing its on-disk token-store
|
|
||||||
// directory. Called when a user's account has just been deleted from the
|
|
||||||
// DB, so nothing in memory keeps referencing a userID that no longer
|
|
||||||
// exists.
|
|
||||||
func (s *Server) removeGarminClient(userID int64) {
|
|
||||||
s.mu.Lock()
|
|
||||||
client, ok := s.userGarmin[userID]
|
|
||||||
delete(s.userGarmin, userID)
|
|
||||||
delete(s.userSync, userID)
|
|
||||||
delete(s.userAuthStatus, userID)
|
|
||||||
delete(s.userAuthMessage, userID)
|
|
||||||
delete(s.userSyncRunning, userID)
|
|
||||||
s.mu.Unlock()
|
|
||||||
|
|
||||||
if ok {
|
|
||||||
if err := client.Close(); err != nil {
|
|
||||||
log.Printf("api: close garmin client for deleted user %d: %v", userID, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if s.GarminBase.TokenStorePath == "" {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
tokenStoreDir := filepath.Join(s.GarminBase.TokenStorePath, strconv.FormatInt(userID, 10))
|
|
||||||
if err := os.RemoveAll(tokenStoreDir); err != nil {
|
|
||||||
log.Printf("api: remove token store dir for deleted user %d: %v", userID, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// RunIncrementalSyncForAllUsers is called on a timer (see main.go) to sync
|
|
||||||
// every provisioned user in turn, replacing the old single-global-Service
|
|
||||||
// background loop.
|
|
||||||
func (s *Server) RunIncrementalSyncForAllUsers(ctx context.Context) {
|
|
||||||
users, err := s.DB.ListUsers(ctx)
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("api: list users for incremental sync: %v", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
for _, u := range users {
|
|
||||||
svc, err := s.syncFor(ctx, u.ID)
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("api: sync service for user %d: %v", u.ID, err)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if err := svc.IncrementalSync(ctx); err != nil {
|
|
||||||
log.Printf("api: incremental sync for user %d: %v", u.ID, err)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if err := svc.FillPendingDetails(ctx, 50); err != nil {
|
|
||||||
log.Printf("api: fill pending details for user %d: %v", u.ID, err)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Router builds the HTTP routes.
|
// Router builds the HTTP routes.
|
||||||
func (s *Server) Router() http.Handler {
|
func (s *Server) Router() http.Handler {
|
||||||
r := chi.NewRouter()
|
r := chi.NewRouter()
|
||||||
|
r.Use(loggingMiddleware)
|
||||||
r.Use(corsMiddleware)
|
r.Use(corsMiddleware)
|
||||||
r.Route("/api", func(r chi.Router) {
|
r.Route("/api", func(r chi.Router) {
|
||||||
r.Get("/health", s.handleHealth)
|
r.Get("/health", s.handleHealth)
|
||||||
@@ -185,12 +93,19 @@ func (s *Server) Router() http.Handler {
|
|||||||
r.Get("/session/callback", s.handleSessionCallback)
|
r.Get("/session/callback", s.handleSessionCallback)
|
||||||
|
|
||||||
r.Group(func(r chi.Router) {
|
r.Group(func(r chi.Router) {
|
||||||
r.Use(auth.RequireSession(s.Session.Secret))
|
r.Use(auth.RequireSession(s.SessionConfig.Secret))
|
||||||
r.Use(s.resolveUser)
|
r.Use(s.resolveUser)
|
||||||
|
|
||||||
r.Get("/session/me", s.handleSessionMe)
|
r.Get("/session/me", s.handleSessionMe)
|
||||||
r.Post("/session/logout", s.handleSessionLogout)
|
r.Post("/session/logout", s.handleSessionLogout)
|
||||||
r.Post("/setup", s.handleSetup)
|
|
||||||
|
r.Route("/setup", func(r chi.Router) {
|
||||||
|
r.Post("/complete", s.handleSetupComplete)
|
||||||
|
r.Route("/garmin", func(r chi.Router) {
|
||||||
|
r.Post("/login", s.handleSetupGarminLogin)
|
||||||
|
r.Post("/mfa", s.handleSetupGarminMFA)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
r.Group(func(r chi.Router) {
|
r.Group(func(r chi.Router) {
|
||||||
r.Use(requireProvisionedUser)
|
r.Use(requireProvisionedUser)
|
||||||
@@ -201,22 +116,27 @@ func (s *Server) Router() http.Handler {
|
|||||||
r.Delete("/", s.handleDeleteProfile)
|
r.Delete("/", s.handleDeleteProfile)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
r.Route("/garmin", func(r chi.Router) {
|
||||||
r.Route("/auth", func(r chi.Router) {
|
r.Route("/auth", func(r chi.Router) {
|
||||||
r.Post("/login", s.handleAuthLogin)
|
r.Post("/login", s.handleGarminAuthLogin)
|
||||||
r.Post("/mfa", s.handleAuthMFA)
|
r.Post("/mfa", s.handleGarminAuthMFA)
|
||||||
r.Get("/status", s.handleAuthStatus)
|
r.Get("/status", s.handleGarminAuthStatus)
|
||||||
})
|
})
|
||||||
|
|
||||||
r.Route("/sync", func(r chi.Router) {
|
r.Route("/sync", func(r chi.Router) {
|
||||||
r.Post("/run", s.handleSyncRun)
|
r.Post("/run", s.handleGarminSyncRun)
|
||||||
r.Post("/reset", s.handleSyncReset)
|
r.Post("/reset", s.handleGarminSyncReset)
|
||||||
r.Get("/runs", s.handleSyncRuns)
|
r.Get("/runs", s.handleGarminSyncRuns)
|
||||||
r.Get("/status", s.handleSyncStatus)
|
r.Get("/status", s.handleGarminSyncStatus)
|
||||||
|
})
|
||||||
|
|
||||||
})
|
})
|
||||||
|
|
||||||
r.Route("/activities", func(r chi.Router) {
|
r.Route("/activities", func(r chi.Router) {
|
||||||
r.Get("/", s.handleListActivities)
|
r.Get("/", s.handleListActivities)
|
||||||
r.Get("/{id}", s.handleGetActivity)
|
r.Post("/{activityID}/assign", s.handleAssignActivity)
|
||||||
|
r.Post("/{activityID}/unlock", s.handleUnlockActivity)
|
||||||
|
r.Post("/{activityID}/unassign", s.handleUnassignActivity)
|
||||||
})
|
})
|
||||||
|
|
||||||
r.Route("/workout-kinds", func(r chi.Router) {
|
r.Route("/workout-kinds", func(r chi.Router) {
|
||||||
@@ -227,12 +147,8 @@ func (s *Server) Router() http.Handler {
|
|||||||
|
|
||||||
r.Post("/reclassify", s.handleReclassifyAll)
|
r.Post("/reclassify", s.handleReclassifyAll)
|
||||||
|
|
||||||
r.Route("/review-queue", func(r chi.Router) {
|
r.Get("/config", s.handleGetConfig)
|
||||||
r.Get("/", s.handleReviewQueue)
|
r.Put("/config", s.handlePutConfig)
|
||||||
r.Post("/{activityID}/resolve", s.handleResolveReview)
|
|
||||||
r.Post("/{activityID}/unlock", s.handleUnlockReview)
|
|
||||||
r.Post("/{activityID}/unassign", s.handleUnassignReview)
|
|
||||||
})
|
|
||||||
|
|
||||||
r.Get("/progression/{kindID}", s.handleProgression)
|
r.Get("/progression/{kindID}", s.handleProgression)
|
||||||
})
|
})
|
||||||
@@ -241,6 +157,37 @@ func (s *Server) Router() http.Handler {
|
|||||||
return r
|
return r
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
|
||||||
|
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// loggingMiddleware logs one type=http JSON line per HTTP request. The
|
||||||
|
// record's meaning is fully carried by its fields (http_method, path,
|
||||||
|
// status, duration_ms), so it has no msg; "method" stays reserved for the
|
||||||
|
// emitting function per the log schema, hence http_method for the verb.
|
||||||
|
func loggingMiddleware(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ww := middleware.NewWrapResponseWriter(w, r.ProtoMajor)
|
||||||
|
start := time.Now()
|
||||||
|
next.ServeHTTP(ww, r)
|
||||||
|
|
||||||
|
level := slog.LevelInfo
|
||||||
|
if ww.Status() >= 500 {
|
||||||
|
level = slog.LevelWarn
|
||||||
|
}
|
||||||
|
slog.Default().LogAttrs(r.Context(), level, "",
|
||||||
|
slog.String("type", "http"),
|
||||||
|
slog.String("package", "api"),
|
||||||
|
slog.String("file", "server.go"),
|
||||||
|
slog.String("method", "loggingMiddleware"),
|
||||||
|
slog.String("http_method", r.Method),
|
||||||
|
slog.String("path", r.URL.Path),
|
||||||
|
slog.Int("status", ww.Status()),
|
||||||
|
slog.Int64("duration_ms", time.Since(start).Milliseconds()),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// corsMiddleware allows the frontend dev server (a different port) to call
|
// corsMiddleware allows the frontend dev server (a different port) to call
|
||||||
// this API. Reflecting any origin back is safe even with credentials
|
// this API. Reflecting any origin back is safe even with credentials
|
||||||
// enabled: this remains a single-operator app whose real access control is
|
// enabled: this remains a single-operator app whose real access control is
|
||||||
@@ -261,20 +208,89 @@ func corsMiddleware(next http.Handler) http.Handler {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
|
// clientFor returns userID's garmin.Client, building and caching it (from
|
||||||
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
// userID's own profile row) on first use.
|
||||||
|
func (s *Server) clientFor(ctx context.Context, userID int64) (garmin.Client, error) {
|
||||||
|
s.mu.Lock()
|
||||||
|
if c, ok := s.userClient[userID]; ok {
|
||||||
|
s.mu.Unlock()
|
||||||
|
return c, nil
|
||||||
|
}
|
||||||
|
s.mu.Unlock()
|
||||||
|
|
||||||
|
profile, err := s.DB.GetProfile(ctx, userID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("load profile for garmin client (user %d): %w", userID, err)
|
||||||
|
}
|
||||||
|
cfg := s.ClientConfig
|
||||||
|
cfg.GarminEmail = profile.GarminEmail
|
||||||
|
cfg.GarminPassword = profile.GarminPassword
|
||||||
|
if cfg.TokenStorePath != "" {
|
||||||
|
cfg.TokenStorePath = filepath.Join(cfg.TokenStorePath, strconv.FormatInt(userID, 10))
|
||||||
|
}
|
||||||
|
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
if c, ok := s.userClient[userID]; ok {
|
||||||
|
return c, nil // built concurrently by another request between our unlock and re-lock
|
||||||
|
}
|
||||||
|
client := s.GarminFactory(cfg)
|
||||||
|
s.userClient[userID] = client
|
||||||
|
return client, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func writeJSON(w http.ResponseWriter, status int, v any) {
|
// removeUserClient drops userID's cached garmin.Client/sync.Service (if
|
||||||
w.Header().Set("Content-Type", "application/json")
|
// any) and every other per-user in-memory entry for userID, terminating the
|
||||||
w.WriteHeader(status)
|
// client's subprocess and best-effort removing its on-disk token-store
|
||||||
if err := json.NewEncoder(w).Encode(v); err != nil {
|
// directory. Called when a user's account has just been deleted from the
|
||||||
log.Printf("api: encode response: %v", err)
|
// DB, so nothing in memory keeps referencing a userID that no longer
|
||||||
|
// exists.
|
||||||
|
func (s *Server) removeUserClient(userID int64) {
|
||||||
|
s.mu.Lock()
|
||||||
|
client, ok := s.userClient[userID]
|
||||||
|
delete(s.userClient, userID)
|
||||||
|
delete(s.userSync, userID)
|
||||||
|
delete(s.userAuthStatus, userID)
|
||||||
|
delete(s.userAuthMessage, userID)
|
||||||
|
delete(s.userSyncRunning, userID)
|
||||||
|
s.mu.Unlock()
|
||||||
|
|
||||||
|
if ok {
|
||||||
|
if err := client.Close(); err != nil {
|
||||||
|
applog.App().Error("close garmin client for deleted user", "user_id", userID, "error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if s.ClientConfig.TokenStorePath == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
tokenStoreDir := filepath.Join(s.ClientConfig.TokenStorePath, strconv.FormatInt(userID, 10))
|
||||||
|
if err := os.RemoveAll(tokenStoreDir); err != nil {
|
||||||
|
applog.App().Error("remove token store dir for deleted user", "user_id", userID, "error", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func writeError(w http.ResponseWriter, status int, msg string) {
|
// syncFor returns userID's sync.Service, building and caching it on first use.
|
||||||
writeJSON(w, status, map[string]string{"error": msg})
|
func (s *Server) syncFor(ctx context.Context, userID int64) (*garmin.Sync, error) {
|
||||||
|
s.mu.Lock()
|
||||||
|
if svc, ok := s.userSync[userID]; ok {
|
||||||
|
s.mu.Unlock()
|
||||||
|
return svc, nil
|
||||||
|
}
|
||||||
|
s.mu.Unlock()
|
||||||
|
|
||||||
|
client, err := s.clientFor(ctx, userID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
if svc, ok := s.userSync[userID]; ok {
|
||||||
|
return svc, nil
|
||||||
|
}
|
||||||
|
svc := garmin.NewSync(client, s.DB, userID, s.SyncConfig, nil)
|
||||||
|
s.userSync[userID] = svc
|
||||||
|
return svc, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// backgroundSync runs fn in a goroutine with a fresh context, guarded so
|
// backgroundSync runs fn in a goroutine with a fresh context, guarded so
|
||||||
@@ -296,8 +312,30 @@ func (s *Server) backgroundSync(userID int64, fn func(ctx context.Context) error
|
|||||||
s.mu.Unlock()
|
s.mu.Unlock()
|
||||||
}()
|
}()
|
||||||
if err := fn(context.Background()); err != nil {
|
if err := fn(context.Background()); err != nil {
|
||||||
log.Printf("api: background sync error (user %d): %v", userID, err)
|
applog.App().Error("background sync failed", "user_id", userID, "error", err)
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// setupTokenStoreDir returns the token-store directory an ephemeral setup
|
||||||
|
// session for sub should use -- hashed rather than sub itself, since sub is
|
||||||
|
// an opaque string from the identity provider and using it verbatim in a
|
||||||
|
// filesystem path would be a directory-traversal risk if it ever contained
|
||||||
|
// path separators.
|
||||||
|
func setupTokenStoreDir(root, sub string) string {
|
||||||
|
h := sha256.Sum256([]byte(sub))
|
||||||
|
return filepath.Join(root, "setup", hex.EncodeToString(h[:]))
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeJSON(w http.ResponseWriter, status int, v any) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(status)
|
||||||
|
if err := json.NewEncoder(w).Encode(v); err != nil {
|
||||||
|
applog.App().Error("encode response", "error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeError(w http.ResponseWriter, status int, msg string) {
|
||||||
|
writeJSON(w, status, map[string]string{"error": msg})
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
package api
|
package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"log"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
applog "geniusrun/backend/internal/log"
|
||||||
|
|
||||||
"geniusrun/backend/internal/auth"
|
"geniusrun/backend/internal/auth"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -14,6 +15,12 @@ import (
|
|||||||
type SessionConfig struct {
|
type SessionConfig struct {
|
||||||
Secret []byte
|
Secret []byte
|
||||||
Duration time.Duration
|
Duration time.Duration
|
||||||
|
// 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
|
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
|
||||||
@@ -36,6 +43,7 @@ type sessionMeResponse struct {
|
|||||||
Email string `json:"email"`
|
Email string `json:"email"`
|
||||||
HasProfile bool `json:"has_profile"`
|
HasProfile bool `json:"has_profile"`
|
||||||
DisplayName string `json:"display_name,omitempty"`
|
DisplayName string `json:"display_name,omitempty"`
|
||||||
|
GarminConnected bool `json:"garmin_connected"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) handleSessionLogin(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleSessionLogin(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -44,7 +52,7 @@ func (s *Server) handleSessionLogin(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeError(w, http.StatusBadGateway, err.Error())
|
writeError(w, http.StatusBadGateway, err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
cookie, err := auth.MintTxnCookie(txn, s.Session.Secret, s.Session.Secure)
|
cookie, err := auth.MintTxnCookie(txn, s.SessionConfig.Secret, s.SessionConfig.Secure)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, http.StatusInternalServerError, err.Error())
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
return
|
return
|
||||||
@@ -56,49 +64,58 @@ func (s *Server) handleSessionLogin(w http.ResponseWriter, r *http.Request) {
|
|||||||
func (s *Server) handleSessionCallback(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleSessionCallback(w http.ResponseWriter, r *http.Request) {
|
||||||
txnCookie, err := r.Cookie(auth.TxnCookieName)
|
txnCookie, err := r.Cookie(auth.TxnCookieName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("session callback: missing txn cookie: %v", err)
|
applog.App().Warn("missing txn cookie", "error", err)
|
||||||
http.Redirect(w, r, s.Session.FrontendURL+"/?auth_error=failed", http.StatusFound)
|
http.Redirect(w, r, s.SessionConfig.FrontendURL+"/?auth_error=failed", http.StatusFound)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
http.SetCookie(w, auth.ClearCookie(auth.TxnCookieName, s.Session.Secure))
|
http.SetCookie(w, auth.ClearCookie(auth.TxnCookieName, s.SessionConfig.Secure))
|
||||||
|
|
||||||
txn, err := auth.ParseTxnCookie(txnCookie, s.Session.Secret)
|
txn, err := auth.ParseTxnCookie(txnCookie, s.SessionConfig.Secret)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("session callback: failed to parse txn cookie: %v", err)
|
applog.App().Warn("failed to parse txn cookie", "error", err)
|
||||||
http.Redirect(w, r, s.Session.FrontendURL+"/?auth_error=failed", http.StatusFound)
|
http.Redirect(w, r, s.SessionConfig.FrontendURL+"/?auth_error=failed", http.StatusFound)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
result, err := s.Auth.HandleCallback(r.Context(), txn, r.URL.Query())
|
result, err := s.Auth.HandleCallback(r.Context(), txn, r.URL.Query())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("session callback: HandleCallback failed (state mismatch, code exchange, or ID-token verification): %v", err)
|
applog.App().Error("callback failed (state mismatch, code exchange, or ID-token verification)", "error", err)
|
||||||
http.Redirect(w, r, s.Session.FrontendURL+"/?auth_error=failed", http.StatusFound)
|
http.Redirect(w, r, s.SessionConfig.FrontendURL+"/?auth_error=failed", http.StatusFound)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if !result.Authorized {
|
if !result.Authorized {
|
||||||
http.Redirect(w, r, s.Session.FrontendURL+"/?auth_error=forbidden", http.StatusFound)
|
http.Redirect(w, r, s.SessionConfig.FrontendURL+"/?auth_error=forbidden", http.StatusFound)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
sessionCookie, err := auth.MintSessionCookie(result.Claims, s.Session.Secret, s.Session.Duration, s.Session.Secure)
|
sessionCookie, err := auth.MintSessionCookie(result.Claims, result.IDToken, s.SessionConfig.Secret, s.SessionConfig.Duration, s.SessionConfig.Secure)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, http.StatusInternalServerError, err.Error())
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
http.SetCookie(w, sessionCookie)
|
http.SetCookie(w, sessionCookie)
|
||||||
http.Redirect(w, r, s.Session.FrontendURL+"/", http.StatusFound)
|
http.Redirect(w, r, s.SessionConfig.FrontendURL+"/", http.StatusFound)
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleSessionLogout clears geniusrun's own session cookie and redirects
|
// handleSessionLogout clears geniusrun's own session cookie and redirects
|
||||||
// through Keycloak's end-session endpoint, passing the session's ID token
|
// through Keycloak's end-session endpoint, passing the session's ID token
|
||||||
// as id_token_hint (see auth.Claims.IDToken) so Keycloak can skip its own
|
// as id_token_hint (read back from the cookie via
|
||||||
// logout-confirmation prompt -- otherwise a user could cancel out of it and
|
// auth.IDTokenFromSessionCookie -- it deliberately doesn't ride in Claims)
|
||||||
// land back on the app with a Keycloak SSO session but no geniusrun profile
|
// so Keycloak can skip its own logout-confirmation prompt -- otherwise a
|
||||||
// (already deleted, in the profile-deletion case this exists for).
|
// user could cancel out of it and land back on the app with a Keycloak SSO
|
||||||
|
// session but no geniusrun profile (already deleted, in the
|
||||||
|
// profile-deletion case this exists for).
|
||||||
func (s *Server) handleSessionLogout(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleSessionLogout(w http.ResponseWriter, r *http.Request) {
|
||||||
claims, _ := auth.ClaimsFromContext(r.Context())
|
claims, _ := auth.ClaimsFromContext(r.Context())
|
||||||
http.SetCookie(w, auth.ClearCookie(auth.SessionCookieName, s.Session.Secure))
|
s.removeSetupSession(claims.Sub)
|
||||||
http.Redirect(w, r, s.Auth.EndSessionURL(s.Session.FrontendURL+"/", claims.IDToken), http.StatusFound)
|
// Best-effort: an unreadable cookie just means logging out without the
|
||||||
|
// hint, at worst showing Keycloak's own confirmation screen.
|
||||||
|
var idToken string
|
||||||
|
if cookie, err := r.Cookie(auth.SessionCookieName); err == nil {
|
||||||
|
idToken, _ = auth.IDTokenFromSessionCookie(cookie, s.SessionConfig.Secret)
|
||||||
|
}
|
||||||
|
http.SetCookie(w, auth.ClearCookie(auth.SessionCookieName, s.SessionConfig.Secure))
|
||||||
|
http.Redirect(w, r, s.Auth.EndSessionURL(s.SessionConfig.FrontendURL+"/", idToken), http.StatusFound)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) handleSessionMe(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleSessionMe(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -112,7 +129,13 @@ func (s *Server) handleSessionMe(w http.ResponseWriter, r *http.Request) {
|
|||||||
resp := sessionMeResponse{Name: claims.Name, Email: claims.Email}
|
resp := sessionMeResponse{Name: claims.Name, Email: claims.Email}
|
||||||
if u, found := userFromContext(r.Context()); found {
|
if u, found := userFromContext(r.Context()); found {
|
||||||
resp.HasProfile = true
|
resp.HasProfile = true
|
||||||
resp.DisplayName = u.DisplayName
|
resp.DisplayName = u.Name
|
||||||
|
profile, err := s.DB.GetProfile(r.Context(), u.ID)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
resp.GarminConnected = profile.GarminConnectedAt != nil
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, resp)
|
writeJSON(w, http.StatusOK, resp)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,11 +3,120 @@ package api
|
|||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
|
||||||
"geniusrun/backend/internal/auth"
|
"geniusrun/backend/internal/auth"
|
||||||
|
"geniusrun/backend/internal/garmin"
|
||||||
|
applog "geniusrun/backend/internal/log"
|
||||||
)
|
)
|
||||||
|
|
||||||
func (s *Server) handleSetup(w http.ResponseWriter, r *http.Request) {
|
// 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)
|
||||||
|
// rather than a user id. Closed (never promoted into Server.userClient) once
|
||||||
|
// /api/setup/complete actually creates the account -- its Client's
|
||||||
|
// garmin.Config.TokenStorePath is permanently pinned to the ephemeral
|
||||||
|
// setup/{hash} directory, so reusing the object after that directory is
|
||||||
|
// renamed to the permanent {userID} one would respawn against a stale path
|
||||||
|
// 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 SessionConfig.SetupTimeout.
|
||||||
|
type setupSession struct {
|
||||||
|
Client garmin.Client
|
||||||
|
Email, Password string
|
||||||
|
Status garmin.AuthStatus
|
||||||
|
Message string
|
||||||
|
LastUsed time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleSetupGarminLogin authenticates against Garmin using an ephemeral,
|
||||||
|
// not-yet-persisted session keyed by the OIDC subject -- no users/profile
|
||||||
|
// row exists yet at this point (see setupSession in server.go).
|
||||||
|
func (s *Server) handleSetupGarminLogin(w http.ResponseWriter, r *http.Request) {
|
||||||
|
claims, ok := auth.ClaimsFromContext(r.Context())
|
||||||
|
if !ok {
|
||||||
|
writeError(w, http.StatusUnauthorized, "not authenticated")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, found := userFromContext(r.Context()); found {
|
||||||
|
writeError(w, http.StatusConflict, "profile already exists for this account")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var body struct {
|
||||||
|
GarminEmail string `json:"garmin_email"`
|
||||||
|
GarminPassword string `json:"garmin_password"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid request body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if body.GarminEmail == "" || body.GarminPassword == "" {
|
||||||
|
writeError(w, http.StatusBadRequest, "garmin_email and garmin_password are required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
sess := s.replaceSetupSession(claims.Sub, body.GarminEmail, body.GarminPassword)
|
||||||
|
res, err := sess.Client.Authenticate(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusBadGateway, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.recordSetupAuthResult(claims.Sub, res)
|
||||||
|
writeJSON(w, http.StatusOK, authResponse{Status: authStatusString(res.Status), Message: res.Message})
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleSetupGarminMFA continues an in-progress ephemeral Garmin login
|
||||||
|
// (started by handleSetupGarminLogin) with an MFA code, on the same
|
||||||
|
// session/subprocess -- never replaces it.
|
||||||
|
func (s *Server) handleSetupGarminMFA(w http.ResponseWriter, r *http.Request) {
|
||||||
|
claims, ok := auth.ClaimsFromContext(r.Context())
|
||||||
|
if !ok {
|
||||||
|
writeError(w, http.StatusUnauthorized, "not authenticated")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var body struct {
|
||||||
|
Code string `json:"code"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid request body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if body.Code == "" {
|
||||||
|
writeError(w, http.StatusBadRequest, "code is required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
sess, ok := s.setupSessionFor(claims.Sub)
|
||||||
|
if !ok {
|
||||||
|
writeError(w, http.StatusConflict, "no Garmin connection attempt in progress; connect again")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
res, err := sess.Client.CompleteMFA(r.Context(), body.Code)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusBadGateway, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.recordSetupAuthResult(claims.Sub, res)
|
||||||
|
writeJSON(w, http.StatusOK, authResponse{Status: authStatusString(res.Status), Message: res.Message})
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleSetupComplete is the single atomic commit point: only reachable
|
||||||
|
// once the ephemeral session for this subject last reported
|
||||||
|
// garmin.AuthSuccess. Provisions the account, persists the Garmin
|
||||||
|
// credentials, marks it connected, closes the ephemeral client, and
|
||||||
|
// renames its token-store directory into the permanent per-user path --
|
||||||
|
// the next real clientFor(ctx, userID) builds a fresh client from scratch
|
||||||
|
// against that now-permanent directory, whose subprocess's lazy
|
||||||
|
// startup-login resumes the just-renamed, still-valid session without
|
||||||
|
// needing to re-authenticate (a cheap local token-store resume, not a
|
||||||
|
// fresh Garmin login).
|
||||||
|
func (s *Server) handleSetupComplete(w http.ResponseWriter, r *http.Request) {
|
||||||
claims, ok := auth.ClaimsFromContext(r.Context())
|
claims, ok := auth.ClaimsFromContext(r.Context())
|
||||||
if !ok {
|
if !ok {
|
||||||
writeError(w, http.StatusUnauthorized, "not authenticated")
|
writeError(w, http.StatusUnauthorized, "not authenticated")
|
||||||
@@ -30,10 +139,145 @@ func (s *Server) handleSetup(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
sess, ok := s.setupSessionFor(claims.Sub)
|
||||||
|
if !ok || sess.Status != garmin.AuthSuccess {
|
||||||
|
writeError(w, http.StatusConflict, "Garmin isn't connected yet -- connect before completing setup")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
userID, err := s.DB.ProvisionUser(r.Context(), claims.Sub, body.DisplayName)
|
userID, err := s.DB.ProvisionUser(r.Context(), claims.Sub, body.DisplayName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, http.StatusInternalServerError, err.Error())
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
profile, err := s.DB.GetProfile(r.Context(), userID)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
profile.GarminEmail = sess.Email
|
||||||
|
profile.GarminPassword = sess.Password
|
||||||
|
if err := s.DB.UpdateProfile(r.Context(), userID, profile); err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := s.DB.MarkGarminConnected(r.Context(), userID); err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// The ephemeral client's own garmin.Config.TokenStorePath was set once,
|
||||||
|
// at construction time in replaceSetupSession, to the ephemeral
|
||||||
|
// setup/{hash} directory being renamed below -- there's no setter to
|
||||||
|
// correct it in place, so promoting this object into s.userGarmin would
|
||||||
|
// leave a client whose subprocess respawns (e.g. on the very next
|
||||||
|
// UpdateCredentials call from a Profile save) using that now-stale
|
||||||
|
// path, recreating a setup/{hash} directory next to the real one.
|
||||||
|
// Closing it here and leaving s.userGarmin empty for this user makes
|
||||||
|
// the next garminFor(ctx, userID) call build a fresh client against the
|
||||||
|
// correct, just-renamed {userID} directory instead -- its subprocess's
|
||||||
|
// lazy startup-login resumes that session without a real Garmin
|
||||||
|
// re-authentication.
|
||||||
|
sess.Client.Close()
|
||||||
|
|
||||||
|
s.mu.Lock()
|
||||||
|
delete(s.setupSession, claims.Sub)
|
||||||
|
s.userAuthStatus[userID] = sess.Status
|
||||||
|
s.userAuthMessage[userID] = sess.Message
|
||||||
|
s.mu.Unlock()
|
||||||
|
|
||||||
|
if s.ClientConfig.TokenStorePath != "" {
|
||||||
|
oldDir := setupTokenStoreDir(s.ClientConfig.TokenStorePath, claims.Sub)
|
||||||
|
newDir := filepath.Join(s.ClientConfig.TokenStorePath, strconv.FormatInt(userID, 10))
|
||||||
|
// A stale {userID} directory can survive from a previous account
|
||||||
|
// with the same id -- pre-production the DB file is freely deleted
|
||||||
|
// and recreated (ids restart at 1) while the token-store root lives
|
||||||
|
// on, and os.Rename refuses to replace a non-empty directory. The
|
||||||
|
// just-validated setup session must win, so clear the target first.
|
||||||
|
if err := os.RemoveAll(newDir); err != nil {
|
||||||
|
applog.App().Error("remove stale token store dir", "user_id", userID, "error", err)
|
||||||
|
}
|
||||||
|
if err := os.Rename(oldDir, newDir); err != nil && !os.IsNotExist(err) {
|
||||||
|
applog.App().Error("rename setup token store dir", "user_id", userID, "error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
writeJSON(w, http.StatusOK, map[string]any{"user_id": userID, "display_name": body.DisplayName})
|
writeJSON(w, http.StatusOK, map[string]any{"user_id": userID, "display_name": body.DisplayName})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// setupSessionFor returns sub's in-progress ephemeral Garmin session, if
|
||||||
|
// any and not stale. A stale session is closed and evicted first, so the
|
||||||
|
// caller always either gets a fresh, live session or none.
|
||||||
|
func (s *Server) setupSessionFor(sub string) (*setupSession, bool) {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
sess, ok := s.setupSession[sub]
|
||||||
|
if !ok {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
if time.Since(sess.LastUsed) > s.SessionConfig.SetupTimeout {
|
||||||
|
sess.Client.Close()
|
||||||
|
delete(s.setupSession, sub)
|
||||||
|
if s.ClientConfig.TokenStorePath != "" {
|
||||||
|
os.RemoveAll(setupTokenStoreDir(s.ClientConfig.TokenStorePath, sub))
|
||||||
|
}
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
return sess, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// replaceSetupSession closes and replaces sub's ephemeral Garmin session
|
||||||
|
// (if any) with a freshly built one for the given credentials -- same
|
||||||
|
// "close old, spawn new" semantics as garmin.Client.UpdateCredentials.
|
||||||
|
func (s *Server) replaceSetupSession(sub, email, password string) *setupSession {
|
||||||
|
s.mu.Lock()
|
||||||
|
old, ok := s.setupSession[sub]
|
||||||
|
s.mu.Unlock()
|
||||||
|
if ok {
|
||||||
|
old.Client.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg := s.ClientConfig
|
||||||
|
cfg.GarminEmail = email
|
||||||
|
cfg.GarminPassword = password
|
||||||
|
if cfg.TokenStorePath != "" {
|
||||||
|
cfg.TokenStorePath = setupTokenStoreDir(s.ClientConfig.TokenStorePath, sub)
|
||||||
|
}
|
||||||
|
sess := &setupSession{Client: s.GarminFactory(cfg), Email: email, Password: password, LastUsed: time.Now()}
|
||||||
|
|
||||||
|
s.mu.Lock()
|
||||||
|
s.setupSession[sub] = sess
|
||||||
|
s.mu.Unlock()
|
||||||
|
return sess
|
||||||
|
}
|
||||||
|
|
||||||
|
// recordSetupAuthResult updates sub's ephemeral session after a login or
|
||||||
|
// MFA attempt. A no-op if the session is gone (e.g. evicted concurrently).
|
||||||
|
func (s *Server) recordSetupAuthResult(sub string, res garmin.AuthResult) {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
if sess, ok := s.setupSession[sub]; ok {
|
||||||
|
sess.Status = res.Status
|
||||||
|
sess.Message = res.Message
|
||||||
|
sess.LastUsed = time.Now()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// removeSetupSession closes and drops sub's ephemeral Garmin session, if
|
||||||
|
// any, and best-effort removes its token-store directory. Used when
|
||||||
|
// abandoning onboarding (logout). handleSetupComplete closes the client the
|
||||||
|
// same way but keeps (renames) the directory instead of removing it, since
|
||||||
|
// that's the real, now-permanent session.
|
||||||
|
func (s *Server) removeSetupSession(sub string) {
|
||||||
|
s.mu.Lock()
|
||||||
|
sess, ok := s.setupSession[sub]
|
||||||
|
delete(s.setupSession, sub)
|
||||||
|
s.mu.Unlock()
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
sess.Client.Close()
|
||||||
|
if s.ClientConfig.TokenStorePath != "" {
|
||||||
|
os.RemoveAll(setupTokenStoreDir(s.ClientConfig.TokenStorePath, sub))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,82 +4,314 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
authmock "geniusrun/backend/internal/auth/mock"
|
authmock "geniusrun/backend/internal/auth/mock"
|
||||||
"geniusrun/backend/internal/garmin"
|
"geniusrun/backend/internal/garmin"
|
||||||
"geniusrun/backend/internal/garmin/mock"
|
|
||||||
"geniusrun/backend/internal/store"
|
"geniusrun/backend/internal/store"
|
||||||
appsync "geniusrun/backend/internal/sync"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestSetup_ProvisionsNewUserWithDisplayName(t *testing.T) {
|
// newUnprovisionedServer builds a Server whose "test-user" OIDC subject
|
||||||
// This test specifically needs an *unprovisioned* session, unlike every
|
// (the identity doJSON's cookie always mints) has no users/profile row yet
|
||||||
// other test in this package -- build the server without the
|
// -- every test in this file needs that starting state, unlike
|
||||||
// newTestServer helper's automatic ProvisionUser call.
|
// newTestServer's auto-provisioned default.
|
||||||
|
func newUnprovisionedServer(t *testing.T) (*Server, *store.DB, *garmin.MockClient) {
|
||||||
|
t.Helper()
|
||||||
db, err := store.Open(filepath.Join(t.TempDir(), "geniusrun_test.db"))
|
db, err := store.Open(filepath.Join(t.TempDir(), "geniusrun_test.db"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("store.Open: %v", err)
|
t.Fatalf("store.Open: %v", err)
|
||||||
}
|
}
|
||||||
t.Cleanup(func() { db.Close() })
|
t.Cleanup(func() { db.Close() })
|
||||||
m := &mock.Client{}
|
m := &garmin.MockClient{}
|
||||||
s := NewServer(db, func(garmin.Config) garmin.Client { return m }, garmin.Config{}, appsync.Config{}, &authmock.Verifier{}, testSessionConfig)
|
garminFactory := func(garmin.ClientConfig) garmin.Client { return m }
|
||||||
|
s := NewServer(db, garminFactory, garmin.ClientConfig{}, garmin.SyncConfig{}, &authmock.Verifier{}, testSessionConfig)
|
||||||
|
return s, db, m
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSetupGarminLogin_AuthenticatesWithoutCreatingAccount(t *testing.T) {
|
||||||
|
s, db, _ := newUnprovisionedServer(t)
|
||||||
router := s.Router()
|
router := s.Router()
|
||||||
|
|
||||||
rec := doJSON(t, router, http.MethodGet, "/api/session/me", nil)
|
rec := doJSON(t, router, http.MethodPost, "/api/setup/garmin/login", map[string]any{
|
||||||
var me sessionMeResponse
|
"garmin_email": "runner@example.com", "garmin_password": "hunter2",
|
||||||
unmarshalBody(t, rec, &me)
|
})
|
||||||
if me.HasProfile {
|
|
||||||
t.Fatal("expected a brand-new session to have no profile yet")
|
|
||||||
}
|
|
||||||
|
|
||||||
rec = doJSON(t, router, http.MethodPost, "/api/setup", map[string]any{"display_name": "Lucie"})
|
|
||||||
if rec.Code != http.StatusOK {
|
if rec.Code != http.StatusOK {
|
||||||
t.Fatalf("setup status = %d, body = %s", rec.Code, rec.Body.String())
|
t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
var resp authResponse
|
||||||
|
unmarshalBody(t, rec, &resp)
|
||||||
|
if resp.Status != "authenticated" {
|
||||||
|
t.Fatalf("status = %q, want authenticated", resp.Status)
|
||||||
}
|
}
|
||||||
|
|
||||||
rec = doJSON(t, router, http.MethodGet, "/api/session/me", nil)
|
if _, found, err := db.GetUserBySub(newCtx(), "test-user"); err != nil || found {
|
||||||
unmarshalBody(t, rec, &me)
|
t.Fatalf("expected no user created yet, found=%v err=%v", found, err)
|
||||||
if !me.HasProfile || me.DisplayName != "Lucie" {
|
|
||||||
t.Fatalf("session/me after setup = %+v, want HasProfile=true DisplayName=Lucie", me)
|
|
||||||
}
|
|
||||||
|
|
||||||
u, found, err := db.GetUserBySub(newCtx(), "test-user") // doJSON's test cookie's Sub
|
|
||||||
if err != nil || !found {
|
|
||||||
t.Fatalf("GetUserBySub: found=%v err=%v", found, err)
|
|
||||||
}
|
|
||||||
if u.DisplayName != "Lucie" {
|
|
||||||
t.Errorf("stored DisplayName = %q, want Lucie", u.DisplayName)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestSetup_RejectsEmptyDisplayName(t *testing.T) {
|
func TestSetupGarminLogin_MFARequiredThenCompleteMFA(t *testing.T) {
|
||||||
|
s, db, m := newUnprovisionedServer(t)
|
||||||
|
m.AuthResults = []garmin.AuthResult{{Status: garmin.AuthMFARequired, Message: "MFA required"}}
|
||||||
|
router := s.Router()
|
||||||
|
|
||||||
|
rec := doJSON(t, router, http.MethodPost, "/api/setup/garmin/login", map[string]any{
|
||||||
|
"garmin_email": "runner@example.com", "garmin_password": "hunter2",
|
||||||
|
})
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("login status = %d, body = %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
var resp authResponse
|
||||||
|
unmarshalBody(t, rec, &resp)
|
||||||
|
if resp.Status != "mfa_required" {
|
||||||
|
t.Fatalf("status = %q, want mfa_required", resp.Status)
|
||||||
|
}
|
||||||
|
|
||||||
|
rec = doJSON(t, router, http.MethodPost, "/api/setup/garmin/mfa", map[string]any{"code": "123456"})
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("mfa status = %d, body = %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
unmarshalBody(t, rec, &resp)
|
||||||
|
if resp.Status != "authenticated" {
|
||||||
|
t.Fatalf("status after mfa = %q, want authenticated", resp.Status)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, found, err := db.GetUserBySub(newCtx(), "test-user"); err != nil || found {
|
||||||
|
t.Fatalf("expected no user created yet even after MFA success, found=%v err=%v", found, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSetupGarminMFA_RejectsWithoutPriorLoginAttempt(t *testing.T) {
|
||||||
|
s, _, _ := newUnprovisionedServer(t)
|
||||||
|
rec := doJSON(t, s.Router(), http.MethodPost, "/api/setup/garmin/mfa", map[string]any{"code": "123456"})
|
||||||
|
if rec.Code != http.StatusConflict {
|
||||||
|
t.Fatalf("status = %d, want 409, body = %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSetupComplete_CreatesAccountWithGarminCredentialsAndClosesEphemeralClient(t *testing.T) {
|
||||||
db, err := store.Open(filepath.Join(t.TempDir(), "geniusrun_test.db"))
|
db, err := store.Open(filepath.Join(t.TempDir(), "geniusrun_test.db"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("store.Open: %v", err)
|
t.Fatalf("store.Open: %v", err)
|
||||||
}
|
}
|
||||||
t.Cleanup(func() { db.Close() })
|
t.Cleanup(func() { db.Close() })
|
||||||
m := &mock.Client{}
|
m := &garmin.MockClient{}
|
||||||
s := NewServer(db, func(garmin.Config) garmin.Client { return m }, garmin.Config{}, appsync.Config{}, &authmock.Verifier{}, testSessionConfig)
|
tokenStoreRoot := t.TempDir()
|
||||||
|
var factoryConfigs []garmin.ClientConfig
|
||||||
|
garminFactory := func(cfg garmin.ClientConfig) garmin.Client {
|
||||||
|
factoryConfigs = append(factoryConfigs, cfg)
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
s := NewServer(db, garminFactory, garmin.ClientConfig{TokenStorePath: tokenStoreRoot}, garmin.SyncConfig{}, &authmock.Verifier{}, testSessionConfig)
|
||||||
|
router := s.Router()
|
||||||
|
|
||||||
rec := doJSON(t, s.Router(), http.MethodPost, "/api/setup", map[string]any{"display_name": ""})
|
rec := doJSON(t, router, http.MethodPost, "/api/setup/garmin/login", map[string]any{
|
||||||
|
"garmin_email": "runner@example.com", "garmin_password": "hunter2",
|
||||||
|
})
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("login status = %d, body = %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
rec = doJSON(t, router, http.MethodPost, "/api/setup/complete", map[string]any{"display_name": "Lucie"})
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("complete status = %d, body = %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
u, found, err := db.GetUserBySub(newCtx(), "test-user")
|
||||||
|
if err != nil || !found {
|
||||||
|
t.Fatalf("GetUserBySub: found=%v err=%v", found, err)
|
||||||
|
}
|
||||||
|
profile, err := db.GetProfile(newCtx(), u.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetProfile: %v", err)
|
||||||
|
}
|
||||||
|
if profile.GarminEmail != "runner@example.com" || profile.GarminPassword != "hunter2" {
|
||||||
|
t.Errorf("profile garmin creds = %+v, want email=runner@example.com password=hunter2", profile)
|
||||||
|
}
|
||||||
|
if profile.GarminConnectedAt == nil {
|
||||||
|
t.Error("expected GarminConnectedAt to be set")
|
||||||
|
}
|
||||||
|
|
||||||
|
if m.AuthenticateCalls != 1 {
|
||||||
|
t.Errorf("AuthenticateCalls = %d, want 1 (only the original login, no redundant re-authentication)", m.AuthenticateCalls)
|
||||||
|
}
|
||||||
|
if !m.ClosedCalled {
|
||||||
|
t.Error("expected the ephemeral client to be Close()d at setup completion, not promoted as-is -- its cfg.TokenStorePath still points at the ephemeral setup/{hash} dir, which would go stale the moment anything (e.g. a Profile save) later respawns it")
|
||||||
|
}
|
||||||
|
|
||||||
|
// A later real use must build a genuinely fresh client, configured
|
||||||
|
// against the permanent {userID} token store directory -- never the
|
||||||
|
// stale ephemeral setup/{hash} one the closed client was carrying.
|
||||||
|
if _, err := s.clientFor(newCtx(), u.ID); err != nil {
|
||||||
|
t.Fatalf("garminFor: %v", err)
|
||||||
|
}
|
||||||
|
wantTokenStorePath := filepath.Join(tokenStoreRoot, itoa(u.ID))
|
||||||
|
var gotTokenStorePath string
|
||||||
|
for _, cfg := range factoryConfigs {
|
||||||
|
if cfg.GarminEmail == "runner@example.com" {
|
||||||
|
gotTokenStorePath = cfg.TokenStorePath
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if gotTokenStorePath != wantTokenStorePath {
|
||||||
|
t.Errorf("garminFor built client with TokenStorePath = %q, want %q", gotTokenStorePath, wantTokenStorePath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSetupComplete_RejectsWithoutSuccessfulGarminConnection(t *testing.T) {
|
||||||
|
s, db, _ := newUnprovisionedServer(t)
|
||||||
|
rec := doJSON(t, s.Router(), http.MethodPost, "/api/setup/complete", map[string]any{"display_name": "Lucie"})
|
||||||
|
if rec.Code != http.StatusConflict {
|
||||||
|
t.Fatalf("status = %d, want 409, body = %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
if _, found, err := db.GetUserBySub(newCtx(), "test-user"); err != nil || found {
|
||||||
|
t.Fatalf("expected no user created, found=%v err=%v", found, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSetupComplete_RejectsEmptyDisplayName(t *testing.T) {
|
||||||
|
s, _, _ := newUnprovisionedServer(t)
|
||||||
|
router := s.Router()
|
||||||
|
rec := doJSON(t, router, http.MethodPost, "/api/setup/garmin/login", map[string]any{
|
||||||
|
"garmin_email": "runner@example.com", "garmin_password": "hunter2",
|
||||||
|
})
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("login status = %d, body = %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
rec = doJSON(t, router, http.MethodPost, "/api/setup/complete", map[string]any{"display_name": ""})
|
||||||
if rec.Code != http.StatusBadRequest {
|
if rec.Code != http.StatusBadRequest {
|
||||||
t.Fatalf("status = %d, want 400", rec.Code)
|
t.Fatalf("status = %d, want 400", rec.Code)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestSetup_RejectsWhenAlreadyProvisioned(t *testing.T) {
|
func TestSetupGarminLogin_RejectsWhenAlreadyProvisioned(t *testing.T) {
|
||||||
s, _, _ := newTestServer(t)
|
s, _, _ := newTestServer(t) // pre-provisioned "test-user"
|
||||||
rec := doJSON(t, s.Router(), http.MethodPost, "/api/setup", map[string]any{"display_name": "Someone Else"})
|
rec := doJSON(t, s.Router(), http.MethodPost, "/api/setup/garmin/login", map[string]any{
|
||||||
|
"garmin_email": "runner@example.com", "garmin_password": "hunter2",
|
||||||
|
})
|
||||||
if rec.Code != http.StatusConflict {
|
if rec.Code != http.StatusConflict {
|
||||||
t.Fatalf("status = %d, want 409, body = %s", rec.Code, rec.Body.String())
|
t.Fatalf("status = %d, want 409, body = %s", rec.Code, rec.Body.String())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestSetupComplete_RejectsWhenAlreadyProvisioned(t *testing.T) {
|
||||||
|
s, _, _ := newTestServer(t) // pre-provisioned "test-user"
|
||||||
|
rec := doJSON(t, s.Router(), http.MethodPost, "/api/setup/complete", map[string]any{"display_name": "Someone Else"})
|
||||||
|
if rec.Code != http.StatusConflict {
|
||||||
|
t.Fatalf("status = %d, want 409, body = %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSetupComplete_RenamesTokenStoreDirectory(t *testing.T) {
|
||||||
|
db, err := store.Open(filepath.Join(t.TempDir(), "geniusrun_test.db"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("store.Open: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { db.Close() })
|
||||||
|
m := &garmin.MockClient{}
|
||||||
|
tokenStoreRoot := t.TempDir()
|
||||||
|
s := NewServer(db, func(garmin.ClientConfig) garmin.Client { return m }, garmin.ClientConfig{TokenStorePath: tokenStoreRoot}, garmin.SyncConfig{}, &authmock.Verifier{}, testSessionConfig)
|
||||||
|
router := s.Router()
|
||||||
|
|
||||||
|
rec := doJSON(t, router, http.MethodPost, "/api/setup/garmin/login", map[string]any{
|
||||||
|
"garmin_email": "runner@example.com", "garmin_password": "hunter2",
|
||||||
|
})
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("login status = %d, body = %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Simulate what a real subprocess would have written under the
|
||||||
|
// ephemeral (subject-keyed) directory during that login call.
|
||||||
|
oldDir := setupTokenStoreDir(tokenStoreRoot, "test-user")
|
||||||
|
if err := os.MkdirAll(oldDir, 0o755); err != nil {
|
||||||
|
t.Fatalf("MkdirAll: %v", err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(oldDir, "session.json"), []byte("{}"), 0o644); err != nil {
|
||||||
|
t.Fatalf("WriteFile: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
rec = doJSON(t, router, http.MethodPost, "/api/setup/complete", map[string]any{"display_name": "Lucie"})
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("complete status = %d, body = %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
u, found, err := db.GetUserBySub(newCtx(), "test-user")
|
||||||
|
if err != nil || !found {
|
||||||
|
t.Fatalf("GetUserBySub: found=%v err=%v", found, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := os.Stat(oldDir); !os.IsNotExist(err) {
|
||||||
|
t.Fatalf("expected old setup token store dir %q to be gone, stat err = %v", oldDir, err)
|
||||||
|
}
|
||||||
|
newDir := filepath.Join(tokenStoreRoot, itoa(u.ID))
|
||||||
|
if _, err := os.Stat(filepath.Join(newDir, "session.json")); err != nil {
|
||||||
|
t.Fatalf("expected renamed token store dir %q to contain session.json: %v", newDir, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func unmarshalBody(t *testing.T, rec *httptest.ResponseRecorder, v any) {
|
func unmarshalBody(t *testing.T, rec *httptest.ResponseRecorder, v any) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
if err := json.Unmarshal(rec.Body.Bytes(), v); err != nil {
|
if err := json.Unmarshal(rec.Body.Bytes(), v); err != nil {
|
||||||
t.Fatalf("unmarshal response body %q: %v", rec.Body.String(), err)
|
t.Fatalf("unmarshal response body %q: %v", rec.Body.String(), err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Regression: os.Rename refuses to replace an existing directory, so a
|
||||||
|
// stale {userID} token-store dir (left behind when the DB file was
|
||||||
|
// recreated -- ids restart at 1 -- while the token-store root survived)
|
||||||
|
// used to make setup completion silently keep the OLD tokens in place.
|
||||||
|
// The fresh, just-validated session's directory must win.
|
||||||
|
func TestSetupComplete_ReplacesStaleTokenStoreDir(t *testing.T) {
|
||||||
|
db, err := store.Open(filepath.Join(t.TempDir(), "setup_stale_dir_test.db"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("store.Open: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { db.Close() })
|
||||||
|
m := &garmin.MockClient{}
|
||||||
|
tokenStoreRoot := t.TempDir()
|
||||||
|
s := NewServer(db, func(garmin.ClientConfig) garmin.Client { return m }, garmin.ClientConfig{TokenStorePath: tokenStoreRoot}, garmin.SyncConfig{}, &authmock.Verifier{}, testSessionConfig)
|
||||||
|
router := s.Router()
|
||||||
|
|
||||||
|
// The ephemeral setup dir the wrapper would have written tokens into.
|
||||||
|
setupDir := setupTokenStoreDir(tokenStoreRoot, "test-user")
|
||||||
|
if err := os.MkdirAll(setupDir, 0o755); err != nil {
|
||||||
|
t.Fatalf("mkdir setup dir: %v", err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(setupDir, "oauth_token"), []byte("fresh"), 0o600); err != nil {
|
||||||
|
t.Fatalf("write fresh token: %v", err)
|
||||||
|
}
|
||||||
|
// A stale dir already occupying the permanent {userID} path (fresh DB
|
||||||
|
// starts ids at 1).
|
||||||
|
staleDir := filepath.Join(tokenStoreRoot, "1")
|
||||||
|
if err := os.MkdirAll(staleDir, 0o755); err != nil {
|
||||||
|
t.Fatalf("mkdir stale dir: %v", err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(staleDir, "oauth_token"), []byte("stale"), 0o600); err != nil {
|
||||||
|
t.Fatalf("write stale token: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
rec := doJSON(t, router, http.MethodPost, "/api/setup/garmin/login", map[string]any{
|
||||||
|
"garmin_email": "runner@example.com", "garmin_password": "hunter2",
|
||||||
|
})
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("login status = %d, body = %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
rec = doJSON(t, router, http.MethodPost, "/api/setup/complete", map[string]any{"display_name": "Lucie"})
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("complete status = %d, body = %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
u, found, err := db.GetUserBySub(newCtx(), "test-user")
|
||||||
|
if err != nil || !found {
|
||||||
|
t.Fatalf("GetUserBySub: found=%v err=%v", found, err)
|
||||||
|
}
|
||||||
|
token, err := os.ReadFile(filepath.Join(tokenStoreRoot, itoa(u.ID), "oauth_token"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read token after complete: %v", err)
|
||||||
|
}
|
||||||
|
if string(token) != "fresh" {
|
||||||
|
t.Fatalf("token content = %q, want the fresh setup session to replace the stale dir", token)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(setupDir); !os.IsNotExist(err) {
|
||||||
|
t.Errorf("ephemeral setup dir still present after rename: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,102 +0,0 @@
|
|||||||
package api
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"net/http"
|
|
||||||
)
|
|
||||||
|
|
||||||
// detailFillBatchSize bounds how many activities' details/splits are fetched
|
|
||||||
// per sync trigger, matching the sequential rate-limited fetch in
|
|
||||||
// internal/sync.Service.FillPendingDetails.
|
|
||||||
const detailFillBatchSize = 50
|
|
||||||
|
|
||||||
// handleSyncRun does a full sync pass: Backfill first (resumes from the
|
|
||||||
// watermark, so widening the configured history horizon between clicks is
|
|
||||||
// picked up automatically), then IncrementalSync (catches anything new since
|
|
||||||
// the latest known activity), then fills in details for whatever's still
|
|
||||||
// missing them. Activities already fully processed are left untouched --
|
|
||||||
// see internal/sync.Service.FillPendingDetails. Recorded as a single
|
|
||||||
// FullSync run so "last sync" reports the combined activity count, not just
|
|
||||||
// whichever of Backfill/IncrementalSync happened to finish last.
|
|
||||||
func (s *Server) handleSyncRun(w http.ResponseWriter, r *http.Request) {
|
|
||||||
userID := userIDFromContext(r.Context())
|
|
||||||
svc, err := s.syncFor(r.Context(), userID)
|
|
||||||
if err != nil {
|
|
||||||
writeError(w, http.StatusInternalServerError, err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
ok := s.backgroundSync(userID, func(ctx context.Context) error {
|
|
||||||
return svc.FullSync(ctx, detailFillBatchSize)
|
|
||||||
})
|
|
||||||
if !ok {
|
|
||||||
writeError(w, http.StatusConflict, "a sync is already in progress")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
writeJSON(w, http.StatusAccepted, map[string]string{"status": "started"})
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleSyncReset wipes every synced activity (and its laps/samples/kind
|
|
||||||
// assignments) and rewinds the backfill watermark, so the next Sync Now
|
|
||||||
// performs a genuinely fresh pull from Garmin. Destructive -- the frontend
|
|
||||||
// gates this behind a confirmation.
|
|
||||||
func (s *Server) handleSyncReset(w http.ResponseWriter, r *http.Request) {
|
|
||||||
userID := userIDFromContext(r.Context())
|
|
||||||
svc, err := s.syncFor(r.Context(), userID)
|
|
||||||
if err != nil {
|
|
||||||
writeError(w, http.StatusInternalServerError, err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
ok := s.backgroundSync(userID, func(ctx context.Context) error {
|
|
||||||
return svc.ResetAll(ctx)
|
|
||||||
})
|
|
||||||
if !ok {
|
|
||||||
writeError(w, http.StatusConflict, "a sync is already in progress")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
writeJSON(w, http.StatusAccepted, map[string]string{"status": "started"})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Server) handleSyncRuns(w http.ResponseWriter, r *http.Request) {
|
|
||||||
userID := userIDFromContext(r.Context())
|
|
||||||
runs, err := s.DB.ListSyncRuns(r.Context(), userID, 20)
|
|
||||||
if err != nil {
|
|
||||||
writeError(w, http.StatusInternalServerError, err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
writeJSON(w, http.StatusOK, runs)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Server) handleSyncStatus(w http.ResponseWriter, r *http.Request) {
|
|
||||||
userID := userIDFromContext(r.Context())
|
|
||||||
run, ok, err := s.DB.LatestSyncRun(r.Context(), userID)
|
|
||||||
if err != nil {
|
|
||||||
writeError(w, http.StatusInternalServerError, err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
remaining, err := s.DB.CountActivitiesMissingDetails(r.Context(), userID)
|
|
||||||
if err != nil {
|
|
||||||
writeError(w, http.StatusInternalServerError, err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
s.mu.Lock()
|
|
||||||
inProgress := s.userSyncRunning[userID]
|
|
||||||
s.mu.Unlock()
|
|
||||||
|
|
||||||
svc, err := s.syncFor(r.Context(), userID)
|
|
||||||
if err != nil {
|
|
||||||
writeError(w, http.StatusInternalServerError, err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
progress := svc.Progress()
|
|
||||||
|
|
||||||
resp := map[string]any{
|
|
||||||
"in_progress": inProgress,
|
|
||||||
"detail_fill_progress": progress,
|
|
||||||
"activities_pending_details": remaining,
|
|
||||||
}
|
|
||||||
if ok {
|
|
||||||
resp["last_run"] = run
|
|
||||||
}
|
|
||||||
writeJSON(w, http.StatusOK, resp)
|
|
||||||
}
|
|
||||||
@@ -15,7 +15,7 @@ const resolvedUserContextKey userContextKey = iota
|
|||||||
// session's OIDC subject.
|
// session's OIDC subject.
|
||||||
type resolvedUser struct {
|
type resolvedUser struct {
|
||||||
ID int64
|
ID int64
|
||||||
DisplayName string
|
Name string
|
||||||
}
|
}
|
||||||
|
|
||||||
// resolveUser runs after auth.RequireSession on every request and looks up
|
// resolveUser runs after auth.RequireSession on every request and looks up
|
||||||
@@ -40,7 +40,7 @@ func (s *Server) resolveUser(next http.Handler) http.Handler {
|
|||||||
}
|
}
|
||||||
ctx := r.Context()
|
ctx := r.Context()
|
||||||
if found {
|
if found {
|
||||||
ctx = context.WithValue(ctx, resolvedUserContextKey, resolvedUser{ID: u.ID, DisplayName: u.DisplayName})
|
ctx = context.WithValue(ctx, resolvedUserContextKey, resolvedUser{ID: u.ID, Name: u.Name})
|
||||||
}
|
}
|
||||||
next.ServeHTTP(w, r.WithContext(ctx))
|
next.ServeHTTP(w, r.WithContext(ctx))
|
||||||
})
|
})
|
||||||
@@ -9,9 +9,7 @@ import (
|
|||||||
"geniusrun/backend/internal/auth"
|
"geniusrun/backend/internal/auth"
|
||||||
authmock "geniusrun/backend/internal/auth/mock"
|
authmock "geniusrun/backend/internal/auth/mock"
|
||||||
"geniusrun/backend/internal/garmin"
|
"geniusrun/backend/internal/garmin"
|
||||||
"geniusrun/backend/internal/garmin/mock"
|
|
||||||
"geniusrun/backend/internal/store"
|
"geniusrun/backend/internal/store"
|
||||||
appsync "geniusrun/backend/internal/sync"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestResolveUser_AttachesResolvedUserWhenProvisioned(t *testing.T) {
|
func TestResolveUser_AttachesResolvedUserWhenProvisioned(t *testing.T) {
|
||||||
@@ -41,8 +39,8 @@ func TestResolveUser_LeavesContextEmptyWhenNotProvisioned(t *testing.T) {
|
|||||||
t.Fatalf("store.Open: %v", err)
|
t.Fatalf("store.Open: %v", err)
|
||||||
}
|
}
|
||||||
t.Cleanup(func() { db.Close() })
|
t.Cleanup(func() { db.Close() })
|
||||||
m := &mock.Client{}
|
m := &garmin.MockClient{}
|
||||||
s := NewServer(db, func(garmin.Config) garmin.Client { return m }, garmin.Config{}, appsync.Config{}, &authmock.Verifier{}, testSessionConfig)
|
s := NewServer(db, func(garmin.ClientConfig) garmin.Client { return m }, garmin.ClientConfig{}, garmin.SyncConfig{}, &authmock.Verifier{}, testSessionConfig)
|
||||||
|
|
||||||
var gotOK bool
|
var gotOK bool
|
||||||
handler := auth.RequireSession(testSessionConfig.Secret)(s.resolveUser(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
handler := auth.RequireSession(testSessionConfig.Secret)(s.resolveUser(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -28,7 +28,7 @@ func TestRequireSession_NoCookie(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestRequireSession_ValidCookie(t *testing.T) {
|
func TestRequireSession_ValidCookie(t *testing.T) {
|
||||||
cookie, err := MintSessionCookie(Claims{Sub: "u1", Name: "Alice", Email: "alice@example.com"}, []byte(testSecret), time.Hour, false)
|
cookie, err := MintSessionCookie(Claims{Sub: "u1", Name: "Alice", Email: "alice@example.com"}, "", []byte(testSecret), time.Hour, false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("mint: %v", err)
|
t.Fatalf("mint: %v", err)
|
||||||
}
|
}
|
||||||
@@ -45,7 +45,7 @@ func TestRequireSession_ValidCookie(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestRequireSession_ExpiredCookie(t *testing.T) {
|
func TestRequireSession_ExpiredCookie(t *testing.T) {
|
||||||
cookie, err := MintSessionCookie(Claims{Sub: "u1"}, []byte(testSecret), -time.Hour, false)
|
cookie, err := MintSessionCookie(Claims{Sub: "u1"}, "", []byte(testSecret), -time.Hour, false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("mint: %v", err)
|
t.Fatalf("mint: %v", err)
|
||||||
}
|
}
|
||||||
@@ -59,11 +59,11 @@ func TestRequireSession_ExpiredCookie(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestRequireSession_TamperedCookie(t *testing.T) {
|
func TestRequireSession_TamperedCookie(t *testing.T) {
|
||||||
cookie, err := MintSessionCookie(Claims{Sub: "u1"}, []byte(testSecret), time.Hour, false)
|
cookie, err := MintSessionCookie(Claims{Sub: "u1"}, "", []byte(testSecret), time.Hour, false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("mint: %v", err)
|
t.Fatalf("mint: %v", err)
|
||||||
}
|
}
|
||||||
cookie.Value = flipLastChar(cookie.Value)
|
cookie.Value = flipSignatureChar(cookie.Value)
|
||||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||||
req.AddCookie(cookie)
|
req.AddCookie(cookie)
|
||||||
rec := httptest.NewRecorder()
|
rec := httptest.NewRecorder()
|
||||||
|
|||||||
@@ -36,6 +36,10 @@ type Verifier interface {
|
|||||||
type LoginResult struct {
|
type LoginResult struct {
|
||||||
Claims Claims
|
Claims Claims
|
||||||
Authorized bool
|
Authorized bool
|
||||||
|
// IDToken is the raw Keycloak ID token JWT, kept apart from Claims:
|
||||||
|
// it's only ever needed once more, at logout (id_token_hint), so it
|
||||||
|
// rides in the session cookie but never in the per-request Claims.
|
||||||
|
IDToken string
|
||||||
}
|
}
|
||||||
|
|
||||||
// OIDCConfig configures NewOIDCVerifier. RequiredRole is checked against the
|
// OIDCConfig configures NewOIDCVerifier. RequiredRole is checked against the
|
||||||
@@ -135,8 +139,9 @@ func (v *oidcVerifier) HandleCallback(ctx context.Context, txn TxnState, query u
|
|||||||
return LoginResult{}, fmt.Errorf("decode id_token claims: %w", err)
|
return LoginResult{}, fmt.Errorf("decode id_token claims: %w", err)
|
||||||
}
|
}
|
||||||
return LoginResult{
|
return LoginResult{
|
||||||
Claims: Claims{Sub: claims.Sub, Name: claims.Name, Email: claims.Email, IDToken: rawIDToken},
|
Claims: Claims{Sub: claims.Sub, Name: claims.Name, Email: claims.Email},
|
||||||
Authorized: claims.hasRole(v.requiredRole),
|
Authorized: claims.hasRole(v.requiredRole),
|
||||||
|
IDToken: rawIDToken,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,8 +5,10 @@
|
|||||||
// tokens are never stored or refreshed -- once HandleCallback verifies the
|
// tokens are never stored or refreshed -- once HandleCallback verifies the
|
||||||
// ID token and role, only this package's own cookie matters for subsequent
|
// ID token and role, only this package's own cookie matters for subsequent
|
||||||
// requests. The one exception is the raw ID token itself, carried opaquely
|
// requests. The one exception is the raw ID token itself, carried opaquely
|
||||||
// inside the signed session cookie (Claims.IDToken) solely so a later
|
// inside the signed session cookie (apart from Claims -- see
|
||||||
// logout can pass it back to Keycloak as id_token_hint -- letting Keycloak
|
// MintSessionCookie's idToken parameter and IDTokenFromSessionCookie)
|
||||||
|
// solely so a later logout can pass it back to Keycloak as
|
||||||
|
// id_token_hint -- letting Keycloak
|
||||||
// skip its own logout-confirmation prompt for a session it can positively
|
// skip its own logout-confirmation prompt for a session it can positively
|
||||||
// identify, rather than leaving the user a chance to cancel out of it after
|
// identify, rather than leaving the user a chance to cancel out of it after
|
||||||
// their geniusrun account (and profile) is already gone.
|
// their geniusrun account (and profile) is already gone.
|
||||||
@@ -32,14 +34,15 @@ const (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// Claims identifies the authenticated user, carried in the signed session
|
// Claims identifies the authenticated user, carried in the signed session
|
||||||
// cookie. IDToken is the raw Keycloak ID token JWT from the OIDC callback,
|
// cookie and stashed in every request's context. Deliberately does NOT
|
||||||
// carried opaquely so a later logout can pass it back to Keycloak as
|
// carry the raw Keycloak ID token: that JWT lives in the cookie payload
|
||||||
// id_token_hint (see EndSessionURL) -- geniusrun never inspects it itself.
|
// separately (see MintSessionCookie/IDTokenFromSessionCookie) because it's
|
||||||
|
// only ever needed once more, at logout, and has no business riding
|
||||||
|
// through every handler's context.
|
||||||
type Claims struct {
|
type Claims struct {
|
||||||
Sub string
|
Sub string
|
||||||
Name string
|
Name string
|
||||||
Email string
|
Email string
|
||||||
IDToken string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type sessionClaims struct {
|
type sessionClaims struct {
|
||||||
@@ -65,13 +68,15 @@ type txnClaims struct {
|
|||||||
|
|
||||||
// MintSessionCookie signs claims into a JWT valid for duration and wraps it
|
// MintSessionCookie signs claims into a JWT valid for duration and wraps it
|
||||||
// in a cookie. secure should be true whenever the app is served over HTTPS.
|
// in a cookie. secure should be true whenever the app is served over HTTPS.
|
||||||
func MintSessionCookie(claims Claims, secret []byte, duration time.Duration, secure bool) (*http.Cookie, error) {
|
// idToken is the raw Keycloak ID token to carry for the eventual logout's
|
||||||
|
// id_token_hint (empty is fine, e.g. in tests -- the hint is optional).
|
||||||
|
func MintSessionCookie(claims Claims, idToken string, secret []byte, duration time.Duration, secure bool) (*http.Cookie, error) {
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, sessionClaims{
|
token := jwt.NewWithClaims(jwt.SigningMethodHS256, sessionClaims{
|
||||||
Sub: claims.Sub,
|
Sub: claims.Sub,
|
||||||
Name: claims.Name,
|
Name: claims.Name,
|
||||||
Email: claims.Email,
|
Email: claims.Email,
|
||||||
IDToken: claims.IDToken,
|
IDToken: idToken,
|
||||||
RegisteredClaims: jwt.RegisteredClaims{
|
RegisteredClaims: jwt.RegisteredClaims{
|
||||||
IssuedAt: jwt.NewNumericDate(now),
|
IssuedAt: jwt.NewNumericDate(now),
|
||||||
ExpiresAt: jwt.NewNumericDate(now.Add(duration)),
|
ExpiresAt: jwt.NewNumericDate(now.Add(duration)),
|
||||||
@@ -102,7 +107,21 @@ func ParseSessionCookie(cookie *http.Cookie, secret []byte) (Claims, error) {
|
|||||||
if _, err := jwt.ParseWithClaims(cookie.Value, &sc, keyfunc(secret), jwt.WithValidMethods([]string{"HS256"})); err != nil {
|
if _, err := jwt.ParseWithClaims(cookie.Value, &sc, keyfunc(secret), jwt.WithValidMethods([]string{"HS256"})); err != nil {
|
||||||
return Claims{}, fmt.Errorf("parse session token: %w", err)
|
return Claims{}, fmt.Errorf("parse session token: %w", err)
|
||||||
}
|
}
|
||||||
return Claims{Sub: sc.Sub, Name: sc.Name, Email: sc.Email, IDToken: sc.IDToken}, nil
|
return Claims{Sub: sc.Sub, Name: sc.Name, Email: sc.Email}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// IDTokenFromSessionCookie verifies the cookie and returns the raw Keycloak
|
||||||
|
// ID token it carries, for logout's id_token_hint. Only the logout handler
|
||||||
|
// needs this -- everything else uses ParseSessionCookie's Claims.
|
||||||
|
func IDTokenFromSessionCookie(cookie *http.Cookie, secret []byte) (string, error) {
|
||||||
|
if cookie == nil {
|
||||||
|
return "", fmt.Errorf("no session cookie")
|
||||||
|
}
|
||||||
|
var sc sessionClaims
|
||||||
|
if _, err := jwt.ParseWithClaims(cookie.Value, &sc, keyfunc(secret), jwt.WithValidMethods([]string{"HS256"})); err != nil {
|
||||||
|
return "", fmt.Errorf("parse session token: %w", err)
|
||||||
|
}
|
||||||
|
return sc.IDToken, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// MintTxnCookie signs an OIDC login transaction into a short-lived cookie.
|
// MintTxnCookie signs an OIDC login transaction into a short-lived cookie.
|
||||||
|
|||||||
@@ -8,8 +8,8 @@ import (
|
|||||||
const testSecret = "test-secret-at-least-32-bytes-long!"
|
const testSecret = "test-secret-at-least-32-bytes-long!"
|
||||||
|
|
||||||
func TestMintAndParseSessionCookie(t *testing.T) {
|
func TestMintAndParseSessionCookie(t *testing.T) {
|
||||||
claims := Claims{Sub: "u1", Name: "Alice", Email: "alice@example.com", IDToken: "raw-id-token-jwt"}
|
claims := Claims{Sub: "u1", Name: "Alice", Email: "alice@example.com"}
|
||||||
cookie, err := MintSessionCookie(claims, []byte(testSecret), time.Hour, true)
|
cookie, err := MintSessionCookie(claims, "raw-id-token-jwt", []byte(testSecret), time.Hour, true)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("mint: %v", err)
|
t.Fatalf("mint: %v", err)
|
||||||
}
|
}
|
||||||
@@ -24,10 +24,20 @@ func TestMintAndParseSessionCookie(t *testing.T) {
|
|||||||
if got != claims {
|
if got != claims {
|
||||||
t.Fatalf("got %+v, want %+v", got, claims)
|
t.Fatalf("got %+v, want %+v", got, claims)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The ID token rides in the cookie apart from Claims, retrievable only
|
||||||
|
// through the dedicated logout-path helper.
|
||||||
|
idToken, err := IDTokenFromSessionCookie(cookie, []byte(testSecret))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("IDTokenFromSessionCookie: %v", err)
|
||||||
|
}
|
||||||
|
if idToken != "raw-id-token-jwt" {
|
||||||
|
t.Fatalf("idToken = %q, want raw-id-token-jwt", idToken)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestParseSessionCookie_Expired(t *testing.T) {
|
func TestParseSessionCookie_Expired(t *testing.T) {
|
||||||
cookie, err := MintSessionCookie(Claims{Sub: "u1"}, []byte(testSecret), -time.Hour, false)
|
cookie, err := MintSessionCookie(Claims{Sub: "u1"}, "", []byte(testSecret), -time.Hour, false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("mint: %v", err)
|
t.Fatalf("mint: %v", err)
|
||||||
}
|
}
|
||||||
@@ -37,33 +47,46 @@ func TestParseSessionCookie_Expired(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestParseSessionCookie_Tampered(t *testing.T) {
|
func TestParseSessionCookie_Tampered(t *testing.T) {
|
||||||
cookie, err := MintSessionCookie(Claims{Sub: "u1"}, []byte(testSecret), time.Hour, false)
|
cookie, err := MintSessionCookie(Claims{Sub: "u1"}, "", []byte(testSecret), time.Hour, false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("mint: %v", err)
|
t.Fatalf("mint: %v", err)
|
||||||
}
|
}
|
||||||
cookie.Value = flipLastChar(cookie.Value)
|
cookie.Value = flipSignatureChar(cookie.Value)
|
||||||
if _, err := ParseSessionCookie(cookie, []byte(testSecret)); err == nil {
|
if _, err := ParseSessionCookie(cookie, []byte(testSecret)); err == nil {
|
||||||
t.Fatal("expected error for tampered cookie")
|
t.Fatal("expected error for tampered cookie")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// flipLastChar corrupts a signed token for tamper tests, guaranteeing the
|
// flipSignatureChar corrupts a signed JWT for tamper tests by changing the
|
||||||
// last character actually changes -- blindly overwriting it with a fixed
|
// second-to-last character of its base64url signature, guaranteeing the
|
||||||
// character (e.g. "x") would occasionally be a no-op if that character
|
// decoded signature bytes actually change. Two pitfalls to avoid here:
|
||||||
// already happened to be there (it's derived from the token's embedded
|
// 1. Blindly overwriting a character with a fixed replacement (e.g. "x")
|
||||||
// timestamp, so this isn't as rare as it sounds), silently passing the
|
// would occasionally be a no-op if that character was already there --
|
||||||
// test without having tampered with anything.
|
// it's derived from the token's embedded timestamp, so this isn't as
|
||||||
func flipLastChar(s string) string {
|
// rare as it sounds.
|
||||||
last := s[len(s)-1]
|
// 2. Flipping the *last* character of the signature specifically (as this
|
||||||
|
// helper used to) is flaky in a subtler way: HMAC-SHA256 produces a
|
||||||
|
// 32-byte digest, which base64url-encodes to 43 characters with a
|
||||||
|
// final 3-character group covering only a 2-byte remainder -- the
|
||||||
|
// true last character encodes 4 real bits plus 2 unused padding bits.
|
||||||
|
// Go's encoding/base64 ignores those padding bits when decoding
|
||||||
|
// (non-strict by default), so about 1 in 4 replacement characters for
|
||||||
|
// that position decode to byte-identical signature bytes, silently
|
||||||
|
// passing the test without having tampered with anything. The
|
||||||
|
// second-to-last character of that final group has no such unused
|
||||||
|
// bits, so corrupting it is deterministic.
|
||||||
|
func flipSignatureChar(s string) string {
|
||||||
|
pos := len(s) - 2
|
||||||
|
orig := s[pos]
|
||||||
replacement := byte('x')
|
replacement := byte('x')
|
||||||
if last == replacement {
|
if orig == replacement {
|
||||||
replacement = 'y'
|
replacement = 'y'
|
||||||
}
|
}
|
||||||
return s[:len(s)-1] + string(replacement)
|
return s[:pos] + string(replacement) + s[pos+1:]
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestParseSessionCookie_WrongSecret(t *testing.T) {
|
func TestParseSessionCookie_WrongSecret(t *testing.T) {
|
||||||
cookie, err := MintSessionCookie(Claims{Sub: "u1"}, []byte(testSecret), time.Hour, false)
|
cookie, err := MintSessionCookie(Claims{Sub: "u1"}, "", []byte(testSecret), time.Hour, false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("mint: %v", err)
|
t.Fatalf("mint: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
103
backend/internal/config/appconfig.go
Normal file
103
backend/internal/config/appconfig.go
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AppKey describes one application-configuration key: instance-global,
|
||||||
|
// stored (as an override) in the DB `config` table, read once at startup.
|
||||||
|
// Cold -- a changed value applies on the next backend restart.
|
||||||
|
type AppKey struct {
|
||||||
|
Key string
|
||||||
|
Default string
|
||||||
|
Description string
|
||||||
|
Validate func(value string) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// KeySessionDuration is the session cookie lifetime, in integer hours.
|
||||||
|
const (
|
||||||
|
KeySessionDuration = "session.duration"
|
||||||
|
// 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.setup_timeout is how long an unfinished setup
|
||||||
|
// attempt survives untouched before its subprocess is torn down
|
||||||
|
// (minutes).
|
||||||
|
KeySessionSetupTimeout = "session.setup_timeout"
|
||||||
|
)
|
||||||
|
|
||||||
|
var appRegistry = []AppKey{
|
||||||
|
{
|
||||||
|
Key: KeySessionDuration,
|
||||||
|
Default: "720",
|
||||||
|
Description: "Session cookie lifetime in hours",
|
||||||
|
Validate: validatePositiveInt,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Key: KeySessionSetupTimeout,
|
||||||
|
Default: "15",
|
||||||
|
Description: "Idle timeout in minutes for an unfinished onboarding Garmin setup session",
|
||||||
|
Validate: validatePositiveInt,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// AppRegistry returns every known application-configuration key, in
|
||||||
|
// display order.
|
||||||
|
func AppRegistry() []AppKey { return appRegistry }
|
||||||
|
|
||||||
|
func validatePositiveInt(v string) error {
|
||||||
|
n, err := strconv.Atoi(v)
|
||||||
|
if err != nil || n <= 0 {
|
||||||
|
return fmt.Errorf("must be a positive integer, got %q", v)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateAppValue rejects unknown keys and invalid values -- every write
|
||||||
|
// path's guard, so the config table can never accumulate junk.
|
||||||
|
func ValidateAppValue(key, value string) error {
|
||||||
|
for _, k := range appRegistry {
|
||||||
|
if k.Key == key {
|
||||||
|
if err := k.Validate(value); err != nil {
|
||||||
|
return fmt.Errorf("%s: %w", key, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fmt.Errorf("unknown configuration key %q", key)
|
||||||
|
}
|
||||||
|
|
||||||
|
// AppConfig is the typed application configuration, built from the DB's
|
||||||
|
// config rows.
|
||||||
|
type AppConfig struct {
|
||||||
|
SessionDuration time.Duration
|
||||||
|
SetupTimeout time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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,
|
||||||
|
SetupTimeout: time.Duration(setupMinutes) * time.Minute,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
88
backend/internal/config/appconfig_test.go
Normal file
88
backend/internal/config/appconfig_test.go
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestLoadApp(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
values map[string]string
|
||||||
|
want time.Duration
|
||||||
|
wantIdle time.Duration
|
||||||
|
wantErr string
|
||||||
|
}{
|
||||||
|
// 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.values)
|
||||||
|
if tt.wantErr != "" {
|
||||||
|
if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
|
||||||
|
t.Fatalf("err = %v, want containing %q", err, tt.wantErr)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LoadApp: %v", err)
|
||||||
|
}
|
||||||
|
if got.SessionDuration != tt.want {
|
||||||
|
t.Fatalf("SessionDuration = %v, want %v", got.SessionDuration, tt.want)
|
||||||
|
}
|
||||||
|
if got.SetupTimeout != tt.wantIdle {
|
||||||
|
t.Fatalf("SetupSessionIdleTimeout = %v, want %v", got.SetupTimeout, tt.wantIdle)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateAppValue(t *testing.T) {
|
||||||
|
if err := ValidateAppValue("session.duration", "24"); err != nil {
|
||||||
|
t.Fatalf("valid value rejected: %v", err)
|
||||||
|
}
|
||||||
|
if err := ValidateAppValue("session.duration", "-1"); err == nil {
|
||||||
|
t.Fatal("negative hours accepted")
|
||||||
|
}
|
||||||
|
if err := ValidateAppValue("session.duration", "1.5"); err == nil {
|
||||||
|
t.Fatal("non-integer accepted")
|
||||||
|
}
|
||||||
|
if err := ValidateAppValue("nope", "1"); err == nil {
|
||||||
|
t.Fatal("unknown key accepted")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDisplayEnv_MasksSecrets(t *testing.T) {
|
||||||
|
cfg := EnvConfig{
|
||||||
|
BackendAddr: ":8080", OIDCClientSecret: "hunter2",
|
||||||
|
SessionSecret: []byte("0123456789abcdef0123456789abcdef"),
|
||||||
|
}
|
||||||
|
entries := map[string]string{}
|
||||||
|
for _, e := range cfg.DisplayEnv() {
|
||||||
|
entries[e.Name] = e.Value
|
||||||
|
}
|
||||||
|
if entries["GENIUSRUN_BACKEND_ADDR"] != ":8080" {
|
||||||
|
t.Errorf("GENIUSRUN_BACKEND_ADDR = %q", entries["GENIUSRUN_BACKEND_ADDR"])
|
||||||
|
}
|
||||||
|
if entries["GENIUSRUN_OIDC_CLIENT_SECRET"] != "•••• (set)" {
|
||||||
|
t.Errorf("client secret not masked: %q", entries["GENIUSRUN_OIDC_CLIENT_SECRET"])
|
||||||
|
}
|
||||||
|
if entries["GENIUSRUN_SESSION_SECRET"] != "•••• (set)" {
|
||||||
|
t.Errorf("session secret not masked: %q", entries["GENIUSRUN_SESSION_SECRET"])
|
||||||
|
}
|
||||||
|
empty := EnvConfig{}
|
||||||
|
for _, e := range empty.DisplayEnv() {
|
||||||
|
if e.Name == "GENIUSRUN_OIDC_CLIENT_SECRET" && e.Value != "(unset)" {
|
||||||
|
t.Errorf("unset secret = %q, want (unset)", e.Value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,36 +7,34 @@ package config
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Config holds geniusrund's process-level configuration.
|
// EnvConfig holds geniusrund's process-level configuration.
|
||||||
type Config struct {
|
type EnvConfig struct {
|
||||||
// Addr is the HTTP listen address, e.g. ":8080".
|
// BackendAddr is the HTTP listen address, e.g. ":8080".
|
||||||
Addr string
|
BackendAddr string
|
||||||
// DBPath is the SQLite database file path.
|
// DBPath is the SQLite database file path.
|
||||||
DBPath string
|
DBPath string
|
||||||
|
|
||||||
// GarminPythonPath is the python3 interpreter used to run the embedded
|
// PythonPath is the python3 interpreter used to run the embedded
|
||||||
// Garmin wrapper script (internal/garmin's go:embed'd wrapper.py).
|
// Garmin wrapper script (internal/garmin's go:embed'd wrapper.py).
|
||||||
// Defaults to "python3" resolved via PATH if unset.
|
// Defaults to "python3" resolved via PATH if unset.
|
||||||
GarminPythonPath string
|
PythonPath string
|
||||||
// GarminTokenStoreRoot is the root directory under which each user's
|
// TokenStoreRoot is the root directory under which each user's
|
||||||
// Garmin session cache lives (one subdirectory per user id, e.g.
|
// Garmin session cache lives (one subdirectory per user id, e.g.
|
||||||
// "<root>/3"). Read from the GARMIN_TOKENSTORE env var; if unset, it
|
// "<root>/3"). Read from the GENIUSRUN_TOKENSTORE_PATH env var, configured
|
||||||
// defaults to a "garmin-tokenstores" directory next to DBPath so every
|
// independently of DBPath -- if unset, it defaults to a ".garmin"
|
||||||
// deployment gets per-user isolation automatically -- multi-tenant
|
// directory relative to the working directory the process is started
|
||||||
// operation always relies on this being a real, distinct-per-user path
|
// from, not derived from DBPath in any way, so every deployment gets
|
||||||
// (see api.Server.garminFor), so it can never be silently left empty.
|
// per-user isolation automatically -- multi-tenant operation always
|
||||||
GarminTokenStoreRoot string
|
// relies on this being a real, distinct-per-user path (see
|
||||||
|
// api.Server.garminFor), so it can never be silently left empty.
|
||||||
|
TokenStoreRoot string
|
||||||
|
|
||||||
MinConfidence float64
|
// LogLevel controls internal/applog's JSON logger ("debug"|"info"|"warn"|"error").
|
||||||
IncrementalSyncEvery time.Duration
|
LogLevel string
|
||||||
|
|
||||||
// OIDC login gate (Keycloak). BackendURL is this app's own externally
|
// OIDC login gate (Keycloak). BackendURL is this app's own externally
|
||||||
// reachable origin (e.g. "https://geniusrun.example.com") -- it derives
|
// reachable origin (e.g. "https://geniusrun.example.com") -- it derives
|
||||||
@@ -61,37 +59,30 @@ type Config struct {
|
|||||||
OIDCRedirectURL string
|
OIDCRedirectURL string
|
||||||
OIDCRequiredRole string
|
OIDCRequiredRole string
|
||||||
SessionSecret []byte
|
SessionSecret []byte
|
||||||
SessionDuration time.Duration
|
|
||||||
SessionSecure bool
|
SessionSecure bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load reads configuration from environment variables, applying defaults
|
// LoadEnv reads configuration from environment variables, applying defaults
|
||||||
// for anything optional. Returns an error if a required variable is unset.
|
// for anything optional. Returns an error if a required variable is unset.
|
||||||
func Load() (Config, error) {
|
func LoadEnv() (EnvConfig, error) {
|
||||||
cfg := Config{
|
cfg := EnvConfig{
|
||||||
Addr: getEnvDefault("GENIUSRUN_ADDR", ":8080"),
|
BackendAddr: getEnvDefault("GENIUSRUN_BACKEND_ADDR", ":8080"),
|
||||||
DBPath: getEnvDefault("GENIUSRUN_DB_PATH", "geniusrun.db"),
|
DBPath: getEnvDefault("GENIUSRUN_DB_PATH", "geniusrun.db"),
|
||||||
GarminPythonPath: getEnvDefault("GARMIN_WRAPPER_PYTHON", "python3"),
|
PythonPath: getEnvDefault("GENIUSRUN_PYTHON_PATH", "python3"),
|
||||||
GarminTokenStoreRoot: os.Getenv("GARMIN_TOKENSTORE"),
|
TokenStoreRoot: getEnvDefault("GENIUSRUN_TOKENSTORE_PATH", ".garmin"),
|
||||||
MinConfidence: getEnvFloat("GENIUSRUN_MIN_CONFIDENCE", 0.6),
|
LogLevel: getEnvDefault("GENIUSRUN_LOG_LEVEL", "info"),
|
||||||
IncrementalSyncEvery: getEnvDuration("GENIUSRUN_INCREMENTAL_SYNC_EVERY", 6*time.Hour),
|
|
||||||
BackendURL: strings.TrimRight(os.Getenv("GENIUSRUN_BACKEND_URL"), "/"),
|
BackendURL: strings.TrimRight(os.Getenv("GENIUSRUN_BACKEND_URL"), "/"),
|
||||||
OIDCIssuerURL: os.Getenv("GENIUSRUN_OIDC_ISSUER_URL"),
|
OIDCIssuerURL: os.Getenv("GENIUSRUN_OIDC_ISSUER_URL"),
|
||||||
OIDCClientID: os.Getenv("GENIUSRUN_OIDC_CLIENT_ID"),
|
OIDCClientID: os.Getenv("GENIUSRUN_OIDC_CLIENT_ID"),
|
||||||
OIDCClientSecret: os.Getenv("GENIUSRUN_OIDC_CLIENT_SECRET"),
|
OIDCClientSecret: os.Getenv("GENIUSRUN_OIDC_CLIENT_SECRET"),
|
||||||
OIDCRequiredRole: getEnvDefault("GENIUSRUN_OIDC_REQUIRED_ROLE", "geniusrun-user"),
|
OIDCRequiredRole: getEnvDefault("GENIUSRUN_OIDC_REQUIRED_ROLE", "geniusrun-user"),
|
||||||
SessionDuration: getEnvDuration("GENIUSRUN_SESSION_DURATION", 720*time.Hour),
|
|
||||||
}
|
|
||||||
|
|
||||||
if cfg.GarminTokenStoreRoot == "" {
|
|
||||||
cfg.GarminTokenStoreRoot = filepath.Join(filepath.Dir(cfg.DBPath), "garmin-tokenstores")
|
|
||||||
log.Printf("GARMIN_TOKENSTORE not set, defaulting to %q", cfg.GarminTokenStoreRoot)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if cfg.BackendURL == "" {
|
if cfg.BackendURL == "" {
|
||||||
return cfg, fmt.Errorf("GENIUSRUN_BACKEND_URL is required (e.g. https://geniusrun.example.com)")
|
return cfg, fmt.Errorf("GENIUSRUN_BACKEND_URL is required (e.g. https://geniusrun.example.com)")
|
||||||
}
|
}
|
||||||
cfg.FrontendURL = strings.TrimRight(getEnvDefault("GENIUSRUN_FRONTEND_URL", cfg.BackendURL), "/")
|
cfg.FrontendURL = strings.TrimRight(getEnvDefault("GENIUSRUN_FRONTEND_URL", cfg.BackendURL), "/")
|
||||||
|
|
||||||
if cfg.OIDCIssuerURL == "" {
|
if cfg.OIDCIssuerURL == "" {
|
||||||
return cfg, fmt.Errorf("GENIUSRUN_OIDC_ISSUER_URL is required")
|
return cfg, fmt.Errorf("GENIUSRUN_OIDC_ISSUER_URL is required")
|
||||||
}
|
}
|
||||||
@@ -101,12 +92,13 @@ func Load() (Config, error) {
|
|||||||
if cfg.OIDCClientSecret == "" {
|
if cfg.OIDCClientSecret == "" {
|
||||||
return cfg, fmt.Errorf("GENIUSRUN_OIDC_CLIENT_SECRET is required")
|
return cfg, fmt.Errorf("GENIUSRUN_OIDC_CLIENT_SECRET is required")
|
||||||
}
|
}
|
||||||
|
cfg.OIDCRedirectURL = cfg.BackendURL + "/api/session/callback"
|
||||||
|
|
||||||
sessionSecret := os.Getenv("GENIUSRUN_SESSION_SECRET")
|
sessionSecret := os.Getenv("GENIUSRUN_SESSION_SECRET")
|
||||||
if len(sessionSecret) < 32 {
|
if len(sessionSecret) < 32 {
|
||||||
return cfg, fmt.Errorf("GENIUSRUN_SESSION_SECRET is required and must be at least 32 characters")
|
return cfg, fmt.Errorf("GENIUSRUN_SESSION_SECRET is required and must be at least 32 characters")
|
||||||
}
|
}
|
||||||
cfg.SessionSecret = []byte(sessionSecret)
|
cfg.SessionSecret = []byte(sessionSecret)
|
||||||
cfg.OIDCRedirectURL = cfg.BackendURL + "/api/session/callback"
|
|
||||||
cfg.SessionSecure = strings.HasPrefix(cfg.BackendURL, "https://")
|
cfg.SessionSecure = strings.HasPrefix(cfg.BackendURL, "https://")
|
||||||
|
|
||||||
return cfg, nil
|
return cfg, nil
|
||||||
@@ -119,20 +111,35 @@ func getEnvDefault(key, def string) string {
|
|||||||
return def
|
return def
|
||||||
}
|
}
|
||||||
|
|
||||||
func getEnvFloat(key string, def float64) float64 {
|
// EnvEntry is one environment-configuration variable as displayed on the
|
||||||
if v := os.Getenv(key); v != "" {
|
// config page. Display-only: secrets are masked here, so raw values never
|
||||||
if f, err := strconv.ParseFloat(v, 64); err == nil {
|
// leave the process.
|
||||||
return f
|
type EnvEntry struct {
|
||||||
}
|
Name string
|
||||||
}
|
Value string
|
||||||
return def
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func getEnvDuration(key string, def time.Duration) time.Duration {
|
// DisplayEnv returns the environment configuration as a display-safe
|
||||||
if v := os.Getenv(key); v != "" {
|
// list, in stable order, secrets masked.
|
||||||
if d, err := time.ParseDuration(v); err == nil {
|
func (c EnvConfig) DisplayEnv() []EnvEntry {
|
||||||
return d
|
mask := func(set bool) string {
|
||||||
|
if set {
|
||||||
|
return "•••• (set)"
|
||||||
}
|
}
|
||||||
|
return "(unset)"
|
||||||
|
}
|
||||||
|
return []EnvEntry{
|
||||||
|
{Name: "GENIUSRUN_BACKEND_ADDR", Value: c.BackendAddr},
|
||||||
|
{Name: "GENIUSRUN_DB_PATH", Value: c.DBPath},
|
||||||
|
{Name: "GENIUSRUN_PYTHON_PATH", Value: c.PythonPath},
|
||||||
|
{Name: "GENIUSRUN_TOKENSTORE_PATH", Value: c.TokenStoreRoot},
|
||||||
|
{Name: "GENIUSRUN_LOG_LEVEL", Value: c.LogLevel},
|
||||||
|
{Name: "GENIUSRUN_BACKEND_URL", Value: c.BackendURL},
|
||||||
|
{Name: "GENIUSRUN_FRONTEND_URL", Value: c.FrontendURL},
|
||||||
|
{Name: "GENIUSRUN_OIDC_ISSUER_URL", Value: c.OIDCIssuerURL},
|
||||||
|
{Name: "GENIUSRUN_OIDC_CLIENT_ID", Value: c.OIDCClientID},
|
||||||
|
{Name: "GENIUSRUN_OIDC_CLIENT_SECRET", Value: mask(c.OIDCClientSecret != "")},
|
||||||
|
{Name: "GENIUSRUN_OIDC_REQUIRED_ROLE", Value: c.OIDCRequiredRole},
|
||||||
|
{Name: "GENIUSRUN_SESSION_SECRET", Value: mask(len(c.SessionSecret) > 0)},
|
||||||
}
|
}
|
||||||
return def
|
|
||||||
}
|
}
|
||||||
@@ -1,9 +1,7 @@
|
|||||||
package config
|
package config
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"path/filepath"
|
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func setRequiredEnv(t *testing.T) {
|
func setRequiredEnv(t *testing.T) {
|
||||||
@@ -18,7 +16,7 @@ func setRequiredEnv(t *testing.T) {
|
|||||||
func TestLoad_DerivesOIDCSettingsFromBackendURL(t *testing.T) {
|
func TestLoad_DerivesOIDCSettingsFromBackendURL(t *testing.T) {
|
||||||
setRequiredEnv(t)
|
setRequiredEnv(t)
|
||||||
|
|
||||||
cfg, err := Load()
|
cfg, err := LoadEnv()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Load: %v", err)
|
t.Fatalf("Load: %v", err)
|
||||||
}
|
}
|
||||||
@@ -31,16 +29,13 @@ func TestLoad_DerivesOIDCSettingsFromBackendURL(t *testing.T) {
|
|||||||
if cfg.OIDCRequiredRole != "geniusrun-user" {
|
if cfg.OIDCRequiredRole != "geniusrun-user" {
|
||||||
t.Errorf("OIDCRequiredRole = %q, want default", cfg.OIDCRequiredRole)
|
t.Errorf("OIDCRequiredRole = %q, want default", cfg.OIDCRequiredRole)
|
||||||
}
|
}
|
||||||
if cfg.SessionDuration != 720*time.Hour {
|
|
||||||
t.Errorf("SessionDuration = %v, want default 720h", cfg.SessionDuration)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestLoad_HTTPBaseURLYieldsInsecureCookies(t *testing.T) {
|
func TestLoad_HTTPBaseURLYieldsInsecureCookies(t *testing.T) {
|
||||||
setRequiredEnv(t)
|
setRequiredEnv(t)
|
||||||
t.Setenv("GENIUSRUN_BACKEND_URL", "http://localhost:8080")
|
t.Setenv("GENIUSRUN_BACKEND_URL", "http://localhost:8080")
|
||||||
|
|
||||||
cfg, err := Load()
|
cfg, err := LoadEnv()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Load: %v", err)
|
t.Fatalf("Load: %v", err)
|
||||||
}
|
}
|
||||||
@@ -63,7 +58,7 @@ func TestLoad_MissingRequiredOIDCVars(t *testing.T) {
|
|||||||
t.Run(missing, func(t *testing.T) {
|
t.Run(missing, func(t *testing.T) {
|
||||||
setRequiredEnv(t)
|
setRequiredEnv(t)
|
||||||
t.Setenv(missing, "")
|
t.Setenv(missing, "")
|
||||||
if _, err := Load(); err == nil {
|
if _, err := LoadEnv(); err == nil {
|
||||||
t.Fatalf("expected error when %s is unset", missing)
|
t.Fatalf("expected error when %s is unset", missing)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -74,63 +69,88 @@ func TestLoad_SessionSecretTooShort(t *testing.T) {
|
|||||||
setRequiredEnv(t)
|
setRequiredEnv(t)
|
||||||
t.Setenv("GENIUSRUN_SESSION_SECRET", "too-short")
|
t.Setenv("GENIUSRUN_SESSION_SECRET", "too-short")
|
||||||
|
|
||||||
if _, err := Load(); err == nil {
|
if _, err := LoadEnv(); err == nil {
|
||||||
t.Fatal("expected error for a session secret under 32 characters")
|
t.Fatal("expected error for a session secret under 32 characters")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestLoad_GarminTokenStoreRootDefaultsNextToDBPath(t *testing.T) {
|
func TestLoad_GarminTokenStoreRootDefaultsIndependentlyOfDBPath(t *testing.T) {
|
||||||
setRequiredEnv(t)
|
setRequiredEnv(t)
|
||||||
t.Setenv("GARMIN_TOKENSTORE", "")
|
t.Setenv("GENIUSRUN_TOKENSTORE_PATH", "")
|
||||||
t.Setenv("GENIUSRUN_DB_PATH", "/var/lib/geniusrun/geniusrun.db")
|
t.Setenv("GENIUSRUN_DB_PATH", "/var/lib/geniusrun/geniusrun.db")
|
||||||
|
|
||||||
cfg, err := Load()
|
cfg, err := LoadEnv()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Load: %v", err)
|
t.Fatalf("Load: %v", err)
|
||||||
}
|
}
|
||||||
want := filepath.Join("/var/lib/geniusrun", "garmin-tokenstores")
|
if cfg.TokenStoreRoot != ".garmin" {
|
||||||
if cfg.GarminTokenStoreRoot != want {
|
t.Errorf("GarminTokenStoreRoot = %q, want %q (never derived from DBPath's directory)", cfg.TokenStoreRoot, ".garmin")
|
||||||
t.Errorf("GarminTokenStoreRoot = %q, want %q", cfg.GarminTokenStoreRoot, want)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestLoad_GarminTokenStoreRootExplicitOverridesDefault(t *testing.T) {
|
func TestLoad_GarminTokenStoreRootExplicitOverridesDefault(t *testing.T) {
|
||||||
setRequiredEnv(t)
|
setRequiredEnv(t)
|
||||||
t.Setenv("GARMIN_TOKENSTORE", "/custom/tokenstores")
|
t.Setenv("GENIUSRUN_TOKENSTORE_PATH", "/custom/tokenstores")
|
||||||
t.Setenv("GENIUSRUN_DB_PATH", "/var/lib/geniusrun/geniusrun.db")
|
t.Setenv("GENIUSRUN_DB_PATH", "/var/lib/geniusrun/geniusrun.db")
|
||||||
|
|
||||||
cfg, err := Load()
|
cfg, err := LoadEnv()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Load: %v", err)
|
t.Fatalf("Load: %v", err)
|
||||||
}
|
}
|
||||||
if cfg.GarminTokenStoreRoot != "/custom/tokenstores" {
|
if cfg.TokenStoreRoot != "/custom/tokenstores" {
|
||||||
t.Errorf("GarminTokenStoreRoot = %q, want explicit override to take precedence", cfg.GarminTokenStoreRoot)
|
t.Errorf("GarminTokenStoreRoot = %q, want explicit override to take precedence", cfg.TokenStoreRoot)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoad_LogLevelDefaultsToInfo(t *testing.T) {
|
||||||
|
setRequiredEnv(t)
|
||||||
|
t.Setenv("GENIUSRUN_LOG_LEVEL", "")
|
||||||
|
|
||||||
|
cfg, err := LoadEnv()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Load: %v", err)
|
||||||
|
}
|
||||||
|
if cfg.LogLevel != "info" {
|
||||||
|
t.Errorf("LogLevel = %q, want default %q", cfg.LogLevel, "info")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoad_LogLevelExplicitOverridesDefault(t *testing.T) {
|
||||||
|
setRequiredEnv(t)
|
||||||
|
t.Setenv("GENIUSRUN_LOG_LEVEL", "debug")
|
||||||
|
|
||||||
|
cfg, err := LoadEnv()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Load: %v", err)
|
||||||
|
}
|
||||||
|
if cfg.LogLevel != "debug" {
|
||||||
|
t.Errorf("LogLevel = %q, want debug", cfg.LogLevel)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestLoad_GarminPythonPathDefaultsToPython3(t *testing.T) {
|
func TestLoad_GarminPythonPathDefaultsToPython3(t *testing.T) {
|
||||||
setRequiredEnv(t)
|
setRequiredEnv(t)
|
||||||
t.Setenv("GARMIN_WRAPPER_PYTHON", "")
|
t.Setenv("GENIUSRUN_PYTHON_PATH", "")
|
||||||
|
|
||||||
cfg, err := Load()
|
cfg, err := LoadEnv()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Load: %v", err)
|
t.Fatalf("Load: %v", err)
|
||||||
}
|
}
|
||||||
if cfg.GarminPythonPath != "python3" {
|
if cfg.PythonPath != "python3" {
|
||||||
t.Errorf("GarminPythonPath = %q, want default %q", cfg.GarminPythonPath, "python3")
|
t.Errorf("GarminPythonPath = %q, want default %q", cfg.PythonPath, "python3")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestLoad_GarminPythonPathExplicitOverridesDefault(t *testing.T) {
|
func TestLoad_GarminPythonPathExplicitOverridesDefault(t *testing.T) {
|
||||||
setRequiredEnv(t)
|
setRequiredEnv(t)
|
||||||
t.Setenv("GARMIN_WRAPPER_PYTHON", "/opt/venv/bin/python3")
|
t.Setenv("GENIUSRUN_PYTHON_PATH", "/opt/venv/bin/python3")
|
||||||
|
|
||||||
cfg, err := Load()
|
cfg, err := LoadEnv()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Load: %v", err)
|
t.Fatalf("Load: %v", err)
|
||||||
}
|
}
|
||||||
if cfg.GarminPythonPath != "/opt/venv/bin/python3" {
|
if cfg.PythonPath != "/opt/venv/bin/python3" {
|
||||||
t.Errorf("GarminPythonPath = %q, want explicit override", cfg.GarminPythonPath)
|
t.Errorf("GarminPythonPath = %q, want explicit override", cfg.PythonPath)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -138,7 +158,7 @@ func TestLoad_FrontendURLDefaultsToBackendURL(t *testing.T) {
|
|||||||
setRequiredEnv(t)
|
setRequiredEnv(t)
|
||||||
t.Setenv("GENIUSRUN_FRONTEND_URL", "")
|
t.Setenv("GENIUSRUN_FRONTEND_URL", "")
|
||||||
|
|
||||||
cfg, err := Load()
|
cfg, err := LoadEnv()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Load: %v", err)
|
t.Fatalf("Load: %v", err)
|
||||||
}
|
}
|
||||||
@@ -151,7 +171,7 @@ func TestLoad_FrontendURLExplicitOverridesDefault(t *testing.T) {
|
|||||||
setRequiredEnv(t)
|
setRequiredEnv(t)
|
||||||
t.Setenv("GENIUSRUN_FRONTEND_URL", "http://localhost:5173/")
|
t.Setenv("GENIUSRUN_FRONTEND_URL", "http://localhost:5173/")
|
||||||
|
|
||||||
cfg, err := Load()
|
cfg, err := LoadEnv()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Load: %v", err)
|
t.Fatalf("Load: %v", err)
|
||||||
}
|
}
|
||||||
@@ -160,19 +180,15 @@ func TestLoad_FrontendURLExplicitOverridesDefault(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestLoad_CustomRoleAndDuration(t *testing.T) {
|
func TestLoad_CustomRole(t *testing.T) {
|
||||||
setRequiredEnv(t)
|
setRequiredEnv(t)
|
||||||
t.Setenv("GENIUSRUN_OIDC_REQUIRED_ROLE", "admin")
|
t.Setenv("GENIUSRUN_OIDC_REQUIRED_ROLE", "admin")
|
||||||
t.Setenv("GENIUSRUN_SESSION_DURATION", "24h")
|
|
||||||
|
|
||||||
cfg, err := Load()
|
cfg, err := LoadEnv()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Load: %v", err)
|
t.Fatalf("Load: %v", err)
|
||||||
}
|
}
|
||||||
if cfg.OIDCRequiredRole != "admin" {
|
if cfg.OIDCRequiredRole != "admin" {
|
||||||
t.Errorf("OIDCRequiredRole = %q", cfg.OIDCRequiredRole)
|
t.Errorf("OIDCRequiredRole = %q", cfg.OIDCRequiredRole)
|
||||||
}
|
}
|
||||||
if cfg.SessionDuration != 24*time.Hour {
|
|
||||||
t.Errorf("SessionDuration = %v", cfg.SessionDuration)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
// Package garmin wraps a direct garminconnect subprocess (see
|
// Package garmin wraps a direct garminconnect subprocess (see
|
||||||
// pyscript/wrapper.py) as a narrow Go client interface, so the rest of
|
// wrapper/wrapper.py) as a narrow Go client interface, so the rest of
|
||||||
// geniusrun never deals with the wire protocol directly.
|
// geniusrun never deals with the wire protocol directly.
|
||||||
package garmin
|
package garmin
|
||||||
|
|
||||||
@@ -8,15 +8,18 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
_ "embed"
|
_ "embed"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"log/slog"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"strconv"
|
"strconv"
|
||||||
"sync"
|
"sync"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
//go:embed pyscript/wrapper.py
|
//go:embed wrapper/wrapper.py
|
||||||
var wrapperScript string
|
var wrapperScript string
|
||||||
|
|
||||||
// maxWrapperLineBytes bounds one JSON-line response from the wrapper
|
// maxWrapperLineBytes bounds one JSON-line response from the wrapper
|
||||||
@@ -52,15 +55,15 @@ type Client interface {
|
|||||||
Close() error
|
Close() error
|
||||||
}
|
}
|
||||||
|
|
||||||
// Config configures how the Garmin wrapper subprocess is spawned.
|
// ClientConfig configures how the Garmin wrapper subprocess is spawned.
|
||||||
type Config struct {
|
type ClientConfig struct {
|
||||||
// PythonPath is the python3 interpreter to run the embedded wrapper
|
// PythonPath is the python3 interpreter to run the embedded wrapper
|
||||||
// script with. Empty defaults to "python3" resolved via PATH.
|
// script with. Empty defaults to "python3" resolved via PATH.
|
||||||
PythonPath string
|
PythonPath string
|
||||||
GarminEmail string
|
GarminEmail string
|
||||||
GarminPassword string
|
GarminPassword string
|
||||||
// TokenStorePath, if set, is passed as GARMIN_TOKENSTORE so the wrapper
|
// TokenStorePath is passed as GARMIN_TOKENSTORE so the wrapper
|
||||||
// persists/resumes a Garmin session there instead of the default ~/.garth.
|
// persists/resumes a Garmin session there.
|
||||||
TokenStorePath string
|
TokenStorePath string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -72,12 +75,26 @@ type wireRequest struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// wireResponse is one line read from the wrapper subprocess's stdout.
|
// wireResponse is one line read from the wrapper subprocess's stdout.
|
||||||
|
// ErrorType/Traceback carry the Python-side failure detail in the protocol
|
||||||
|
// itself (see wrapper.py's dispatch), so the Go side logs one complete
|
||||||
|
// structured record per failed call instead of correlating stderr noise.
|
||||||
type wireResponse struct {
|
type wireResponse struct {
|
||||||
ID int `json:"id"`
|
ID int `json:"id"`
|
||||||
Result json.RawMessage `json:"result,omitempty"`
|
Result json.RawMessage `json:"result,omitempty"`
|
||||||
Error string `json:"error,omitempty"`
|
Error string `json:"error,omitempty"`
|
||||||
|
ErrorType string `json:"error_type,omitempty"`
|
||||||
|
Traceback string `json:"traceback,omitempty"`
|
||||||
|
NotFound bool `json:"not_found,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ErrNotFound wraps any error a Client method returns when the wrapper
|
||||||
|
// reported a definitive HTTP 404 (garminconnect's own
|
||||||
|
// GarminConnectNotFoundError) -- e.g. GetWorkoutByID for a workout deleted
|
||||||
|
// on Garmin's side after being linked to an activity. Callers use
|
||||||
|
// errors.Is(err, ErrNotFound) to distinguish this from a transient failure
|
||||||
|
// worth retrying.
|
||||||
|
var ErrNotFound = errors.New("garmin: resource not found")
|
||||||
|
|
||||||
// callParams is the Params payload for a generic "call" request: dispatches
|
// callParams is the Params payload for a generic "call" request: dispatches
|
||||||
// to any garminconnect.Garmin method by name.
|
// to any garminconnect.Garmin method by name.
|
||||||
type callParams struct {
|
type callParams struct {
|
||||||
@@ -107,7 +124,7 @@ func mapAuthStatus(s string) AuthStatus {
|
|||||||
// subprocessClient is the real Client implementation, backed by a wrapper
|
// subprocessClient is the real Client implementation, backed by a wrapper
|
||||||
// subprocess spoken to over newline-delimited JSON on stdio.
|
// subprocess spoken to over newline-delimited JSON on stdio.
|
||||||
type subprocessClient struct {
|
type subprocessClient struct {
|
||||||
cfg Config
|
cfg ClientConfig
|
||||||
|
|
||||||
mu sync.Mutex // serializes calls; the wrapper holds a single shared Garmin client
|
mu sync.Mutex // serializes calls; the wrapper holds a single shared Garmin client
|
||||||
cmd *exec.Cmd
|
cmd *exec.Cmd
|
||||||
@@ -122,7 +139,7 @@ type subprocessClient struct {
|
|||||||
|
|
||||||
// ensureStarted spawns the wrapper subprocess if it isn't already running.
|
// ensureStarted spawns the wrapper subprocess if it isn't already running.
|
||||||
// Callers must hold c.mu.
|
// Callers must hold c.mu.
|
||||||
func (c *subprocessClient) ensureStarted() error {
|
func (c *subprocessClient) ensureStarted(ctx context.Context) error {
|
||||||
if c.started {
|
if c.started {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -130,14 +147,14 @@ func (c *subprocessClient) ensureStarted() error {
|
|||||||
if c.scriptPath == "" {
|
if c.scriptPath == "" {
|
||||||
f, err := os.CreateTemp("", "geniusrun-garmin-wrapper-*.py")
|
f, err := os.CreateTemp("", "geniusrun-garmin-wrapper-*.py")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("write embedded wrapper script: %w", err)
|
return fmt.Errorf("write wrapper script: %w", err)
|
||||||
}
|
}
|
||||||
if _, err := f.WriteString(wrapperScript); err != nil {
|
if _, err := f.WriteString(wrapperScript); err != nil {
|
||||||
f.Close()
|
f.Close()
|
||||||
return fmt.Errorf("write embedded wrapper script: %w", err)
|
return fmt.Errorf("write wrapper script: %w", err)
|
||||||
}
|
}
|
||||||
if err := f.Close(); err != nil {
|
if err := f.Close(); err != nil {
|
||||||
return fmt.Errorf("write embedded wrapper script: %w", err)
|
return fmt.Errorf("write wrapper script: %w", err)
|
||||||
}
|
}
|
||||||
c.scriptPath = f.Name()
|
c.scriptPath = f.Name()
|
||||||
}
|
}
|
||||||
@@ -151,11 +168,9 @@ func (c *subprocessClient) ensureStarted() error {
|
|||||||
extraEnv := []string{
|
extraEnv := []string{
|
||||||
"GARMIN_EMAIL=" + c.cfg.GarminEmail,
|
"GARMIN_EMAIL=" + c.cfg.GarminEmail,
|
||||||
"GARMIN_PASSWORD=" + c.cfg.GarminPassword,
|
"GARMIN_PASSWORD=" + c.cfg.GarminPassword,
|
||||||
|
"GARMIN_TOKENSTORE=" + c.cfg.TokenStorePath,
|
||||||
"PYTHONUNBUFFERED=1",
|
"PYTHONUNBUFFERED=1",
|
||||||
}
|
}
|
||||||
if c.cfg.TokenStorePath != "" {
|
|
||||||
extraEnv = append(extraEnv, "GARMIN_TOKENSTORE="+c.cfg.TokenStorePath)
|
|
||||||
}
|
|
||||||
cmd.Env = append(os.Environ(), extraEnv...)
|
cmd.Env = append(os.Environ(), extraEnv...)
|
||||||
|
|
||||||
stdin, err := cmd.StdinPipe()
|
stdin, err := cmd.StdinPipe()
|
||||||
@@ -172,15 +187,18 @@ func (c *subprocessClient) ensureStarted() error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if err := cmd.Start(); err != nil {
|
if err := cmd.Start(); err != nil {
|
||||||
return fmt.Errorf("spawn garmin wrapper subprocess: %w", err)
|
return fmt.Errorf("spawn wrapper subprocess: %w", err)
|
||||||
}
|
}
|
||||||
// wrapper.py logs auth/rate-limit diagnostics to stderr. Per os/exec's
|
// wrapper.py logs auth/rate-limit diagnostics to stderr as JSON lines
|
||||||
// StderrPipe docs, it's incorrect to call Wait before all reads from the
|
// (see its _log helper); forwardWrapperStderr re-emits them through the
|
||||||
// pipe have completed, so close() waits on stderrDone before Wait-ing.
|
// application logger so the backend's output stays one JSON stream. Per
|
||||||
|
// os/exec's StderrPipe docs, it's incorrect to call Wait before all
|
||||||
|
// reads from the pipe have completed, so close() waits on stderrDone
|
||||||
|
// before Wait-ing.
|
||||||
c.stderrDone = make(chan struct{})
|
c.stderrDone = make(chan struct{})
|
||||||
stderrDone := c.stderrDone
|
stderrDone := c.stderrDone
|
||||||
go func() {
|
go func() {
|
||||||
io.Copy(os.Stderr, stderr)
|
forwardWrapperStderr(stderr)
|
||||||
close(stderrDone)
|
close(stderrDone)
|
||||||
}()
|
}()
|
||||||
|
|
||||||
@@ -192,48 +210,95 @@ func (c *subprocessClient) ensureStarted() error {
|
|||||||
c.scanner = scanner
|
c.scanner = scanner
|
||||||
c.started = true
|
c.started = true
|
||||||
c.nextID = 0
|
c.nextID = 0
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// roundTrip sends one request and returns its result payload, or an error
|
// execute sends one request and returns its result payload, or an error
|
||||||
// if the wrapper reported one. Callers must hold c.mu and have already
|
// if the wrapper reported one. Callers must hold c.mu and have already
|
||||||
// called ensureStarted.
|
// called ensureStarted. Logs exactly one type=wrapper line regardless of
|
||||||
func (c *subprocessClient) roundTrip(cmdName string, params any) (json.RawMessage, error) {
|
// outcome (the record's meaning is carried by its fields, no msg) --
|
||||||
|
// cmd/params are always safe to log in full here: Garmin credentials only
|
||||||
|
// ever reach the subprocess via env vars at spawn time (see
|
||||||
|
// ensureStarted), never through these wire params.
|
||||||
|
func (c *subprocessClient) execute(ctx context.Context, cmd string, params any) (result json.RawMessage, err error) {
|
||||||
|
start := time.Now()
|
||||||
|
var errorType, traceback string // Python-side failure detail, set from the error response
|
||||||
|
defer func() {
|
||||||
|
attrs := []slog.Attr{
|
||||||
|
slog.String("type", "wrapper"),
|
||||||
|
slog.String("package", "garmin"),
|
||||||
|
slog.String("file", "client.go"),
|
||||||
|
slog.String("class", "subprocessClient"),
|
||||||
|
slog.String("method", "execute"),
|
||||||
|
slog.String("cmd", cmd),
|
||||||
|
slog.Any("params", params),
|
||||||
|
slog.Int64("duration_ms", time.Since(start).Milliseconds()),
|
||||||
|
}
|
||||||
|
level := slog.LevelInfo
|
||||||
|
if result != nil {
|
||||||
|
attrs = append(attrs, slog.String("result", truncate(string(result), 500)))
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
level = slog.LevelError
|
||||||
|
attrs = append(attrs, slog.String("error", err.Error()))
|
||||||
|
if errorType != "" {
|
||||||
|
attrs = append(attrs, slog.String("error_type", errorType))
|
||||||
|
}
|
||||||
|
if traceback != "" {
|
||||||
|
attrs = append(attrs, slog.String("traceback", traceback))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
slog.Default().LogAttrs(ctx, level, "", attrs...)
|
||||||
|
}()
|
||||||
|
|
||||||
c.nextID++
|
c.nextID++
|
||||||
id := c.nextID
|
id := c.nextID
|
||||||
|
|
||||||
if err := c.enc.Encode(wireRequest{ID: id, Cmd: cmdName, Params: params}); err != nil {
|
if err = c.enc.Encode(wireRequest{ID: id, Cmd: cmd, Params: params}); err != nil {
|
||||||
return nil, fmt.Errorf("write %s request: %w", cmdName, err)
|
err = fmt.Errorf("write %s request: %w", cmd, err)
|
||||||
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
if !c.scanner.Scan() {
|
if !c.scanner.Scan() {
|
||||||
if err := c.scanner.Err(); err != nil {
|
if serr := c.scanner.Err(); serr != nil {
|
||||||
return nil, fmt.Errorf("read %s response: %w", cmdName, err)
|
err = fmt.Errorf("read %s response: %w", cmd, serr)
|
||||||
|
} else {
|
||||||
|
err = fmt.Errorf("read %s response: subprocess closed its output", cmd)
|
||||||
}
|
}
|
||||||
return nil, fmt.Errorf("read %s response: subprocess closed its output", cmdName)
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
var resp wireResponse
|
var resp wireResponse
|
||||||
if err := json.Unmarshal(c.scanner.Bytes(), &resp); err != nil {
|
if uerr := json.Unmarshal(c.scanner.Bytes(), &resp); uerr != nil {
|
||||||
return nil, fmt.Errorf("parse %s response: %w", cmdName, err)
|
err = fmt.Errorf("parse %s response: %w", cmd, uerr)
|
||||||
|
return nil, err
|
||||||
}
|
}
|
||||||
if resp.ID != id {
|
if resp.ID != id {
|
||||||
return nil, fmt.Errorf("%s response id mismatch: got %d, want %d", cmdName, resp.ID, id)
|
err = fmt.Errorf("%s response id mismatch: got %d, want %d", cmd, resp.ID, id)
|
||||||
|
return nil, err
|
||||||
}
|
}
|
||||||
if resp.Error != "" {
|
if resp.Error != "" {
|
||||||
return nil, fmt.Errorf("%s: %s", cmdName, resp.Error)
|
errorType, traceback = resp.ErrorType, resp.Traceback
|
||||||
|
if resp.NotFound {
|
||||||
|
err = fmt.Errorf("%s: %s: %w", cmd, resp.Error, ErrNotFound)
|
||||||
|
} else {
|
||||||
|
err = fmt.Errorf("%s: %s", cmd, resp.Error)
|
||||||
}
|
}
|
||||||
return resp.Result, nil
|
return nil, err
|
||||||
|
}
|
||||||
|
result = resp.Result
|
||||||
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *subprocessClient) Authenticate(ctx context.Context) (AuthResult, error) {
|
func (c *subprocessClient) Authenticate(ctx context.Context) (AuthResult, error) {
|
||||||
c.mu.Lock()
|
c.mu.Lock()
|
||||||
defer c.mu.Unlock()
|
defer c.mu.Unlock()
|
||||||
|
|
||||||
if err := c.ensureStarted(); err != nil {
|
if err := c.ensureStarted(ctx); err != nil {
|
||||||
return AuthResult{}, err
|
return AuthResult{}, err
|
||||||
}
|
}
|
||||||
raw, err := c.roundTrip("authenticate", nil)
|
raw, err := c.execute(ctx, "authenticate", nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return AuthResult{}, err
|
return AuthResult{}, err
|
||||||
}
|
}
|
||||||
@@ -248,10 +313,10 @@ func (c *subprocessClient) CompleteMFA(ctx context.Context, code string) (AuthRe
|
|||||||
c.mu.Lock()
|
c.mu.Lock()
|
||||||
defer c.mu.Unlock()
|
defer c.mu.Unlock()
|
||||||
|
|
||||||
if err := c.ensureStarted(); err != nil {
|
if err := c.ensureStarted(ctx); err != nil {
|
||||||
return AuthResult{}, err
|
return AuthResult{}, err
|
||||||
}
|
}
|
||||||
raw, err := c.roundTrip("complete_mfa", map[string]any{"code": code})
|
raw, err := c.execute(ctx, "complete_mfa", map[string]any{"code": code})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return AuthResult{}, err
|
return AuthResult{}, err
|
||||||
}
|
}
|
||||||
@@ -306,6 +371,55 @@ func (c *subprocessClient) close() error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// forwardWrapperStderr re-emits the wrapper subprocess's stderr through
|
||||||
|
// the application logger. wrapper.py writes JSON lines ({"level","msg",
|
||||||
|
// ...attrs} -- see its _log helper), which are decoded and re-logged at
|
||||||
|
// the corresponding level with their attrs preserved. Anything that isn't
|
||||||
|
// such a line (a Python startup crash before _log exists, a chatty
|
||||||
|
// third-party library printing directly) is wrapped as a warn-level
|
||||||
|
// type=wrapper record carrying the raw "line" rather than passed through, so the
|
||||||
|
// backend's combined output is JSON no matter what the subprocess does.
|
||||||
|
func forwardWrapperStderr(r io.Reader) {
|
||||||
|
scanner := bufio.NewScanner(r)
|
||||||
|
scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024) // tracebacks can exceed the default token size
|
||||||
|
for scanner.Scan() {
|
||||||
|
line := scanner.Text()
|
||||||
|
var entry map[string]any
|
||||||
|
if err := json.Unmarshal([]byte(line), &entry); err != nil || entry["msg"] == nil {
|
||||||
|
slog.Warn("", "type", "wrapper", "package", "garmin", "file", "client.go", "method", "forwardWrapperStderr", "line", line)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
msg, _ := entry["msg"].(string)
|
||||||
|
levelName, _ := entry["level"].(string)
|
||||||
|
delete(entry, "msg")
|
||||||
|
delete(entry, "level")
|
||||||
|
// Python-emitted lines carry no "package" (a Go-only field) and no
|
||||||
|
// "class" (wrapper.py has none); method arrives in the entry itself
|
||||||
|
// (wrapper.py's _log stamps the emitting function).
|
||||||
|
attrs := make([]slog.Attr, 0, len(entry)+2)
|
||||||
|
attrs = append(attrs, slog.String("type", "wrapper"), slog.String("file", "wrapper.py"))
|
||||||
|
for k, v := range entry {
|
||||||
|
attrs = append(attrs, slog.Any(k, v))
|
||||||
|
}
|
||||||
|
slog.Default().LogAttrs(context.Background(), wrapperLogLevel(levelName), msg, attrs...)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// wrapperLogLevel maps wrapper.py's level strings onto slog levels,
|
||||||
|
// defaulting to info for anything unrecognized.
|
||||||
|
func wrapperLogLevel(level string) slog.Level {
|
||||||
|
switch level {
|
||||||
|
case "debug":
|
||||||
|
return slog.LevelDebug
|
||||||
|
case "warn", "warning":
|
||||||
|
return slog.LevelWarn
|
||||||
|
case "error":
|
||||||
|
return slog.LevelError
|
||||||
|
default:
|
||||||
|
return slog.LevelInfo
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func truncate(s string, n int) string {
|
func truncate(s string, n int) string {
|
||||||
if len(s) <= n {
|
if len(s) <= n {
|
||||||
return s
|
return s
|
||||||
@@ -317,7 +431,7 @@ var _ Client = (*subprocessClient)(nil)
|
|||||||
|
|
||||||
// NewClient builds a Client. The subprocess is not spawned until the first
|
// NewClient builds a Client. The subprocess is not spawned until the first
|
||||||
// call that needs it (Authenticate, or any data call once authenticated).
|
// call that needs it (Authenticate, or any data call once authenticated).
|
||||||
func NewClient(cfg Config) Client {
|
func NewClient(cfg ClientConfig) Client {
|
||||||
return &subprocessClient{cfg: cfg}
|
return &subprocessClient{cfg: cfg}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -325,10 +439,10 @@ func (c *subprocessClient) GetActivities(ctx context.Context, startDate, endDate
|
|||||||
c.mu.Lock()
|
c.mu.Lock()
|
||||||
defer c.mu.Unlock()
|
defer c.mu.Unlock()
|
||||||
|
|
||||||
if err := c.ensureStarted(); err != nil {
|
if err := c.ensureStarted(ctx); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
raw, err := c.roundTrip("call", callParams{
|
raw, err := c.execute(ctx, "call", callParams{
|
||||||
Method: "get_activities_by_date",
|
Method: "get_activities_by_date",
|
||||||
Args: map[string]any{"startdate": startDate, "enddate": endDate},
|
Args: map[string]any{"startdate": startDate, "enddate": endDate},
|
||||||
})
|
})
|
||||||
@@ -360,10 +474,10 @@ func (c *subprocessClient) GetActivitySplits(ctx context.Context, activityID int
|
|||||||
c.mu.Lock()
|
c.mu.Lock()
|
||||||
defer c.mu.Unlock()
|
defer c.mu.Unlock()
|
||||||
|
|
||||||
if err := c.ensureStarted(); err != nil {
|
if err := c.ensureStarted(ctx); err != nil {
|
||||||
return ActivitySplits{}, err
|
return ActivitySplits{}, err
|
||||||
}
|
}
|
||||||
raw, err := c.roundTrip("call", callParams{
|
raw, err := c.execute(ctx, "call", callParams{
|
||||||
Method: "get_activity_splits",
|
Method: "get_activity_splits",
|
||||||
Args: map[string]any{"activity_id": strconv.FormatInt(activityID, 10)},
|
Args: map[string]any{"activity_id": strconv.FormatInt(activityID, 10)},
|
||||||
})
|
})
|
||||||
@@ -395,10 +509,10 @@ func (c *subprocessClient) GetActivityDetails(ctx context.Context, activityID in
|
|||||||
c.mu.Lock()
|
c.mu.Lock()
|
||||||
defer c.mu.Unlock()
|
defer c.mu.Unlock()
|
||||||
|
|
||||||
if err := c.ensureStarted(); err != nil {
|
if err := c.ensureStarted(ctx); err != nil {
|
||||||
return ActivityDetails{}, err
|
return ActivityDetails{}, err
|
||||||
}
|
}
|
||||||
raw, err := c.roundTrip("call", callParams{
|
raw, err := c.execute(ctx, "call", callParams{
|
||||||
Method: "get_activity_details",
|
Method: "get_activity_details",
|
||||||
Args: map[string]any{"activity_id": strconv.FormatInt(activityID, 10)},
|
Args: map[string]any{"activity_id": strconv.FormatInt(activityID, 10)},
|
||||||
})
|
})
|
||||||
@@ -418,10 +532,10 @@ func (c *subprocessClient) GetWorkoutByID(ctx context.Context, workoutID int64)
|
|||||||
c.mu.Lock()
|
c.mu.Lock()
|
||||||
defer c.mu.Unlock()
|
defer c.mu.Unlock()
|
||||||
|
|
||||||
if err := c.ensureStarted(); err != nil {
|
if err := c.ensureStarted(ctx); err != nil {
|
||||||
return Workout{}, err
|
return Workout{}, err
|
||||||
}
|
}
|
||||||
raw, err := c.roundTrip("call", callParams{
|
raw, err := c.execute(ctx, "call", callParams{
|
||||||
Method: "get_workout_by_id",
|
Method: "get_workout_by_id",
|
||||||
Args: map[string]any{"workout_id": strconv.FormatInt(workoutID, 10)},
|
Args: map[string]any{"workout_id": strconv.FormatInt(workoutID, 10)},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -2,13 +2,18 @@ package garmin
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bufio"
|
"bufio"
|
||||||
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"io"
|
"io"
|
||||||
|
"log/slog"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"geniusrun/backend/internal/log"
|
||||||
)
|
)
|
||||||
|
|
||||||
// wireResponsePayload is what a fake wrapper handler returns for one
|
// wireResponsePayload is what a fake wrapper handler returns for one
|
||||||
@@ -16,6 +21,7 @@ import (
|
|||||||
type wireResponsePayload struct {
|
type wireResponsePayload struct {
|
||||||
result json.RawMessage
|
result json.RawMessage
|
||||||
err string
|
err string
|
||||||
|
notFound bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func fakeResult(v any) wireResponsePayload {
|
func fakeResult(v any) wireResponsePayload {
|
||||||
@@ -30,6 +36,10 @@ func fakeError(msg string) wireResponsePayload {
|
|||||||
return wireResponsePayload{err: msg}
|
return wireResponsePayload{err: msg}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func fakeNotFoundError(msg string) wireResponsePayload {
|
||||||
|
return wireResponsePayload{err: msg, notFound: true}
|
||||||
|
}
|
||||||
|
|
||||||
// newFakeWrapperClient wires a subprocessClient to an in-process goroutine
|
// newFakeWrapperClient wires a subprocessClient to an in-process goroutine
|
||||||
// that plays the Python wrapper's role, so protocol-level Go logic can be
|
// that plays the Python wrapper's role, so protocol-level Go logic can be
|
||||||
// tested without python3/garminconnect installed.
|
// tested without python3/garminconnect installed.
|
||||||
@@ -57,7 +67,7 @@ func newFakeWrapperClient(t *testing.T, handle func(cmd string, params json.RawM
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
payload := handle(req.Cmd, req.Params)
|
payload := handle(req.Cmd, req.Params)
|
||||||
resp := wireResponse{ID: req.ID, Result: payload.result, Error: payload.err}
|
resp := wireResponse{ID: req.ID, Result: payload.result, Error: payload.err, NotFound: payload.notFound}
|
||||||
if err := enc.Encode(resp); err != nil {
|
if err := enc.Encode(resp); err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -91,7 +101,7 @@ func TestSubprocessClient_RoundTrip_DetectsIDMismatch(t *testing.T) {
|
|||||||
scanner.Buffer(make([]byte, 0, 64*1024), maxWrapperLineBytes)
|
scanner.Buffer(make([]byte, 0, 64*1024), maxWrapperLineBytes)
|
||||||
c := &subprocessClient{started: true, enc: json.NewEncoder(reqW), scanner: scanner}
|
c := &subprocessClient{started: true, enc: json.NewEncoder(reqW), scanner: scanner}
|
||||||
|
|
||||||
_, err := c.roundTrip("authenticate", nil)
|
_, err := c.execute(context.Background(), "authenticate", nil)
|
||||||
if err == nil || !strings.Contains(err.Error(), "id mismatch") {
|
if err == nil || !strings.Contains(err.Error(), "id mismatch") {
|
||||||
t.Fatalf("roundTrip error = %v, want an id mismatch error", err)
|
t.Fatalf("roundTrip error = %v, want an id mismatch error", err)
|
||||||
}
|
}
|
||||||
@@ -113,7 +123,7 @@ func TestSubprocessClient_RoundTrip_SubprocessClosedIsError(t *testing.T) {
|
|||||||
scanner.Buffer(make([]byte, 0, 64*1024), maxWrapperLineBytes)
|
scanner.Buffer(make([]byte, 0, 64*1024), maxWrapperLineBytes)
|
||||||
c := &subprocessClient{started: true, enc: json.NewEncoder(reqW), scanner: scanner}
|
c := &subprocessClient{started: true, enc: json.NewEncoder(reqW), scanner: scanner}
|
||||||
|
|
||||||
_, err := c.roundTrip("authenticate", nil)
|
_, err := c.execute(context.Background(), "authenticate", nil)
|
||||||
if err == nil || !strings.Contains(err.Error(), "closed") {
|
if err == nil || !strings.Contains(err.Error(), "closed") {
|
||||||
t.Fatalf("roundTrip error = %v, want a subprocess-closed error", err)
|
t.Fatalf("roundTrip error = %v, want a subprocess-closed error", err)
|
||||||
}
|
}
|
||||||
@@ -124,14 +134,39 @@ func TestSubprocessClient_RoundTrip_WrapperErrorPropagates(t *testing.T) {
|
|||||||
return fakeError("boom")
|
return fakeError("boom")
|
||||||
})
|
})
|
||||||
|
|
||||||
_, err := c.roundTrip("authenticate", nil)
|
_, err := c.execute(context.Background(), "authenticate", nil)
|
||||||
if err == nil || !strings.Contains(err.Error(), "boom") {
|
if err == nil || !strings.Contains(err.Error(), "boom") {
|
||||||
t.Fatalf("roundTrip error = %v, want it to mention %q", err, "boom")
|
t.Fatalf("roundTrip error = %v, want it to mention %q", err, "boom")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestSubprocessClient_RoundTrip_NotFoundWrapsErrNotFound(t *testing.T) {
|
||||||
|
c := newFakeWrapperClient(t, func(cmd string, params json.RawMessage) wireResponsePayload {
|
||||||
|
return fakeNotFoundError("API Error 404")
|
||||||
|
})
|
||||||
|
|
||||||
|
_, err := c.execute(context.Background(), "call", nil)
|
||||||
|
if !errors.Is(err, ErrNotFound) {
|
||||||
|
t.Fatalf("roundTrip error = %v, want errors.Is(err, ErrNotFound)", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "API Error 404") {
|
||||||
|
t.Errorf("roundTrip error = %v, want it to still contain the original message", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSubprocessClient_RoundTrip_OrdinaryErrorDoesNotWrapErrNotFound(t *testing.T) {
|
||||||
|
c := newFakeWrapperClient(t, func(cmd string, params json.RawMessage) wireResponsePayload {
|
||||||
|
return fakeError("boom")
|
||||||
|
})
|
||||||
|
|
||||||
|
_, err := c.execute(context.Background(), "call", nil)
|
||||||
|
if errors.Is(err, ErrNotFound) {
|
||||||
|
t.Fatalf("roundTrip error = %v, want errors.Is(err, ErrNotFound) to be false", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestSubprocessClient_UpdateCredentials_ResetsStartedState(t *testing.T) {
|
func TestSubprocessClient_UpdateCredentials_ResetsStartedState(t *testing.T) {
|
||||||
c := &subprocessClient{cfg: Config{GarminEmail: "old@example.com", GarminPassword: "old"}}
|
c := &subprocessClient{cfg: ClientConfig{GarminEmail: "old@example.com", GarminPassword: "old"}}
|
||||||
c.started = true // simulate an already-spawned subprocess, no real cmd/pipes
|
c.started = true // simulate an already-spawned subprocess, no real cmd/pipes
|
||||||
|
|
||||||
c.UpdateCredentials("new@example.com", "new")
|
c.UpdateCredentials("new@example.com", "new")
|
||||||
@@ -394,3 +429,108 @@ func TestSubprocessClient_GetWorkoutByID_ParsesSegments(t *testing.T) {
|
|||||||
t.Fatalf("workout = %+v", workout)
|
t.Fatalf("workout = %+v", workout)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestSubprocessClient_RoundTrip_LogsCallWithResultPreview(t *testing.T) {
|
||||||
|
c := newFakeWrapperClient(t, func(cmd string, params json.RawMessage) wireResponsePayload {
|
||||||
|
return fakeResult(authResultWire{Status: "mfa_required", Message: "MFA required."})
|
||||||
|
})
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
prev := slog.Default()
|
||||||
|
slog.SetDefault(applog.NewLogger("info", &buf))
|
||||||
|
defer slog.SetDefault(prev)
|
||||||
|
|
||||||
|
if _, err := c.Authenticate(context.Background()); err != nil {
|
||||||
|
t.Fatalf("Authenticate: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var entry map[string]any
|
||||||
|
if err := json.Unmarshal(buf.Bytes(), &entry); err != nil {
|
||||||
|
t.Fatalf("log output is not valid JSON: %v (%q)", err, buf.String())
|
||||||
|
}
|
||||||
|
if entry["type"] != "wrapper" || entry["package"] != "garmin" || entry["file"] != "client.go" || entry["class"] != "subprocessClient" || entry["method"] != "execute" {
|
||||||
|
t.Errorf("schema fields = %v, want type=wrapper package=garmin file=client.go class=subprocessClient method=execute", entry)
|
||||||
|
}
|
||||||
|
if _, hasMsg := entry["msg"]; hasMsg {
|
||||||
|
t.Errorf("expected no msg on the wrapper-call record, got %v", entry["msg"])
|
||||||
|
}
|
||||||
|
if entry["cmd"] != "authenticate" {
|
||||||
|
t.Errorf("cmd = %v, want authenticate", entry["cmd"])
|
||||||
|
}
|
||||||
|
preview, _ := entry["result"].(string)
|
||||||
|
if !strings.Contains(preview, "mfa_required") {
|
||||||
|
t.Errorf("result = %q, want it to contain mfa_required", preview)
|
||||||
|
}
|
||||||
|
if _, hasError := entry["error"]; hasError {
|
||||||
|
t.Errorf("expected no error field on a successful call, got %v", entry["error"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSubprocessClient_RoundTrip_LogsFailedCallAtErrorLevel(t *testing.T) {
|
||||||
|
c := newFakeWrapperClient(t, func(cmd string, params json.RawMessage) wireResponsePayload {
|
||||||
|
return fakeError("boom")
|
||||||
|
})
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
prev := slog.Default()
|
||||||
|
slog.SetDefault(applog.NewLogger("info", &buf))
|
||||||
|
defer slog.SetDefault(prev)
|
||||||
|
|
||||||
|
if _, err := c.Authenticate(context.Background()); err == nil {
|
||||||
|
t.Fatal("expected Authenticate to return an error")
|
||||||
|
}
|
||||||
|
|
||||||
|
var entry map[string]any
|
||||||
|
if err := json.Unmarshal(buf.Bytes(), &entry); err != nil {
|
||||||
|
t.Fatalf("log output is not valid JSON: %v (%q)", err, buf.String())
|
||||||
|
}
|
||||||
|
if entry["level"] != "ERROR" {
|
||||||
|
t.Errorf("level = %v, want ERROR for a failed call", entry["level"])
|
||||||
|
}
|
||||||
|
if errMsg, _ := entry["error"].(string); !strings.Contains(errMsg, "boom") {
|
||||||
|
t.Errorf("error field = %q, want it to mention \"boom\"", errMsg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestForwardWrapperStderr_ReemitsJSONAndWrapsFreeText(t *testing.T) {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
prev := slog.Default()
|
||||||
|
slog.SetDefault(applog.NewLogger("debug", &buf))
|
||||||
|
defer slog.SetDefault(prev)
|
||||||
|
|
||||||
|
stderr := strings.NewReader(
|
||||||
|
`{"level":"warn","msg":"startup tokenstore login failed","error":"boom"}` + "\n" +
|
||||||
|
"Traceback (most recent call last): free text\n",
|
||||||
|
)
|
||||||
|
forwardWrapperStderr(stderr)
|
||||||
|
|
||||||
|
lines := strings.Split(strings.TrimSpace(buf.String()), "\n")
|
||||||
|
if len(lines) != 2 {
|
||||||
|
t.Fatalf("expected 2 log lines, got %d: %q", len(lines), buf.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
var first map[string]any
|
||||||
|
if err := json.Unmarshal([]byte(lines[0]), &first); err != nil {
|
||||||
|
t.Fatalf("first line not JSON: %v", err)
|
||||||
|
}
|
||||||
|
if first["level"] != "WARN" || first["msg"] != "startup tokenstore login failed" {
|
||||||
|
t.Errorf("first = %v, want the wrapper's level and msg preserved", first)
|
||||||
|
}
|
||||||
|
if first["error"] != "boom" || first["type"] != "wrapper" || first["file"] != "wrapper.py" {
|
||||||
|
t.Errorf("first = %v, want error attr preserved and type=wrapper file=wrapper.py", first)
|
||||||
|
}
|
||||||
|
|
||||||
|
var second map[string]any
|
||||||
|
if err := json.Unmarshal([]byte(lines[1]), &second); err != nil {
|
||||||
|
t.Fatalf("second line not JSON: %v", err)
|
||||||
|
}
|
||||||
|
if _, hasMsg := second["msg"]; hasMsg {
|
||||||
|
t.Errorf("second = %v, want no msg on a wrapped free-text record", second)
|
||||||
|
}
|
||||||
|
if second["type"] != "wrapper" || second["level"] != "WARN" {
|
||||||
|
t.Errorf("second = %v, want free text wrapped as a warn type=wrapper record", second)
|
||||||
|
}
|
||||||
|
if line, _ := second["line"].(string); !strings.Contains(line, "Traceback") {
|
||||||
|
t.Errorf("second line attr = %q, want the raw text preserved", line)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
package sync
|
package garmin
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
@@ -6,7 +6,6 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"geniusrun/backend/internal/classify"
|
"geniusrun/backend/internal/classify"
|
||||||
"geniusrun/backend/internal/garmin"
|
|
||||||
"geniusrun/backend/internal/store"
|
"geniusrun/backend/internal/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -19,7 +18,7 @@ func isRunningActivityType(typeKey string) bool {
|
|||||||
return strings.Contains(strings.ToLower(typeKey), "run")
|
return strings.Contains(strings.ToLower(typeKey), "run")
|
||||||
}
|
}
|
||||||
|
|
||||||
func toActivityRow(a garmin.Activity) store.Activity {
|
func toActivityRow(a Activity) store.Activity {
|
||||||
return store.Activity{
|
return store.Activity{
|
||||||
GarminActivityID: a.ActivityID,
|
GarminActivityID: a.ActivityID,
|
||||||
EventTypeKey: a.EventType.TypeKey,
|
EventTypeKey: a.EventType.TypeKey,
|
||||||
@@ -52,7 +51,7 @@ func nonZero(v float64) *float64 {
|
|||||||
// fall within that lap's time window. Lap boundaries are derived from
|
// fall within that lap's time window. Lap boundaries are derived from
|
||||||
// cumulative elapsed duration rather than parsing StartTimeGMT, since laps
|
// cumulative elapsed duration rather than parsing StartTimeGMT, since laps
|
||||||
// are contiguous and this sidesteps timezone parsing entirely.
|
// are contiguous and this sidesteps timezone parsing entirely.
|
||||||
func toLapRows(laps []garmin.Lap, samples []garmin.Sample, targets []*garmin.WorkoutStep, profile store.Profile) []store.Lap {
|
func toLapRows(laps []Lap, samples []Sample, targets []*WorkoutStep, profile store.Profile) []store.Lap {
|
||||||
rows := make([]store.Lap, 0, len(laps))
|
rows := make([]store.Lap, 0, len(laps))
|
||||||
var elapsedStart float64
|
var elapsedStart float64
|
||||||
for i, l := range laps {
|
for i, l := range laps {
|
||||||
@@ -94,9 +93,9 @@ func toLapRows(laps []garmin.Lap, samples []garmin.Sample, targets []*garmin.Wor
|
|||||||
return rows
|
return rows
|
||||||
}
|
}
|
||||||
|
|
||||||
// alignWorkoutTargets zips an activity's recorded laps against its
|
// alignWorkoutTargets zips an activity's lap count against its structured
|
||||||
// structured workout's flattened steps, returning one *garmin.WorkoutStep
|
// workout's flattened steps, returning one *garmin.WorkoutStep per lap (nil
|
||||||
// per lap (nil where unavailable).
|
// where unavailable).
|
||||||
//
|
//
|
||||||
// Confirmed against a real activity (via Garmin Connect's own workout view)
|
// Confirmed against a real activity (via Garmin Connect's own workout view)
|
||||||
// that recording sometimes continues one lap past the end of the workout's
|
// that recording sometimes continues one lap past the end of the workout's
|
||||||
@@ -110,11 +109,11 @@ func toLapRows(laps []garmin.Lap, samples []garmin.Sample, targets []*garmin.Wor
|
|||||||
// Any other mismatch (extra manual laps, auto-lap-by-distance also firing,
|
// Any other mismatch (extra manual laps, auto-lap-by-distance also firing,
|
||||||
// etc.) can't be trusted at all, so every entry comes back nil rather than
|
// etc.) can't be trusted at all, so every entry comes back nil rather than
|
||||||
// risk showing a target against the wrong lap.
|
// risk showing a target against the wrong lap.
|
||||||
func alignWorkoutTargets(laps []garmin.Lap, workout garmin.Workout) []*garmin.WorkoutStep {
|
func alignWorkoutTargets(lapCount int, workout Workout) []*WorkoutStep {
|
||||||
steps := workout.FlattenSteps()
|
steps := workout.FlattenSteps()
|
||||||
out := make([]*garmin.WorkoutStep, len(laps))
|
out := make([]*WorkoutStep, lapCount)
|
||||||
|
|
||||||
switch len(laps) - len(steps) {
|
switch lapCount - len(steps) {
|
||||||
case 0, 1:
|
case 0, 1:
|
||||||
for i := range steps {
|
for i := range steps {
|
||||||
s := steps[i]
|
s := steps[i]
|
||||||
@@ -126,7 +125,7 @@ func alignWorkoutTargets(laps []garmin.Lap, workout garmin.Workout) []*garmin.Wo
|
|||||||
|
|
||||||
// targetPaceRange returns the (low, high) m/s bounds of a workout step's
|
// targetPaceRange returns the (low, high) m/s bounds of a workout step's
|
||||||
// pace-zone target, or (nil, nil) if it doesn't target pace.
|
// pace-zone target, or (nil, nil) if it doesn't target pace.
|
||||||
func targetPaceRange(step garmin.WorkoutStep) (*float64, *float64) {
|
func targetPaceRange(step WorkoutStep) (*float64, *float64) {
|
||||||
if step.TargetType.TypeKey != "pace.zone" || step.TargetValueOne == nil || step.TargetValueTwo == nil {
|
if step.TargetType.TypeKey != "pace.zone" || step.TargetValueOne == nil || step.TargetValueTwo == nil {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
@@ -141,7 +140,7 @@ func targetPaceRange(step garmin.WorkoutStep) (*float64, *float64) {
|
|||||||
// heart-rate-zone target, or (nil, nil) if it doesn't target heart rate.
|
// heart-rate-zone target, or (nil, nil) if it doesn't target heart rate.
|
||||||
// Steps that target a named zone (ZoneNumber) rather than a custom bpm
|
// Steps that target a named zone (ZoneNumber) rather than a custom bpm
|
||||||
// range are resolved via the user's Karvonen profile.
|
// range are resolved via the user's Karvonen profile.
|
||||||
func targetHRRange(step garmin.WorkoutStep, profile store.Profile) (*float64, *float64) {
|
func targetHRRange(step WorkoutStep, profile store.Profile) (*float64, *float64) {
|
||||||
if step.TargetType.TypeKey != "heart.rate.zone" {
|
if step.TargetType.TypeKey != "heart.rate.zone" {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
@@ -188,7 +187,7 @@ func karvonenBounds(p store.Profile, zone int) (lowBpm, highBpm float64, ok bool
|
|||||||
return restHR + (minPct/100)*(maxHR-restHR), restHR + (maxPct/100)*(maxHR-restHR), true
|
return restHR + (minPct/100)*(maxHR-restHR), restHR + (maxPct/100)*(maxHR-restHR), true
|
||||||
}
|
}
|
||||||
|
|
||||||
func samplesInWindow(samples []garmin.Sample, start, end float64) []classify.SampleInfo {
|
func samplesInWindow(samples []Sample, start, end float64) []classify.SampleInfo {
|
||||||
var out []classify.SampleInfo
|
var out []classify.SampleInfo
|
||||||
for _, s := range samples {
|
for _, s := range samples {
|
||||||
if s.ElapsedSeconds < start || s.ElapsedSeconds >= end {
|
if s.ElapsedSeconds < start || s.ElapsedSeconds >= end {
|
||||||
@@ -199,7 +198,7 @@ func samplesInWindow(samples []garmin.Sample, start, end float64) []classify.Sam
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
func toSampleRows(samples []garmin.Sample) []store.Sample {
|
func toSampleRows(samples []Sample) []store.Sample {
|
||||||
rows := make([]store.Sample, len(samples))
|
rows := make([]store.Sample, len(samples))
|
||||||
for i, s := range samples {
|
for i, s := range samples {
|
||||||
rows[i] = store.Sample{
|
rows[i] = store.Sample{
|
||||||
114
backend/internal/garmin/mock.go
Normal file
114
backend/internal/garmin/mock.go
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
// MockClient support: a fake Client for tests and frontend/dev
|
||||||
|
// work without a live Garmin account or the wrapper subprocess.
|
||||||
|
package garmin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// MockClient is a fake Client returning data supplied by the test/caller.
|
||||||
|
type MockClient struct {
|
||||||
|
AuthResults []AuthResult // consumed in order by Authenticate/CompleteMFA calls
|
||||||
|
Activities []Activity
|
||||||
|
Splits map[int64]ActivitySplits
|
||||||
|
Details map[int64]ActivityDetails
|
||||||
|
Workouts map[int64]Workout
|
||||||
|
// WorkoutErrByID, if set for a given workout ID, makes GetWorkoutByID
|
||||||
|
// return that error for that ID specifically -- independent of the
|
||||||
|
// all-calls-fail Err field below -- so a test can simulate one
|
||||||
|
// activity's workout fetch failing while others in the same batch
|
||||||
|
// succeed.
|
||||||
|
WorkoutErrByID map[int64]error
|
||||||
|
// Delay, if set, is slept (ctx-cancellable) at the start of every
|
||||||
|
// GetActivities call -- lets a test give sync.Service's discovering
|
||||||
|
// phase (backfillCore/incrementalSyncCore) real wall-clock duration, so
|
||||||
|
// a concurrent goroutine can observe Progress() mid-flight instead of
|
||||||
|
// the call returning instantly.
|
||||||
|
Delay time.Duration
|
||||||
|
Err error // if set, every call returns this error
|
||||||
|
authResultCursor int
|
||||||
|
ClosedCalled bool
|
||||||
|
GetActivitiesCalls int
|
||||||
|
AuthenticateCalls int
|
||||||
|
LastEmail string
|
||||||
|
LastPassword string
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ Client = (*MockClient)(nil)
|
||||||
|
|
||||||
|
func (c *MockClient) nextAuthResult() AuthResult {
|
||||||
|
if c.authResultCursor >= len(c.AuthResults) {
|
||||||
|
return AuthResult{Status: AuthSuccess, Message: "Authenticated successfully."}
|
||||||
|
}
|
||||||
|
r := c.AuthResults[c.authResultCursor]
|
||||||
|
c.authResultCursor++
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *MockClient) Authenticate(ctx context.Context) (AuthResult, error) {
|
||||||
|
c.AuthenticateCalls++
|
||||||
|
if c.Err != nil {
|
||||||
|
return AuthResult{}, c.Err
|
||||||
|
}
|
||||||
|
return c.nextAuthResult(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *MockClient) CompleteMFA(ctx context.Context, code string) (AuthResult, error) {
|
||||||
|
if c.Err != nil {
|
||||||
|
return AuthResult{}, c.Err
|
||||||
|
}
|
||||||
|
return c.nextAuthResult(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *MockClient) GetActivities(ctx context.Context, startDate, endDate string, limit int) ([]Activity, error) {
|
||||||
|
c.GetActivitiesCalls++
|
||||||
|
if c.Delay > 0 {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return nil, ctx.Err()
|
||||||
|
case <-time.After(c.Delay):
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if c.Err != nil {
|
||||||
|
return nil, c.Err
|
||||||
|
}
|
||||||
|
if limit > 0 && limit < len(c.Activities) {
|
||||||
|
return c.Activities[:limit], nil
|
||||||
|
}
|
||||||
|
return c.Activities, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *MockClient) GetActivitySplits(ctx context.Context, activityID int64) (ActivitySplits, error) {
|
||||||
|
if c.Err != nil {
|
||||||
|
return ActivitySplits{}, c.Err
|
||||||
|
}
|
||||||
|
return c.Splits[activityID], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *MockClient) GetActivityDetails(ctx context.Context, activityID int64) (ActivityDetails, error) {
|
||||||
|
if c.Err != nil {
|
||||||
|
return ActivityDetails{}, c.Err
|
||||||
|
}
|
||||||
|
return c.Details[activityID], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *MockClient) GetWorkoutByID(ctx context.Context, workoutID int64) (Workout, error) {
|
||||||
|
if c.Err != nil {
|
||||||
|
return Workout{}, c.Err
|
||||||
|
}
|
||||||
|
if err, ok := c.WorkoutErrByID[workoutID]; ok {
|
||||||
|
return Workout{}, err
|
||||||
|
}
|
||||||
|
return c.Workouts[workoutID], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *MockClient) UpdateCredentials(email, password string) {
|
||||||
|
c.LastEmail = email
|
||||||
|
c.LastPassword = password
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *MockClient) Close() error {
|
||||||
|
c.ClosedCalled = true
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -1,91 +0,0 @@
|
|||||||
// Package mock provides a fake garmin.Client for tests and frontend/dev
|
|
||||||
// work without a live Garmin account or the wrapper subprocess.
|
|
||||||
package mock
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
|
|
||||||
"geniusrun/backend/internal/garmin"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Client is a fake garmin.Client returning data supplied by the test/caller.
|
|
||||||
type Client struct {
|
|
||||||
AuthResults []garmin.AuthResult // consumed in order by Authenticate/CompleteMFA calls
|
|
||||||
Activities []garmin.Activity
|
|
||||||
Splits map[int64]garmin.ActivitySplits
|
|
||||||
Details map[int64]garmin.ActivityDetails
|
|
||||||
Workouts map[int64]garmin.Workout
|
|
||||||
Err error // if set, every call returns this error
|
|
||||||
authResultCursor int
|
|
||||||
ClosedCalled bool
|
|
||||||
GetActivitiesCalls int
|
|
||||||
LastEmail string
|
|
||||||
LastPassword string
|
|
||||||
}
|
|
||||||
|
|
||||||
var _ garmin.Client = (*Client)(nil)
|
|
||||||
|
|
||||||
func (c *Client) nextAuthResult() garmin.AuthResult {
|
|
||||||
if c.authResultCursor >= len(c.AuthResults) {
|
|
||||||
return garmin.AuthResult{Status: garmin.AuthSuccess, Message: "Authenticated successfully."}
|
|
||||||
}
|
|
||||||
r := c.AuthResults[c.authResultCursor]
|
|
||||||
c.authResultCursor++
|
|
||||||
return r
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *Client) Authenticate(ctx context.Context) (garmin.AuthResult, error) {
|
|
||||||
if c.Err != nil {
|
|
||||||
return garmin.AuthResult{}, c.Err
|
|
||||||
}
|
|
||||||
return c.nextAuthResult(), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *Client) CompleteMFA(ctx context.Context, code string) (garmin.AuthResult, error) {
|
|
||||||
if c.Err != nil {
|
|
||||||
return garmin.AuthResult{}, c.Err
|
|
||||||
}
|
|
||||||
return c.nextAuthResult(), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *Client) GetActivities(ctx context.Context, startDate, endDate string, limit int) ([]garmin.Activity, error) {
|
|
||||||
c.GetActivitiesCalls++
|
|
||||||
if c.Err != nil {
|
|
||||||
return nil, c.Err
|
|
||||||
}
|
|
||||||
if limit > 0 && limit < len(c.Activities) {
|
|
||||||
return c.Activities[:limit], nil
|
|
||||||
}
|
|
||||||
return c.Activities, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *Client) GetActivitySplits(ctx context.Context, activityID int64) (garmin.ActivitySplits, error) {
|
|
||||||
if c.Err != nil {
|
|
||||||
return garmin.ActivitySplits{}, c.Err
|
|
||||||
}
|
|
||||||
return c.Splits[activityID], nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *Client) GetActivityDetails(ctx context.Context, activityID int64) (garmin.ActivityDetails, error) {
|
|
||||||
if c.Err != nil {
|
|
||||||
return garmin.ActivityDetails{}, c.Err
|
|
||||||
}
|
|
||||||
return c.Details[activityID], nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *Client) GetWorkoutByID(ctx context.Context, workoutID int64) (garmin.Workout, error) {
|
|
||||||
if c.Err != nil {
|
|
||||||
return garmin.Workout{}, c.Err
|
|
||||||
}
|
|
||||||
return c.Workouts[workoutID], nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *Client) UpdateCredentials(email, password string) {
|
|
||||||
c.LastEmail = email
|
|
||||||
c.LastPassword = password
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *Client) Close() error {
|
|
||||||
c.ClosedCalled = true
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
@@ -1,202 +0,0 @@
|
|||||||
"""Subprocess wrapper around garminconnect, spoken to over newline-delimited
|
|
||||||
JSON on stdin/stdout by geniusrund's internal/garmin package. See
|
|
||||||
docs/superpowers/specs/2026-07-25-garmin-direct-wrapper-design.md."""
|
|
||||||
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
import queue
|
|
||||||
import sys
|
|
||||||
import threading
|
|
||||||
import traceback
|
|
||||||
|
|
||||||
from garminconnect import Garmin
|
|
||||||
|
|
||||||
TOKENSTORE = os.path.expanduser(os.environ.get("GARMIN_TOKENSTORE", "~/.garth"))
|
|
||||||
|
|
||||||
_client = None
|
|
||||||
_auth_state = "unauthenticated"
|
|
||||||
_mfa_input_queue = queue.Queue()
|
|
||||||
_login_result_queue = queue.Queue()
|
|
||||||
|
|
||||||
|
|
||||||
def _debug(msg):
|
|
||||||
print(f"[garmin-wrapper debug] {msg}", file=sys.stderr, flush=True)
|
|
||||||
|
|
||||||
|
|
||||||
def _prompt_mfa():
|
|
||||||
_debug("garminconnect invoked prompt_mfa() -- this is a REAL MFA challenge from Garmin")
|
|
||||||
code = _mfa_input_queue.get(timeout=300)
|
|
||||||
_debug(f"prompt_mfa() handing code of length {len(code)} back to garminconnect")
|
|
||||||
return code
|
|
||||||
|
|
||||||
|
|
||||||
def _startup_login():
|
|
||||||
"""Silently resume a cached tokenstore session at process start, so a
|
|
||||||
freshly (re)spawned subprocess is already authenticated for background
|
|
||||||
syncs that never call the explicit authenticate command.
|
|
||||||
|
|
||||||
Bounded to the same 10s timeout as _handle_authenticate: the actual
|
|
||||||
login runs on a background daemon thread, and this function waits up
|
|
||||||
to 10s for it before returning either way. This runs before main()
|
|
||||||
enters its stdin dispatch loop, so a slow/rate-limited Garmin login
|
|
||||||
must never block indefinitely here -- if it times out, the thread
|
|
||||||
keeps running and will update _auth_state whenever it eventually
|
|
||||||
finishes (success or failure), same as today, just without wedging
|
|
||||||
the whole subprocess unresponsive in the meantime.
|
|
||||||
|
|
||||||
Deliberately uses its own private, function-local result queue rather
|
|
||||||
than the module-level _login_result_queue that _handle_authenticate and
|
|
||||||
_handle_complete_mfa share -- those two are legitimately two halves of
|
|
||||||
one explicit, MFA-capable login flow and must share a queue for the MFA
|
|
||||||
handoff to work, but this is a plain background tokenstore resume with
|
|
||||||
no MFA involved. Sharing the queue here would let this thread's stale
|
|
||||||
result (arriving after the 10s timeout below) be dequeued by an
|
|
||||||
unrelated, later authenticate/complete_mfa call instead of that call's
|
|
||||||
own fresh result."""
|
|
||||||
global _client, _auth_state
|
|
||||||
email = os.environ.get("GARMIN_EMAIL")
|
|
||||||
password = os.environ.get("GARMIN_PASSWORD")
|
|
||||||
if not email or not password:
|
|
||||||
return
|
|
||||||
|
|
||||||
_client = Garmin(email, password)
|
|
||||||
result_queue = queue.Queue() # private to this call, never shared with authenticate/complete_mfa
|
|
||||||
|
|
||||||
def _do_login():
|
|
||||||
try:
|
|
||||||
_client.login(tokenstore=TOKENSTORE)
|
|
||||||
result_queue.put(("success", None))
|
|
||||||
except Exception as exc:
|
|
||||||
result_queue.put(("error", str(exc)))
|
|
||||||
|
|
||||||
threading.Thread(target=_do_login, daemon=True).start()
|
|
||||||
|
|
||||||
try:
|
|
||||||
status, err = result_queue.get(timeout=10)
|
|
||||||
if status == "success":
|
|
||||||
_auth_state = "authenticated"
|
|
||||||
else:
|
|
||||||
_debug(f"startup tokenstore login failed: {err}")
|
|
||||||
_auth_state = "unauthenticated"
|
|
||||||
except queue.Empty:
|
|
||||||
_debug(
|
|
||||||
"startup tokenstore login hit the 10s timeout -- still running in the "
|
|
||||||
"background and will update auth state whenever it finishes"
|
|
||||||
)
|
|
||||||
_auth_state = "unauthenticated"
|
|
||||||
|
|
||||||
|
|
||||||
def _handle_authenticate(_params):
|
|
||||||
global _client, _auth_state
|
|
||||||
|
|
||||||
email = os.environ.get("GARMIN_EMAIL", "")
|
|
||||||
password = os.environ.get("GARMIN_PASSWORD", "")
|
|
||||||
if not email or not password:
|
|
||||||
return {
|
|
||||||
"status": "failed",
|
|
||||||
"message": "GARMIN_EMAIL and GARMIN_PASSWORD environment variables are required.",
|
|
||||||
}
|
|
||||||
|
|
||||||
def _do_login():
|
|
||||||
_debug(f"background login thread starting _client.login(tokenstore={TOKENSTORE})")
|
|
||||||
try:
|
|
||||||
_client.login(tokenstore=TOKENSTORE)
|
|
||||||
_debug("_client.login() returned successfully")
|
|
||||||
_login_result_queue.put(("success", None))
|
|
||||||
except Exception as exc:
|
|
||||||
_debug(f"_client.login() raised {type(exc).__name__}: {exc}")
|
|
||||||
_debug(traceback.format_exc())
|
|
||||||
_login_result_queue.put(("error", str(exc)))
|
|
||||||
|
|
||||||
_client = Garmin(email, password)
|
|
||||||
_client.prompt_mfa = _prompt_mfa
|
|
||||||
threading.Thread(target=_do_login, daemon=True).start()
|
|
||||||
|
|
||||||
try:
|
|
||||||
status, err = _login_result_queue.get(timeout=10)
|
|
||||||
_debug(f"authenticate got result within 10s timeout: status={status}")
|
|
||||||
if status == "success":
|
|
||||||
_auth_state = "authenticated"
|
|
||||||
return {"status": "success", "message": "Authenticated successfully."}
|
|
||||||
return {"status": "failed", "message": f"Authentication failed: {err}"}
|
|
||||||
except queue.Empty:
|
|
||||||
_debug(
|
|
||||||
"authenticate hit the 10s timeout with no result yet -- reporting mfa_required, "
|
|
||||||
"but this does NOT necessarily mean prompt_mfa() was actually invoked; check "
|
|
||||||
"whether the 'REAL MFA challenge' debug line above appears to tell real MFA "
|
|
||||||
"apart from a merely slow login."
|
|
||||||
)
|
|
||||||
_auth_state = "mfa_pending"
|
|
||||||
return {
|
|
||||||
"status": "mfa_required",
|
|
||||||
"message": "MFA required. Garmin has sent a verification code to your registered email or phone.",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _handle_complete_mfa(params):
|
|
||||||
global _auth_state
|
|
||||||
|
|
||||||
if _auth_state != "mfa_pending":
|
|
||||||
return {"status": "failed", "message": "No MFA in progress. Call authenticate first."}
|
|
||||||
|
|
||||||
code = params["code"]
|
|
||||||
_debug(f"complete_mfa received a code of length {len(code)}, pushing to mfa queue")
|
|
||||||
_mfa_input_queue.put(code)
|
|
||||||
|
|
||||||
try:
|
|
||||||
status, err = _login_result_queue.get(timeout=30)
|
|
||||||
_debug(f"complete_mfa got result: status={status} err={err}")
|
|
||||||
if status == "success":
|
|
||||||
_auth_state = "authenticated"
|
|
||||||
return {"status": "success", "message": "MFA accepted. Authenticated successfully."}
|
|
||||||
return {"status": "failed", "message": f"Authentication failed after MFA: {err}"}
|
|
||||||
except queue.Empty:
|
|
||||||
_auth_state = "unauthenticated"
|
|
||||||
return {
|
|
||||||
"status": "failed",
|
|
||||||
"message": "Timed out waiting for authentication to complete. Call authenticate again.",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _handle_call(params):
|
|
||||||
if _auth_state != "authenticated":
|
|
||||||
raise RuntimeError("Not authenticated. Call authenticate first.")
|
|
||||||
method = params["method"]
|
|
||||||
args = params.get("args") or {}
|
|
||||||
fn = getattr(_client, method)
|
|
||||||
return fn(**args)
|
|
||||||
|
|
||||||
|
|
||||||
_HANDLERS = {
|
|
||||||
"authenticate": _handle_authenticate,
|
|
||||||
"complete_mfa": _handle_complete_mfa,
|
|
||||||
"call": _handle_call,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def dispatch(req):
|
|
||||||
handler = _HANDLERS.get(req.get("cmd"))
|
|
||||||
if handler is None:
|
|
||||||
return {"id": req.get("id"), "error": f"unknown cmd {req.get('cmd')!r}"}
|
|
||||||
try:
|
|
||||||
result = handler(req.get("params") or {})
|
|
||||||
return {"id": req["id"], "result": result}
|
|
||||||
except Exception as exc:
|
|
||||||
_debug(f"{req.get('cmd')} raised {type(exc).__name__}: {exc}")
|
|
||||||
_debug(traceback.format_exc())
|
|
||||||
return {"id": req.get("id"), "error": str(exc)}
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
|
||||||
_startup_login()
|
|
||||||
for line in sys.stdin:
|
|
||||||
line = line.strip()
|
|
||||||
if not line:
|
|
||||||
continue
|
|
||||||
req = json.loads(line)
|
|
||||||
resp = dispatch(req)
|
|
||||||
print(json.dumps(resp), flush=True)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
@@ -1,27 +1,27 @@
|
|||||||
// Package sync orchestrates fetching activities from Garmin (via
|
// Package garmin orchestrates fetching activities from Garmin (via
|
||||||
// internal/garmin), persisting them (via internal/store), and classifying
|
// internal/garmin), persisting them (via internal/store), and classifying
|
||||||
// them (via internal/classify). It's the only package that depends on all
|
// them (via internal/classify). It's the only package that depends on all
|
||||||
// three, keeping garmin/store/classify decoupled from each other.
|
// three, keeping garmin/store/classify decoupled from each other.
|
||||||
package sync
|
package garmin
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"geniusrun/backend/internal/classify"
|
"geniusrun/backend/internal/classify"
|
||||||
"geniusrun/backend/internal/garmin"
|
applog "geniusrun/backend/internal/log"
|
||||||
"geniusrun/backend/internal/store"
|
"geniusrun/backend/internal/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Config tunes sync behavior. Zero values fall back to sensible defaults in
|
// Config tunes sync behavior. Zero values fall back to sensible defaults in
|
||||||
// NewService. How far back Backfill reaches is not here -- it's
|
// NewSync. How far back Backfill reaches is not here -- it's
|
||||||
// Profile.BackfillHorizonDays, read fresh on every call so a user-edited
|
// Profile.BackfillHorizonDays, read fresh on every call so a user-edited
|
||||||
// value takes effect on the next sync without a server restart.
|
// value takes effect on the next sync without a server restart.
|
||||||
type Config struct {
|
type SyncConfig struct {
|
||||||
// BackfillWindowDays is the page size for each get_activities call
|
// BackfillWindowDays is the page size for each get_activities call
|
||||||
// during backfill.
|
// during backfill.
|
||||||
BackfillWindowDays int
|
BackfillWindowDays int
|
||||||
@@ -38,7 +38,7 @@ type Config struct {
|
|||||||
MinConfidence float64
|
MinConfidence float64
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c Config) withDefaults() Config {
|
func (c SyncConfig) withDefaults() SyncConfig {
|
||||||
if c.BackfillWindowDays == 0 {
|
if c.BackfillWindowDays == 0 {
|
||||||
c.BackfillWindowDays = 90
|
c.BackfillWindowDays = 90
|
||||||
}
|
}
|
||||||
@@ -54,49 +54,64 @@ func (c Config) withDefaults() Config {
|
|||||||
return c
|
return c
|
||||||
}
|
}
|
||||||
|
|
||||||
// Progress reports how far a currently-running (or just-finished)
|
// Phase values reported by Progress.Phase.
|
||||||
// FillPendingDetails pass has gotten, for a status banner to poll.
|
const (
|
||||||
|
PhaseIdle = "idle"
|
||||||
|
PhaseDiscovering = "discovering"
|
||||||
|
PhaseActivities = "activities"
|
||||||
|
PhaseWorkouts = "workouts"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Progress reports how far a currently-running (or just-finished) FullSync
|
||||||
|
// has gotten, for a status modal to poll. PhaseDiscovering (backfillCore/
|
||||||
|
// incrementalSyncCore) has no meaningful Total -- discovering how many
|
||||||
|
// activities exist IS the act of fetching them, so it's reported as an
|
||||||
|
// indeterminate step (Done/Total both 0) rather than a fake percentage.
|
||||||
|
// PhaseActivities/PhaseWorkouts do have a real Total, taken once from a
|
||||||
|
// local DB count at the start of each pass (see FillPendingDetails).
|
||||||
type Progress struct {
|
type Progress struct {
|
||||||
|
Phase string
|
||||||
Done int
|
Done int
|
||||||
Total int
|
Total int
|
||||||
}
|
}
|
||||||
|
|
||||||
// Service is the sync orchestrator, scoped to one user -- every store call
|
// Sync is the sync orchestrator, scoped to one user -- every store call
|
||||||
// it makes is for userID's data only.
|
// it makes is for userID's data only.
|
||||||
type Service struct {
|
type Sync struct {
|
||||||
garmin garmin.Client
|
garmin Client
|
||||||
db *store.DB
|
db *store.DB
|
||||||
userID int64
|
userID int64
|
||||||
cfg Config
|
cfg SyncConfig
|
||||||
now func() time.Time
|
now func() time.Time
|
||||||
|
|
||||||
progressMu sync.Mutex
|
progressMu sync.Mutex
|
||||||
progress Progress
|
progress Progress
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewService builds a Service scoped to userID. now defaults to time.Now if
|
// NewSync builds a Sync scoped to userID. now defaults to time.Now if
|
||||||
// nil (tests can override it for deterministic date windows).
|
// nil (tests can override it for deterministic date windows).
|
||||||
func NewService(g garmin.Client, db *store.DB, userID int64, cfg Config, now func() time.Time) *Service {
|
func NewSync(g Client, db *store.DB, userID int64, cfg SyncConfig, now func() time.Time) *Sync {
|
||||||
if now == nil {
|
if now == nil {
|
||||||
now = time.Now
|
now = time.Now
|
||||||
}
|
}
|
||||||
return &Service{garmin: g, db: db, userID: userID, cfg: cfg.withDefaults(), now: now}
|
return &Sync{garmin: g, db: db, userID: userID, cfg: cfg.withDefaults(), now: now, progress: Progress{Phase: PhaseIdle}}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Progress returns the current detail-fill progress (0/0 when idle).
|
// Progress returns the current sync progress (phase idle, 0/0 when nothing
|
||||||
func (s *Service) Progress() Progress {
|
// is running).
|
||||||
|
func (s *Sync) Progress() Progress {
|
||||||
s.progressMu.Lock()
|
s.progressMu.Lock()
|
||||||
defer s.progressMu.Unlock()
|
defer s.progressMu.Unlock()
|
||||||
return s.progress
|
return s.progress
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) setProgress(done, total int) {
|
func (s *Sync) setProgress(phase string, done, total int) {
|
||||||
s.progressMu.Lock()
|
s.progressMu.Lock()
|
||||||
s.progress = Progress{Done: done, Total: total}
|
s.progress = Progress{Phase: phase, Done: done, Total: total}
|
||||||
s.progressMu.Unlock()
|
s.progressMu.Unlock()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Backfill pages backward in Config.BackfillWindowDays windows until
|
// backfillCore pages backward in Config.BackfillWindowDays windows until
|
||||||
// Profile.BackfillHorizonDays is reached or Garmin returns an empty page.
|
// Profile.BackfillHorizonDays is reached or Garmin returns an empty page.
|
||||||
// The horizon is read fresh from the profile on every call (not fixed at
|
// The horizon is read fresh from the profile on every call (not fixed at
|
||||||
// server startup), so a user-edited value takes effect on the very next
|
// server startup), so a user-edited value takes effect on the very next
|
||||||
@@ -106,28 +121,11 @@ func (s *Service) setProgress(done, total int) {
|
|||||||
// completed backfill, or is a fast no-op if the configured horizon is
|
// completed backfill, or is a fast no-op if the configured horizon is
|
||||||
// already fully covered -- it does not re-walk years of already-known
|
// already fully covered -- it does not re-walk years of already-known
|
||||||
// history. Widening the horizon between calls resumes further back instead
|
// history. Widening the horizon between calls resumes further back instead
|
||||||
// of re-fetching everything.
|
// of re-fetching everything. Used by FullSync as one step of its single
|
||||||
func (s *Service) Backfill(ctx context.Context) error {
|
// combined SyncRun; there is no standalone entrypoint for this anymore
|
||||||
runID, err := s.db.StartSyncRun(ctx, s.userID, store.SyncKindBackfill)
|
// (the periodic background sync loop that used to call one is gone -- see
|
||||||
if err != nil {
|
// 4d2cbe4 refactor: remove automatic background incremental sync).
|
||||||
return err
|
func (s *Sync) backfillCore(ctx context.Context) (int, error) {
|
||||||
}
|
|
||||||
|
|
||||||
total, err := s.backfillCore(ctx)
|
|
||||||
if err != nil {
|
|
||||||
msg := err.Error()
|
|
||||||
s.db.FinishSyncRun(ctx, s.userID, runID, total, &msg)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return s.db.FinishSyncRun(ctx, s.userID, runID, total, nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
// backfillCore holds Backfill's actual fetch logic, without the SyncRun
|
|
||||||
// bookkeeping, so FullSync can run it as one step of a single combined run
|
|
||||||
// instead of its own separately-recorded one. The returned count reflects
|
|
||||||
// whatever was fetched even when an error is also returned, matching
|
|
||||||
// Backfill's own partial-progress-on-error behavior.
|
|
||||||
func (s *Service) backfillCore(ctx context.Context) (int, error) {
|
|
||||||
profile, err := s.db.GetProfile(ctx, s.userID)
|
profile, err := s.db.GetProfile(ctx, s.userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, fmt.Errorf("load profile: %w", err)
|
return 0, fmt.Errorf("load profile: %w", err)
|
||||||
@@ -191,26 +189,11 @@ func (s *Service) backfillCore(ctx context.Context) (int, error) {
|
|||||||
return total, nil
|
return total, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// IncrementalSync fetches activities from just before the latest known
|
// incrementalSyncCore fetches activities from just before the latest known
|
||||||
// activity (or a short recent window if none exist yet) through today.
|
// activity (or a short recent window if none exist yet) through today. Used
|
||||||
func (s *Service) IncrementalSync(ctx context.Context) error {
|
// by FullSync as one step of its single combined SyncRun -- see
|
||||||
runID, err := s.db.StartSyncRun(ctx, s.userID, store.SyncKindIncremental)
|
// backfillCore's comment for why there's no standalone entrypoint.
|
||||||
if err != nil {
|
func (s *Sync) incrementalSyncCore(ctx context.Context) (int, error) {
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
n, err := s.incrementalSyncCore(ctx)
|
|
||||||
if err != nil {
|
|
||||||
msg := err.Error()
|
|
||||||
s.db.FinishSyncRun(ctx, s.userID, runID, n, &msg)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return s.db.FinishSyncRun(ctx, s.userID, runID, n, nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
// incrementalSyncCore holds IncrementalSync's actual fetch logic, without
|
|
||||||
// the SyncRun bookkeeping -- see backfillCore.
|
|
||||||
func (s *Service) incrementalSyncCore(ctx context.Context) (int, error) {
|
|
||||||
start := s.now().AddDate(0, 0, -s.cfg.IncrementalOverlapDays)
|
start := s.now().AddDate(0, 0, -s.cfg.IncrementalOverlapDays)
|
||||||
if latest, ok, err := s.db.LatestActivityStartTime(ctx, s.userID); err == nil && ok {
|
if latest, ok, err := s.db.LatestActivityStartTime(ctx, s.userID); err == nil && ok {
|
||||||
if t, err := time.Parse("2006-01-02 15:04:05", latest); err == nil {
|
if t, err := time.Parse("2006-01-02 15:04:05", latest); err == nil {
|
||||||
@@ -221,20 +204,21 @@ func (s *Service) incrementalSyncCore(ctx context.Context) (int, error) {
|
|||||||
return newCount, err
|
return newCount, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// FullSync performs a complete manual "Sync now" pass -- Backfill (resumes
|
// FullSync performs a complete manual "Sync now" pass -- backfillCore
|
||||||
// from the watermark), then IncrementalSync (catches anything new since the
|
// (resumes from the watermark), then incrementalSyncCore (catches anything
|
||||||
// latest known activity), then FillPendingDetails -- recorded as a single
|
// new since the latest known activity), then FillPendingDetails --
|
||||||
// SyncRun. Backfill and IncrementalSync each record their own SyncRun when
|
// recorded as a single SyncRun so the reported activity count covers the
|
||||||
// called on their own (used by the periodic background loop), but a manual
|
// whole action instead of only whichever stage happened to finish last.
|
||||||
// sync runs both back to back, and FillPendingDetails records no run at all;
|
func (s *Sync) FullSync(ctx context.Context, detailFillLimit int) error {
|
||||||
// showing the user only the most recently *recorded* run (IncrementalSync's)
|
|
||||||
// would silently hide however many activities Backfill fetched. Recording
|
|
||||||
// one combined run makes the reported count match the whole action.
|
|
||||||
func (s *Service) FullSync(ctx context.Context, detailFillLimit int) error {
|
|
||||||
runID, err := s.db.StartSyncRun(ctx, s.userID, store.SyncKindFull)
|
runID, err := s.db.StartSyncRun(ctx, s.userID, store.SyncKindFull)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
// Reset to idle on every return path, including an early error return
|
||||||
|
// from backfillCore/incrementalSyncCore before FillPendingDetails (which
|
||||||
|
// otherwise owns its own idle-reset) ever runs.
|
||||||
|
s.setProgress(PhaseDiscovering, 0, 0)
|
||||||
|
defer s.setProgress(PhaseIdle, 0, 0)
|
||||||
|
|
||||||
backfillCount, err := s.backfillCore(ctx)
|
backfillCount, err := s.backfillCore(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -264,7 +248,7 @@ func (s *Service) FullSync(ctx context.Context, detailFillLimit int) error {
|
|||||||
// assignments) and rewinds the backfill watermark, so the next Backfill
|
// assignments) and rewinds the backfill watermark, so the next Backfill
|
||||||
// call performs a genuinely fresh pull from Garmin instead of resuming from
|
// call performs a genuinely fresh pull from Garmin instead of resuming from
|
||||||
// wherever the previous one left off. Workout kinds are left untouched.
|
// wherever the previous one left off. Workout kinds are left untouched.
|
||||||
func (s *Service) ResetAll(ctx context.Context) error {
|
func (s *Sync) ResetAll(ctx context.Context) error {
|
||||||
return s.db.ResetAllSyncedData(ctx, s.userID)
|
return s.db.ResetAllSyncedData(ctx, s.userID)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -280,7 +264,7 @@ func (s *Service) ResetAll(ctx context.Context) error {
|
|||||||
// produced a confusing, meaningless number (e.g. "2 activities" on a sync
|
// produced a confusing, meaningless number (e.g. "2 activities" on a sync
|
||||||
// that found nothing new, just because 2 already-known activities happened
|
// that found nothing new, just because 2 already-known activities happened
|
||||||
// to fall inside the queried window).
|
// to fall inside the queried window).
|
||||||
func (s *Service) fetchAndStoreWindow(ctx context.Context, startDate, endDate string) (rawCount, newCount int, err error) {
|
func (s *Sync) fetchAndStoreWindow(ctx context.Context, startDate, endDate string) (rawCount, newCount int, err error) {
|
||||||
activities, err := s.garmin.GetActivities(ctx, startDate, endDate, 500)
|
activities, err := s.garmin.GetActivities(ctx, startDate, endDate, 500)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, 0, fmt.Errorf("get_activities(%s, %s): %w", startDate, endDate, err)
|
return 0, 0, fmt.Errorf("get_activities(%s, %s): %w", startDate, endDate, err)
|
||||||
@@ -306,11 +290,22 @@ func (s *Service) fetchAndStoreWindow(ctx context.Context, startDate, endDate st
|
|||||||
return len(activities), newCount, nil
|
return len(activities), newCount, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// FillPendingDetails fetches get_activity_splits/get_activity_details for up
|
// FillPendingDetails fetches activity details/splits for up to limit
|
||||||
// to limit activities that don't have them yet, then (re)classifies each one.
|
// activities missing them, then fetches workouts for up to limit activities
|
||||||
// Calls are made sequentially with Config.InterCallDelay between them to
|
// missing those (independently -- see ActivitiesMissingWorkout), then
|
||||||
|
// (re)classifies every activity touched by the first pass. Each pass makes
|
||||||
|
// its Garmin calls sequentially with Config.InterCallDelay between them to
|
||||||
// avoid Garmin/Cloudflare rate limiting.
|
// avoid Garmin/Cloudflare rate limiting.
|
||||||
func (s *Service) FillPendingDetails(ctx context.Context, limit int) error {
|
func (s *Sync) FillPendingDetails(ctx context.Context, limit int) error {
|
||||||
|
defer s.setProgress(PhaseIdle, 0, 0)
|
||||||
|
|
||||||
|
if err := s.fillPendingActivityDetails(ctx, limit); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return s.fillPendingWorkouts(ctx, limit)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Sync) fillPendingActivityDetails(ctx context.Context, limit int) error {
|
||||||
pending, err := s.db.ActivitiesMissingDetails(ctx, s.userID, limit)
|
pending, err := s.db.ActivitiesMissingDetails(ctx, s.userID, limit)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -320,8 +315,7 @@ func (s *Service) FillPendingDetails(ctx context.Context, limit int) error {
|
|||||||
return fmt.Errorf("load profile: %w", err)
|
return fmt.Errorf("load profile: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
s.setProgress(0, len(pending))
|
s.setProgress(PhaseActivities, 0, len(pending))
|
||||||
defer s.setProgress(0, 0)
|
|
||||||
|
|
||||||
for i, a := range pending {
|
for i, a := range pending {
|
||||||
if i > 0 {
|
if i > 0 {
|
||||||
@@ -337,12 +331,57 @@ func (s *Service) FillPendingDetails(ctx context.Context, limit int) error {
|
|||||||
if err := s.ClassifyActivity(ctx, a.ID); err != nil {
|
if err := s.ClassifyActivity(ctx, a.ID); err != nil {
|
||||||
return fmt.Errorf("classify activity %d: %w", a.GarminActivityID, err)
|
return fmt.Errorf("classify activity %d: %w", a.GarminActivityID, err)
|
||||||
}
|
}
|
||||||
s.setProgress(i+1, len(pending))
|
s.setProgress(PhaseActivities, i+1, len(pending))
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) fillActivityDetails(ctx context.Context, a store.Activity, profile store.Profile) error {
|
// fillPendingWorkouts fetches get_workout_by_id for up to limit activities
|
||||||
|
// missing it. Unlike fillPendingActivityDetails, one activity's workout
|
||||||
|
// fetch failing is non-fatal (logged, loop continues) -- workout target
|
||||||
|
// bands are enrichment, not core activity data, and workout_raw_json
|
||||||
|
// staying NULL means ActivitiesMissingWorkout will naturally retry it on
|
||||||
|
// the next sync.
|
||||||
|
func (s *Sync) fillPendingWorkouts(ctx context.Context, limit int) error {
|
||||||
|
pending, err := s.db.ActivitiesMissingWorkout(ctx, s.userID, limit)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
profile, err := s.db.GetProfile(ctx, s.userID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("load profile: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
s.setProgress(PhaseWorkouts, 0, len(pending))
|
||||||
|
|
||||||
|
for i, a := range pending {
|
||||||
|
if i > 0 {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return ctx.Err()
|
||||||
|
case <-time.After(s.cfg.InterCallDelay):
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := s.fillActivityWorkout(ctx, a, profile); err != nil {
|
||||||
|
if errors.Is(err, ErrNotFound) {
|
||||||
|
// A definitive 404 (the workout was deleted on Garmin's side
|
||||||
|
// after being linked to this activity) will never succeed on
|
||||||
|
// retry -- mark it so ActivitiesMissingWorkout stops
|
||||||
|
// surfacing it, instead of retrying forever.
|
||||||
|
applog.App().Warn("workout not found on Garmin, marking as such (will not retry)", "garmin_activity_id", a.GarminActivityID, "error", err)
|
||||||
|
if serr := s.db.SetActivityWorkoutNotFound(ctx, s.userID, a.ID); serr != nil {
|
||||||
|
return fmt.Errorf("mark activity %d workout not found: %w", a.GarminActivityID, serr)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
applog.App().Warn("fill workout failed, will retry next sync", "garmin_activity_id", a.GarminActivityID, "error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
s.setProgress(PhaseWorkouts, i+1, len(pending))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Sync) fillActivityDetails(ctx context.Context, a store.Activity, profile store.Profile) error {
|
||||||
splits, err := s.garmin.GetActivitySplits(ctx, a.GarminActivityID)
|
splits, err := s.garmin.GetActivitySplits(ctx, a.GarminActivityID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("get_activity_splits: %w", err)
|
return fmt.Errorf("get_activity_splits: %w", err)
|
||||||
@@ -352,23 +391,14 @@ func (s *Service) fillActivityDetails(ctx context.Context, a store.Activity, pro
|
|||||||
return fmt.Errorf("get_activity_details: %w", err)
|
return fmt.Errorf("get_activity_details: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
targets := make([]*garmin.WorkoutStep, len(splits.Laps))
|
samples := ExtractSamples(details)
|
||||||
if a.WorkoutID != nil {
|
|
||||||
workout, err := s.garmin.GetWorkoutByID(ctx, *a.WorkoutID)
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("sync: get_workout_by_id(%d) for activity %d failed, continuing without target zones: %v", *a.WorkoutID, a.GarminActivityID, err)
|
|
||||||
} else {
|
|
||||||
targets = alignWorkoutTargets(splits.Laps, workout)
|
|
||||||
if err := s.db.SetActivityWorkout(ctx, s.userID, a.ID, string(workout.Raw)); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
samples := garmin.ExtractSamples(details)
|
|
||||||
if err := s.db.ReplaceActivitySamples(ctx, s.userID, a.ID, toSampleRows(samples)); err != nil {
|
if err := s.db.ReplaceActivitySamples(ctx, s.userID, a.ID, toSampleRows(samples)); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
// No workout-target alignment here -- that's fillActivityWorkout's job,
|
||||||
|
// run as its own later pass (see fillPendingWorkouts). targets is an
|
||||||
|
// all-nil placeholder the same length as splits.Laps.
|
||||||
|
targets := make([]*WorkoutStep, len(splits.Laps))
|
||||||
if err := s.db.ReplaceLaps(ctx, s.userID, a.ID, toLapRows(splits.Laps, samples, targets, profile)); err != nil {
|
if err := s.db.ReplaceLaps(ctx, s.userID, a.ID, toLapRows(splits.Laps, samples, targets, profile)); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -378,10 +408,39 @@ func (s *Service) fillActivityDetails(ctx context.Context, a store.Activity, pro
|
|||||||
return s.db.SetActivitySplitsFetched(ctx, s.userID, a.ID)
|
return s.db.SetActivitySplitsFetched(ctx, s.userID, a.ID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// fillActivityWorkout fetches a's structured workout and re-derives its
|
||||||
|
// laps' target pace/HR bands from it. Requires a.WorkoutID to be set --
|
||||||
|
// only ever called for activities ActivitiesMissingWorkout returned, which
|
||||||
|
// already filters on that. Reads laps back from the DB (already written by
|
||||||
|
// fillActivityDetails, in some earlier pass or run) rather than needing the
|
||||||
|
// original garmin.Lap data again, since alignWorkoutTargets only needs a
|
||||||
|
// count.
|
||||||
|
func (s *Sync) fillActivityWorkout(ctx context.Context, a store.Activity, profile store.Profile) error {
|
||||||
|
workout, err := s.garmin.GetWorkoutByID(ctx, *a.WorkoutID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("get_workout_by_id: %w", err)
|
||||||
|
}
|
||||||
|
laps, err := s.db.LapsForActivity(ctx, s.userID, a.ID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
targets := alignWorkoutTargets(len(laps), workout)
|
||||||
|
for i := range laps {
|
||||||
|
if i < len(targets) && targets[i] != nil {
|
||||||
|
laps[i].TargetPaceLowMps, laps[i].TargetPaceHighMps = targetPaceRange(*targets[i])
|
||||||
|
laps[i].TargetHRLowBpm, laps[i].TargetHRHighBpm = targetHRRange(*targets[i], profile)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := s.db.ReplaceLaps(ctx, s.userID, a.ID, laps); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return s.db.SetActivityWorkout(ctx, s.userID, a.ID, string(workout.Raw))
|
||||||
|
}
|
||||||
|
|
||||||
// ClassifyActivity (re)runs the rule engine for one activity against the
|
// ClassifyActivity (re)runs the rule engine for one activity against the
|
||||||
// currently active workout kinds and appends a new kind_assignments row.
|
// currently active workout kinds and appends a new kind_assignments row.
|
||||||
// Safe to call repeatedly (e.g. after editing a workout kind's rule).
|
// Safe to call repeatedly (e.g. after editing a workout kind's rule).
|
||||||
func (s *Service) ClassifyActivity(ctx context.Context, activityID int64) error {
|
func (s *Sync) ClassifyActivity(ctx context.Context, activityID int64) error {
|
||||||
activity, ok, err := s.db.GetActivity(ctx, s.userID, activityID)
|
activity, ok, err := s.db.GetActivity(ctx, s.userID, activityID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
1005
backend/internal/garmin/sync_test.go
Normal file
1005
backend/internal/garmin/sync_test.go
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,6 @@
|
|||||||
|
import json
|
||||||
import os
|
import os
|
||||||
|
import time
|
||||||
import queue
|
import queue
|
||||||
import threading
|
import threading
|
||||||
from unittest.mock import MagicMock, patch
|
from unittest.mock import MagicMock, patch
|
||||||
@@ -12,15 +14,18 @@ import wrapper
|
|||||||
def reset_state():
|
def reset_state():
|
||||||
original_state = wrapper._auth_state
|
original_state = wrapper._auth_state
|
||||||
original_client = wrapper._client
|
original_client = wrapper._client
|
||||||
|
original_startup_attempted = wrapper._startup_login_attempted
|
||||||
for q in (wrapper._mfa_input_queue, wrapper._login_result_queue):
|
for q in (wrapper._mfa_input_queue, wrapper._login_result_queue):
|
||||||
while not q.empty():
|
while not q.empty():
|
||||||
try:
|
try:
|
||||||
q.get_nowait()
|
q.get_nowait()
|
||||||
except queue.Empty:
|
except queue.Empty:
|
||||||
break
|
break
|
||||||
|
wrapper._startup_login_attempted = False
|
||||||
yield
|
yield
|
||||||
wrapper._auth_state = original_state
|
wrapper._auth_state = original_state
|
||||||
wrapper._client = original_client
|
wrapper._client = original_client
|
||||||
|
wrapper._startup_login_attempted = original_startup_attempted
|
||||||
|
|
||||||
|
|
||||||
def test_authenticate_success():
|
def test_authenticate_success():
|
||||||
@@ -93,7 +98,15 @@ def test_call_dispatches_to_named_garminconnect_method():
|
|||||||
|
|
||||||
|
|
||||||
def test_call_unauthenticated_is_error():
|
def test_call_unauthenticated_is_error():
|
||||||
|
# Explicitly absent GARMIN_EMAIL/PASSWORD: this now indirectly triggers
|
||||||
|
# the lazy _startup_login fallback (see _handle_call), which must
|
||||||
|
# return immediately with nothing to resume, leaving this the same
|
||||||
|
# "Not authenticated" error as before -- not dependent on whatever
|
||||||
|
# happens to be in the ambient shell environment.
|
||||||
wrapper._auth_state = "unauthenticated"
|
wrapper._auth_state = "unauthenticated"
|
||||||
|
with patch.dict("os.environ", {}, clear=False):
|
||||||
|
os.environ.pop("GARMIN_EMAIL", None)
|
||||||
|
os.environ.pop("GARMIN_PASSWORD", None)
|
||||||
resp = wrapper.dispatch({
|
resp = wrapper.dispatch({
|
||||||
"id": 7, "cmd": "call", "params": {"method": "get_activities_by_date", "args": {}},
|
"id": 7, "cmd": "call", "params": {"method": "get_activities_by_date", "args": {}},
|
||||||
})
|
})
|
||||||
@@ -118,7 +131,13 @@ def test_call_propagates_garminconnect_exception_as_error():
|
|||||||
resp = wrapper.dispatch({
|
resp = wrapper.dispatch({
|
||||||
"id": 9, "cmd": "call", "params": {"method": "get_activity_splits", "args": {"activity_id": "999"}},
|
"id": 9, "cmd": "call", "params": {"method": "get_activity_splits", "args": {"activity_id": "999"}},
|
||||||
})
|
})
|
||||||
assert resp == {"id": 9, "error": "not found"}
|
assert resp["id"] == 9
|
||||||
|
assert resp["error"] == "not found"
|
||||||
|
# Failure detail travels in the protocol so the Go side can log one
|
||||||
|
# complete structured record (no stderr correlation needed).
|
||||||
|
assert resp["error_type"] == "Exception"
|
||||||
|
assert "Exception: not found" in resp["traceback"]
|
||||||
|
assert "not_found" not in resp
|
||||||
|
|
||||||
|
|
||||||
def test_dispatch_unknown_cmd_is_error():
|
def test_dispatch_unknown_cmd_is_error():
|
||||||
@@ -206,3 +225,142 @@ def test_startup_login_does_not_leak_into_shared_authenticate_queue():
|
|||||||
|
|
||||||
assert resp == {"id": 20, "result": {"status": "success", "message": "Authenticated successfully."}}
|
assert resp == {"id": 20, "result": {"status": "success", "message": "Authenticated successfully."}}
|
||||||
assert wrapper._auth_state == "authenticated"
|
assert wrapper._auth_state == "authenticated"
|
||||||
|
|
||||||
|
|
||||||
|
def test_call_triggers_startup_login_lazily_on_first_call():
|
||||||
|
"""A data 'call' with no prior explicit authenticate on this subprocess
|
||||||
|
-- e.g. "Sync now" reaching an already-connected user's client right
|
||||||
|
after a backend restart -- gets exactly one lazy attempt to silently
|
||||||
|
resume a cached tokenstore session before giving up."""
|
||||||
|
wrapper._auth_state = "unauthenticated"
|
||||||
|
wrapper._client = MagicMock()
|
||||||
|
wrapper._client.get_activities_by_date.return_value = []
|
||||||
|
|
||||||
|
def fake_startup_login():
|
||||||
|
wrapper._auth_state = "authenticated"
|
||||||
|
|
||||||
|
with patch("wrapper._startup_login", side_effect=fake_startup_login) as mock_startup:
|
||||||
|
resp = wrapper.dispatch({
|
||||||
|
"id": 30, "cmd": "call", "params": {"method": "get_activities_by_date", "args": {}},
|
||||||
|
})
|
||||||
|
|
||||||
|
mock_startup.assert_called_once()
|
||||||
|
assert resp == {"id": 30, "result": []}
|
||||||
|
|
||||||
|
|
||||||
|
def test_call_does_not_retry_startup_login_after_first_attempt():
|
||||||
|
"""If the lazy startup-login attempt doesn't actually authenticate, a
|
||||||
|
second 'call' must not try it again -- one attempt per subprocess
|
||||||
|
lifetime, not once per call."""
|
||||||
|
wrapper._auth_state = "unauthenticated"
|
||||||
|
|
||||||
|
with patch("wrapper._startup_login") as mock_startup: # no-op: leaves state unauthenticated
|
||||||
|
wrapper.dispatch({"id": 31, "cmd": "call", "params": {"method": "x", "args": {}}})
|
||||||
|
wrapper.dispatch({"id": 32, "cmd": "call", "params": {"method": "x", "args": {}}})
|
||||||
|
|
||||||
|
mock_startup.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
def test_authenticate_prevents_later_lazy_startup_login():
|
||||||
|
"""Once authenticate has been explicitly called (regardless of
|
||||||
|
outcome), a later 'call' must never fall back to _startup_login -- that
|
||||||
|
would either duplicate a login authenticate already did, or waste an
|
||||||
|
extra hit against Garmin after a failure."""
|
||||||
|
env = {"GARMIN_EMAIL": "test@example.com", "GARMIN_PASSWORD": "secret"}
|
||||||
|
with patch.dict("os.environ", env):
|
||||||
|
with patch("wrapper.Garmin") as mock_garmin_cls:
|
||||||
|
mock_garmin_cls.return_value = MagicMock()
|
||||||
|
wrapper.dispatch({"id": 33, "cmd": "authenticate"})
|
||||||
|
|
||||||
|
wrapper._auth_state = "unauthenticated" # simulate a later, separate call finding no session
|
||||||
|
with patch("wrapper._startup_login") as mock_startup:
|
||||||
|
wrapper.dispatch({"id": 34, "cmd": "call", "params": {"method": "x", "args": {}}})
|
||||||
|
|
||||||
|
mock_startup.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
def test_call_marks_not_found_error_specifically():
|
||||||
|
from garminconnect import GarminConnectNotFoundError
|
||||||
|
|
||||||
|
wrapper._auth_state = "authenticated"
|
||||||
|
wrapper._client = MagicMock()
|
||||||
|
wrapper._client.get_workout_by_id.side_effect = GarminConnectNotFoundError("API Error 404")
|
||||||
|
resp = wrapper.dispatch({
|
||||||
|
"id": 11, "cmd": "call", "params": {"method": "get_workout_by_id", "args": {"workout_id": "999"}},
|
||||||
|
})
|
||||||
|
assert resp["id"] == 11
|
||||||
|
assert resp["error"] == "API Error 404"
|
||||||
|
assert resp["not_found"] is True
|
||||||
|
assert resp["error_type"] == "GarminConnectNotFoundError"
|
||||||
|
|
||||||
|
|
||||||
|
def test_call_does_not_mark_other_errors_as_not_found():
|
||||||
|
wrapper._auth_state = "authenticated"
|
||||||
|
wrapper._client = MagicMock()
|
||||||
|
wrapper._client.get_workout_by_id.side_effect = Exception("rate limited")
|
||||||
|
resp = wrapper.dispatch({
|
||||||
|
"id": 12, "cmd": "call", "params": {"method": "get_workout_by_id", "args": {"workout_id": "999"}},
|
||||||
|
})
|
||||||
|
assert resp["id"] == 12
|
||||||
|
assert resp["error"] == "rate limited"
|
||||||
|
assert "not_found" not in resp
|
||||||
|
|
||||||
|
|
||||||
|
def test_startup_login_late_success_flips_auth_state_for_later_calls():
|
||||||
|
"""A tokenstore resume that outlives the 10s wait must still recover
|
||||||
|
the subprocess: the background thread itself flips _auth_state on
|
||||||
|
success, so the next `call` command passes the auth check without an
|
||||||
|
explicit authenticate."""
|
||||||
|
env = {"GARMIN_EMAIL": "test@example.com", "GARMIN_PASSWORD": "secret"}
|
||||||
|
release = threading.Event()
|
||||||
|
mock_client = MagicMock()
|
||||||
|
mock_client.login.side_effect = lambda **kwargs: release.wait(timeout=5)
|
||||||
|
wrapper._auth_state = "unauthenticated"
|
||||||
|
with patch.dict("os.environ", env):
|
||||||
|
with patch("wrapper.Garmin", return_value=mock_client):
|
||||||
|
with patch("wrapper.queue.Queue") as mock_queue_cls:
|
||||||
|
# Simulate the 10s timeout: the reader gives up immediately,
|
||||||
|
# while the (still-blocked) login thread keeps running.
|
||||||
|
mock_queue_cls.return_value.get.side_effect = queue.Empty
|
||||||
|
wrapper._startup_login()
|
||||||
|
assert wrapper._auth_state == "unauthenticated"
|
||||||
|
|
||||||
|
release.set() # the slow login now completes, after the timeout
|
||||||
|
for _ in range(100):
|
||||||
|
if wrapper._auth_state == "authenticated":
|
||||||
|
break
|
||||||
|
time.sleep(0.01)
|
||||||
|
assert wrapper._auth_state == "authenticated"
|
||||||
|
|
||||||
|
|
||||||
|
def test_startup_login_late_success_never_overrides_explicit_auth_flow():
|
||||||
|
"""If an explicit authenticate/MFA flow moved the state while the
|
||||||
|
startup resume was still in flight, the late success must not clobber
|
||||||
|
it -- the explicit flow owns the state once it has moved it."""
|
||||||
|
env = {"GARMIN_EMAIL": "test@example.com", "GARMIN_PASSWORD": "secret"}
|
||||||
|
release = threading.Event()
|
||||||
|
mock_client = MagicMock()
|
||||||
|
mock_client.login.side_effect = lambda **kwargs: release.wait(timeout=5)
|
||||||
|
wrapper._auth_state = "unauthenticated"
|
||||||
|
with patch.dict("os.environ", env):
|
||||||
|
with patch("wrapper.Garmin", return_value=mock_client):
|
||||||
|
with patch("wrapper.queue.Queue") as mock_queue_cls:
|
||||||
|
mock_queue_cls.return_value.get.side_effect = queue.Empty
|
||||||
|
wrapper._startup_login()
|
||||||
|
|
||||||
|
wrapper._auth_state = "mfa_pending" # an explicit flow took over meanwhile
|
||||||
|
release.set()
|
||||||
|
time.sleep(0.2) # give the thread ample time to (wrongly) flip it
|
||||||
|
assert wrapper._auth_state == "mfa_pending"
|
||||||
|
|
||||||
|
|
||||||
|
def test_log_stamps_emitting_method(capsys):
|
||||||
|
"""Every wrapper log line carries the emitting function as "method" --
|
||||||
|
the Go side forwards it into the unified log schema."""
|
||||||
|
|
||||||
|
def some_emitter():
|
||||||
|
wrapper._log("info", "hello", extra=1)
|
||||||
|
|
||||||
|
some_emitter()
|
||||||
|
entry = json.loads(capsys.readouterr().err.strip())
|
||||||
|
assert entry == {"level": "info", "msg": "hello", "method": "some_emitter", "extra": 1}
|
||||||
312
backend/internal/garmin/wrapper/wrapper.py
Normal file
312
backend/internal/garmin/wrapper/wrapper.py
Normal file
@@ -0,0 +1,312 @@
|
|||||||
|
"""Subprocess wrapper around garminconnect, spoken to over newline-delimited
|
||||||
|
JSON on stdin/stdout by geniusrund's internal/garmin package. See
|
||||||
|
docs/superpowers/specs/2026-07-25-garmin-direct-wrapper-design.md."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import queue
|
||||||
|
import sys
|
||||||
|
import threading
|
||||||
|
import traceback
|
||||||
|
|
||||||
|
from garminconnect import Garmin, GarminConnectNotFoundError
|
||||||
|
|
||||||
|
TOKENSTORE = os.path.expanduser(os.environ.get("GARMIN_TOKENSTORE", "~/.garmin"))
|
||||||
|
|
||||||
|
_client = None
|
||||||
|
_auth_state = "unauthenticated"
|
||||||
|
_mfa_input_queue = queue.Queue()
|
||||||
|
_login_result_queue = queue.Queue()
|
||||||
|
# Set the first time this subprocess attempts any login, whichever path
|
||||||
|
# gets there first (see _handle_call's lazy call and _handle_authenticate) --
|
||||||
|
# guarantees _startup_login runs at most once per subprocess lifetime, and
|
||||||
|
# never at all once an explicit authenticate has been attempted.
|
||||||
|
_startup_login_attempted = False
|
||||||
|
|
||||||
|
|
||||||
|
def _log(level, msg, **attrs):
|
||||||
|
"""Emit one JSON log line on stderr. internal/garmin/client.go reads
|
||||||
|
stderr line by line and re-emits these through the Go application
|
||||||
|
logger, so the backend's combined output stays a single JSON stream --
|
||||||
|
never print free text to stderr or stdout from this process (stdout is
|
||||||
|
reserved for the request/response protocol). The emitting function is
|
||||||
|
stamped as "method" automatically; the Go side adds the rest of the
|
||||||
|
log schema (type=wrapper, file=wrapper.py)."""
|
||||||
|
entry = {"level": level, "msg": msg, "method": sys._getframe(1).f_code.co_name}
|
||||||
|
entry.update(attrs)
|
||||||
|
print(json.dumps(entry), file=sys.stderr, flush=True)
|
||||||
|
|
||||||
|
|
||||||
|
def _prompt_mfa():
|
||||||
|
_log("debug", "invoked")
|
||||||
|
code = _mfa_input_queue.get(timeout=300)
|
||||||
|
_log("debug", "returning MFA code", code_length=len(code))
|
||||||
|
return code
|
||||||
|
|
||||||
|
|
||||||
|
def _startup_login():
|
||||||
|
"""Silently resume a cached tokenstore session, so a freshly
|
||||||
|
(re)spawned subprocess can already be authenticated for a data 'call'
|
||||||
|
that never goes through the explicit authenticate command -- e.g.
|
||||||
|
"Sync now" reaching an already-connected user's client right after a
|
||||||
|
backend restart cleared the in-memory cache.
|
||||||
|
|
||||||
|
Called lazily (see _handle_call), at most once per subprocess lifetime,
|
||||||
|
and only if nothing has explicitly called authenticate first. Running
|
||||||
|
it unconditionally at process start used to be actively counterproductive
|
||||||
|
whenever the very first command actually was authenticate: on success it
|
||||||
|
just duplicated a login _handle_authenticate was about to redo anyway
|
||||||
|
(it always rebuilds _client from scratch), and on failure it was a
|
||||||
|
wasted, unauthenticated hit against Garmin's servers moments before the
|
||||||
|
real attempt -- extra load that only makes rate-limiting worse.
|
||||||
|
|
||||||
|
Bounded to the same 10s timeout as _handle_authenticate: the actual
|
||||||
|
login runs on a background daemon thread, and this function waits up
|
||||||
|
to 10s for it before returning either way, so a slow/rate-limited
|
||||||
|
Garmin login can never block the caller indefinitely. On timeout the
|
||||||
|
triggering call fails ("Not authenticated"), but the thread keeps
|
||||||
|
running and, if the login eventually succeeds, flips _auth_state to
|
||||||
|
"authenticated" itself -- only ever from "unauthenticated", never
|
||||||
|
overriding an explicit authenticate/MFA flow that ran in the
|
||||||
|
meantime -- so subsequent calls recover without user action. A late
|
||||||
|
failure changes nothing (the state is already "unauthenticated").
|
||||||
|
|
||||||
|
Deliberately uses its own private, function-local result queue rather
|
||||||
|
than the module-level _login_result_queue that _handle_authenticate and
|
||||||
|
_handle_complete_mfa share -- those two are legitimately two halves of
|
||||||
|
one explicit, MFA-capable login flow and must share a queue for the MFA
|
||||||
|
handoff to work, but this is a plain background tokenstore resume with
|
||||||
|
no MFA involved. Sharing the queue here would let this thread's stale
|
||||||
|
result (arriving after the 10s timeout below) be dequeued by an
|
||||||
|
unrelated, later authenticate/complete_mfa call instead of that call's
|
||||||
|
own fresh result."""
|
||||||
|
global _client, _auth_state
|
||||||
|
|
||||||
|
email = os.environ.get("GARMIN_EMAIL")
|
||||||
|
password = os.environ.get("GARMIN_PASSWORD")
|
||||||
|
if not email or not password:
|
||||||
|
return
|
||||||
|
|
||||||
|
_client = Garmin(email, password)
|
||||||
|
result_queue = queue.Queue() # private to this call, never shared with authenticate/complete_mfa
|
||||||
|
|
||||||
|
def _do_startup_login():
|
||||||
|
global _auth_state
|
||||||
|
_log("debug", "background thread starting _client.login()", tokenstore=TOKENSTORE)
|
||||||
|
try:
|
||||||
|
_client.login(tokenstore=TOKENSTORE)
|
||||||
|
_log("debug", "_client.login() returned successfully")
|
||||||
|
result_queue.put(("success", None))
|
||||||
|
# A success arriving after the 10s timeout below would land in
|
||||||
|
# an abandoned queue -- flip the state here too, so later calls
|
||||||
|
# benefit from the resume. Only from "unauthenticated": a
|
||||||
|
# concurrent explicit authenticate/MFA flow owns the state once
|
||||||
|
# it has moved it anywhere else.
|
||||||
|
if _auth_state == "unauthenticated":
|
||||||
|
_auth_state = "authenticated"
|
||||||
|
except Exception as exc:
|
||||||
|
_log(
|
||||||
|
"error",
|
||||||
|
"_client.login() failed",
|
||||||
|
error=str(exc),
|
||||||
|
error_type=type(exc).__name__,
|
||||||
|
traceback=traceback.format_exc(),
|
||||||
|
)
|
||||||
|
result_queue.put(("error", str(exc)))
|
||||||
|
|
||||||
|
threading.Thread(target=_do_startup_login, daemon=True).start()
|
||||||
|
|
||||||
|
try:
|
||||||
|
status, err = result_queue.get(timeout=10)
|
||||||
|
_log("debug", "got result within 10s timeout", status=status)
|
||||||
|
if status == "success":
|
||||||
|
_auth_state = "authenticated"
|
||||||
|
else:
|
||||||
|
_log("error", "login failed", error=err)
|
||||||
|
_auth_state = "unauthenticated"
|
||||||
|
except queue.Empty:
|
||||||
|
# No assignment here: the state is already "unauthenticated", and
|
||||||
|
# writing it again could clobber a success the background thread
|
||||||
|
# records right around the timeout boundary (see _do_startup_login).
|
||||||
|
_log(
|
||||||
|
"warn",
|
||||||
|
"hit the 10s timeout but still running in the "
|
||||||
|
"background and will update auth state if it eventually succeeds",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _handle_authenticate(_params):
|
||||||
|
global _client, _auth_state, _startup_login_attempted
|
||||||
|
# An explicit authenticate is happening (successful or not) -- the lazy
|
||||||
|
# startup-login fallback in _handle_call must never fire after this, it
|
||||||
|
# would be redundant at best and a wasted extra hit against Garmin at
|
||||||
|
# worst.
|
||||||
|
_startup_login_attempted = True
|
||||||
|
|
||||||
|
email = os.environ.get("GARMIN_EMAIL", "")
|
||||||
|
password = os.environ.get("GARMIN_PASSWORD", "")
|
||||||
|
if not email or not password:
|
||||||
|
return {
|
||||||
|
"status": "failed",
|
||||||
|
"message": "GARMIN_EMAIL and GARMIN_PASSWORD environment variables are required.",
|
||||||
|
}
|
||||||
|
|
||||||
|
def _do_authenticate_login():
|
||||||
|
_log("debug", "background thread starting _client.login()", tokenstore=TOKENSTORE)
|
||||||
|
try:
|
||||||
|
_client.login(tokenstore=TOKENSTORE)
|
||||||
|
_log("debug", "_client.login() returned successfully")
|
||||||
|
_login_result_queue.put(("success", None))
|
||||||
|
except Exception as exc:
|
||||||
|
_log(
|
||||||
|
"error",
|
||||||
|
"_client.login() failed",
|
||||||
|
error=str(exc),
|
||||||
|
error_type=type(exc).__name__,
|
||||||
|
traceback=traceback.format_exc(),
|
||||||
|
)
|
||||||
|
_login_result_queue.put(("error", str(exc)))
|
||||||
|
|
||||||
|
_client = Garmin(email, password)
|
||||||
|
_client.prompt_mfa = _prompt_mfa
|
||||||
|
threading.Thread(target=_do_authenticate_login, daemon=True).start()
|
||||||
|
|
||||||
|
try:
|
||||||
|
status, err = _login_result_queue.get(timeout=10)
|
||||||
|
_log("debug", "got result within 10s timeout", status=status)
|
||||||
|
if status == "success":
|
||||||
|
_auth_state = "authenticated"
|
||||||
|
return {"status": "success", "message": "Authenticated successfully."}
|
||||||
|
else:
|
||||||
|
_log("error", "login failed", error=err)
|
||||||
|
return {"status": "failed", "message": f"Authentication failed: {err}"}
|
||||||
|
except queue.Empty:
|
||||||
|
_log(
|
||||||
|
"warn",
|
||||||
|
"hit the 10s timeout with no result yet, reporting mfa_required",
|
||||||
|
)
|
||||||
|
_auth_state = "mfa_pending"
|
||||||
|
return {
|
||||||
|
"status": "mfa_required",
|
||||||
|
"message": "MFA required. Garmin has sent a verification code to your registered email or phone.",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _handle_complete_mfa(params):
|
||||||
|
global _auth_state
|
||||||
|
|
||||||
|
if _auth_state != "mfa_pending":
|
||||||
|
return {"status": "failed", "message": "No MFA in progress. Call authenticate first."}
|
||||||
|
|
||||||
|
code = params["code"]
|
||||||
|
_log("debug", "received a code, pushing to mfa queue", code_length=len(code))
|
||||||
|
_mfa_input_queue.put(code)
|
||||||
|
|
||||||
|
try:
|
||||||
|
status, err = _login_result_queue.get(timeout=30)
|
||||||
|
_log("debug", "got result", status=status, error=err)
|
||||||
|
if status == "success":
|
||||||
|
_auth_state = "authenticated"
|
||||||
|
return {"status": "success", "message": "MFA accepted. Authenticated successfully."}
|
||||||
|
return {"status": "failed", "message": f"Authentication failed after MFA: {err}"}
|
||||||
|
except queue.Empty:
|
||||||
|
_log(
|
||||||
|
"error",
|
||||||
|
"hit the 30s timeout with no result yet, reporting unauthenticated",
|
||||||
|
)
|
||||||
|
_auth_state = "unauthenticated"
|
||||||
|
return {
|
||||||
|
"status": "failed",
|
||||||
|
"message": "Timed out waiting for authentication to complete.",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _handle_call(params):
|
||||||
|
global _startup_login_attempted
|
||||||
|
if _auth_state != "authenticated" and not _startup_login_attempted:
|
||||||
|
_startup_login_attempted = True
|
||||||
|
_startup_login()
|
||||||
|
if _auth_state != "authenticated":
|
||||||
|
raise RuntimeError("Not authenticated. Call authenticate first.")
|
||||||
|
method = params["method"]
|
||||||
|
args = params.get("args") or {}
|
||||||
|
fn = getattr(_client, method)
|
||||||
|
return fn(**args)
|
||||||
|
|
||||||
|
|
||||||
|
_HANDLERS = {
|
||||||
|
"authenticate": _handle_authenticate,
|
||||||
|
"complete_mfa": _handle_complete_mfa,
|
||||||
|
"call": _handle_call,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def dispatch(req):
|
||||||
|
handler = _HANDLERS.get(req.get("cmd"))
|
||||||
|
if handler is None:
|
||||||
|
return {"id": req.get("id"), "error": f"unknown cmd {req.get('cmd')!r}"}
|
||||||
|
try:
|
||||||
|
result = handler(req.get("params") or {})
|
||||||
|
return {"id": req["id"], "result": result}
|
||||||
|
except Exception as exc:
|
||||||
|
# The full failure detail travels IN the response -- error text,
|
||||||
|
# exception class, and traceback -- so the Go side can log one
|
||||||
|
# complete structured record instead of correlating stderr noise.
|
||||||
|
resp = {
|
||||||
|
"id": req.get("id"),
|
||||||
|
"error": str(exc),
|
||||||
|
"error_type": type(exc).__name__,
|
||||||
|
"traceback": traceback.format_exc(),
|
||||||
|
}
|
||||||
|
# A 404 (e.g. get_workout_by_id for a workout deleted on Garmin's
|
||||||
|
# side after being linked to an activity) is definitive, not a
|
||||||
|
# transient failure worth retrying forever -- marked specifically so
|
||||||
|
# internal/garmin/client.go can tell the two apart (see
|
||||||
|
# docs/superpowers/specs/2026-07-27-workout-not-found-design.md).
|
||||||
|
if isinstance(exc, GarminConnectNotFoundError):
|
||||||
|
resp["not_found"] = True
|
||||||
|
return resp
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
for line in sys.stdin:
|
||||||
|
line = line.strip()
|
||||||
|
if not line:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
req = json.loads(line)
|
||||||
|
except json.JSONDecodeError as exc:
|
||||||
|
# A malformed request has no id to answer to -- log and keep
|
||||||
|
# serving rather than crashing the subprocess.
|
||||||
|
_log("error", "malformed request line", error=str(exc))
|
||||||
|
continue
|
||||||
|
resp = dispatch(req)
|
||||||
|
try:
|
||||||
|
print(json.dumps(resp), flush=True)
|
||||||
|
except (TypeError, ValueError) as exc:
|
||||||
|
# A non-JSON-serializable handler result must still produce a
|
||||||
|
# protocol response, or the Go side would block on a reply.
|
||||||
|
print(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"id": req.get("id"),
|
||||||
|
"error": f"unserializable result: {exc}",
|
||||||
|
"error_type": type(exc).__name__,
|
||||||
|
}
|
||||||
|
),
|
||||||
|
flush=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
try:
|
||||||
|
main()
|
||||||
|
except Exception as exc: # last resort: die loudly, but still in JSON
|
||||||
|
_log(
|
||||||
|
"error",
|
||||||
|
"wrapper crashed",
|
||||||
|
error=str(exc),
|
||||||
|
error_type=type(exc).__name__,
|
||||||
|
traceback=traceback.format_exc(),
|
||||||
|
)
|
||||||
|
raise SystemExit(1)
|
||||||
87
backend/internal/log/log.go
Normal file
87
backend/internal/log/log.go
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
// Package applog provides geniusrun's structured JSON logging: a
|
||||||
|
// log/slog-based logger writing to stdout, plus the App helper that tags
|
||||||
|
// records with the schema fields every geniusrun log line carries --
|
||||||
|
// "type" ("app" | "http" | "wrapper"), "package" (Go package; absent on
|
||||||
|
// Python-emitted lines), "file" (Go/Python source file basename), "class"
|
||||||
|
// (Go receiver type or Python class, omitted when there is none), and
|
||||||
|
// "method" (the emitting Go/Python function). "msg" is optional:
|
||||||
|
// NewLogger's handler drops it when empty.
|
||||||
|
package applog
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// NewLogger builds a JSON-handler *slog.Logger writing to w at the given
|
||||||
|
// level ("debug"|"info"|"warn"|"error", case-insensitive; anything else
|
||||||
|
// defaults to info). Empty msg values are omitted from the output --
|
||||||
|
// records whose meaning is fully carried by type/class/method and attrs
|
||||||
|
// (e.g. the HTTP request line) don't need one.
|
||||||
|
func NewLogger(level string, w io.Writer) *slog.Logger {
|
||||||
|
return slog.New(slog.NewJSONHandler(w, &slog.HandlerOptions{
|
||||||
|
Level: parseLevel(level),
|
||||||
|
ReplaceAttr: func(groups []string, a slog.Attr) slog.Attr {
|
||||||
|
if len(groups) == 0 && a.Key == slog.MessageKey && a.Value.String() == "" {
|
||||||
|
return slog.Attr{}
|
||||||
|
}
|
||||||
|
return a
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
// App returns the default logger tagged with the schema fields for an
|
||||||
|
// ordinary application record (type=app), deriving the emitting location
|
||||||
|
// from the caller via the runtime: package, file, class (the receiver
|
||||||
|
// type, omitted for free functions), method. Derivation instead of
|
||||||
|
// hand-typed strings means the labels can never drift from the code. The
|
||||||
|
// http and wrapper emitters (internal/api's logging middleware,
|
||||||
|
// internal/garmin's execute/forwardWrapperStderr) tag their own type and
|
||||||
|
// location instead.
|
||||||
|
func App() *slog.Logger {
|
||||||
|
pkg, file, class, method := location(1)
|
||||||
|
logger := slog.Default().With("type", "app", "package", pkg, "file", file)
|
||||||
|
if class != "" {
|
||||||
|
logger = logger.With("class", class)
|
||||||
|
}
|
||||||
|
return logger.With("method", method)
|
||||||
|
}
|
||||||
|
|
||||||
|
// location resolves the caller (skip frames above this function's caller)
|
||||||
|
// into the log schema's package/file/class/method fields. A method's
|
||||||
|
// runtime name looks like "geniusrun/backend/internal/api.(*Server).X";
|
||||||
|
// a free function's like "geniusrun/backend/internal/api.X"; a closure
|
||||||
|
// gets its parent's name plus ".funcN", kept verbatim in method.
|
||||||
|
func location(skip int) (pkg, file, class, method string) {
|
||||||
|
pc, path, _, ok := runtime.Caller(skip + 1)
|
||||||
|
if !ok {
|
||||||
|
return "unknown", "unknown", "", "unknown"
|
||||||
|
}
|
||||||
|
file = filepath.Base(path)
|
||||||
|
full := runtime.FuncForPC(pc).Name()
|
||||||
|
base := full[strings.LastIndex(full, "/")+1:]
|
||||||
|
pkg, rest, _ := strings.Cut(base, ".")
|
||||||
|
if strings.HasPrefix(rest, "(*") {
|
||||||
|
if end := strings.Index(rest, ")."); end != -1 {
|
||||||
|
class = rest[2:end]
|
||||||
|
rest = rest[end+2:]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return pkg, file, class, rest
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseLevel(level string) slog.Level {
|
||||||
|
switch strings.ToLower(level) {
|
||||||
|
case "debug":
|
||||||
|
return slog.LevelDebug
|
||||||
|
case "warn":
|
||||||
|
return slog.LevelWarn
|
||||||
|
case "error":
|
||||||
|
return slog.LevelError
|
||||||
|
default:
|
||||||
|
return slog.LevelInfo
|
||||||
|
}
|
||||||
|
}
|
||||||
78
backend/internal/log/log_test.go
Normal file
78
backend/internal/log/log_test.go
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
package applog
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"log/slog"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestNewLogger_FiltersBelowConfiguredLevel(t *testing.T) {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
logger := NewLogger("warn", &buf)
|
||||||
|
|
||||||
|
logger.Info("should be dropped")
|
||||||
|
if buf.Len() != 0 {
|
||||||
|
t.Fatalf("expected no output for an Info message under a warn-level logger, got %q", buf.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.Warn("should appear")
|
||||||
|
if !strings.Contains(buf.String(), "should appear") {
|
||||||
|
t.Fatalf("expected the Warn message in output, got %q", buf.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewLogger_WritesValidJSON(t *testing.T) {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
logger := NewLogger("info", &buf)
|
||||||
|
logger.Info("hello", "key", "value")
|
||||||
|
|
||||||
|
var decoded map[string]any
|
||||||
|
if err := json.Unmarshal(buf.Bytes(), &decoded); err != nil {
|
||||||
|
t.Fatalf("output is not valid JSON: %v (%q)", err, buf.String())
|
||||||
|
}
|
||||||
|
if decoded["msg"] != "hello" || decoded["key"] != "value" {
|
||||||
|
t.Errorf("decoded = %+v, want msg=hello key=value", decoded)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewLogger_OmitsEmptyMsg(t *testing.T) {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
logger := NewLogger("info", &buf)
|
||||||
|
logger.Info("", "type", "http", "status", 200)
|
||||||
|
|
||||||
|
var decoded map[string]any
|
||||||
|
if err := json.Unmarshal(buf.Bytes(), &decoded); err != nil {
|
||||||
|
t.Fatalf("output not JSON: %v (%q)", err, buf.String())
|
||||||
|
}
|
||||||
|
if _, present := decoded["msg"]; present {
|
||||||
|
t.Errorf("empty msg should be omitted, got %+v", decoded)
|
||||||
|
}
|
||||||
|
if decoded["type"] != "http" {
|
||||||
|
t.Errorf("attrs lost alongside dropped msg: %+v", decoded)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApp_TagsMandatoryFields(t *testing.T) {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
prev := slog.Default()
|
||||||
|
slog.SetDefault(NewLogger("info", &buf))
|
||||||
|
defer slog.SetDefault(prev)
|
||||||
|
|
||||||
|
App().Info("did it", "extra", 1)
|
||||||
|
|
||||||
|
var decoded map[string]any
|
||||||
|
if err := json.Unmarshal(buf.Bytes(), &decoded); err != nil {
|
||||||
|
t.Fatalf("output not JSON: %v", err)
|
||||||
|
}
|
||||||
|
if decoded["type"] != "app" || decoded["package"] != "log" || decoded["file"] != "log_test.go" {
|
||||||
|
t.Errorf("fields = %+v, want type=app package=log (import-path element) file=log_test.go", decoded)
|
||||||
|
}
|
||||||
|
if decoded["method"] != "TestApp_TagsMandatoryFields" {
|
||||||
|
t.Errorf("method = %v, want the emitting function name", decoded["method"])
|
||||||
|
}
|
||||||
|
if _, hasClass := decoded["class"]; hasClass {
|
||||||
|
t.Errorf("class should be omitted for a free function, got %v", decoded["class"])
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -45,6 +45,11 @@ type Activity struct {
|
|||||||
// internal/sync/mapping.go's alignWorkoutTargets). Nil when the activity
|
// internal/sync/mapping.go's alignWorkoutTargets). Nil when the activity
|
||||||
// has no WorkoutID, or was synced before this column existed.
|
// has no WorkoutID, or was synced before this column existed.
|
||||||
WorkoutRawJSON *string
|
WorkoutRawJSON *string
|
||||||
|
// WorkoutNotFoundAt is set when get_workout_by_id returned a definitive
|
||||||
|
// HTTP 404 for this activity's WorkoutID -- distinct from
|
||||||
|
// WorkoutRawJSON staying nil for "not yet fetched" (see
|
||||||
|
// SetActivityWorkoutNotFound).
|
||||||
|
WorkoutNotFoundAt *string
|
||||||
CreatedAt string
|
CreatedAt string
|
||||||
UpdatedAt string
|
UpdatedAt string
|
||||||
}
|
}
|
||||||
@@ -102,7 +107,7 @@ func scanActivity(row interface{ Scan(...any) error }) (Activity, error) {
|
|||||||
&a.AvgSpeedMps, &a.ElevationGainM,
|
&a.AvgSpeedMps, &a.ElevationGainM,
|
||||||
&a.AerobicTrainingEffect, &a.AnaerobicTrainingEffect, &a.VO2MaxValue,
|
&a.AerobicTrainingEffect, &a.AnaerobicTrainingEffect, &a.VO2MaxValue,
|
||||||
&a.RawJSON, &a.DetailsFetchedAt, &a.DetailsRawJSON, &a.SplitsFetchedAt, &a.WorkoutRawJSON,
|
&a.RawJSON, &a.DetailsFetchedAt, &a.DetailsRawJSON, &a.SplitsFetchedAt, &a.WorkoutRawJSON,
|
||||||
&a.CreatedAt, &a.UpdatedAt,
|
&a.WorkoutNotFoundAt, &a.CreatedAt, &a.UpdatedAt,
|
||||||
)
|
)
|
||||||
return a, err
|
return a, err
|
||||||
}
|
}
|
||||||
@@ -113,7 +118,7 @@ const activityColumns = `
|
|||||||
avg_speed_mps, elevation_gain_m,
|
avg_speed_mps, elevation_gain_m,
|
||||||
aerobic_training_effect, anaerobic_training_effect, vo2max_value,
|
aerobic_training_effect, anaerobic_training_effect, vo2max_value,
|
||||||
raw_json, details_fetched_at, details_raw_json, splits_fetched_at, workout_raw_json,
|
raw_json, details_fetched_at, details_raw_json, splits_fetched_at, workout_raw_json,
|
||||||
created_at, updated_at
|
workout_not_found_at, created_at, updated_at
|
||||||
`
|
`
|
||||||
|
|
||||||
// GetActivity fetches one activity by its internal id, scoped to userID.
|
// GetActivity fetches one activity by its internal id, scoped to userID.
|
||||||
@@ -225,6 +230,21 @@ func (db *DB) SetActivityWorkout(ctx context.Context, userID, activityID int64,
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetActivityWorkoutNotFound records that get_workout_by_id returned a
|
||||||
|
// definitive HTTP 404 for this activity's WorkoutID -- the workout was
|
||||||
|
// deleted on Garmin's side after being linked to this activity.
|
||||||
|
// WorkoutRawJSON is deliberately left nil (never fabricated); this is a
|
||||||
|
// separate marker so ActivitiesMissingWorkout stops retrying it forever.
|
||||||
|
func (db *DB) SetActivityWorkoutNotFound(ctx context.Context, userID, activityID int64) error {
|
||||||
|
_, err := db.ExecContext(ctx, `
|
||||||
|
UPDATE activities SET workout_not_found_at = datetime('now'), updated_at = datetime('now')
|
||||||
|
WHERE id = ? AND user_id = ?`, activityID, userID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("set activity %d workout not found for user %d: %w", activityID, userID, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// SetActivitySplitsFetched records that get_activity_splits has been fetched
|
// SetActivitySplitsFetched records that get_activity_splits has been fetched
|
||||||
// for this activity.
|
// for this activity.
|
||||||
func (db *DB) SetActivitySplitsFetched(ctx context.Context, userID, activityID int64) error {
|
func (db *DB) SetActivitySplitsFetched(ctx context.Context, userID, activityID int64) error {
|
||||||
@@ -272,3 +292,58 @@ func (db *DB) CountActivitiesMissingDetails(ctx context.Context, userID int64) (
|
|||||||
}
|
}
|
||||||
return n, nil
|
return n, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ActivitiesMissingWorkout returns userID's activities that have a
|
||||||
|
// structured workout (WorkoutID set at initial upsert time, straight from
|
||||||
|
// Garmin's activity summary) but haven't had get_workout_by_id fetched yet.
|
||||||
|
// Gated on details_fetched_at IS NOT NULL: fillActivityWorkout resolves each
|
||||||
|
// lap's target pace/HR band via alignWorkoutTargets, which needs the
|
||||||
|
// activity's laps already written by fillActivityDetails -- without this
|
||||||
|
// gate, a structured-workout activity whose details/laps haven't been
|
||||||
|
// fetched yet would fall into a separate, independently LIMIT-bounded batch
|
||||||
|
// than fillPendingDetails's, get its workout_raw_json set against zero laps
|
||||||
|
// (a silent no-op alignment), and then never be retried once its laps
|
||||||
|
// finally arrive, since workout_raw_json IS NULL is the only signal this
|
||||||
|
// query has left. This is still independent of splits_fetched_at (unlike
|
||||||
|
// ActivitiesMissingDetails, which requires both): splits/laps aren't needed
|
||||||
|
// to resolve workout targets, only details_fetched_at is. It also still
|
||||||
|
// covers the intended retry case -- an activity whose workout fetch
|
||||||
|
// previously failed after details succeeded always has details_fetched_at
|
||||||
|
// already set, so it's still surfaced here -- while excluding activities
|
||||||
|
// that simply haven't been processed by fillActivityDetails at all yet.
|
||||||
|
func (db *DB) ActivitiesMissingWorkout(ctx context.Context, userID int64, limit int) ([]Activity, error) {
|
||||||
|
rows, err := db.QueryContext(ctx, `SELECT `+activityColumns+` FROM activities
|
||||||
|
WHERE user_id = ? AND workout_id IS NOT NULL AND workout_raw_json IS NULL
|
||||||
|
AND details_fetched_at IS NOT NULL AND workout_not_found_at IS NULL
|
||||||
|
ORDER BY start_time_utc DESC LIMIT ?`, userID, limit)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("list activities missing workout for user %d: %w", userID, err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
activities := []Activity{}
|
||||||
|
for rows.Next() {
|
||||||
|
a, err := scanActivity(rows)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("scan activity row: %w", err)
|
||||||
|
}
|
||||||
|
activities = append(activities, a)
|
||||||
|
}
|
||||||
|
return activities, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// CountActivitiesMissingWorkout returns how many of userID's activities
|
||||||
|
// still need get_workout_by_id fetched, regardless of any per-call batch
|
||||||
|
// limit -- used to report overall remaining work, mirroring
|
||||||
|
// CountActivitiesMissingDetails. See ActivitiesMissingWorkout for why this
|
||||||
|
// is additionally gated on details_fetched_at IS NOT NULL.
|
||||||
|
func (db *DB) CountActivitiesMissingWorkout(ctx context.Context, userID int64) (int, error) {
|
||||||
|
var n int
|
||||||
|
err := db.QueryRowContext(ctx, `SELECT COUNT(*) FROM activities
|
||||||
|
WHERE user_id = ? AND workout_id IS NOT NULL AND workout_raw_json IS NULL
|
||||||
|
AND details_fetched_at IS NOT NULL AND workout_not_found_at IS NULL`, userID).Scan(&n)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("count activities missing workout for user %d: %w", userID, err)
|
||||||
|
}
|
||||||
|
return n, nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -76,31 +76,6 @@ func (db *DB) CurrentAssignment(ctx context.Context, userID, activityID int64) (
|
|||||||
return a, true, nil
|
return a, true, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ReviewQueue returns userID's activities whose current assignment status
|
|
||||||
// is needs_review, newest first.
|
|
||||||
func (db *DB) ReviewQueue(ctx context.Context, userID int64) ([]KindAssignment, error) {
|
|
||||||
rows, err := db.QueryContext(ctx, `
|
|
||||||
SELECT `+kindAssignmentColumns+`
|
|
||||||
FROM current_kind_assignment a
|
|
||||||
JOIN activities ON activities.id = a.activity_id
|
|
||||||
WHERE activities.user_id = ? AND a.status = ?
|
|
||||||
ORDER BY a.created_at DESC`, userID, AssignmentStatusNeedsReview)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("review queue for user %d: %w", userID, err)
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
|
|
||||||
assignments := []KindAssignment{}
|
|
||||||
for rows.Next() {
|
|
||||||
a, err := scanKindAssignment(rows)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("scan kind assignment row: %w", err)
|
|
||||||
}
|
|
||||||
assignments = append(assignments, a)
|
|
||||||
}
|
|
||||||
return assignments, rows.Err()
|
|
||||||
}
|
|
||||||
|
|
||||||
// AllCurrentAssignments returns the latest assignment for every one of
|
// AllCurrentAssignments returns the latest assignment for every one of
|
||||||
// userID's activities that has one, regardless of status or source -- the
|
// userID's activities that has one, regardless of status or source -- the
|
||||||
// basis for deciding which activities a global reclassify pass may touch.
|
// basis for deciding which activities a global reclassify pass may touch.
|
||||||
|
|||||||
40
backend/internal/store/config.go
Normal file
40
backend/internal/store/config.go
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 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 {
|
||||||
|
return nil, fmt.Errorf("config values: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
values := map[string]string{}
|
||||||
|
for rows.Next() {
|
||||||
|
var k, v string
|
||||||
|
if err := rows.Scan(&k, &v); err != nil {
|
||||||
|
return nil, fmt.Errorf("scan config row: %w", err)
|
||||||
|
}
|
||||||
|
values[k] = v
|
||||||
|
}
|
||||||
|
return values, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetConfigValue upserts one override row. Key validation is the caller's
|
||||||
|
// job (config.ValidateAppValue) -- the store stays a dumb K/V layer.
|
||||||
|
func (db *DB) SetConfigValue(ctx context.Context, key, value string) error {
|
||||||
|
if _, err := db.ExecContext(ctx, `
|
||||||
|
INSERT INTO config (key, value, updated_at) VALUES (?, ?, datetime('now'))
|
||||||
|
ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at`,
|
||||||
|
key, value); err != nil {
|
||||||
|
return fmt.Errorf("set config %q: %w", key, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
35
backend/internal/store/config_test.go
Normal file
35
backend/internal/store/config_test.go
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestConfigValues_EmptyThenUpsertOverwrites(t *testing.T) {
|
||||||
|
db := openTestDB(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
values, err := db.ConfigValues(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ConfigValues (empty): %v", err)
|
||||||
|
}
|
||||||
|
if len(values) != 0 {
|
||||||
|
t.Fatalf("expected no overrides in a fresh DB, got %v", values)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := db.SetConfigValue(ctx, "session.duration", "168"); err != nil {
|
||||||
|
t.Fatalf("SetConfigValue: %v", err)
|
||||||
|
}
|
||||||
|
// Same key again: upsert must overwrite, not error or duplicate.
|
||||||
|
if err := db.SetConfigValue(ctx, "session.duration", "24"); err != nil {
|
||||||
|
t.Fatalf("SetConfigValue (overwrite): %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
values, err = db.ConfigValues(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ConfigValues: %v", err)
|
||||||
|
}
|
||||||
|
if len(values) != 1 || values["session.duration"] != "24" {
|
||||||
|
t.Fatalf("expected {session.duration: 24}, got %v", values)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -137,7 +137,7 @@ func TestIsolation_SyncStateAndRunsNeverLeakAcrossUsers(t *testing.T) {
|
|||||||
t.Fatalf("userA's UpdateSyncState leaked into userB's sync_state: %+v", stateB)
|
t.Fatalf("userA's UpdateSyncState leaked into userB's sync_state: %+v", stateB)
|
||||||
}
|
}
|
||||||
|
|
||||||
runID, err := db.StartSyncRun(ctx, userA, SyncKindBackfill)
|
runID, err := db.StartSyncRun(ctx, userA, SyncKindFull)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("StartSyncRun(a): %v", err)
|
t.Fatalf("StartSyncRun(a): %v", err)
|
||||||
}
|
}
|
||||||
@@ -188,12 +188,12 @@ func TestIsolation_DeleteUserLeavesOtherUsersDataIntact(t *testing.T) {
|
|||||||
if _, found, err := db.GetUserBySub(ctx, "sub-b"); err != nil || !found {
|
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)
|
t.Fatalf("expected userB to survive userA's deletion, found=%v err=%v", found, err)
|
||||||
}
|
}
|
||||||
profileB, err := db.GetProfile(ctx, userB)
|
if _, err := db.GetProfile(ctx, userB); err != nil {
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("GetProfile(b) after deleting a: %v", err)
|
t.Fatalf("GetProfile(b) after deleting a: %v", err)
|
||||||
}
|
}
|
||||||
if profileB.Name != "B" {
|
userBRow, found, err := db.GetUserBySub(ctx, "sub-b")
|
||||||
t.Errorf("userB's profile changed after deleting userA: %+v", profileB)
|
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)
|
kindsB, err := db.ListWorkoutKinds(ctx, userB, false)
|
||||||
if err != nil || len(kindsB) != 8 {
|
if err != nil || len(kindsB) != 8 {
|
||||||
@@ -204,3 +204,170 @@ func TestIsolation_DeleteUserLeavesOtherUsersDataIntact(t *testing.T) {
|
|||||||
t.Fatalf("userB's activities affected by deleting userA: len=%d err=%v", len(activitiesB), err)
|
t.Fatalf("userB's activities affected by deleting userA: len=%d err=%v", len(activitiesB), err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestIsolation_ActivitiesMissingWorkoutNeverLeaksAcrossUsers confirms
|
||||||
|
// ActivitiesMissingWorkout/CountActivitiesMissingWorkout only ever surface
|
||||||
|
// userID's own pending-workout activities, never another user's, even
|
||||||
|
// though both users have activities in the exact shape (workout_id set,
|
||||||
|
// details fetched, workout_raw_json still NULL) the query selects for.
|
||||||
|
func TestIsolation_ActivitiesMissingWorkoutNeverLeaksAcrossUsers(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)
|
||||||
|
}
|
||||||
|
|
||||||
|
workoutID := int64(999)
|
||||||
|
idA, err := db.UpsertActivity(ctx, userA, Activity{
|
||||||
|
GarminActivityID: 1, WorkoutID: &workoutID,
|
||||||
|
StartTimeUTC: "2026-07-01 06:00:00", RawJSON: "{}",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("UpsertActivity(a): %v", err)
|
||||||
|
}
|
||||||
|
if err := db.SetActivityDetails(ctx, userA, idA, "{}"); err != nil {
|
||||||
|
t.Fatalf("SetActivityDetails(a): %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
idB, err := db.UpsertActivity(ctx, userB, Activity{
|
||||||
|
GarminActivityID: 1, WorkoutID: &workoutID,
|
||||||
|
StartTimeUTC: "2026-07-01 06:00:00", RawJSON: "{}",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("UpsertActivity(b): %v", err)
|
||||||
|
}
|
||||||
|
if err := db.SetActivityDetails(ctx, userB, idB, "{}"); err != nil {
|
||||||
|
t.Fatalf("SetActivityDetails(b): %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
pendingA, err := db.ActivitiesMissingWorkout(ctx, userA, 10)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ActivitiesMissingWorkout(a): %v", err)
|
||||||
|
}
|
||||||
|
if len(pendingA) != 1 || pendingA[0].ID != idA {
|
||||||
|
t.Fatalf("ActivitiesMissingWorkout(a) = %+v, want only userA's own activity (id %d)", pendingA, idA)
|
||||||
|
}
|
||||||
|
|
||||||
|
countA, err := db.CountActivitiesMissingWorkout(ctx, userA)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CountActivitiesMissingWorkout(a): %v", err)
|
||||||
|
}
|
||||||
|
if countA != 1 {
|
||||||
|
t.Fatalf("CountActivitiesMissingWorkout(a) = %d, want 1 (userB's identical-looking activity must not be counted)", countA)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calling SetActivityWorkoutNotFound with the WRONG user's ID against
|
||||||
|
// userA's activity (idA) must be a no-op: the WHERE id = ? AND user_id = ?
|
||||||
|
// won't match any row, so it should neither error nor mark idA not-found.
|
||||||
|
// (This runs before the SetActivityWorkout resolution below, while idA
|
||||||
|
// is still pending -- once workout_raw_json is set, idA drops out of
|
||||||
|
// ActivitiesMissingWorkout regardless of workout_not_found_at, which
|
||||||
|
// would make the "still appears" assertion vacuous.)
|
||||||
|
if err := db.SetActivityWorkoutNotFound(ctx, userB, idA); err != nil {
|
||||||
|
t.Fatalf("SetActivityWorkoutNotFound(b, idA): %v", err)
|
||||||
|
}
|
||||||
|
pendingA, err = db.ActivitiesMissingWorkout(ctx, userA, 10)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ActivitiesMissingWorkout(a) after userB's mismatched SetActivityWorkoutNotFound call: %v", err)
|
||||||
|
}
|
||||||
|
if len(pendingA) != 1 || pendingA[0].ID != idA {
|
||||||
|
t.Fatalf("ActivitiesMissingWorkout(a) = %+v, want idA still pending -- userB's mismatched call must not mark it not-found", pendingA)
|
||||||
|
}
|
||||||
|
countA, err = db.CountActivitiesMissingWorkout(ctx, userA)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CountActivitiesMissingWorkout(a) after userB's mismatched SetActivityWorkoutNotFound call: %v", err)
|
||||||
|
}
|
||||||
|
if countA != 1 {
|
||||||
|
t.Fatalf("CountActivitiesMissingWorkout(a) = %d, want 1 (unaffected by userB's mismatched-user SetActivityWorkoutNotFound call)", countA)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Now the CORRECT user marks their own activity not-found: idA must
|
||||||
|
// disappear from userA's own results, and userB's own idB (still
|
||||||
|
// pending) must remain wholly unaffected.
|
||||||
|
if err := db.SetActivityWorkoutNotFound(ctx, userA, idA); err != nil {
|
||||||
|
t.Fatalf("SetActivityWorkoutNotFound(a, idA): %v", err)
|
||||||
|
}
|
||||||
|
pendingA, err = db.ActivitiesMissingWorkout(ctx, userA, 10)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ActivitiesMissingWorkout(a) after userA marked idA not-found: %v", err)
|
||||||
|
}
|
||||||
|
if len(pendingA) != 0 {
|
||||||
|
t.Fatalf("ActivitiesMissingWorkout(a) = %+v, want empty after userA marked their own idA not-found", pendingA)
|
||||||
|
}
|
||||||
|
countA, err = db.CountActivitiesMissingWorkout(ctx, userA)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CountActivitiesMissingWorkout(a) after userA marked idA not-found: %v", err)
|
||||||
|
}
|
||||||
|
if countA != 0 {
|
||||||
|
t.Fatalf("CountActivitiesMissingWorkout(a) = %d, want 0 after userA marked their own idA not-found", countA)
|
||||||
|
}
|
||||||
|
pendingB, err := db.ActivitiesMissingWorkout(ctx, userB, 10)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ActivitiesMissingWorkout(b) after userA marked idA not-found: %v", err)
|
||||||
|
}
|
||||||
|
if len(pendingB) != 1 || pendingB[0].ID != idB {
|
||||||
|
t.Fatalf("ActivitiesMissingWorkout(b) = %+v, want userB's idB still pending, unaffected by userA's SetActivityWorkoutNotFound call", pendingB)
|
||||||
|
}
|
||||||
|
countB, err := db.CountActivitiesMissingWorkout(ctx, userB)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CountActivitiesMissingWorkout(b) after userA marked idA not-found: %v", err)
|
||||||
|
}
|
||||||
|
if countB != 1 {
|
||||||
|
t.Fatalf("CountActivitiesMissingWorkout(b) = %d, want 1 (unaffected by userA's SetActivityWorkoutNotFound call)", countB)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolving userA's workout (now moot for idA specifically, since it was
|
||||||
|
// already marked not-found above, but still exercises SetActivityWorkout
|
||||||
|
// itself) must not affect userB's pending activity.
|
||||||
|
if err := db.SetActivityWorkout(ctx, userA, idA, `{"segments":[]}`); err != nil {
|
||||||
|
t.Fatalf("SetActivityWorkout(a): %v", err)
|
||||||
|
}
|
||||||
|
pendingB, err = db.ActivitiesMissingWorkout(ctx, userB, 10)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ActivitiesMissingWorkout(b): %v", err)
|
||||||
|
}
|
||||||
|
if len(pendingB) != 1 || pendingB[0].ID != idB {
|
||||||
|
t.Fatalf("ActivitiesMissingWorkout(b) = %+v, want userB's activity unaffected by userA's SetActivityWorkout", pendingB)
|
||||||
|
}
|
||||||
|
countB, err = db.CountActivitiesMissingWorkout(ctx, userB)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CountActivitiesMissingWorkout(b): %v", err)
|
||||||
|
}
|
||||||
|
if countB != 1 {
|
||||||
|
t.Fatalf("CountActivitiesMissingWorkout(b) = %d, want 1 (unaffected by userA's SetActivityWorkout call)", countB)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestIsolation_MarkGarminConnectedNeverLeaksAcrossUsers confirms marking
|
||||||
|
// one user's Garmin connection never sets another user's flag.
|
||||||
|
func TestIsolation_MarkGarminConnectedNeverLeaksAcrossUsers(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)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := db.MarkGarminConnected(ctx, userA); err != nil {
|
||||||
|
t.Fatalf("MarkGarminConnected(a): %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
profileB, err := db.GetProfile(ctx, userB)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetProfile(b): %v", err)
|
||||||
|
}
|
||||||
|
if profileB.GarminConnectedAt != nil {
|
||||||
|
t.Fatal("userA's MarkGarminConnected call leaked into userB's profile")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -55,13 +55,13 @@ func (db *DB) ReplaceLaps(ctx context.Context, userID, activityID int64, laps []
|
|||||||
}
|
}
|
||||||
defer tx.Rollback()
|
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)
|
return fmt.Errorf("delete existing laps for activity %d: %w", activityID, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, l := range laps {
|
for _, l := range laps {
|
||||||
_, err := tx.ExecContext(ctx, `
|
_, err := tx.ExecContext(ctx, `
|
||||||
INSERT INTO laps (
|
INSERT INTO activity_laps (
|
||||||
activity_id, lap_index, avg_speed_mps,
|
activity_id, lap_index, avg_speed_mps,
|
||||||
intensity_type, hr_drift_bpm_per_min, hr_recovery_bpm_per_min,
|
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
|
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,
|
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.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
|
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
|
JOIN activities ON activities.id = laps.activity_id
|
||||||
WHERE laps.activity_id = ? AND activities.user_id = ?
|
WHERE laps.activity_id = ? AND activities.user_id = ?
|
||||||
ORDER BY laps.lap_index`, activityID, userID)
|
ORDER BY laps.lap_index`, activityID, userID)
|
||||||
|
|||||||
@@ -8,11 +8,14 @@ import (
|
|||||||
// Profile is the single active user's Garmin credentials plus every
|
// Profile is the single active user's Garmin credentials plus every
|
||||||
// tunable analysis-engine parameter. Always exactly one row (id=1).
|
// tunable analysis-engine parameter. Always exactly one row (id=1).
|
||||||
type Profile struct {
|
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
|
GarminEmail string
|
||||||
GarminPassword string
|
GarminPassword string
|
||||||
|
// GarminConnectedAt is nil until this user's first successful Garmin
|
||||||
|
// authentication (see store.MarkGarminConnected) -- the login gate uses
|
||||||
|
// it, not UpdateProfile, so it's deliberately excluded from
|
||||||
|
// UpdateProfile's SET clause below and can only ever move from nil to
|
||||||
|
// set, never reset by a normal profile save.
|
||||||
|
GarminConnectedAt *string
|
||||||
RollingWindowDays int
|
RollingWindowDays int
|
||||||
// BackfillHorizonDays bounds how far back "Sync now" reaches when
|
// BackfillHorizonDays bounds how far back "Sync now" reaches when
|
||||||
// walking backward from today; it's read fresh on every sync (not fixed
|
// walking backward from today; it's read fresh on every sync (not fixed
|
||||||
@@ -70,7 +73,7 @@ type Profile struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const profileColumns = `
|
const profileColumns = `
|
||||||
name, garmin_email, garmin_password, 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_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_zone3_min_pct, hr_zone3_max_pct, hr_zone4_min_pct, hr_zone4_max_pct,
|
||||||
hr_zone5_min_pct, hr_zone5_max_pct,
|
hr_zone5_min_pct, hr_zone5_max_pct,
|
||||||
@@ -84,8 +87,8 @@ const profileColumns = `
|
|||||||
// GetProfile returns the profile row for userID.
|
// GetProfile returns the profile row for userID.
|
||||||
func (db *DB) GetProfile(ctx context.Context, userID int64) (Profile, error) {
|
func (db *DB) GetProfile(ctx context.Context, userID int64) (Profile, error) {
|
||||||
var p Profile
|
var p Profile
|
||||||
err := db.QueryRowContext(ctx, `SELECT `+profileColumns+` FROM profile WHERE user_id = ?`, userID).Scan(
|
err := db.QueryRowContext(ctx, `SELECT `+profileColumns+` FROM profiles WHERE user_id = ?`, userID).Scan(
|
||||||
&p.Name, &p.GarminEmail, &p.GarminPassword, &p.RollingWindowDays, &p.BackfillHorizonDays, &p.MaxHeartRate, &p.RestingHeartRate,
|
&p.GarminEmail, &p.GarminPassword, &p.GarminConnectedAt, &p.RollingWindowDays, &p.BackfillHorizonDays, &p.MaxHeartRate, &p.RestingHeartRate,
|
||||||
&p.HRZone1MinPct, &p.HRZone1MaxPct, &p.HRZone2MinPct, &p.HRZone2MaxPct,
|
&p.HRZone1MinPct, &p.HRZone1MaxPct, &p.HRZone2MinPct, &p.HRZone2MaxPct,
|
||||||
&p.HRZone3MinPct, &p.HRZone3MaxPct, &p.HRZone4MinPct, &p.HRZone4MaxPct,
|
&p.HRZone3MinPct, &p.HRZone3MaxPct, &p.HRZone4MinPct, &p.HRZone4MaxPct,
|
||||||
&p.HRZone5MinPct, &p.HRZone5MaxPct,
|
&p.HRZone5MinPct, &p.HRZone5MaxPct,
|
||||||
@@ -106,8 +109,8 @@ func (db *DB) GetProfile(ctx context.Context, userID int64) (Profile, error) {
|
|||||||
// replaces every column.
|
// replaces every column.
|
||||||
func (db *DB) UpdateProfile(ctx context.Context, userID int64, p Profile) error {
|
func (db *DB) UpdateProfile(ctx context.Context, userID int64, p Profile) error {
|
||||||
_, err := db.ExecContext(ctx, `
|
_, err := db.ExecContext(ctx, `
|
||||||
UPDATE profile SET
|
UPDATE profiles SET
|
||||||
name=?, garmin_email=?, garmin_password=?, rolling_window_days=?, backfill_horizon_days=?, max_heart_rate=?, resting_heart_rate=?,
|
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_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_zone3_min_pct=?, hr_zone3_max_pct=?, hr_zone4_min_pct=?, hr_zone4_max_pct=?,
|
||||||
hr_zone5_min_pct=?, hr_zone5_max_pct=?,
|
hr_zone5_min_pct=?, hr_zone5_max_pct=?,
|
||||||
@@ -117,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=?,
|
main_line_tint_pct=?, background_darken_pct=?, target_brighten_pct=?,
|
||||||
updated_at=datetime('now')
|
updated_at=datetime('now')
|
||||||
WHERE user_id = ?`,
|
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.HRZone1MinPct, p.HRZone1MaxPct, p.HRZone2MinPct, p.HRZone2MaxPct,
|
||||||
p.HRZone3MinPct, p.HRZone3MaxPct, p.HRZone4MinPct, p.HRZone4MaxPct,
|
p.HRZone3MinPct, p.HRZone3MaxPct, p.HRZone4MinPct, p.HRZone4MaxPct,
|
||||||
p.HRZone5MinPct, p.HRZone5MaxPct,
|
p.HRZone5MinPct, p.HRZone5MaxPct,
|
||||||
@@ -132,3 +135,14 @@ func (db *DB) UpdateProfile(ctx context.Context, userID int64, p Profile) error
|
|||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MarkGarminConnected records the first time userID successfully
|
||||||
|
// 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 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)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -26,15 +26,12 @@ func TestProfile_DefaultsThenUpdate(t *testing.T) {
|
|||||||
if p.WarmupMinutes != 10 || p.CooldownMinutes != 5 {
|
if p.WarmupMinutes != 10 || p.CooldownMinutes != 5 {
|
||||||
t.Errorf("phase-minute defaults = %+v, want warmup=10, cooldown=5", p)
|
t.Errorf("phase-minute defaults = %+v, want warmup=10, cooldown=5", p)
|
||||||
}
|
}
|
||||||
if p.BackfillHorizonDays != 1095 {
|
if p.BackfillHorizonDays != 90 {
|
||||||
t.Errorf("BackfillHorizonDays = %d, want 1095 (migration default)", p.BackfillHorizonDays)
|
t.Errorf("BackfillHorizonDays = %d, want 90 (schema default)", p.BackfillHorizonDays)
|
||||||
}
|
}
|
||||||
if p.MinRepresentativePaceSecPerKm != 720 || p.MinRepresentativeTimeSeconds != 3 {
|
if p.MinRepresentativePaceSecPerKm != 720 || p.MinRepresentativeTimeSeconds != 3 {
|
||||||
t.Errorf("pace artifact filter defaults = %+v, want pace=720, time=3", p)
|
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" {
|
if p.PaceColor != "#3b82f6" || p.HeartRateColor != "#ef4444" {
|
||||||
t.Errorf("main line color defaults = %+v, want pace=#3b82f6, heartRate=#ef4444", p)
|
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
|
maxHR, restingHR := 190.0, 50.0
|
||||||
p.Name = "Kriss"
|
|
||||||
p.PaceColor = "#111111"
|
p.PaceColor = "#111111"
|
||||||
p.EffortColor = "#222222"
|
p.EffortColor = "#222222"
|
||||||
p.MainLineTintPct = 45
|
p.MainLineTintPct = 45
|
||||||
@@ -79,9 +75,6 @@ func TestProfile_DefaultsThenUpdate(t *testing.T) {
|
|||||||
if got.GarminEmail != "runner@example.com" || got.RollingWindowDays != 120 {
|
if got.GarminEmail != "runner@example.com" || got.RollingWindowDays != 120 {
|
||||||
t.Errorf("got = %+v, want updated email/window", got)
|
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 {
|
if got.MaxHeartRate == nil || *got.MaxHeartRate != 190 {
|
||||||
t.Errorf("MaxHeartRate = %v, want 190", got.MaxHeartRate)
|
t.Errorf("MaxHeartRate = %v, want 190", got.MaxHeartRate)
|
||||||
}
|
}
|
||||||
@@ -107,3 +100,44 @@ func TestProfile_DefaultsThenUpdate(t *testing.T) {
|
|||||||
t.Errorf("TargetBrightenPct after update = %v, want 50", got.TargetBrightenPct)
|
t.Errorf("TargetBrightenPct after update = %v, want 50", got.TargetBrightenPct)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestMarkGarminConnected_SetsTimestampOnceOnFirstCall(t *testing.T) {
|
||||||
|
db := openTestDB(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
userID, err := db.ProvisionUser(ctx, "test-sub", "Test")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ProvisionUser: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
before, err := db.GetProfile(ctx, userID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetProfile: %v", err)
|
||||||
|
}
|
||||||
|
if before.GarminConnectedAt != nil {
|
||||||
|
t.Fatalf("expected a fresh profile to have nil GarminConnectedAt, got %v", *before.GarminConnectedAt)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := db.MarkGarminConnected(ctx, userID); err != nil {
|
||||||
|
t.Fatalf("MarkGarminConnected: %v", err)
|
||||||
|
}
|
||||||
|
afterFirst, err := db.GetProfile(ctx, userID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetProfile after first mark: %v", err)
|
||||||
|
}
|
||||||
|
if afterFirst.GarminConnectedAt == nil {
|
||||||
|
t.Fatal("expected GarminConnectedAt to be set after MarkGarminConnected")
|
||||||
|
}
|
||||||
|
firstValue := *afterFirst.GarminConnectedAt
|
||||||
|
|
||||||
|
// A second call must not change the recorded first-connection time.
|
||||||
|
if err := db.MarkGarminConnected(ctx, userID); err != nil {
|
||||||
|
t.Fatalf("MarkGarminConnected (second call): %v", err)
|
||||||
|
}
|
||||||
|
afterSecond, err := db.GetProfile(ctx, userID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetProfile after second mark: %v", err)
|
||||||
|
}
|
||||||
|
if afterSecond.GarminConnectedAt == nil || *afterSecond.GarminConnectedAt != firstValue {
|
||||||
|
t.Fatalf("GarminConnectedAt changed on second call: first=%q second=%v", firstValue, afterSecond.GarminConnectedAt)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,15 +8,16 @@
|
|||||||
-- here rather than appending an ALTER TABLE migration.
|
-- here rather than appending an ALTER TABLE migration.
|
||||||
|
|
||||||
-- One geniusrun account per OIDC subject. Every other table below is scoped
|
-- One geniusrun account per OIDC subject. Every other table below is scoped
|
||||||
-- to a user_id, directly (profile/workout_kinds/activities/sync_state/
|
-- to a user_id, directly (profiles/workout_kinds/activities/sync_state/
|
||||||
-- sync_runs) or transitively through a JOIN to the owning row (laps/
|
-- sync_runs) or transitively through a JOIN to the owning row (activity_laps/
|
||||||
-- activity_samples/kind_assignments/workout_type_paces, which have no
|
-- activity_samples/kind_assignments/workout_type_paces, which have no
|
||||||
-- user_id column of their own since they're never queried except through a
|
-- 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 (
|
CREATE TABLE users (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
oidc_sub TEXT NOT NULL UNIQUE,
|
oidc_sub TEXT NOT NULL UNIQUE,
|
||||||
display_name TEXT NOT NULL,
|
name TEXT NOT NULL,
|
||||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -24,17 +25,22 @@ CREATE TABLE users (
|
|||||||
-- parameter (HR zones, phase-detection minutes, pace-artifact filtering,
|
-- parameter (HR zones, phase-detection minutes, pace-artifact filtering,
|
||||||
-- chart colors). store.ProvisionUser creates this (and everything else
|
-- chart colors). store.ProvisionUser creates this (and everything else
|
||||||
-- below) in one transaction when a new account signs up.
|
-- below) in one transaction when a new account signs up.
|
||||||
CREATE TABLE profile (
|
CREATE TABLE profiles (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
name TEXT NOT NULL DEFAULT 'Default',
|
|
||||||
garmin_email TEXT NOT NULL DEFAULT '',
|
garmin_email TEXT NOT NULL DEFAULT '',
|
||||||
garmin_password TEXT NOT NULL DEFAULT '',
|
garmin_password TEXT NOT NULL DEFAULT '',
|
||||||
|
-- Set once, the first time this user successfully authenticates with
|
||||||
|
-- Garmin (handleAuthLogin/handleAuthMFA). Null means never connected --
|
||||||
|
-- the login gate uses this (not the in-memory auth status, which
|
||||||
|
-- resets on restart) to decide whether a returning user must
|
||||||
|
-- reconnect before entering the app.
|
||||||
|
garmin_connected_at TEXT,
|
||||||
rolling_window_days INTEGER NOT NULL DEFAULT 90,
|
rolling_window_days INTEGER NOT NULL DEFAULT 90,
|
||||||
-- BackfillHorizonDays bounds how far back "Sync now" reaches when
|
-- BackfillHorizonDays bounds how far back "Sync now" reaches when
|
||||||
-- walking backward from today; read fresh on every sync (not cached at
|
-- walking backward from today; read fresh on every sync (not cached at
|
||||||
-- server startup), so changing it here takes effect on the next click.
|
-- server startup), so changing it here takes effect on the next click.
|
||||||
backfill_horizon_days INTEGER NOT NULL DEFAULT 1095,
|
backfill_horizon_days INTEGER NOT NULL DEFAULT 90,
|
||||||
max_heart_rate REAL,
|
max_heart_rate REAL,
|
||||||
resting_heart_rate REAL,
|
resting_heart_rate REAL,
|
||||||
hr_zone1_min_pct REAL NOT NULL DEFAULT 50,
|
hr_zone1_min_pct REAL NOT NULL DEFAULT 50,
|
||||||
@@ -145,6 +151,14 @@ CREATE TABLE activities (
|
|||||||
-- Genuine raw get_workout_by_id() response, the source used to compute
|
-- Genuine raw get_workout_by_id() response, the source used to compute
|
||||||
-- alignWorkoutTargets. Null when the activity has no workout_id.
|
-- alignWorkoutTargets. Null when the activity has no workout_id.
|
||||||
workout_raw_json TEXT,
|
workout_raw_json TEXT,
|
||||||
|
-- Set when get_workout_by_id returned a definitive HTTP 404 (the
|
||||||
|
-- workout was deleted on Garmin's side after being linked to this
|
||||||
|
-- activity) -- distinct from workout_raw_json staying null for "not yet
|
||||||
|
-- fetched": this activity is excluded from ActivitiesMissingWorkout so
|
||||||
|
-- it stops being retried forever (see
|
||||||
|
-- docs/superpowers/specs/2026-07-27-workout-not-found-design.md).
|
||||||
|
-- workout_raw_json itself is never fabricated; it just stays null.
|
||||||
|
workout_not_found_at TEXT,
|
||||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
UNIQUE(user_id, garmin_activity_id)
|
UNIQUE(user_id, garmin_activity_id)
|
||||||
@@ -154,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
|
-- One row per lap/split (from get_activity_splits), plus HR drift/recovery
|
||||||
-- derived from activity_samples. No user_id column -- always accessed
|
-- derived from activity_samples. No user_id column -- always accessed
|
||||||
-- through a specific owning activity.
|
-- through a specific owning activity.
|
||||||
CREATE TABLE laps (
|
CREATE TABLE activity_laps (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
activity_id INTEGER NOT NULL REFERENCES activities(id) ON DELETE CASCADE,
|
activity_id INTEGER NOT NULL REFERENCES activities(id) ON DELETE CASCADE,
|
||||||
lap_index INTEGER NOT NULL,
|
lap_index INTEGER NOT NULL,
|
||||||
@@ -237,3 +251,16 @@ CREATE TABLE sync_runs (
|
|||||||
status TEXT NOT NULL CHECK(status IN ('running','success','error')),
|
status TEXT NOT NULL CHECK(status IN ('running','success','error')),
|
||||||
error_message TEXT
|
error_message TEXT
|
||||||
);
|
);
|
||||||
|
|
||||||
|
-- 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. 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,
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
|||||||
@@ -149,12 +149,12 @@ func TestKindAssignment_AppendOnlyHistoryAndCurrentView(t *testing.T) {
|
|||||||
t.Fatalf("InsertKindAssignment (rule engine): %v", err)
|
t.Fatalf("InsertKindAssignment (rule engine): %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
queue, err := db.ReviewQueue(ctx, userID)
|
pending, ok, err := db.CurrentAssignment(ctx, userID, activityID)
|
||||||
if err != nil {
|
if err != nil || !ok {
|
||||||
t.Fatalf("ReviewQueue: %v", err)
|
t.Fatalf("CurrentAssignment (needs_review): ok=%v err=%v", ok, err)
|
||||||
}
|
}
|
||||||
if len(queue) != 1 {
|
if pending.Status != AssignmentStatusNeedsReview {
|
||||||
t.Fatalf("expected 1 item in review queue, got %d", len(queue))
|
t.Fatalf("current status = %q, want needs_review", pending.Status)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Then: user manually resolves it.
|
// Then: user manually resolves it.
|
||||||
@@ -167,14 +167,6 @@ func TestKindAssignment_AppendOnlyHistoryAndCurrentView(t *testing.T) {
|
|||||||
t.Fatalf("InsertKindAssignment (manual): %v", err)
|
t.Fatalf("InsertKindAssignment (manual): %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
queue, err = db.ReviewQueue(ctx, userID)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("ReviewQueue after resolve: %v", err)
|
|
||||||
}
|
|
||||||
if len(queue) != 0 {
|
|
||||||
t.Fatalf("expected 0 items in review queue after manual resolve, got %d", len(queue))
|
|
||||||
}
|
|
||||||
|
|
||||||
current, ok, err := db.CurrentAssignment(ctx, userID, activityID)
|
current, ok, err := db.CurrentAssignment(ctx, userID, activityID)
|
||||||
if err != nil || !ok {
|
if err != nil || !ok {
|
||||||
t.Fatalf("CurrentAssignment: ok=%v err=%v", ok, err)
|
t.Fatalf("CurrentAssignment: ok=%v err=%v", ok, err)
|
||||||
@@ -244,7 +236,7 @@ func TestSchema_PerUserUniqueConstraints(t *testing.T) {
|
|||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
// UNIQUE(user_id, name) allows the same name across two different users.
|
// 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)
|
t.Fatalf("insert users: %v", err)
|
||||||
}
|
}
|
||||||
if _, err := db.ExecContext(ctx, `
|
if _, err := db.ExecContext(ctx, `
|
||||||
@@ -255,10 +247,10 @@ func TestSchema_PerUserUniqueConstraints(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// profile/sync_state UNIQUE(user_id) still rejects a genuine duplicate.
|
// 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)
|
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)")
|
t.Fatal("expected a second profile row for the same user_id to violate UNIQUE(user_id)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -356,6 +348,183 @@ func TestCurrentAssignment_ScopedToOwningUser(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestActivitiesMissingWorkout_OnlyIncludesActivitiesWithDetailsAlreadyFetched
|
||||||
|
// is a regression test for a bug where ActivitiesMissingWorkout selected
|
||||||
|
// activities purely on workout_id IS NOT NULL AND workout_raw_json IS NULL,
|
||||||
|
// with no regard for whether get_activity_details had ever run for that
|
||||||
|
// activity. Since fillPendingDetails and fillPendingWorkouts are each
|
||||||
|
// independently LIMIT-bounded over different candidate sets, a
|
||||||
|
// structured-workout activity could fall inside the workouts pass's window
|
||||||
|
// while still outside the details pass's window on a large first backfill.
|
||||||
|
// fillActivityWorkout would then call LapsForActivity on an activity with no
|
||||||
|
// laps yet written, compute alignWorkoutTargets against zero laps (a
|
||||||
|
// no-op), and unconditionally call SetActivityWorkout anyway -- permanently
|
||||||
|
// marking workout_raw_json non-NULL before the activity ever had a chance
|
||||||
|
// to get real target pace/HR bands once its laps finally arrived. The fix
|
||||||
|
// gates the query on details_fetched_at IS NOT NULL, so an activity is only
|
||||||
|
// eligible for a workout fetch once fillActivityDetails has actually given
|
||||||
|
// it laps to align against.
|
||||||
|
func TestActivitiesMissingWorkout_OnlyIncludesActivitiesWithDetailsAlreadyFetched(t *testing.T) {
|
||||||
|
db := openTestDB(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
userID, err := db.ProvisionUser(ctx, "test-sub", "Test")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ProvisionUser: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
workoutID := int64(999)
|
||||||
|
// Activity a: has a workout_id, but details/splits have never been
|
||||||
|
// fetched (the exact shape of the bug above). Must now be EXCLUDED --
|
||||||
|
// this is the regression assertion for the bug.
|
||||||
|
withWorkoutNoDetails := Activity{
|
||||||
|
GarminActivityID: 1, WorkoutID: &workoutID,
|
||||||
|
StartTimeUTC: "2026-07-01 06:00:00", RawJSON: "{}",
|
||||||
|
}
|
||||||
|
idA, err := db.UpsertActivity(ctx, userID, withWorkoutNoDetails)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("UpsertActivity (a): %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Activity b: has a workout_id, and its details/splits have already
|
||||||
|
// been fetched (e.g. its workout fetch failed in some prior run after
|
||||||
|
// details succeeded). This is the one case ActivitiesMissingWorkout must
|
||||||
|
// still surface, since ActivitiesMissingDetails would never pick this
|
||||||
|
// activity up again once details_fetched_at/splits_fetched_at are set.
|
||||||
|
withWorkoutAndDetails := Activity{
|
||||||
|
GarminActivityID: 2, WorkoutID: &workoutID,
|
||||||
|
StartTimeUTC: "2026-07-02 06:00:00", RawJSON: "{}",
|
||||||
|
}
|
||||||
|
idB, err := db.UpsertActivity(ctx, userID, withWorkoutAndDetails)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("UpsertActivity (b): %v", err)
|
||||||
|
}
|
||||||
|
if err := db.SetActivityDetails(ctx, userID, idB, "{}"); err != nil {
|
||||||
|
t.Fatalf("SetActivityDetails (b): %v", err)
|
||||||
|
}
|
||||||
|
if err := db.SetActivitySplitsFetched(ctx, userID, idB); err != nil {
|
||||||
|
t.Fatalf("SetActivitySplitsFetched (b): %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Activity c: no workout_id at all. Must be excluded.
|
||||||
|
noWorkout := Activity{
|
||||||
|
GarminActivityID: 3, StartTimeUTC: "2026-07-03 06:00:00", RawJSON: "{}",
|
||||||
|
}
|
||||||
|
if _, err := db.UpsertActivity(ctx, userID, noWorkout); err != nil {
|
||||||
|
t.Fatalf("UpsertActivity (c): %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Activity d: already has its workout_raw_json set. Must be excluded.
|
||||||
|
alreadyHasWorkout := Activity{
|
||||||
|
GarminActivityID: 4, WorkoutID: &workoutID,
|
||||||
|
StartTimeUTC: "2026-07-04 06:00:00", RawJSON: "{}",
|
||||||
|
}
|
||||||
|
idD, err := db.UpsertActivity(ctx, userID, alreadyHasWorkout)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("UpsertActivity (d): %v", err)
|
||||||
|
}
|
||||||
|
if err := db.SetActivityWorkout(ctx, userID, idD, `{"segments":[]}`); err != nil {
|
||||||
|
t.Fatalf("SetActivityWorkout (d): %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
n, err := db.CountActivitiesMissingWorkout(ctx, userID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CountActivitiesMissingWorkout: %v", err)
|
||||||
|
}
|
||||||
|
if n != 1 {
|
||||||
|
t.Fatalf("CountActivitiesMissingWorkout = %d, want 1 (activity b only -- activity a must be excluded since its details were never fetched)", n)
|
||||||
|
}
|
||||||
|
|
||||||
|
pending, err := db.ActivitiesMissingWorkout(ctx, userID, 10)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ActivitiesMissingWorkout: %v", err)
|
||||||
|
}
|
||||||
|
if len(pending) != 1 {
|
||||||
|
t.Fatalf("ActivitiesMissingWorkout returned %d activities, want 1", len(pending))
|
||||||
|
}
|
||||||
|
if pending[0].ID != idB {
|
||||||
|
t.Errorf("ActivitiesMissingWorkout = %+v, want only activity b (id %d)", pending, idB)
|
||||||
|
}
|
||||||
|
for _, a := range pending {
|
||||||
|
if a.ID == idA {
|
||||||
|
t.Errorf("ActivitiesMissingWorkout incorrectly included activity a (workout_id set but details never fetched) -- regression for the silent-loss bug")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestActivitiesMissingWorkout_ExcludesConfirmedNotFound(t *testing.T) {
|
||||||
|
db := openTestDB(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
userID, err := db.ProvisionUser(ctx, "test-sub", "Test")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ProvisionUser: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
workoutID := int64(999)
|
||||||
|
// Details already fetched, workout confirmed 404 on Garmin -- must not
|
||||||
|
// be retried, so must not appear in ActivitiesMissingWorkout/Count.
|
||||||
|
notFound := Activity{
|
||||||
|
GarminActivityID: 1, WorkoutID: &workoutID,
|
||||||
|
StartTimeUTC: "2026-07-01 06:00:00", RawJSON: "{}",
|
||||||
|
}
|
||||||
|
idA, err := db.UpsertActivity(ctx, userID, notFound)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("UpsertActivity (a): %v", err)
|
||||||
|
}
|
||||||
|
if err := db.SetActivityDetails(ctx, userID, idA, "{}"); err != nil {
|
||||||
|
t.Fatalf("SetActivityDetails (a): %v", err)
|
||||||
|
}
|
||||||
|
if err := db.SetActivitySplitsFetched(ctx, userID, idA); err != nil {
|
||||||
|
t.Fatalf("SetActivitySplitsFetched (a): %v", err)
|
||||||
|
}
|
||||||
|
if err := db.SetActivityWorkoutNotFound(ctx, userID, idA); err != nil {
|
||||||
|
t.Fatalf("SetActivityWorkoutNotFound (a): %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A genuinely still-pending activity (details fetched, workout not yet
|
||||||
|
// attempted) must still be included, for contrast.
|
||||||
|
stillPending := Activity{
|
||||||
|
GarminActivityID: 2, WorkoutID: &workoutID,
|
||||||
|
StartTimeUTC: "2026-07-02 06:00:00", RawJSON: "{}",
|
||||||
|
}
|
||||||
|
idB, err := db.UpsertActivity(ctx, userID, stillPending)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("UpsertActivity (b): %v", err)
|
||||||
|
}
|
||||||
|
if err := db.SetActivityDetails(ctx, userID, idB, "{}"); err != nil {
|
||||||
|
t.Fatalf("SetActivityDetails (b): %v", err)
|
||||||
|
}
|
||||||
|
if err := db.SetActivitySplitsFetched(ctx, userID, idB); err != nil {
|
||||||
|
t.Fatalf("SetActivitySplitsFetched (b): %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
n, err := db.CountActivitiesMissingWorkout(ctx, userID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CountActivitiesMissingWorkout: %v", err)
|
||||||
|
}
|
||||||
|
if n != 1 {
|
||||||
|
t.Fatalf("CountActivitiesMissingWorkout = %d, want 1 (only activity b)", n)
|
||||||
|
}
|
||||||
|
|
||||||
|
pending, err := db.ActivitiesMissingWorkout(ctx, userID, 10)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ActivitiesMissingWorkout: %v", err)
|
||||||
|
}
|
||||||
|
if len(pending) != 1 || pending[0].ID != idB {
|
||||||
|
t.Fatalf("ActivitiesMissingWorkout = %+v, want only activity b (id %d)", pending, idB)
|
||||||
|
}
|
||||||
|
|
||||||
|
confirmed, _, err := db.GetActivity(ctx, userID, idA)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetActivity (a): %v", err)
|
||||||
|
}
|
||||||
|
if confirmed.WorkoutNotFoundAt == nil {
|
||||||
|
t.Error("activity a's WorkoutNotFoundAt is nil, want it set")
|
||||||
|
}
|
||||||
|
if confirmed.WorkoutRawJSON != nil {
|
||||||
|
t.Error("activity a's WorkoutRawJSON should stay nil -- not-found is recorded separately, not faked")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func contains(s string, substrs ...string) bool {
|
func contains(s string, substrs ...string) bool {
|
||||||
lower := strings.ToLower(s)
|
lower := strings.ToLower(s)
|
||||||
for _, substr := range substrs {
|
for _, substr := range substrs {
|
||||||
|
|||||||
@@ -7,12 +7,10 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
SyncKindBackfill = "backfill"
|
// SyncKindFull is a manually-triggered "Sync now" pass: backfillCore
|
||||||
SyncKindIncremental = "incremental"
|
// followed by incrementalSyncCore followed by FillPendingDetails,
|
||||||
// SyncKindFull is a manually-triggered "Sync now" pass: Backfill followed
|
// recorded as one run so the reported activity count covers the whole
|
||||||
// by IncrementalSync followed by FillPendingDetails, recorded as one run
|
// action instead of only whichever stage happened to finish last.
|
||||||
// so the reported activity count covers the whole action instead of only
|
|
||||||
// whichever stage happened to finish last.
|
|
||||||
SyncKindFull = "full"
|
SyncKindFull = "full"
|
||||||
|
|
||||||
SyncStatusRunning = "running"
|
SyncStatusRunning = "running"
|
||||||
@@ -20,7 +18,10 @@ const (
|
|||||||
SyncStatusError = "error"
|
SyncStatusError = "error"
|
||||||
)
|
)
|
||||||
|
|
||||||
// SyncRun records one backfill or incremental sync attempt.
|
// SyncRun records one sync attempt. Every run recorded today is
|
||||||
|
// SyncKindFull (the "Sync now" action, covering backfill + incremental sync
|
||||||
|
// + detail/workout fill in a single pass) -- "backfill"/"incremental" only
|
||||||
|
// ever appear in historical rows predating that consolidation.
|
||||||
type SyncRun struct {
|
type SyncRun struct {
|
||||||
ID int64
|
ID int64
|
||||||
Kind string
|
Kind string
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import (
|
|||||||
type User struct {
|
type User struct {
|
||||||
ID int64
|
ID int64
|
||||||
OIDCSub string
|
OIDCSub string
|
||||||
DisplayName string
|
Name string
|
||||||
CreatedAt string
|
CreatedAt string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -21,8 +21,8 @@ type User struct {
|
|||||||
// the session-resolution middleware ever uses.
|
// the session-resolution middleware ever uses.
|
||||||
func (db *DB) GetUserBySub(ctx context.Context, oidcSub string) (User, bool, error) {
|
func (db *DB) GetUserBySub(ctx context.Context, oidcSub string) (User, bool, error) {
|
||||||
var u User
|
var u User
|
||||||
err := db.QueryRowContext(ctx, `SELECT id, oidc_sub, display_name, created_at FROM users WHERE oidc_sub = ?`, oidcSub).
|
err := db.QueryRowContext(ctx, `SELECT id, oidc_sub, name, created_at FROM users WHERE oidc_sub = ?`, oidcSub).
|
||||||
Scan(&u.ID, &u.OIDCSub, &u.DisplayName, &u.CreatedAt)
|
Scan(&u.ID, &u.OIDCSub, &u.Name, &u.CreatedAt)
|
||||||
if err == sql.ErrNoRows {
|
if err == sql.ErrNoRows {
|
||||||
return User{}, false, nil
|
return User{}, false, nil
|
||||||
}
|
}
|
||||||
@@ -32,26 +32,6 @@ func (db *DB) GetUserBySub(ctx context.Context, oidcSub string) (User, bool, err
|
|||||||
return u, true, nil
|
return u, true, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ListUsers returns every provisioned user, for the background incremental
|
|
||||||
// sync loop 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 placeholder every fresh install's rule-engine kinds
|
// neverMatchRule is the placeholder every fresh install's rule-engine kinds
|
||||||
// start with -- every activity lands in needs_review until the user tunes
|
// start with -- every activity lands in needs_review until the user tunes
|
||||||
// real rules.
|
// real rules.
|
||||||
@@ -76,14 +56,14 @@ var defaultWorkoutKindSeeds = []WorkoutKind{
|
|||||||
// default workout kinds (with their paired workout_type_paces rows), and an
|
// default workout kinds (with their paired workout_type_paces rows), and an
|
||||||
// initial sync_state row -- all in one transaction, so a partially
|
// initial sync_state row -- all in one transaction, so a partially
|
||||||
// provisioned user is never observable.
|
// provisioned user is never observable.
|
||||||
func (db *DB) ProvisionUser(ctx context.Context, oidcSub, displayName string) (int64, error) {
|
func (db *DB) ProvisionUser(ctx context.Context, oidcSub, name string) (int64, error) {
|
||||||
tx, err := db.BeginTx(ctx, nil)
|
tx, err := db.BeginTx(ctx, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, fmt.Errorf("begin provision user tx: %w", err)
|
return 0, fmt.Errorf("begin provision user tx: %w", err)
|
||||||
}
|
}
|
||||||
defer tx.Rollback()
|
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, name)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, fmt.Errorf("create user %q: %w", oidcSub, err)
|
return 0, fmt.Errorf("create user %q: %w", oidcSub, err)
|
||||||
}
|
}
|
||||||
@@ -92,7 +72,7 @@ func (db *DB) ProvisionUser(ctx context.Context, oidcSub, displayName string) (i
|
|||||||
return 0, err
|
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)
|
return 0, fmt.Errorf("create profile for user %d: %w", userID, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -133,3 +113,12 @@ func (db *DB) DeleteUser(ctx context.Context, userID int64) error {
|
|||||||
}
|
}
|
||||||
return nil
|
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 {
|
if err != nil || !found {
|
||||||
t.Fatalf("GetUserBySub: found=%v err=%v", found, err)
|
t.Fatalf("GetUserBySub: found=%v err=%v", found, err)
|
||||||
}
|
}
|
||||||
if u.ID != userID || u.DisplayName != "Lucie" {
|
if u.ID != userID || u.Name != "Lucie" {
|
||||||
t.Fatalf("got %+v, want ID=%d DisplayName=Lucie", u, userID)
|
t.Fatalf("got %+v, want ID=%d Name=Lucie", u, userID)
|
||||||
}
|
}
|
||||||
|
|
||||||
profile, err := db.GetProfile(ctx, userID)
|
profile, err := db.GetProfile(ctx, userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("GetProfile: %v", err)
|
t.Fatalf("GetProfile: %v", err)
|
||||||
}
|
}
|
||||||
if profile.Name != "Lucie" {
|
|
||||||
t.Errorf("profile.Name = %q, want %q", profile.Name, "Lucie")
|
|
||||||
}
|
|
||||||
if profile.RollingWindowDays != 90 {
|
if profile.RollingWindowDays != 90 {
|
||||||
t.Errorf("profile.RollingWindowDays = %d, want 90 (default)", profile.RollingWindowDays)
|
t.Errorf("profile.RollingWindowDays = %d, want 90 (default)", profile.RollingWindowDays)
|
||||||
}
|
}
|
||||||
@@ -126,7 +123,7 @@ func TestDeleteUser_RemovesUserAndCascadesEverything(t *testing.T) {
|
|||||||
if err := db.UpdateSyncState(ctx, userID, "2020-01-01", true); err != nil {
|
if err := db.UpdateSyncState(ctx, userID, "2020-01-01", true); err != nil {
|
||||||
t.Fatalf("UpdateSyncState: %v", err)
|
t.Fatalf("UpdateSyncState: %v", err)
|
||||||
}
|
}
|
||||||
if _, err := db.StartSyncRun(ctx, userID, SyncKindBackfill); err != nil {
|
if _, err := db.StartSyncRun(ctx, userID, SyncKindFull); err != nil {
|
||||||
t.Fatalf("StartSyncRun: %v", err)
|
t.Fatalf("StartSyncRun: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -142,11 +139,11 @@ func TestDeleteUser_RemovesUserAndCascadesEverything(t *testing.T) {
|
|||||||
query string
|
query string
|
||||||
arg int64
|
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_kinds WHERE user_id = ?`, userID},
|
||||||
{`SELECT COUNT(*) FROM workout_type_paces WHERE workout_kind_id = ?`, kindID},
|
{`SELECT COUNT(*) FROM workout_type_paces WHERE workout_kind_id = ?`, kindID},
|
||||||
{`SELECT COUNT(*) FROM activities WHERE user_id = ?`, userID},
|
{`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 activity_samples WHERE activity_id = ?`, activityID},
|
||||||
{`SELECT COUNT(*) FROM kind_assignments WHERE activity_id = ?`, activityID},
|
{`SELECT COUNT(*) FROM kind_assignments WHERE activity_id = ?`, activityID},
|
||||||
{`SELECT COUNT(*) FROM sync_state WHERE user_id = ?`, userID},
|
{`SELECT COUNT(*) FROM sync_state WHERE user_id = ?`, userID},
|
||||||
|
|||||||
@@ -1,728 +0,0 @@
|
|||||||
package sync
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"path/filepath"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"geniusrun/backend/internal/classify"
|
|
||||||
"geniusrun/backend/internal/garmin"
|
|
||||||
"geniusrun/backend/internal/garmin/mock"
|
|
||||||
"geniusrun/backend/internal/store"
|
|
||||||
)
|
|
||||||
|
|
||||||
func f(v float64) *float64 { return &v }
|
|
||||||
|
|
||||||
func openTestDB(t *testing.T) *store.DB {
|
|
||||||
t.Helper()
|
|
||||||
db, err := store.Open(filepath.Join(t.TempDir(), "geniusrun_test.db"))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("store.Open: %v", err)
|
|
||||||
}
|
|
||||||
t.Cleanup(func() { db.Close() })
|
|
||||||
return db
|
|
||||||
}
|
|
||||||
|
|
||||||
func fixedNow(t time.Time) func() time.Time {
|
|
||||||
return func() time.Time { return t }
|
|
||||||
}
|
|
||||||
|
|
||||||
func provisionTestUser(t *testing.T, db *store.DB) int64 {
|
|
||||||
t.Helper()
|
|
||||||
userID, err := db.ProvisionUser(context.Background(), "test-sub", "Test")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("ProvisionUser: %v", err)
|
|
||||||
}
|
|
||||||
return userID
|
|
||||||
}
|
|
||||||
|
|
||||||
// setBackfillHorizon sets Profile.BackfillHorizonDays, which Backfill reads
|
|
||||||
// fresh on every call (it's no longer part of Config).
|
|
||||||
func setBackfillHorizon(t *testing.T, db *store.DB, userID int64, days int) {
|
|
||||||
t.Helper()
|
|
||||||
ctx := context.Background()
|
|
||||||
profile, err := db.GetProfile(ctx, userID)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("GetProfile: %v", err)
|
|
||||||
}
|
|
||||||
profile.BackfillHorizonDays = days
|
|
||||||
if err := db.UpdateProfile(ctx, userID, profile); err != nil {
|
|
||||||
t.Fatalf("UpdateProfile: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestBackfill_StoresActivitiesAndRecordsSyncRun(t *testing.T) {
|
|
||||||
db := openTestDB(t)
|
|
||||||
ctx := context.Background()
|
|
||||||
userID := provisionTestUser(t, db)
|
|
||||||
|
|
||||||
m := &mock.Client{Activities: []garmin.Activity{
|
|
||||||
{ActivityID: 1, ActivityName: "Morning Run", ActivityType: garmin.ActivityType{TypeKey: "running"},
|
|
||||||
StartTimeGMT: "2026-07-01 06:00:00", Distance: 5000, Duration: 1500, AverageSpeed: 3.33, AverageHR: 145},
|
|
||||||
}}
|
|
||||||
svc := NewService(m, db, userID, Config{}, fixedNow(time.Date(2026, 7, 11, 0, 0, 0, 0, time.UTC)))
|
|
||||||
|
|
||||||
if err := svc.Backfill(ctx); err != nil {
|
|
||||||
t.Fatalf("Backfill: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
activities, err := db.ListActivities(ctx, userID, store.ActivityFilter{})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("ListActivities: %v", err)
|
|
||||||
}
|
|
||||||
if len(activities) != 1 {
|
|
||||||
t.Fatalf("expected 1 stored activity, got %d", len(activities))
|
|
||||||
}
|
|
||||||
if activities[0].GarminActivityID != 1 {
|
|
||||||
t.Errorf("GarminActivityID = %d, want 1", activities[0].GarminActivityID)
|
|
||||||
}
|
|
||||||
|
|
||||||
runs, err := db.ListSyncRuns(ctx, userID, 10)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("ListSyncRuns: %v", err)
|
|
||||||
}
|
|
||||||
if len(runs) != 1 || runs[0].Status != store.SyncStatusSuccess {
|
|
||||||
t.Fatalf("expected 1 successful sync run, got %+v", runs)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAlignWorkoutTargets_MatchesLapsToFlattenedSteps(t *testing.T) {
|
|
||||||
laps := []garmin.Lap{{LapIndex: 1}, {LapIndex: 2}}
|
|
||||||
workout := garmin.Workout{Segments: []garmin.WorkoutSegment{
|
|
||||||
{Steps: []garmin.WorkoutStep{
|
|
||||||
{Type: "ExecutableStepDTO", TargetType: garmin.WorkoutTargetType{TypeKey: "pace.zone"}, TargetValueOne: f(3), TargetValueTwo: f(4)},
|
|
||||||
{Type: "ExecutableStepDTO", TargetType: garmin.WorkoutTargetType{TypeKey: "no.target"}},
|
|
||||||
}},
|
|
||||||
}}
|
|
||||||
|
|
||||||
targets := alignWorkoutTargets(laps, workout)
|
|
||||||
if len(targets) != 2 {
|
|
||||||
t.Fatalf("expected 2 aligned targets, got %d", len(targets))
|
|
||||||
}
|
|
||||||
if targets[0] == nil || targets[0].TargetType.TypeKey != "pace.zone" {
|
|
||||||
t.Errorf("targets[0] = %+v, want pace.zone step", targets[0])
|
|
||||||
}
|
|
||||||
if targets[1] == nil || targets[1].TargetType.TypeKey != "no.target" {
|
|
||||||
t.Errorf("targets[1] = %+v, want no.target step", targets[1])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAlignWorkoutTargets_OneExtraTrailingLapKeepsOtherTargets(t *testing.T) {
|
|
||||||
// Confirmed via Garmin Connect against a real activity: recording
|
|
||||||
// sometimes continues one lap past the workout's last step (e.g. a
|
|
||||||
// 5-minute prescribed cool-down followed by another 6:46 the athlete
|
|
||||||
// just kept running). The extra lap should have no target, but every
|
|
||||||
// other lap's real target must still come through.
|
|
||||||
laps := []garmin.Lap{{LapIndex: 1}, {LapIndex: 2}, {LapIndex: 3}}
|
|
||||||
workout := garmin.Workout{Segments: []garmin.WorkoutSegment{
|
|
||||||
{Steps: []garmin.WorkoutStep{
|
|
||||||
{Type: "ExecutableStepDTO", TargetType: garmin.WorkoutTargetType{TypeKey: "pace.zone"}, TargetValueOne: f(3), TargetValueTwo: f(4)},
|
|
||||||
{Type: "ExecutableStepDTO", TargetType: garmin.WorkoutTargetType{TypeKey: "pace.zone"}, TargetValueOne: f(2), TargetValueTwo: f(2.5)},
|
|
||||||
}},
|
|
||||||
}}
|
|
||||||
|
|
||||||
targets := alignWorkoutTargets(laps, workout)
|
|
||||||
if len(targets) != 3 {
|
|
||||||
t.Fatalf("expected 3 slots (one per lap), got %d", len(targets))
|
|
||||||
}
|
|
||||||
if targets[0] == nil || targets[0].TargetType.TypeKey != "pace.zone" {
|
|
||||||
t.Errorf("targets[0] = %+v, want the first step's pace.zone target", targets[0])
|
|
||||||
}
|
|
||||||
if targets[1] == nil || targets[1].TargetType.TypeKey != "pace.zone" {
|
|
||||||
t.Errorf("targets[1] = %+v, want the second step's pace.zone target", targets[1])
|
|
||||||
}
|
|
||||||
if targets[2] != nil {
|
|
||||||
t.Errorf("targets[2] = %+v, want nil for the trailing unplanned continuation lap", targets[2])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAlignWorkoutTargets_MismatchedCountYieldsAllNil(t *testing.T) {
|
|
||||||
laps := []garmin.Lap{{LapIndex: 1}, {LapIndex: 2}, {LapIndex: 3}}
|
|
||||||
workout := garmin.Workout{Segments: []garmin.WorkoutSegment{
|
|
||||||
{Steps: []garmin.WorkoutStep{
|
|
||||||
{Type: "ExecutableStepDTO"},
|
|
||||||
}},
|
|
||||||
}}
|
|
||||||
|
|
||||||
targets := alignWorkoutTargets(laps, workout)
|
|
||||||
if len(targets) != 3 {
|
|
||||||
t.Fatalf("expected 3 slots (one per lap), got %d", len(targets))
|
|
||||||
}
|
|
||||||
for i, target := range targets {
|
|
||||||
if target != nil {
|
|
||||||
t.Errorf("targets[%d] = %+v, want nil on count mismatch", i, target)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestTargetPaceRange_OnlyForPaceZoneAndOrdersLowHigh(t *testing.T) {
|
|
||||||
lo, hi := targetPaceRange(garmin.WorkoutStep{
|
|
||||||
TargetType: garmin.WorkoutTargetType{TypeKey: "pace.zone"}, TargetValueOne: f(4), TargetValueTwo: f(3),
|
|
||||||
})
|
|
||||||
if lo == nil || hi == nil || *lo != 3 || *hi != 4 {
|
|
||||||
t.Errorf("targetPaceRange = (%v, %v), want (3, 4) reordered", lo, hi)
|
|
||||||
}
|
|
||||||
|
|
||||||
lo, hi = targetPaceRange(garmin.WorkoutStep{TargetType: garmin.WorkoutTargetType{TypeKey: "heart.rate.zone"}, TargetValueOne: f(3), TargetValueTwo: f(4)})
|
|
||||||
if lo != nil || hi != nil {
|
|
||||||
t.Errorf("targetPaceRange for a non-pace step = (%v, %v), want (nil, nil)", lo, hi)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestTargetHRRange_CustomRangeAndZoneNumberViaKarvonen(t *testing.T) {
|
|
||||||
lo, hi := targetHRRange(garmin.WorkoutStep{
|
|
||||||
TargetType: garmin.WorkoutTargetType{TypeKey: "heart.rate.zone"}, TargetValueOne: f(160), TargetValueTwo: f(150),
|
|
||||||
}, store.Profile{})
|
|
||||||
if lo == nil || hi == nil || *lo != 150 || *hi != 160 {
|
|
||||||
t.Errorf("custom bpm range = (%v, %v), want (150, 160) reordered", lo, hi)
|
|
||||||
}
|
|
||||||
|
|
||||||
zone := 3
|
|
||||||
profile := store.Profile{
|
|
||||||
MaxHeartRate: f(190), RestingHeartRate: f(50),
|
|
||||||
HRZone3MinPct: 70, HRZone3MaxPct: 80,
|
|
||||||
}
|
|
||||||
lo, hi = targetHRRange(garmin.WorkoutStep{TargetType: garmin.WorkoutTargetType{TypeKey: "heart.rate.zone"}, ZoneNumber: &zone}, profile)
|
|
||||||
// Karvonen: restHR + pct * (maxHR - restHR) = 50 + 0.70*140 = 148, 50 + 0.80*140 = 162
|
|
||||||
if lo == nil || hi == nil || *lo != 148 || *hi != 162 {
|
|
||||||
t.Errorf("zone-based bpm range = (%v, %v), want (148, 162)", lo, hi)
|
|
||||||
}
|
|
||||||
|
|
||||||
lo, hi = targetHRRange(garmin.WorkoutStep{TargetType: garmin.WorkoutTargetType{TypeKey: "heart.rate.zone"}, ZoneNumber: &zone}, store.Profile{})
|
|
||||||
if lo != nil || hi != nil {
|
|
||||||
t.Errorf("zone-based range without max/resting HR configured = (%v, %v), want (nil, nil)", lo, hi)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestBackfill_SkipsNonRunningActivities(t *testing.T) {
|
|
||||||
db := openTestDB(t)
|
|
||||||
ctx := context.Background()
|
|
||||||
userID := provisionTestUser(t, db)
|
|
||||||
|
|
||||||
m := &mock.Client{Activities: []garmin.Activity{
|
|
||||||
{ActivityID: 1, ActivityType: garmin.ActivityType{TypeKey: "running"},
|
|
||||||
StartTimeGMT: "2026-07-01 06:00:00", Distance: 5000, Duration: 1500},
|
|
||||||
{ActivityID: 2, ActivityType: garmin.ActivityType{TypeKey: "trail_running"},
|
|
||||||
StartTimeGMT: "2026-07-02 06:00:00", Distance: 8000, Duration: 2400},
|
|
||||||
{ActivityID: 3, ActivityType: garmin.ActivityType{TypeKey: "paddelball"},
|
|
||||||
StartTimeGMT: "2026-07-03 06:00:00", Distance: 0, Duration: 1800},
|
|
||||||
{ActivityID: 4, ActivityType: garmin.ActivityType{TypeKey: "indoor_cycling"},
|
|
||||||
StartTimeGMT: "2026-07-04 06:00:00", Distance: 0, Duration: 1800},
|
|
||||||
}}
|
|
||||||
svc := NewService(m, db, userID, Config{}, fixedNow(time.Date(2026, 7, 11, 0, 0, 0, 0, time.UTC)))
|
|
||||||
|
|
||||||
if err := svc.Backfill(ctx); err != nil {
|
|
||||||
t.Fatalf("Backfill: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
activities, err := db.ListActivities(ctx, userID, store.ActivityFilter{})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("ListActivities: %v", err)
|
|
||||||
}
|
|
||||||
if len(activities) != 2 {
|
|
||||||
t.Fatalf("expected 2 stored (running-only) activities, got %d: %+v", len(activities), activities)
|
|
||||||
}
|
|
||||||
for _, a := range activities {
|
|
||||||
if a.GarminActivityID != 1 && a.GarminActivityID != 2 {
|
|
||||||
t.Errorf("unexpected non-running activity stored: %+v", a)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestFillPendingDetailsAndClassify_EndToEnd(t *testing.T) {
|
|
||||||
db := openTestDB(t)
|
|
||||||
ctx := context.Background()
|
|
||||||
userID := provisionTestUser(t, db)
|
|
||||||
|
|
||||||
const garminActivityID = 42
|
|
||||||
m := &mock.Client{
|
|
||||||
Activities: []garmin.Activity{
|
|
||||||
{ActivityID: garminActivityID, ActivityType: garmin.ActivityType{TypeKey: "running"},
|
|
||||||
StartTimeGMT: "2026-07-01 06:00:00", Distance: 5000, Duration: 1500, AverageSpeed: 3.33, AverageHR: 145},
|
|
||||||
},
|
|
||||||
Splits: map[int64]garmin.ActivitySplits{
|
|
||||||
garminActivityID: {ActivityID: garminActivityID, Laps: []garmin.Lap{
|
|
||||||
{LapIndex: 1, Duration: 900, ElapsedDuration: 900, Distance: 3000, AverageHR: 150, AverageSpeed: 3.33, IntensityType: "ACTIVE"},
|
|
||||||
}},
|
|
||||||
},
|
|
||||||
Details: map[int64]garmin.ActivityDetails{
|
|
||||||
garminActivityID: {
|
|
||||||
ActivityID: garminActivityID,
|
|
||||||
MetricDescriptors: []garmin.MetricDescriptor{
|
|
||||||
{Key: "directHeartRate", MetricsIndex: 0},
|
|
||||||
{Key: "sumElapsedDuration", MetricsIndex: 1},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
svc := NewService(m, db, userID, Config{MinConfidence: 0.5}, fixedNow(time.Date(2026, 7, 11, 0, 0, 0, 0, time.UTC)))
|
|
||||||
|
|
||||||
if err := svc.Backfill(ctx); err != nil {
|
|
||||||
t.Fatalf("Backfill: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// A workout kind that should cleanly match the seeded activity's pace.
|
|
||||||
ruleJSON := `{"match":"all","conditions":[{"metric":"avg_pace_sec_per_km","op":"between","value":[280,320]}]}`
|
|
||||||
if _, err := db.CreateWorkoutKind(ctx, userID, store.WorkoutKind{Name: "Test Classification Tempo", RuleJSON: ruleJSON, IsActive: true}); err != nil {
|
|
||||||
t.Fatalf("CreateWorkoutKind: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := svc.FillPendingDetails(ctx, 10); err != nil {
|
|
||||||
t.Fatalf("FillPendingDetails: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
activities, err := db.ListActivities(ctx, userID, store.ActivityFilter{})
|
|
||||||
if err != nil || len(activities) != 1 {
|
|
||||||
t.Fatalf("ListActivities: %v, %+v", err, activities)
|
|
||||||
}
|
|
||||||
activityID := activities[0].ID
|
|
||||||
|
|
||||||
if activities[0].DetailsFetchedAt == nil {
|
|
||||||
t.Error("expected DetailsFetchedAt to be set after FillPendingDetails")
|
|
||||||
}
|
|
||||||
if activities[0].SplitsFetchedAt == nil {
|
|
||||||
t.Error("expected SplitsFetchedAt to be set after FillPendingDetails")
|
|
||||||
}
|
|
||||||
|
|
||||||
laps, err := db.LapsForActivity(ctx, userID, activityID)
|
|
||||||
if err != nil || len(laps) != 1 {
|
|
||||||
t.Fatalf("LapsForActivity: %v, %+v", err, laps)
|
|
||||||
}
|
|
||||||
|
|
||||||
assignment, ok, err := db.CurrentAssignment(ctx, userID, activityID)
|
|
||||||
if err != nil || !ok {
|
|
||||||
t.Fatalf("CurrentAssignment: ok=%v err=%v", ok, err)
|
|
||||||
}
|
|
||||||
if assignment.Status != classify.StatusAssigned {
|
|
||||||
t.Fatalf("assignment.Status = %q, want %q (candidates: %s)", assignment.Status, classify.StatusAssigned, assignment.CandidateKindsJSON)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestFillPendingDetails_ResolvesWorkoutTargetsOntoLaps(t *testing.T) {
|
|
||||||
db := openTestDB(t)
|
|
||||||
ctx := context.Background()
|
|
||||||
userID := provisionTestUser(t, db)
|
|
||||||
|
|
||||||
const garminActivityID = 55
|
|
||||||
const workoutID = 999
|
|
||||||
workoutIDPtr := int64(workoutID)
|
|
||||||
m := &mock.Client{
|
|
||||||
Activities: []garmin.Activity{
|
|
||||||
{ActivityID: garminActivityID, ActivityType: garmin.ActivityType{TypeKey: "running"}, WorkoutID: &workoutIDPtr,
|
|
||||||
StartTimeGMT: "2026-07-01 06:00:00", Distance: 5000, Duration: 1500},
|
|
||||||
},
|
|
||||||
Splits: map[int64]garmin.ActivitySplits{
|
|
||||||
garminActivityID: {ActivityID: garminActivityID, Laps: []garmin.Lap{
|
|
||||||
{LapIndex: 1, Duration: 900, ElapsedDuration: 900, Distance: 3000, IntensityType: "ACTIVE"},
|
|
||||||
}},
|
|
||||||
},
|
|
||||||
Details: map[int64]garmin.ActivityDetails{garminActivityID: {ActivityID: garminActivityID}},
|
|
||||||
Workouts: map[int64]garmin.Workout{
|
|
||||||
workoutID: {Segments: []garmin.WorkoutSegment{
|
|
||||||
{Steps: []garmin.WorkoutStep{
|
|
||||||
{Type: "ExecutableStepDTO", TargetType: garmin.WorkoutTargetType{TypeKey: "pace.zone"}, TargetValueOne: f(3.0), TargetValueTwo: f(3.5)},
|
|
||||||
}},
|
|
||||||
}},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
svc := NewService(m, db, userID, Config{}, fixedNow(time.Date(2026, 7, 11, 0, 0, 0, 0, time.UTC)))
|
|
||||||
|
|
||||||
if err := svc.Backfill(ctx); err != nil {
|
|
||||||
t.Fatalf("Backfill: %v", err)
|
|
||||||
}
|
|
||||||
if err := svc.FillPendingDetails(ctx, 10); err != nil {
|
|
||||||
t.Fatalf("FillPendingDetails: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
activities, err := db.ListActivities(ctx, userID, store.ActivityFilter{})
|
|
||||||
if err != nil || len(activities) != 1 {
|
|
||||||
t.Fatalf("ListActivities: %v, %+v", err, activities)
|
|
||||||
}
|
|
||||||
laps, err := db.LapsForActivity(ctx, userID, activities[0].ID)
|
|
||||||
if err != nil || len(laps) != 1 {
|
|
||||||
t.Fatalf("LapsForActivity: %v, %+v", err, laps)
|
|
||||||
}
|
|
||||||
if laps[0].TargetPaceLowMps == nil || *laps[0].TargetPaceLowMps != 3.0 {
|
|
||||||
t.Errorf("TargetPaceLowMps = %v, want 3.0", laps[0].TargetPaceLowMps)
|
|
||||||
}
|
|
||||||
if laps[0].TargetPaceHighMps == nil || *laps[0].TargetPaceHighMps != 3.5 {
|
|
||||||
t.Errorf("TargetPaceHighMps = %v, want 3.5", laps[0].TargetPaceHighMps)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestFillPendingDetails_OneExtraTrailingLapKeepsOtherLapsTargets(t *testing.T) {
|
|
||||||
db := openTestDB(t)
|
|
||||||
ctx := context.Background()
|
|
||||||
userID := provisionTestUser(t, db)
|
|
||||||
|
|
||||||
const garminActivityID = 56
|
|
||||||
const workoutID = 1000
|
|
||||||
workoutIDPtr := int64(workoutID)
|
|
||||||
m := &mock.Client{
|
|
||||||
Activities: []garmin.Activity{
|
|
||||||
{ActivityID: garminActivityID, ActivityType: garmin.ActivityType{TypeKey: "running"}, WorkoutID: &workoutIDPtr,
|
|
||||||
StartTimeGMT: "2026-07-01 06:00:00", Distance: 5000, Duration: 1500},
|
|
||||||
},
|
|
||||||
Splits: map[int64]garmin.ActivitySplits{
|
|
||||||
garminActivityID: {ActivityID: garminActivityID, Laps: []garmin.Lap{
|
|
||||||
{LapIndex: 1, Duration: 900, ElapsedDuration: 900, Distance: 3000, IntensityType: "ACTIVE"},
|
|
||||||
{LapIndex: 2, Duration: 600, ElapsedDuration: 600, Distance: 2000, IntensityType: "REST"},
|
|
||||||
}},
|
|
||||||
},
|
|
||||||
Details: map[int64]garmin.ActivityDetails{garminActivityID: {ActivityID: garminActivityID}},
|
|
||||||
Workouts: map[int64]garmin.Workout{
|
|
||||||
// One step for two recorded laps -- the second lap is an
|
|
||||||
// unplanned continuation past the workout's end (confirmed via
|
|
||||||
// Garmin Connect against a real activity), not a genuine
|
|
||||||
// mismatch, so the first lap should still get a real target.
|
|
||||||
workoutID: {Segments: []garmin.WorkoutSegment{
|
|
||||||
{Steps: []garmin.WorkoutStep{
|
|
||||||
{Type: "ExecutableStepDTO", TargetType: garmin.WorkoutTargetType{TypeKey: "pace.zone"}, TargetValueOne: f(3.0), TargetValueTwo: f(3.5)},
|
|
||||||
}},
|
|
||||||
}},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
svc := NewService(m, db, userID, Config{}, fixedNow(time.Date(2026, 7, 11, 0, 0, 0, 0, time.UTC)))
|
|
||||||
|
|
||||||
if err := svc.Backfill(ctx); err != nil {
|
|
||||||
t.Fatalf("Backfill: %v", err)
|
|
||||||
}
|
|
||||||
if err := svc.FillPendingDetails(ctx, 10); err != nil {
|
|
||||||
t.Fatalf("FillPendingDetails: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
activities, _ := db.ListActivities(ctx, userID, store.ActivityFilter{})
|
|
||||||
laps, err := db.LapsForActivity(ctx, userID, activities[0].ID)
|
|
||||||
if err != nil || len(laps) != 2 {
|
|
||||||
t.Fatalf("LapsForActivity: %v, %+v", err, laps)
|
|
||||||
}
|
|
||||||
if laps[0].TargetPaceLowMps == nil || *laps[0].TargetPaceLowMps != 3.0 {
|
|
||||||
t.Errorf("laps[0].TargetPaceLowMps = %v, want 3.0 (the one defined step's target)", laps[0].TargetPaceLowMps)
|
|
||||||
}
|
|
||||||
if laps[1].TargetPaceLowMps != nil || laps[1].TargetPaceHighMps != nil {
|
|
||||||
t.Errorf("laps[1] target should be nil (the trailing unplanned continuation lap), got low=%v high=%v", laps[1].TargetPaceLowMps, laps[1].TargetPaceHighMps)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestBuildMetricContext_DerivesExpectedMetrics(t *testing.T) {
|
|
||||||
activity := store.Activity{
|
|
||||||
DurationSeconds: 1800,
|
|
||||||
DistanceMeters: 6000,
|
|
||||||
AvgSpeedMps: f(3.33),
|
|
||||||
AvgHR: f(152),
|
|
||||||
AerobicTrainingEffect: f(3.2),
|
|
||||||
}
|
|
||||||
laps := []store.Lap{
|
|
||||||
{IntensityType: "ACTIVE", AvgSpeedMps: f(3.33), HRDriftBpmPerMin: f(2.5)},
|
|
||||||
{IntensityType: "REST", AvgSpeedMps: f(1.5), HRRecoveryBpmPerMin: f(4.0)},
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx := buildMetricContext(activity, laps, 190)
|
|
||||||
|
|
||||||
if got := ctx["avg_pace_sec_per_km"]; got < 300 || got > 301 {
|
|
||||||
t.Errorf("avg_pace_sec_per_km = %v, want ~300.3 (1000/3.33)", got)
|
|
||||||
}
|
|
||||||
if got, want := ctx["avg_hr_pct_max"], 152.0/190.0; got != want {
|
|
||||||
t.Errorf("avg_hr_pct_max = %v, want %v", got, want)
|
|
||||||
}
|
|
||||||
if got := ctx["lap_hr_drift_bpm_per_min"]; got != 2.5 {
|
|
||||||
t.Errorf("lap_hr_drift_bpm_per_min = %v, want 2.5", got)
|
|
||||||
}
|
|
||||||
if got := ctx["lap_hr_recovery_bpm_per_min"]; got != 4.0 {
|
|
||||||
t.Errorf("lap_hr_recovery_bpm_per_min = %v, want 4.0", got)
|
|
||||||
}
|
|
||||||
// Only one ACTIVE + one REST lap, not repeated -- should not look like a
|
|
||||||
// structured interval workout.
|
|
||||||
if got := ctx["lap_interval_pattern"]; got != 0 {
|
|
||||||
t.Errorf("lap_interval_pattern = %v, want 0", got)
|
|
||||||
}
|
|
||||||
if got := ctx["is_race"]; got != 0 {
|
|
||||||
t.Errorf("is_race = %v, want 0 (EventTypeKey not set)", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestBuildMetricContext_DerivesIsRace(t *testing.T) {
|
|
||||||
ctx := buildMetricContext(store.Activity{EventTypeKey: "race"}, nil, 0)
|
|
||||||
if got := ctx["is_race"]; got != 1 {
|
|
||||||
t.Errorf("is_race = %v, want 1 when EventTypeKey is \"race\"", got)
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx = buildMetricContext(store.Activity{EventTypeKey: "training"}, nil, 0)
|
|
||||||
if got := ctx["is_race"]; got != 0 {
|
|
||||||
t.Errorf("is_race = %v, want 0 when EventTypeKey is not \"race\"", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestBackfill_SecondRunIsANoOpOnceHorizonFullyCovered(t *testing.T) {
|
|
||||||
db := openTestDB(t)
|
|
||||||
ctx := context.Background()
|
|
||||||
userID := provisionTestUser(t, db)
|
|
||||||
|
|
||||||
m := &mock.Client{Activities: []garmin.Activity{
|
|
||||||
{ActivityID: 1, ActivityType: garmin.ActivityType{TypeKey: "running"}, StartTimeGMT: "2026-07-05 06:00:00", Distance: 5000, Duration: 1500},
|
|
||||||
}}
|
|
||||||
svc := NewService(m, db, userID, Config{BackfillWindowDays: 10},
|
|
||||||
fixedNow(time.Date(2026, 7, 11, 0, 0, 0, 0, time.UTC)))
|
|
||||||
setBackfillHorizon(t, db, userID, 10)
|
|
||||||
|
|
||||||
if err := svc.Backfill(ctx); err != nil {
|
|
||||||
t.Fatalf("first Backfill: %v", err)
|
|
||||||
}
|
|
||||||
firstCallCount := m.GetActivitiesCalls
|
|
||||||
if firstCallCount == 0 {
|
|
||||||
t.Fatal("expected first backfill to call GetActivities at least once")
|
|
||||||
}
|
|
||||||
|
|
||||||
state, err := db.GetSyncState(ctx, userID)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("GetSyncState: %v", err)
|
|
||||||
}
|
|
||||||
if !state.BackfillComplete {
|
|
||||||
t.Fatalf("expected backfill_complete=true after covering the full horizon, got %+v", state)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := svc.Backfill(ctx); err != nil {
|
|
||||||
t.Fatalf("second Backfill: %v", err)
|
|
||||||
}
|
|
||||||
if m.GetActivitiesCalls != firstCallCount {
|
|
||||||
t.Errorf("second Backfill made %d more GetActivities call(s); want 0 (should be a no-op once horizon is covered)",
|
|
||||||
m.GetActivitiesCalls-firstCallCount)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestResetAll_AllowsFreshBackfillAfterwards(t *testing.T) {
|
|
||||||
db := openTestDB(t)
|
|
||||||
ctx := context.Background()
|
|
||||||
userID := provisionTestUser(t, db)
|
|
||||||
|
|
||||||
m := &mock.Client{Activities: []garmin.Activity{
|
|
||||||
{ActivityID: 1, ActivityType: garmin.ActivityType{TypeKey: "running"}, StartTimeGMT: "2026-07-05 06:00:00", Distance: 5000, Duration: 1500},
|
|
||||||
}}
|
|
||||||
svc := NewService(m, db, userID, Config{BackfillWindowDays: 10},
|
|
||||||
fixedNow(time.Date(2026, 7, 11, 0, 0, 0, 0, time.UTC)))
|
|
||||||
setBackfillHorizon(t, db, userID, 10)
|
|
||||||
|
|
||||||
if err := svc.Backfill(ctx); err != nil {
|
|
||||||
t.Fatalf("first Backfill: %v", err)
|
|
||||||
}
|
|
||||||
firstCallCount := m.GetActivitiesCalls
|
|
||||||
|
|
||||||
if err := svc.ResetAll(ctx); err != nil {
|
|
||||||
t.Fatalf("ResetAll: %v", err)
|
|
||||||
}
|
|
||||||
activities, err := db.ListActivities(ctx, userID, store.ActivityFilter{})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("ListActivities: %v", err)
|
|
||||||
}
|
|
||||||
if len(activities) != 0 {
|
|
||||||
t.Fatalf("expected 0 activities after ResetAll, got %d", len(activities))
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := svc.Backfill(ctx); err != nil {
|
|
||||||
t.Fatalf("Backfill after reset: %v", err)
|
|
||||||
}
|
|
||||||
if m.GetActivitiesCalls <= firstCallCount {
|
|
||||||
t.Errorf("expected Backfill after ResetAll to call GetActivities again (fresh pull), call count stayed at %d", m.GetActivitiesCalls)
|
|
||||||
}
|
|
||||||
activities, err = db.ListActivities(ctx, userID, store.ActivityFilter{})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("ListActivities after re-backfill: %v", err)
|
|
||||||
}
|
|
||||||
if len(activities) != 1 {
|
|
||||||
t.Fatalf("expected 1 activity re-fetched after reset+backfill, got %d", len(activities))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestBackfill_ResumesFromWatermarkWhenHorizonGrows(t *testing.T) {
|
|
||||||
db := openTestDB(t)
|
|
||||||
ctx := context.Background()
|
|
||||||
userID := provisionTestUser(t, db)
|
|
||||||
|
|
||||||
m := &mock.Client{Activities: []garmin.Activity{
|
|
||||||
{ActivityID: 1, ActivityType: garmin.ActivityType{TypeKey: "running"}, StartTimeGMT: "2026-07-05 06:00:00", Distance: 5000, Duration: 1500},
|
|
||||||
}}
|
|
||||||
now := fixedNow(time.Date(2026, 7, 11, 0, 0, 0, 0, time.UTC))
|
|
||||||
|
|
||||||
svc := NewService(m, db, userID, Config{BackfillWindowDays: 10}, now)
|
|
||||||
setBackfillHorizon(t, db, userID, 10)
|
|
||||||
if err := svc.Backfill(ctx); err != nil {
|
|
||||||
t.Fatalf("first Backfill: %v", err)
|
|
||||||
}
|
|
||||||
firstCallCount := m.GetActivitiesCalls
|
|
||||||
|
|
||||||
// Simulate the user widening the horizon later -- should resume from the
|
|
||||||
// watermark (not re-fetch the already-covered recent window) but still
|
|
||||||
// make progress toward the new, deeper horizon.
|
|
||||||
setBackfillHorizon(t, db, userID, 30)
|
|
||||||
svc2 := NewService(m, db, userID, Config{BackfillWindowDays: 10}, now)
|
|
||||||
if err := svc2.Backfill(ctx); err != nil {
|
|
||||||
t.Fatalf("second Backfill: %v", err)
|
|
||||||
}
|
|
||||||
if m.GetActivitiesCalls <= firstCallCount {
|
|
||||||
t.Errorf("expected additional GetActivities calls when horizon grows, got %d total (was %d)", m.GetActivitiesCalls, firstCallCount)
|
|
||||||
}
|
|
||||||
|
|
||||||
state, err := db.GetSyncState(ctx, userID)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("GetSyncState: %v", err)
|
|
||||||
}
|
|
||||||
if !state.BackfillComplete {
|
|
||||||
t.Fatalf("expected backfill_complete=true after covering the new horizon, got %+v", state)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestFullSync_RecordsOneCombinedSyncRun(t *testing.T) {
|
|
||||||
db := openTestDB(t)
|
|
||||||
ctx := context.Background()
|
|
||||||
userID := provisionTestUser(t, db)
|
|
||||||
|
|
||||||
m := &mock.Client{
|
|
||||||
Activities: []garmin.Activity{
|
|
||||||
{ActivityID: 1, ActivityType: garmin.ActivityType{TypeKey: "running"}, StartTimeGMT: "2026-07-05 06:00:00", Distance: 5000, Duration: 1500},
|
|
||||||
{ActivityID: 2, ActivityType: garmin.ActivityType{TypeKey: "running"}, StartTimeGMT: "2026-07-08 06:00:00", Distance: 5000, Duration: 1500},
|
|
||||||
},
|
|
||||||
Splits: map[int64]garmin.ActivitySplits{
|
|
||||||
1: {ActivityID: 1}, 2: {ActivityID: 2},
|
|
||||||
},
|
|
||||||
Details: map[int64]garmin.ActivityDetails{
|
|
||||||
1: {ActivityID: 1}, 2: {ActivityID: 2},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
svc := NewService(m, db, userID, Config{BackfillWindowDays: 10},
|
|
||||||
fixedNow(time.Date(2026, 7, 11, 0, 0, 0, 0, time.UTC)))
|
|
||||||
setBackfillHorizon(t, db, userID, 10)
|
|
||||||
|
|
||||||
if err := svc.FullSync(ctx, 10); err != nil {
|
|
||||||
t.Fatalf("FullSync: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
runs, err := db.ListSyncRuns(ctx, userID, 10)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("ListSyncRuns: %v", err)
|
|
||||||
}
|
|
||||||
if len(runs) != 1 {
|
|
||||||
t.Fatalf("expected exactly 1 sync run recorded by FullSync (not one per stage), got %d: %+v", len(runs), runs)
|
|
||||||
}
|
|
||||||
run := runs[0]
|
|
||||||
if run.Kind != store.SyncKindFull {
|
|
||||||
t.Errorf("Kind = %q, want %q", run.Kind, store.SyncKindFull)
|
|
||||||
}
|
|
||||||
if run.Status != store.SyncStatusSuccess {
|
|
||||||
t.Errorf("Status = %q, want success", run.Status)
|
|
||||||
}
|
|
||||||
// Backfill (one window covering the whole horizon) stores both of the
|
|
||||||
// mock's activities as genuinely new (2). IncrementalSync then runs its
|
|
||||||
// own separate fetch and -- since the fake client ignores the date range
|
|
||||||
// it's called with -- sees the exact same 2 activities again, but they're
|
|
||||||
// already stored by then, so it contributes 0 new ones. The combined
|
|
||||||
// run's count (2) must reflect that dedup, not naively sum each stage's
|
|
||||||
// raw fetch count (which would double-count to 4) or report only
|
|
||||||
// whichever stage happened to run last (which would silently drop
|
|
||||||
// Backfill's count) -- both are bugs this test guards against.
|
|
||||||
if run.ActivitiesFetched != 2 {
|
|
||||||
t.Errorf("ActivitiesFetched = %d, want 2 (genuinely new activities, deduped across stages)", run.ActivitiesFetched)
|
|
||||||
}
|
|
||||||
|
|
||||||
activities, err := db.ListActivities(ctx, userID, store.ActivityFilter{})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("ListActivities: %v", err)
|
|
||||||
}
|
|
||||||
if len(activities) != 2 {
|
|
||||||
t.Fatalf("expected 2 stored activities (upserted, not duplicated), got %d", len(activities))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestFillPendingDetails_ReportsLiveProgress(t *testing.T) {
|
|
||||||
db := openTestDB(t)
|
|
||||||
ctx := context.Background()
|
|
||||||
userID := provisionTestUser(t, db)
|
|
||||||
|
|
||||||
m := &mock.Client{
|
|
||||||
Activities: []garmin.Activity{},
|
|
||||||
Splits: map[int64]garmin.ActivitySplits{},
|
|
||||||
Details: map[int64]garmin.ActivityDetails{},
|
|
||||||
}
|
|
||||||
const n = 3
|
|
||||||
for i := int64(1); i <= n; i++ {
|
|
||||||
m.Activities = append(m.Activities, garmin.Activity{
|
|
||||||
ActivityID: i, ActivityType: garmin.ActivityType{TypeKey: "running"},
|
|
||||||
StartTimeGMT: "2026-07-0" + string(rune('0'+i)) + " 06:00:00", Distance: 5000, Duration: 1500,
|
|
||||||
})
|
|
||||||
m.Splits[i] = garmin.ActivitySplits{ActivityID: i}
|
|
||||||
m.Details[i] = garmin.ActivityDetails{ActivityID: i}
|
|
||||||
}
|
|
||||||
|
|
||||||
svc := NewService(m, db, userID, Config{InterCallDelay: 150 * time.Millisecond},
|
|
||||||
fixedNow(time.Date(2026, 7, 11, 0, 0, 0, 0, time.UTC)))
|
|
||||||
if err := svc.Backfill(ctx); err != nil {
|
|
||||||
t.Fatalf("Backfill: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if p := svc.Progress(); p.Total != 0 {
|
|
||||||
t.Fatalf("Progress before FillPendingDetails = %+v, want zero value", p)
|
|
||||||
}
|
|
||||||
|
|
||||||
done := make(chan error, 1)
|
|
||||||
go func() { done <- svc.FillPendingDetails(ctx, n) }()
|
|
||||||
|
|
||||||
time.Sleep(200 * time.Millisecond) // into the delay before the 2nd or 3rd item
|
|
||||||
mid := svc.Progress()
|
|
||||||
if mid.Total != n {
|
|
||||||
t.Errorf("mid-flight Progress().Total = %d, want %d", mid.Total, n)
|
|
||||||
}
|
|
||||||
if mid.Done <= 0 || mid.Done >= n {
|
|
||||||
t.Errorf("mid-flight Progress().Done = %d, want strictly between 0 and %d (i.e. actually in progress)", mid.Done, n)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := <-done; err != nil {
|
|
||||||
t.Fatalf("FillPendingDetails: %v", err)
|
|
||||||
}
|
|
||||||
if final := svc.Progress(); final.Total != 0 || final.Done != 0 {
|
|
||||||
t.Errorf("Progress after completion = %+v, want zero value (idle)", final)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestService_TwoUsersSyncIndependently(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)
|
|
||||||
}
|
|
||||||
|
|
||||||
mA := &mock.Client{Activities: []garmin.Activity{
|
|
||||||
{ActivityID: 1, ActivityType: garmin.ActivityType{TypeKey: "running"}, StartTimeGMT: "2026-07-01 06:00:00", Distance: 5000, Duration: 1500},
|
|
||||||
}}
|
|
||||||
mB := &mock.Client{Activities: []garmin.Activity{
|
|
||||||
{ActivityID: 2, ActivityType: garmin.ActivityType{TypeKey: "running"}, StartTimeGMT: "2026-07-01 06:00:00", Distance: 8000, Duration: 2400},
|
|
||||||
}}
|
|
||||||
svcA := NewService(mA, db, userA, Config{}, fixedNow(time.Date(2026, 7, 11, 0, 0, 0, 0, time.UTC)))
|
|
||||||
svcB := NewService(mB, db, userB, Config{}, fixedNow(time.Date(2026, 7, 11, 0, 0, 0, 0, time.UTC)))
|
|
||||||
|
|
||||||
if err := svcA.Backfill(ctx); err != nil {
|
|
||||||
t.Fatalf("Backfill(a): %v", err)
|
|
||||||
}
|
|
||||||
if err := svcB.Backfill(ctx); err != nil {
|
|
||||||
t.Fatalf("Backfill(b): %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
activitiesA, err := db.ListActivities(ctx, userA, store.ActivityFilter{})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("ListActivities(a): %v", err)
|
|
||||||
}
|
|
||||||
activitiesB, err := db.ListActivities(ctx, userB, store.ActivityFilter{})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("ListActivities(b): %v", err)
|
|
||||||
}
|
|
||||||
if len(activitiesA) != 1 || activitiesA[0].GarminActivityID != 1 {
|
|
||||||
t.Fatalf("userA's activities = %+v, want exactly garmin id 1", activitiesA)
|
|
||||||
}
|
|
||||||
if len(activitiesB) != 1 || activitiesB[0].GarminActivityID != 2 {
|
|
||||||
t.Fatalf("userB's activities = %+v, want exactly garmin id 2", activitiesB)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -5,15 +5,16 @@ Generated from the live schema via `go run ./cmd/dumpschema` -- do not hand-edit
|
|||||||
## Tables
|
## Tables
|
||||||
|
|
||||||
- [`users`](#users)
|
- [`users`](#users)
|
||||||
- [`profile`](#profile)
|
- [`profiles`](#profiles)
|
||||||
- [`workout_kinds`](#workout_kinds)
|
- [`workout_kinds`](#workout_kinds)
|
||||||
- [`workout_type_paces`](#workout_type_paces)
|
- [`workout_type_paces`](#workout_type_paces)
|
||||||
- [`activities`](#activities)
|
- [`activities`](#activities)
|
||||||
- [`laps`](#laps)
|
- [`activity_laps`](#activity_laps)
|
||||||
- [`activity_samples`](#activity_samples)
|
- [`activity_samples`](#activity_samples)
|
||||||
- [`kind_assignments`](#kind_assignments)
|
- [`kind_assignments`](#kind_assignments)
|
||||||
- [`sync_state`](#sync_state)
|
- [`sync_state`](#sync_state)
|
||||||
- [`sync_runs`](#sync_runs)
|
- [`sync_runs`](#sync_runs)
|
||||||
|
- [`config`](#config)
|
||||||
|
|
||||||
## `users`
|
## `users`
|
||||||
|
|
||||||
@@ -21,25 +22,30 @@ Generated from the live schema via `go run ./cmd/dumpschema` -- do not hand-edit
|
|||||||
CREATE TABLE users (
|
CREATE TABLE users (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
oidc_sub TEXT NOT NULL UNIQUE,
|
oidc_sub TEXT NOT NULL UNIQUE,
|
||||||
display_name TEXT NOT NULL,
|
name TEXT NOT NULL,
|
||||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
);
|
);
|
||||||
```
|
```
|
||||||
|
|
||||||
## `profile`
|
## `profiles`
|
||||||
|
|
||||||
```sql
|
```sql
|
||||||
CREATE TABLE profile (
|
CREATE TABLE profiles (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
name TEXT NOT NULL DEFAULT 'Default',
|
|
||||||
garmin_email TEXT NOT NULL DEFAULT '',
|
garmin_email TEXT NOT NULL DEFAULT '',
|
||||||
garmin_password TEXT NOT NULL DEFAULT '',
|
garmin_password TEXT NOT NULL DEFAULT '',
|
||||||
|
-- Set once, the first time this user successfully authenticates with
|
||||||
|
-- Garmin (handleAuthLogin/handleAuthMFA). Null means never connected --
|
||||||
|
-- the login gate uses this (not the in-memory auth status, which
|
||||||
|
-- resets on restart) to decide whether a returning user must
|
||||||
|
-- reconnect before entering the app.
|
||||||
|
garmin_connected_at TEXT,
|
||||||
rolling_window_days INTEGER NOT NULL DEFAULT 90,
|
rolling_window_days INTEGER NOT NULL DEFAULT 90,
|
||||||
-- BackfillHorizonDays bounds how far back "Sync now" reaches when
|
-- BackfillHorizonDays bounds how far back "Sync now" reaches when
|
||||||
-- walking backward from today; read fresh on every sync (not cached at
|
-- walking backward from today; read fresh on every sync (not cached at
|
||||||
-- server startup), so changing it here takes effect on the next click.
|
-- server startup), so changing it here takes effect on the next click.
|
||||||
backfill_horizon_days INTEGER NOT NULL DEFAULT 1095,
|
backfill_horizon_days INTEGER NOT NULL DEFAULT 90,
|
||||||
max_heart_rate REAL,
|
max_heart_rate REAL,
|
||||||
resting_heart_rate REAL,
|
resting_heart_rate REAL,
|
||||||
hr_zone1_min_pct REAL NOT NULL DEFAULT 50,
|
hr_zone1_min_pct REAL NOT NULL DEFAULT 50,
|
||||||
@@ -144,6 +150,14 @@ CREATE TABLE activities (
|
|||||||
-- Genuine raw get_workout_by_id() response, the source used to compute
|
-- Genuine raw get_workout_by_id() response, the source used to compute
|
||||||
-- alignWorkoutTargets. Null when the activity has no workout_id.
|
-- alignWorkoutTargets. Null when the activity has no workout_id.
|
||||||
workout_raw_json TEXT,
|
workout_raw_json TEXT,
|
||||||
|
-- Set when get_workout_by_id returned a definitive HTTP 404 (the
|
||||||
|
-- workout was deleted on Garmin's side after being linked to this
|
||||||
|
-- activity) -- distinct from workout_raw_json staying null for "not yet
|
||||||
|
-- fetched": this activity is excluded from ActivitiesMissingWorkout so
|
||||||
|
-- it stops being retried forever (see
|
||||||
|
-- docs/superpowers/specs/2026-07-27-workout-not-found-design.md).
|
||||||
|
-- workout_raw_json itself is never fabricated; it just stays null.
|
||||||
|
workout_not_found_at TEXT,
|
||||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
UNIQUE(user_id, garmin_activity_id)
|
UNIQUE(user_id, garmin_activity_id)
|
||||||
@@ -156,10 +170,10 @@ Indexes:
|
|||||||
CREATE INDEX idx_activities_start_time ON activities(start_time_utc);
|
CREATE INDEX idx_activities_start_time ON activities(start_time_utc);
|
||||||
```
|
```
|
||||||
|
|
||||||
## `laps`
|
## `activity_laps`
|
||||||
|
|
||||||
```sql
|
```sql
|
||||||
CREATE TABLE laps (
|
CREATE TABLE activity_laps (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
activity_id INTEGER NOT NULL REFERENCES activities(id) ON DELETE CASCADE,
|
activity_id INTEGER NOT NULL REFERENCES activities(id) ON DELETE CASCADE,
|
||||||
lap_index INTEGER NOT NULL,
|
lap_index INTEGER NOT NULL,
|
||||||
@@ -249,6 +263,16 @@ CREATE TABLE sync_runs (
|
|||||||
);
|
);
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## `config`
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE config (
|
||||||
|
key TEXT PRIMARY KEY,
|
||||||
|
value TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
## Views
|
## Views
|
||||||
|
|
||||||
### `current_kind_assignment`
|
### `current_kind_assignment`
|
||||||
|
|||||||
@@ -7,17 +7,68 @@ writing-plans flow (see `docs/superpowers/specs/` and `docs/superpowers/plans/`
|
|||||||
for that history) and remove it from here once a spec exists.
|
for that history) and remove it from here once a spec exists.
|
||||||
|
|
||||||
## Backlog
|
## Backlog
|
||||||
|
- new workout kinds
|
||||||
- improve first connection page: in CreateProfile.tsx, in addition to the display name, we should directly ask garmin login/password and manage MFA from there, so the synchronization experience in profile page will be easier later on
|
- add "Recovery", "Quick", and "Sprint" workout kinds
|
||||||
- dedicated workouts section: add workouts table in database and create a dedicated page in UI (use an "objective" icon)
|
- order to follow (in activities page filter buttons, in analysis page combobox, in profile page workout kinds cards) is Recovery -> Easy -> Long -> Tempo -> 60' Threshold -> 30' Threshold -> Quick -> Intervals -> MAS Test -> Sprint -> Race
|
||||||
- improve activities/workouts download: make it modal, add progress bar, error management
|
- adapt the color palette from blue to purple according to this order (starting with blue for recovery -> green -> yellow -> orange -> red until purple for race), put the color code in DB (UI shall not resolve it with kindColor based on workout kind name)
|
||||||
- better UX when backend is not available (instead of "TypeError failed to fetch")
|
- workout templates
|
||||||
- better UX for activities (list layout without the graphs, detailed view with different graphs for each phase incl. delta with workout)
|
- provide workout templates in YAML (see docs/workouts.yaml for the structure)
|
||||||
- add icon to indicate if an activity has an associated workout
|
- first time setup page
|
||||||
- modern error displays: have error messages displayed in an ephemeral window
|
- once connection is validated, add a step to ask user its best race results on 5k, 10k, half-marathon and marathon (at least one shall be provided) and store it in the profile. these values will be updated later during synchronization (as we can detect race kinds with their distance)
|
||||||
|
- add another step to enter latest known MAS (in mm:ss/km) and store it in the profile. this value will be updated later during synchronization (as we can assign an activity with "MAS test" kind)
|
||||||
|
- profile page
|
||||||
|
- move "Heart rate" card before "Activity analysis" card and rename it to "Characteristics"
|
||||||
|
- add genre (male/female) and age in "Characteristics"
|
||||||
|
- rename "Activity analysis" card to "Activities"
|
||||||
|
- in activity analysis, remove "rolling window" and consider the future AI-assisted activity kind analysis will be done on all the synced activities
|
||||||
|
- in activity analysis, remove the "warm-up" and "cool-down" as we'll detect them during activities synchronization and block identification
|
||||||
|
- Move all "Chart colors" items inside "Activities" card (and remove "Chart colors" card)
|
||||||
|
- add a personal records cards for 1k, 5k, 10k, half-marathon, marathon (we'll have at least one value coming from "first time setup page", or more after synchronization). show the date if it comes from an activity
|
||||||
|
- add a performance card showing latest know MAS (we'll have at least one value coming from "first time setup page", or a true one after synchronization). show the date if it comes from an activity
|
||||||
|
- rename "Training types" card to "Workouts"
|
||||||
|
- for each workout kind in "Workout kinds" card
|
||||||
|
- add a radio button to declare a warm-up or not (when checked, add a field to enter duration in minutes)
|
||||||
|
- add a radio button to declare a cool-down or not (when checked, add a field to enter duration in minutes)
|
||||||
|
- add a way to choose its primary objective (either "heart rate", either "% MAS")
|
||||||
|
- if "heart rate" is selected, make appear the "min heart rate", "max heart rate", "min pace", "max pace" fields like before (heart rate fields are mandatory, but not the pace ones)
|
||||||
|
- if "% MAS" is selected, make appear a "% MAS" field requiring a value between 0 and 150, and a "pace amplitude (s/km)" field requiring a number between 0 and 60
|
||||||
|
- add Claude API key (keep it optional, this will be for AI-assisted services)
|
||||||
|
- analysis page
|
||||||
|
- refactor /api/progress in /api/analysis
|
||||||
|
- there should now be history graphs per workout kind, like today (remove VO2max metric as we'll move it elsewhere) and general history graphs to monitor new metrics not linked to a workout kind
|
||||||
|
- general metrics are :
|
||||||
|
- VO2max
|
||||||
|
- (as soon as if we have a 5k race in the profile) "Endurance Index (5k)" which is (100-%MAS)/(ln(6/t)) where %MAS is 5k pace divided by MAS speed (get it from the profile) multiplied by 100, and t the duration of 5k race in minutes
|
||||||
|
- (as soon as if we have a 10k race in the profile) "Endurance Index (10k)" which is (100-%MAS)/(ln(6/t)) where %MAS is 10k pace divided by MAS speed (get it from the profile) multiplied by 100, and t the duration of 10k race in minutes
|
||||||
|
- (as soon as if we have a half-marathon race in the profile) "Endurance Index (HM)" which is (100-%MAS)/(ln(6/t)) where %MAS is half-marathon pace divided by MAS speed (get it from the profile) multiplied by 100, and t the duration of half-marathon race in minutes
|
||||||
|
- (as soon as if we have a marathon race in the profile) "Endurance Index (M)" which is (100-%MAS)/(ln(6/t)) where %MAS is marathon pace divided by MAS speed (get it from the profile) multiplied by 100, and t the duration of marathon race in minutes
|
||||||
|
- general metrics history policy is :
|
||||||
|
- VO2max, one history point for all activities (whatever their kind)
|
||||||
|
- Endurance Index metrics, one history point as soon as one formula value is changing (aka race time or MAS)
|
||||||
|
- create a "Status" card with following elements before the history graphs
|
||||||
|
- a line scale indicating current VO2max (declare and use application configuration vo2max.gxxyyy where g could be "m" for male or "f" for female, xx could be 20,30,40,50,60,70 and yyy could be "fair", "good", "excellent", "superior", get default values from https://www8.garmin.com/manuals/webhelp/venu/EN-US/GUID-1FBCCD9E-19E1-4E4C-BD60-1793B5B97EB3.html, for example vo2max.m40good=42.4)
|
||||||
|
- (as soon as if we have a 5k race in the profile) a line scale indicating current "Endurance Index (5k)" (declare and use application configuration ei.min with default value -15 and ei.max with default value 0)
|
||||||
|
- (as soon as if we have a 10k race in the profile) a line scale indicating current "Endurance Index (10k)" (declare and use application configuration ei.min with default value -15 and ei.max with default value 0)
|
||||||
|
- (as soon as if we have a half-marathon race in the profile) a line scale indicating current "Endurance Index (HM)" (declare and use application configuration ei.min with default value -15 and ei.max with default value 0)
|
||||||
|
- (as soon as if we have a marathon race in the profile) a line scale indicating current "Endurance Index (M)" (declare and use application configuration ei.min with default value -15 and ei.max with default value 0)
|
||||||
|
- training plan page
|
||||||
|
- we need to store our workouts and dedicated /api/workouts (these are not the garmin workouts)
|
||||||
|
- this page will list all the planned workouts of the plan, no consideration of schedule for now, just a list of workouts
|
||||||
|
- for each workout, we have to see
|
||||||
|
- kind (with same color policy as for the activities)
|
||||||
|
- objective (as given in workout type profile)
|
||||||
|
- min pace / max pace ()
|
||||||
|
- activities page
|
||||||
|
- add an indicator if a personal record has been beaten (with a cup icon)
|
||||||
|
- add an indicator for the maximum heart rate (just after the average heart rate)
|
||||||
|
- AI-assisted ranking button that will set workout kind on unlocked activities (based on workout kind description in profile page)
|
||||||
|
- discovery of warm-up, cool-down phases, so we can read activity per blocks (with detection of extension of a block compared to linked workout)
|
||||||
|
- discovery of additional blocks done but not requested by linked workout (for example, additional small sprints at the end of an easy run)
|
||||||
|
- add icon to indicate if an activity has an associated workout
|
||||||
|
- manage 2 layouts
|
||||||
|
- list layout with smaller graphs (no scale, no targets)
|
||||||
|
- detailed layout (once clicked on details...) showing graphs for each block incl. delta with workout)
|
||||||
|
|
||||||
## Someday / maybe
|
## Someday / maybe
|
||||||
|
|
||||||
- dedicated tab for past workouts (or find a way to show them in the "training plan" tab)
|
|
||||||
- training programs with phases (tapering, easier weeks, post-race recovery)
|
- training programs with phases (tapering, easier weeks, post-race recovery)
|
||||||
-
|
|
||||||
@@ -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"
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -1256,8 +1256,8 @@ git commit -m "feat: fix workout taxonomy to 7 types, add pace/HR-zone fields to
|
|||||||
### Task 7: Classification reads max HR from the profile, not static config
|
### Task 7: Classification reads max HR from the profile, not static config
|
||||||
|
|
||||||
**Files:**
|
**Files:**
|
||||||
- Modify: `backend/internal/sync/service.go` (`Config` struct lines 21-40, `ClassifyActivity` lines 279-310)
|
- Modify: `../../../backend/internal/garmin/sync.go` (`Config` struct lines 21-40, `ClassifyActivity` lines 279-310)
|
||||||
- Modify: `backend/internal/sync/service_test.go` (`TestFillPendingDetailsAndClassify_EndToEnd`)
|
- Modify: `../../../backend/internal/garmin/sync_test.go` (`TestFillPendingDetailsAndClassify_EndToEnd`)
|
||||||
|
|
||||||
**Interfaces:**
|
**Interfaces:**
|
||||||
- Consumes: `store.Profile.MaxHeartRate` (Task 1), `db.GetProfile` (Task 1).
|
- Consumes: `store.Profile.MaxHeartRate` (Task 1), `db.GetProfile` (Task 1).
|
||||||
@@ -1265,7 +1265,7 @@ git commit -m "feat: fix workout taxonomy to 7 types, add pace/HR-zone fields to
|
|||||||
|
|
||||||
- [ ] **Step 1: Update the test first**
|
- [ ] **Step 1: Update the test first**
|
||||||
|
|
||||||
In `backend/internal/sync/service_test.go`, find this line inside `TestFillPendingDetailsAndClassify_EndToEnd` (currently constructs the service with `MaxHR: 190`):
|
In `../../../backend/internal/garmin/sync_test.go`, find this line inside `TestFillPendingDetailsAndClassify_EndToEnd` (currently constructs the service with `MaxHR: 190`):
|
||||||
|
|
||||||
```go
|
```go
|
||||||
svc := NewService(m, db, Config{MinConfidence: 0.5, MaxHR: 190}, fixedNow(time.Date(2026, 7, 11, 0, 0, 0, 0, time.UTC)))
|
svc := NewService(m, db, Config{MinConfidence: 0.5, MaxHR: 190}, fixedNow(time.Date(2026, 7, 11, 0, 0, 0, 0, time.UTC)))
|
||||||
@@ -1284,7 +1284,7 @@ Expected: passes as-is right now (we haven't removed the field yet) — this ste
|
|||||||
|
|
||||||
- [ ] **Step 3: Remove `MaxHR` from `Config` and source it from the profile in `ClassifyActivity`**
|
- [ ] **Step 3: Remove `MaxHR` from `Config` and source it from the profile in `ClassifyActivity`**
|
||||||
|
|
||||||
In `backend/internal/sync/service.go`, the `Config` struct currently ends with (lines 35-39):
|
In `../../../backend/internal/garmin/sync.go`, the `Config` struct currently ends with (lines 35-39):
|
||||||
|
|
||||||
```go
|
```go
|
||||||
// MinConfidence is the classify.Classify threshold below which even a
|
// MinConfidence is the classify.Classify threshold below which even a
|
||||||
@@ -1381,7 +1381,7 @@ Expected: all PASS. If `cmd/geniusrund/main.go` fails to build because it still
|
|||||||
- [ ] **Step 6: Commit**
|
- [ ] **Step 6: Commit**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd backend && git add internal/sync/service.go internal/sync/service_test.go
|
cd backend && git add internal/sync/sync.go internal/sync/sync_test.go
|
||||||
git commit -m "feat: classification reads max HR from the profile instead of static config"
|
git commit -m "feat: classification reads max HR from the profile instead of static config"
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -1390,7 +1390,7 @@ git commit -m "feat: classification reads max HR from the profile instead of sta
|
|||||||
### Task 8: `internal/config` and `cmd/geniusrund` — credentials come from the profile
|
### Task 8: `internal/config` and `cmd/geniusrund` — credentials come from the profile
|
||||||
|
|
||||||
**Files:**
|
**Files:**
|
||||||
- Modify: `backend/internal/config/config.go` (full rewrite — small file, shown complete below)
|
- Modify: `../../../backend/internal/config/envconfig.go` (full rewrite — small file, shown complete below)
|
||||||
- Modify: `backend/cmd/geniusrund/main.go` (lines 21-46)
|
- Modify: `backend/cmd/geniusrund/main.go` (lines 21-46)
|
||||||
|
|
||||||
**Interfaces:**
|
**Interfaces:**
|
||||||
@@ -1399,7 +1399,7 @@ git commit -m "feat: classification reads max HR from the profile instead of sta
|
|||||||
|
|
||||||
- [ ] **Step 1: Rewrite `internal/config/config.go`**
|
- [ ] **Step 1: Rewrite `internal/config/config.go`**
|
||||||
|
|
||||||
Replace the full contents of `backend/internal/config/config.go` with:
|
Replace the full contents of `../../../backend/internal/config/envconfig.go` with:
|
||||||
|
|
||||||
```go
|
```go
|
||||||
// Package config loads geniusrund's runtime infrastructure configuration
|
// Package config loads geniusrund's runtime infrastructure configuration
|
||||||
@@ -1590,7 +1590,7 @@ Expected: the server starts (no "GARMIN_EMAIL required" error), `/api/profile` r
|
|||||||
- [ ] **Step 5: Commit**
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd backend && git add internal/config/config.go cmd/geniusrund/main.go
|
cd backend && git add internal/config/envconfig.go cmd/geniusrund/main.go
|
||||||
git commit -m "feat: source Garmin credentials from the profile instead of env vars"
|
git commit -m "feat: source Garmin credentials from the profile instead of env vars"
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -61,15 +61,15 @@ CLAUDE.md [MODIFY] document the new env vars / auth architectur
|
|||||||
### Task 1: Config — OIDC/session settings
|
### Task 1: Config — OIDC/session settings
|
||||||
|
|
||||||
**Files:**
|
**Files:**
|
||||||
- Modify: `backend/internal/config/config.go`
|
- Modify: `../../../backend/internal/config/envconfig.go`
|
||||||
- Create: `backend/internal/config/config_test.go`
|
- Create: `../../../backend/internal/config/envconfig_test.go`
|
||||||
|
|
||||||
**Interfaces:**
|
**Interfaces:**
|
||||||
- Produces: `Config.OIDCIssuerURL/OIDCClientID/OIDCClientSecret/OIDCRedirectURL/OIDCRequiredRole string`, `Config.PublicBaseURL string`, `Config.SessionSecret []byte`, `Config.SessionDuration time.Duration`, `Config.SessionSecure bool` — consumed by Task 6 (`main.go`) to build `auth.OIDCConfig` and `api.SessionConfig`.
|
- Produces: `Config.OIDCIssuerURL/OIDCClientID/OIDCClientSecret/OIDCRedirectURL/OIDCRequiredRole string`, `Config.PublicBaseURL string`, `Config.SessionSecret []byte`, `Config.SessionDuration time.Duration`, `Config.SessionSecure bool` — consumed by Task 6 (`main.go`) to build `auth.OIDCConfig` and `api.SessionConfig`.
|
||||||
|
|
||||||
- [ ] **Step 1: Write the failing tests**
|
- [ ] **Step 1: Write the failing tests**
|
||||||
|
|
||||||
Create `backend/internal/config/config_test.go`:
|
Create `../../../backend/internal/config/envconfig_test.go`:
|
||||||
|
|
||||||
```go
|
```go
|
||||||
package config
|
package config
|
||||||
@@ -179,7 +179,7 @@ Expected: compile errors (`cfg.OIDCRedirectURL` etc. undefined) — that's the e
|
|||||||
|
|
||||||
- [ ] **Step 3: Implement the config fields and validation**
|
- [ ] **Step 3: Implement the config fields and validation**
|
||||||
|
|
||||||
In `backend/internal/config/config.go`, add `"strings"` to the imports, add these fields to `Config` (after `MinConfidence`/`IncrementalSyncEvery`):
|
In `../../../backend/internal/config/envconfig.go`, add `"strings"` to the imports, add these fields to `Config` (after `MinConfidence`/`IncrementalSyncEvery`):
|
||||||
|
|
||||||
```go
|
```go
|
||||||
// OIDC login gate (Keycloak). PublicBaseURL is this app's own externally
|
// OIDC login gate (Keycloak). PublicBaseURL is this app's own externally
|
||||||
@@ -256,7 +256,7 @@ Expected: PASS (all `TestLoad_*` cases).
|
|||||||
- [ ] **Step 5: Commit**
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git add backend/internal/config/config.go backend/internal/config/config_test.go
|
git add backend/internal/config/envconfig.go backend/internal/config/envconfig_test.go
|
||||||
git commit -m "config: add OIDC/session env vars for the login gate"
|
git commit -m "config: add OIDC/session env vars for the login gate"
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -17,25 +17,25 @@
|
|||||||
- `GARMIN_WRAPPER_PYTHON` is optional, defaulting to `"python3"` (resolved via `PATH`) — `MCP_GARMIN_PYTHON`/`MCP_GARMIN_SERVER` are retired entirely, not renamed.
|
- `GARMIN_WRAPPER_PYTHON` is optional, defaulting to `"python3"` (resolved via `PATH`) — `MCP_GARMIN_PYTHON`/`MCP_GARMIN_SERVER` are retired entirely, not renamed.
|
||||||
- `garminconnect`'s actual method kwargs must be used exactly: `get_activities_by_date(startdate, enddate)`, `get_activity_splits(activity_id)`, `get_activity_details(activity_id)`, `get_workout_by_id(workout_id)` (confirmed via `inspect.signature` against the installed package — note `startdate`/`enddate`, not `start_date`/`end_date`).
|
- `garminconnect`'s actual method kwargs must be used exactly: `get_activities_by_date(startdate, enddate)`, `get_activity_splits(activity_id)`, `get_activity_details(activity_id)`, `get_workout_by_id(workout_id)` (confirmed via `inspect.signature` against the installed package — note `startdate`/`enddate`, not `start_date`/`end_date`).
|
||||||
- `gofmt -l .` must report nothing; `go build ./...`, `go vet ./...`, and `go test ./...` must all pass before the final commit.
|
- `gofmt -l .` must report nothing; `go build ./...`, `go vet ./...`, and `go test ./...` must all pass before the final commit.
|
||||||
- `pytest` must pass in `backend/internal/garmin/pyscript/`.
|
- `pytest` must pass in `../../../backend/internal/garmin/wrapper/`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### Task 1: Python wrapper (`wrapper.py`) with its own test suite
|
### Task 1: Python wrapper (`wrapper.py`) with its own test suite
|
||||||
|
|
||||||
**Files:**
|
**Files:**
|
||||||
- Create: `backend/internal/garmin/pyscript/wrapper.py`
|
- Create: `../../../backend/internal/garmin/wrapper/wrapper.py`
|
||||||
- Create: `backend/internal/garmin/pyscript/pyproject.toml`
|
- Create: `../../../backend/internal/garmin/wrapper/pyproject.toml`
|
||||||
- Create: `backend/internal/garmin/pyscript/.gitignore`
|
- Create: `../../../backend/internal/garmin/wrapper/.gitignore`
|
||||||
- Test: `backend/internal/garmin/pyscript/tests/__init__.py`
|
- Test: `../../../backend/internal/garmin/wrapper/tests/__init__.py`
|
||||||
- Test: `backend/internal/garmin/pyscript/tests/test_wrapper.py`
|
- Test: `../../../backend/internal/garmin/wrapper/tests/test_wrapper.py`
|
||||||
|
|
||||||
**Interfaces:**
|
**Interfaces:**
|
||||||
- Produces: `wrapper.dispatch(req: dict) -> dict` — the single entry point Go's subprocess talks to indirectly via stdin/stdout; also `wrapper._auth_state`, `wrapper._client`, `wrapper._mfa_input_queue`, `wrapper._login_result_queue`, `wrapper.Garmin` (re-exported name, patchable in tests), used only within this task's own test suite.
|
- Produces: `wrapper.dispatch(req: dict) -> dict` — the single entry point Go's subprocess talks to indirectly via stdin/stdout; also `wrapper._auth_state`, `wrapper._client`, `wrapper._mfa_input_queue`, `wrapper._login_result_queue`, `wrapper.Garmin` (re-exported name, patchable in tests), used only within this task's own test suite.
|
||||||
|
|
||||||
- [ ] **Step 1: Create the Python project manifest**
|
- [ ] **Step 1: Create the Python project manifest**
|
||||||
|
|
||||||
`backend/internal/garmin/pyscript/pyproject.toml`:
|
`../../../backend/internal/garmin/wrapper/pyproject.toml`:
|
||||||
|
|
||||||
```toml
|
```toml
|
||||||
[project]
|
[project]
|
||||||
@@ -55,7 +55,7 @@ requires = ["hatchling"]
|
|||||||
build-backend = "hatchling.build"
|
build-backend = "hatchling.build"
|
||||||
```
|
```
|
||||||
|
|
||||||
`backend/internal/garmin/pyscript/.gitignore`:
|
`../../../backend/internal/garmin/wrapper/.gitignore`:
|
||||||
|
|
||||||
```
|
```
|
||||||
.venv/
|
.venv/
|
||||||
@@ -68,7 +68,7 @@ __pycache__/
|
|||||||
|
|
||||||
Run:
|
Run:
|
||||||
```bash
|
```bash
|
||||||
cd backend/internal/garmin/pyscript
|
cd backend/internal/garmin/wrapper
|
||||||
python3 -m venv .venv
|
python3 -m venv .venv
|
||||||
.venv/bin/pip install -e ".[dev]"
|
.venv/bin/pip install -e ".[dev]"
|
||||||
```
|
```
|
||||||
@@ -76,9 +76,9 @@ Expected: installs `garminconnect` and `pytest` into `.venv` with no errors.
|
|||||||
|
|
||||||
- [ ] **Step 3: Write the failing test suite**
|
- [ ] **Step 3: Write the failing test suite**
|
||||||
|
|
||||||
`backend/internal/garmin/pyscript/tests/__init__.py`: empty file.
|
`../../../backend/internal/garmin/wrapper/tests/__init__.py`: empty file.
|
||||||
|
|
||||||
`backend/internal/garmin/pyscript/tests/test_wrapper.py`:
|
`../../../backend/internal/garmin/wrapper/tests/test_wrapper.py`:
|
||||||
|
|
||||||
```python
|
```python
|
||||||
import os
|
import os
|
||||||
@@ -216,7 +216,7 @@ Expected: `ModuleNotFoundError: No module named 'wrapper'` (or collection failur
|
|||||||
|
|
||||||
- [ ] **Step 5: Write `wrapper.py`**
|
- [ ] **Step 5: Write `wrapper.py`**
|
||||||
|
|
||||||
`backend/internal/garmin/pyscript/wrapper.py`:
|
`../../../backend/internal/garmin/wrapper/wrapper.py`:
|
||||||
|
|
||||||
```python
|
```python
|
||||||
"""Subprocess wrapper around garminconnect, spoken to over newline-delimited
|
"""Subprocess wrapper around garminconnect, spoken to over newline-delimited
|
||||||
@@ -393,7 +393,7 @@ Expected: all tests PASS.
|
|||||||
- [ ] **Step 7: Commit**
|
- [ ] **Step 7: Commit**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git add backend/internal/garmin/pyscript
|
git add backend/internal/garmin/wrapper
|
||||||
git commit -m "$(cat <<'EOF'
|
git commit -m "$(cat <<'EOF'
|
||||||
feat(garmin): add direct garminconnect wrapper script
|
feat(garmin): add direct garminconnect wrapper script
|
||||||
|
|
||||||
@@ -415,7 +415,7 @@ EOF
|
|||||||
|
|
||||||
**Interfaces:**
|
**Interfaces:**
|
||||||
- Consumes: nothing from other tasks.
|
- Consumes: nothing from other tasks.
|
||||||
- Produces: `Config{PythonPath, GarminEmail, GarminPassword, TokenStorePath string}`, `NewClient(cfg Config) Client`, `subprocessClient` (unexported struct implementing `Client`), `wireRequest{ID int; Cmd string; Params any}`, `wireResponse{ID int; Result json.RawMessage; Error string}`, `callParams{Method string; Args map[string]any}`, `(*subprocessClient).roundTrip(cmd string, params any) (json.RawMessage, error)`, `(*subprocessClient).ensureStarted() error`, `(*subprocessClient).close() error` (unexported, no-lock; `Close()`/`UpdateCredentials` call it while already holding `c.mu`). Later tasks (3, 4) add methods to `subprocessClient` using `roundTrip`/`ensureStarted` and must not redefine these types.
|
- Produces: `Config{PythonPath, GarminEmail, GarminPassword, TokenStorePath string}`, `NewClient(cfg Config) Client`, `subprocessClient` (unexported struct implementing `Client`), `wireRequest{ID int; Cmd string; Params any}`, `wireResponse{ID int; Result json.RawMessage; Error string}`, `callParams{Method string; Args map[string]any}`, `(*subprocessClient).roundTrip(cmd string, params any) (json.RawMessage, error)`, `(*subprocessClient).ensureStarted() error`, `(*subprocessClient).close() error` (unexported, no-lock; `Close()`/`UpdateCredentials` call it while already holding `c.mu`). Later tasks (3, 4) add methods to `subprocessClient` using `execute`/`ensureStarted` and must not redefine these types.
|
||||||
|
|
||||||
- [ ] **Step 1: Write the failing tests for the transport layer**
|
- [ ] **Step 1: Write the failing tests for the transport layer**
|
||||||
|
|
||||||
@@ -576,7 +576,7 @@ func TestSubprocessClient_Close_NoOpWhenNotStarted(t *testing.T) {
|
|||||||
- [ ] **Step 2: Run the tests to verify they fail**
|
- [ ] **Step 2: Run the tests to verify they fail**
|
||||||
|
|
||||||
Run: `cd backend && go test ./internal/garmin/... -run TestSubprocessClient -v`
|
Run: `cd backend && go test ./internal/garmin/... -run TestSubprocessClient -v`
|
||||||
Expected: compile failure — `subprocessClient`, `wireResponse`, `roundTrip`, `maxWrapperLineBytes`, `Config` (new shape) don't exist yet.
|
Expected: compile failure — `subprocessClient`, `wireResponse`, `execute`, `maxWrapperLineBytes`, `Config` (new shape) don't exist yet.
|
||||||
|
|
||||||
- [ ] **Step 3: Rewrite `client.go`**
|
- [ ] **Step 3: Rewrite `client.go`**
|
||||||
|
|
||||||
@@ -584,7 +584,7 @@ Expected: compile failure — `subprocessClient`, `wireResponse`, `roundTrip`, `
|
|||||||
|
|
||||||
```go
|
```go
|
||||||
// Package garmin wraps a direct garminconnect subprocess (see
|
// Package garmin wraps a direct garminconnect subprocess (see
|
||||||
// pyscript/wrapper.py) as a narrow Go client interface, so the rest of
|
// wrapper/wrapper.py) as a narrow Go client interface, so the rest of
|
||||||
// geniusrun never deals with the wire protocol directly.
|
// geniusrun never deals with the wire protocol directly.
|
||||||
package garmin
|
package garmin
|
||||||
|
|
||||||
@@ -861,7 +861,7 @@ git add backend/internal/garmin/client.go backend/internal/garmin/client_test.go
|
|||||||
git commit -m "$(cat <<'EOF'
|
git commit -m "$(cat <<'EOF'
|
||||||
feat(garmin): replace mcp-go transport with a JSON-lines subprocess client
|
feat(garmin): replace mcp-go transport with a JSON-lines subprocess client
|
||||||
|
|
||||||
subprocessClient spawns the embedded pyscript/wrapper.py over os/exec and
|
subprocessClient spawns the embedded wrapper/wrapper.py over os/exec and
|
||||||
speaks newline-delimited JSON instead of MCP. Auth/data methods land in
|
speaks newline-delimited JSON instead of MCP. Auth/data methods land in
|
||||||
follow-up commits; this is the transport + lifecycle plumbing only.
|
follow-up commits; this is the transport + lifecycle plumbing only.
|
||||||
|
|
||||||
@@ -879,7 +879,7 @@ EOF
|
|||||||
- Modify: `backend/internal/garmin/client_test.go`
|
- Modify: `backend/internal/garmin/client_test.go`
|
||||||
|
|
||||||
**Interfaces:**
|
**Interfaces:**
|
||||||
- Consumes: `subprocessClient`, `roundTrip`, `ensureStarted`, `mapAuthStatus`, `authResultWire`, `newFakeWrapperClient`/`fakeResult`/`fakeError` from Task 2.
|
- Consumes: `subprocessClient`, `execute`, `ensureStarted`, `mapAuthStatus`, `authResultWire`, `newFakeWrapperClient`/`fakeResult`/`fakeError` from Task 2.
|
||||||
- Produces: `(*subprocessClient).Authenticate(ctx) (AuthResult, error)`, `(*subprocessClient).CompleteMFA(ctx, code) (AuthResult, error)`.
|
- Produces: `(*subprocessClient).Authenticate(ctx) (AuthResult, error)`, `(*subprocessClient).CompleteMFA(ctx, code) (AuthResult, error)`.
|
||||||
|
|
||||||
- [ ] **Step 1: Write the failing tests**
|
- [ ] **Step 1: Write the failing tests**
|
||||||
@@ -992,7 +992,7 @@ Expected: compile failure — `Authenticate`/`CompleteMFA` methods don't exist o
|
|||||||
|
|
||||||
- [ ] **Step 3: Implement `Authenticate`/`CompleteMFA`**
|
- [ ] **Step 3: Implement `Authenticate`/`CompleteMFA`**
|
||||||
|
|
||||||
Append to `backend/internal/garmin/client.go` (after `roundTrip`):
|
Append to `backend/internal/garmin/client.go` (after `execute`):
|
||||||
|
|
||||||
```go
|
```go
|
||||||
func (c *subprocessClient) Authenticate(ctx context.Context) (AuthResult, error) {
|
func (c *subprocessClient) Authenticate(ctx context.Context) (AuthResult, error) {
|
||||||
@@ -1370,8 +1370,8 @@ EOF
|
|||||||
### Task 5: Update `internal/config` (drop server-path env var, default the python path)
|
### Task 5: Update `internal/config` (drop server-path env var, default the python path)
|
||||||
|
|
||||||
**Files:**
|
**Files:**
|
||||||
- Modify: `backend/internal/config/config.go`
|
- Modify: `../../../backend/internal/config/envconfig.go`
|
||||||
- Modify: `backend/internal/config/config_test.go`
|
- Modify: `../../../backend/internal/config/envconfig_test.go`
|
||||||
|
|
||||||
**Interfaces:**
|
**Interfaces:**
|
||||||
- Consumes: nothing from Tasks 1–4.
|
- Consumes: nothing from Tasks 1–4.
|
||||||
@@ -1379,7 +1379,7 @@ EOF
|
|||||||
|
|
||||||
- [ ] **Step 1: Write the failing tests**
|
- [ ] **Step 1: Write the failing tests**
|
||||||
|
|
||||||
In `backend/internal/config/config_test.go`, update `setRequiredEnv` (remove the two now-optional lines):
|
In `../../../backend/internal/config/envconfig_test.go`, update `setRequiredEnv` (remove the two now-optional lines):
|
||||||
|
|
||||||
```go
|
```go
|
||||||
func setRequiredEnv(t *testing.T) {
|
func setRequiredEnv(t *testing.T) {
|
||||||
@@ -1425,11 +1425,11 @@ func TestLoad_GarminPythonPathExplicitOverridesDefault(t *testing.T) {
|
|||||||
- [ ] **Step 2: Run the tests to verify they fail**
|
- [ ] **Step 2: Run the tests to verify they fail**
|
||||||
|
|
||||||
Run: `cd backend && go test ./internal/config/... -v`
|
Run: `cd backend && go test ./internal/config/... -v`
|
||||||
Expected: `TestLoad_GarminPythonPathDefaultsToPython3` fails (`GarminPythonPath` is currently `""`, and `Load()` currently errors out entirely since `MCP_GARMIN_PYTHON`/`MCP_GARMIN_SERVER` are no longer set by the trimmed `setRequiredEnv`) — every other test in the package should also now fail with "MCP_GARMIN_PYTHON is required", confirming the removed env vars were the only thing missing.
|
Expected: `TestLoad_GarminPythonPathDefaultsToPython3` fails (`PythonPath` is currently `""`, and `Load()` currently errors out entirely since `MCP_GARMIN_PYTHON`/`MCP_GARMIN_SERVER` are no longer set by the trimmed `setRequiredEnv`) — every other test in the package should also now fail with "MCP_GARMIN_PYTHON is required", confirming the removed env vars were the only thing missing.
|
||||||
|
|
||||||
- [ ] **Step 3: Update `config.go`**
|
- [ ] **Step 3: Update `envconfig.go`**
|
||||||
|
|
||||||
In `backend/internal/config/config.go`, replace the two Garmin subprocess-path fields:
|
In `../../../backend/internal/config/envconfig.go`, replace the two Garmin subprocess-path fields:
|
||||||
|
|
||||||
```go
|
```go
|
||||||
// GarminPythonPath is mcp-garmin's venv python executable.
|
// GarminPythonPath is mcp-garmin's venv python executable.
|
||||||
@@ -1479,7 +1479,7 @@ Expected: all tests PASS.
|
|||||||
- [ ] **Step 5: Commit**
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git add backend/internal/config/config.go backend/internal/config/config_test.go
|
git add backend/internal/config/envconfig.go backend/internal/config/envconfig_test.go
|
||||||
git commit -m "$(cat <<'EOF'
|
git commit -m "$(cat <<'EOF'
|
||||||
feat(config): drop MCP_GARMIN_SERVER, default GARMIN_WRAPPER_PYTHON
|
feat(config): drop MCP_GARMIN_SERVER, default GARMIN_WRAPPER_PYTHON
|
||||||
|
|
||||||
@@ -1528,7 +1528,7 @@ to:
|
|||||||
}, appsync.Config{
|
}, appsync.Config{
|
||||||
```
|
```
|
||||||
|
|
||||||
- [ ] **Step 2: Update the `GarminBase` doc comment in `server.go`**
|
- [ ] **Step 2: Update the `ClientConfig` doc comment in `server.go`**
|
||||||
|
|
||||||
In `backend/internal/api/server.go`, change:
|
In `backend/internal/api/server.go`, change:
|
||||||
|
|
||||||
@@ -1596,16 +1596,37 @@ Replace the entire `## mcp-garmin integration` section:
|
|||||||
```markdown
|
```markdown
|
||||||
## mcp-garmin integration
|
## mcp-garmin integration
|
||||||
|
|
||||||
`internal/garmin` is a Go MCP **client** (via `github.com/mark3labs/mcp-go`'s stdio transport) that spawns `mcp-garmin`'s `server.py` as a subprocess. Key things learned the hard way, that would otherwise get rediscovered:
|
`internal/garmin` is a Go MCP **client** (via `github.com/mark3labs/mcp-go`'s stdio transport) that spawns `mcp-garmin`
|
||||||
|
's `server.py` as a subprocess. Key things learned the hard way, that would otherwise get rediscovered:
|
||||||
|
|
||||||
- **`authenticate()`/`complete_mfa()` return plain strings**, not structured JSON (`"Authenticated successfully."`, `"MFA required. ..."`, `"Authentication failed: ..."`). `internal/garmin/client.go`'s `parseAuthResult` pattern-matches these.
|
- **`authenticate()`/`complete_mfa()` return plain strings**, not structured JSON (`"Authenticated successfully."`,
|
||||||
- **The 10s "MFA required" timeout in mcp-garmin's `authenticate()` is a false-positive trap.** A login that's merely slow (e.g. Garmin/Cloudflare rate-limiting) looks identical to a real MFA challenge unless you check whether `prompt_mfa()` was actually invoked (mcp-garmin now logs this to stderr for exactly this reason).
|
`"MFA required. ..."`, `"Authentication failed: ..."`). `internal/garmin/client.go`'s `parseAuthResult`
|
||||||
- **mcp-garmin persists Garmin sessions** via a `GARMIN_TOKENSTORE` env var passed to `Garmin.login(tokenstore=...)` — without this, every process start does a full SSO login, which is what trips Garmin's rate limiting under repeated testing. Since geniusrun became multi-tenant, `GARMIN_TOKENSTORE` (`config.GarminTokenStoreRoot`) is a **root directory**, not a single session path: `api.Server.garminFor` namespaces each user's subprocess under `{root}/{userID}` so concurrent users never share a Garmin session. If unset, `config.Load()` now defaults it to a `garmin-tokenstores` directory next to the DB file (logged at startup) rather than leaving per-user namespacing silently skipped.
|
pattern-matches these.
|
||||||
- **`activityId` is a large int64** — never round-trip it through `float64`/generic `map[string]any` JSON decoding, or it corrupts into scientific notation. Decode into typed structs (`garmin.Activity`, not `map[string]any`).
|
- **The 10s "MFA required" timeout in mcp-garmin's `authenticate()` is a false-positive trap.** A login that's merely
|
||||||
- **`get_activity_details()` returns raw per-second telemetry** (`activityDetailMetrics` + `metricDescriptors`), not lap/split summaries, despite what its docstring used to say. Its `metricDescriptors` index-to-field mapping is **not stable across activities/devices** — `garmin.ExtractSamples` always resolves fields by descriptor key, never by fixed array position.
|
slow (e.g. Garmin/Cloudflare rate-limiting) looks identical to a real MFA challenge unless you check whether
|
||||||
|
`prompt_mfa()` was actually invoked (mcp-garmin now logs this to stderr for exactly this reason).
|
||||||
|
- **mcp-garmin persists Garmin sessions** via a `GARMIN_TOKENSTORE` env var passed to `Garmin.login(tokenstore=...)` —
|
||||||
|
without this, every process start does a full SSO login, which is what trips Garmin's rate limiting under repeated
|
||||||
|
testing. Since geniusrun became multi-tenant, `GARMIN_TOKENSTORE` (`config.GarminTokenStoreRoot`) is a **root
|
||||||
|
directory**, not a single session path: `api.Server.garminFor` namespaces each user's subprocess under
|
||||||
|
`{root}/{userID}` so concurrent users never share a Garmin session. If unset, `config.Load()` now defaults it to a
|
||||||
|
`.garmin` directory next to the DB file (logged at startup) rather than leaving per-user namespacing silently skipped.
|
||||||
|
- **`activityId` is a large int64** — never round-trip it through `float64`/generic `map[string]any` JSON decoding, or
|
||||||
|
it corrupts into scientific notation. Decode into typed structs (`garmin.Activity`, not `map[string]any`).
|
||||||
|
- **`get_activity_details()` returns raw per-second telemetry** (`activityDetailMetrics` + `metricDescriptors`), not
|
||||||
|
lap/split summaries, despite what its docstring used to say. Its `metricDescriptors` index-to-field mapping is **not
|
||||||
|
stable across activities/devices** — `garmin.ExtractSamples` always resolves fields by descriptor key, never by fixed
|
||||||
|
array position.
|
||||||
- **`get_activity_splits()`** returns the actual lap/split summaries (`lapDTOs`).
|
- **`get_activity_splits()`** returns the actual lap/split summaries (`lapDTOs`).
|
||||||
- **`get_workout_by_id()`** returns a structured Garmin workout's flattened steps (target pace/HR per step); `internal/sync/mapping.go`'s `alignWorkoutTargets` zips these 1:1 against an activity's recorded laps (when the activity carries a `workout_id` and the lap count matches, tolerating exactly one extra trailing lap) to resolve each lap's expected pace/HR band.
|
- **`get_workout_by_id()`** returns a structured Garmin workout's flattened steps (target pace/HR per step);
|
||||||
- Each user has their own lazily-built, cached `garmin.Client` (`api.Server.garminFor`, double-checked-locked, keyed by `userID`). Saving that user's profile calls `UpdateCredentials` on their own cached client instance, which resets only that client's "started" state and terminates only that user's already-spawned subprocess so the next call respawns fresh under the new credentials — no separate reconnect UI needed beyond the existing connect/MFA flow, and no risk of one user's credential change touching another's session.
|
`internal/sync/mapping.go`'s `alignWorkoutTargets` zips these 1:1 against an activity's recorded laps (when the
|
||||||
|
activity carries a `workout_id` and the lap count matches, tolerating exactly one extra trailing lap) to resolve each
|
||||||
|
lap's expected pace/HR band.
|
||||||
|
- Each user has their own lazily-built, cached `garmin.Client` (`api.Server.garminFor`, double-checked-locked, keyed by
|
||||||
|
`userID`). Saving that user's profile calls `UpdateCredentials` on their own cached client instance, which resets only
|
||||||
|
that client's "started" state and terminates only that user's already-spawned subprocess so the next call respawns
|
||||||
|
fresh under the new credentials — no separate reconnect UI needed beyond the existing connect/MFA flow, and no risk of
|
||||||
|
one user's credential change touching another's session.
|
||||||
```
|
```
|
||||||
|
|
||||||
with:
|
with:
|
||||||
@@ -1613,16 +1634,40 @@ with:
|
|||||||
```markdown
|
```markdown
|
||||||
## Garmin integration (direct wrapper, no MCP)
|
## Garmin integration (direct wrapper, no MCP)
|
||||||
|
|
||||||
`internal/garmin` spawns a small Python wrapper script (`internal/garmin/pyscript/wrapper.py`, embedded into the Go binary via `go:embed` — see `docs/superpowers/specs/2026-07-25-garmin-direct-wrapper-design.md`) that imports `garminconnect` directly and speaks a minimal newline-delimited-JSON protocol over stdin/stdout: `{"id","cmd","params"}` in, `{"id","result"}` or `{"id","error"}` out. `cmd` is `authenticate`, `complete_mfa`, or a fully generic `call` (`{"method": "<any garminconnect.Garmin method>", "args": {...}}`) — there's no MCP layer, and the separate `mcp-garmin` repo this used to depend on is retired. Key things learned the hard way, that would otherwise get rediscovered:
|
`internal/garmin` spawns a small Python wrapper script (`internal/garmin/pyscript/wrapper.py`, embedded into the Go
|
||||||
|
binary via `go:embed` — see `docs/superpowers/specs/2026-07-25-garmin-direct-wrapper-design.md`) that imports
|
||||||
|
`garminconnect` directly and speaks a minimal newline-delimited-JSON protocol over stdin/stdout: `{"id","cmd","params"}`
|
||||||
|
in, `{"id","result"}` or `{"id","error"}` out. `cmd` is `authenticate`, `complete_mfa`, or a fully generic `call`
|
||||||
|
(`{"method": "<any garminconnect.Garmin method>", "args": {...}}`) — there's no MCP layer, and the separate `mcp-garmin`
|
||||||
|
repo this used to depend on is retired. Key things learned the hard way, that would otherwise get rediscovered:
|
||||||
|
|
||||||
- **`authenticate`/`complete_mfa` return structured JSON** (`{"status": "success"|"mfa_required"|"failed", "message": "..."}`), not plain strings — `internal/garmin/client.go` maps `status` directly to `AuthStatus`, no string pattern-matching.
|
- **`authenticate`/`complete_mfa` return structured JSON**
|
||||||
- **The 10s "MFA required" timeout in `wrapper.py`'s `authenticate` handler is a false-positive trap.** A login that's merely slow (e.g. Garmin/Cloudflare rate-limiting) looks identical to a real MFA challenge unless you check whether `prompt_mfa()` was actually invoked (`wrapper.py` logs this to stderr for exactly this reason).
|
(`{"status": "success"|"mfa_required"|"failed", "message": "..."}`), not plain strings — `internal/garmin/client.go`
|
||||||
- **`wrapper.py` persists Garmin sessions** via a `GARMIN_TOKENSTORE` env var passed to `Garmin.login(tokenstore=...)` — without this, every process start does a full SSO login, which is what trips Garmin's rate limiting under repeated testing. Since geniusrun is multi-tenant, `GARMIN_TOKENSTORE` (`config.GarminTokenStoreRoot`) is a **root directory**, not a single session path: `api.Server.garminFor` namespaces each user's subprocess under `{root}/{userID}` so concurrent users never share a Garmin session. If unset, `config.Load()` defaults it to a `garmin-tokenstores` directory next to the DB file (logged at startup) rather than leaving per-user namespacing silently skipped.
|
maps `status` directly to `AuthStatus`, no string pattern-matching.
|
||||||
- **`activityId` is a large int64** — never round-trip it through `float64`/generic `map[string]any` JSON decoding, or it corrupts into scientific notation. Decode into typed structs (`garmin.Activity`, not `map[string]any`).
|
- **The 10s "MFA required" timeout in `wrapper.py`'s `authenticate` handler is a false-positive trap.** A login that's
|
||||||
- **`get_activity_details` returns raw per-second telemetry** (`activityDetailMetrics` + `metricDescriptors`), not lap/split summaries. Its `metricDescriptors` index-to-field mapping is **not stable across activities/devices** — `garmin.ExtractSamples` always resolves fields by descriptor key, never by fixed array position.
|
merely slow (e.g. Garmin/Cloudflare rate-limiting) looks identical to a real MFA challenge unless you check whether
|
||||||
|
`prompt_mfa()` was actually invoked (`wrapper.py` logs this to stderr for exactly this reason).
|
||||||
|
- **`wrapper.py` persists Garmin sessions** via a `GARMIN_TOKENSTORE` env var passed to `Garmin.login(tokenstore=...)` —
|
||||||
|
without this, every process start does a full SSO login, which is what trips Garmin's rate limiting under repeated
|
||||||
|
testing. Since geniusrun is multi-tenant, `GARMIN_TOKENSTORE` (`config.GarminTokenStoreRoot`) is a **root directory**,
|
||||||
|
not a single session path: `api.Server.garminFor` namespaces each user's subprocess under `{root}/{userID}` so
|
||||||
|
concurrent users never share a Garmin session. If unset, `config.Load()` defaults it to a `.garmin` directory next to
|
||||||
|
the DB file (logged at startup) rather than leaving per-user namespacing silently skipped.
|
||||||
|
- **`activityId` is a large int64** — never round-trip it through `float64`/generic `map[string]any` JSON decoding, or
|
||||||
|
it corrupts into scientific notation. Decode into typed structs (`garmin.Activity`, not `map[string]any`).
|
||||||
|
- **`get_activity_details` returns raw per-second telemetry** (`activityDetailMetrics` + `metricDescriptors`), not
|
||||||
|
lap/split summaries. Its `metricDescriptors` index-to-field mapping is **not stable across activities/devices** —
|
||||||
|
`garmin.ExtractSamples` always resolves fields by descriptor key, never by fixed array position.
|
||||||
- **`get_activity_splits`** returns the actual lap/split summaries (`lapDTOs`).
|
- **`get_activity_splits`** returns the actual lap/split summaries (`lapDTOs`).
|
||||||
- **`get_workout_by_id`** returns a structured Garmin workout's flattened steps (target pace/HR per step); `internal/sync/mapping.go`'s `alignWorkoutTargets` zips these 1:1 against an activity's recorded laps (when the activity carries a `workout_id` and the lap count matches, tolerating exactly one extra trailing lap) to resolve each lap's expected pace/HR band.
|
- **`get_workout_by_id`** returns a structured Garmin workout's flattened steps (target pace/HR per step);
|
||||||
- Each user has their own lazily-built, cached `garmin.Client` (`api.Server.garminFor`, double-checked-locked, keyed by `userID`). Saving that user's profile calls `UpdateCredentials` on their own cached client instance, which resets only that client's "started" state and terminates only that user's already-spawned subprocess so the next call respawns fresh under the new credentials — no separate reconnect UI needed beyond the existing connect/MFA flow, and no risk of one user's credential change touching another's session.
|
`internal/sync/mapping.go`'s `alignWorkoutTargets` zips these 1:1 against an activity's recorded laps (when the
|
||||||
|
activity carries a `workout_id` and the lap count matches, tolerating exactly one extra trailing lap) to resolve each
|
||||||
|
lap's expected pace/HR band.
|
||||||
|
- Each user has their own lazily-built, cached `garmin.Client` (`api.Server.garminFor`, double-checked-locked, keyed by
|
||||||
|
`userID`). Saving that user's profile calls `UpdateCredentials` on their own cached client instance, which resets only
|
||||||
|
that client's "started" state and terminates only that user's already-spawned subprocess so the next call respawns
|
||||||
|
fresh under the new credentials — no separate reconnect UI needed beyond the existing connect/MFA flow, and no risk of
|
||||||
|
one user's credential change touching another's session.
|
||||||
```
|
```
|
||||||
|
|
||||||
- [ ] **Step 5: Verify the whole backend builds**
|
- [ ] **Step 5: Verify the whole backend builds**
|
||||||
@@ -1704,4 +1749,4 @@ EOF
|
|||||||
|
|
||||||
## Follow-up note (not a task in this plan)
|
## Follow-up note (not a task in this plan)
|
||||||
|
|
||||||
The standalone `mcp-garmin` repo (`/Users/kriss/Dev/scm/scm.vilanet.fr/kriss/mcp-garmin`) is superseded by `backend/internal/garmin/pyscript/` and can be archived once this plan lands — that's a separate-repo action for you to do directly (e.g. marking it archived on your SCM host), not something this plan's tasks touch.
|
The standalone `mcp-garmin` repo (`/Users/kriss/Dev/scm/scm.vilanet.fr/kriss/mcp-garmin`) is superseded by `../../../backend/internal/garmin/wrapper/` and can be archived once this plan lands — that's a separate-repo action for you to do directly (e.g. marking it archived on your SCM host), not something this plan's tasks touch.
|
||||||
|
|||||||
@@ -20,15 +20,15 @@
|
|||||||
### Task 1: Add `FrontendURL` to `internal/config`
|
### Task 1: Add `FrontendURL` to `internal/config`
|
||||||
|
|
||||||
**Files:**
|
**Files:**
|
||||||
- Modify: `backend/internal/config/config.go`
|
- Modify: `../../../backend/internal/config/envconfig.go`
|
||||||
- Modify: `backend/internal/config/config_test.go`
|
- Modify: `../../../backend/internal/config/envconfig_test.go`
|
||||||
|
|
||||||
**Interfaces:**
|
**Interfaces:**
|
||||||
- Produces: `Config.FrontendURL string` — set by `Load()`, defaults to `Config.PublicBaseURL` when `GENIUSRUN_FRONTEND_URL` is unset, trimmed of any trailing slash otherwise. Consumed by Task 2's `main.go` wiring.
|
- Produces: `Config.FrontendURL string` — set by `Load()`, defaults to `Config.PublicBaseURL` when `GENIUSRUN_FRONTEND_URL` is unset, trimmed of any trailing slash otherwise. Consumed by Task 2's `main.go` wiring.
|
||||||
|
|
||||||
- [ ] **Step 1: Write the failing tests**
|
- [ ] **Step 1: Write the failing tests**
|
||||||
|
|
||||||
Add to `backend/internal/config/config_test.go` (anywhere in the file, e.g. after `TestLoad_GarminPythonPathExplicitOverridesDefault`):
|
Add to `../../../backend/internal/config/envconfig_test.go` (anywhere in the file, e.g. after `TestLoad_GarminPythonPathExplicitOverridesDefault`):
|
||||||
|
|
||||||
```go
|
```go
|
||||||
func TestLoad_FrontendURLDefaultsToPublicBaseURL(t *testing.T) {
|
func TestLoad_FrontendURLDefaultsToPublicBaseURL(t *testing.T) {
|
||||||
@@ -65,7 +65,7 @@ Expected: compile failure — `Config.FrontendURL` doesn't exist yet.
|
|||||||
|
|
||||||
- [ ] **Step 3: Add the field and default logic**
|
- [ ] **Step 3: Add the field and default logic**
|
||||||
|
|
||||||
In `backend/internal/config/config.go`, add a new field right after `PublicBaseURL` in the `Config` struct:
|
In `../../../backend/internal/config/envconfig.go`, add a new field right after `PublicBaseURL` in the `Config` struct:
|
||||||
|
|
||||||
```go
|
```go
|
||||||
// OIDC login gate (Keycloak). PublicBaseURL is this app's own externally
|
// OIDC login gate (Keycloak). PublicBaseURL is this app's own externally
|
||||||
@@ -104,7 +104,7 @@ Expected: all tests PASS, including the two new ones.
|
|||||||
- [ ] **Step 5: Commit**
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git add backend/internal/config/config.go backend/internal/config/config_test.go
|
git add backend/internal/config/envconfig.go backend/internal/config/envconfig_test.go
|
||||||
git commit -m "$(cat <<'EOF'
|
git commit -m "$(cat <<'EOF'
|
||||||
feat(config): add GENIUSRUN_FRONTEND_URL, defaulting to PublicBaseURL
|
feat(config): add GENIUSRUN_FRONTEND_URL, defaulting to PublicBaseURL
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
@@ -2223,14 +2223,14 @@ EOF
|
|||||||
## Task 10: `internal/sync.Service` becomes per-user
|
## Task 10: `internal/sync.Service` becomes per-user
|
||||||
|
|
||||||
**Files:**
|
**Files:**
|
||||||
- Modify: `backend/internal/sync/service.go`
|
- Modify: `../../../backend/internal/garmin/sync.go`
|
||||||
- Modify: `backend/internal/sync/service_test.go`
|
- Modify: `../../../backend/internal/garmin/sync_test.go`
|
||||||
|
|
||||||
**Interfaces:**
|
**Interfaces:**
|
||||||
- Consumes: every scoped store method from Tasks 4-8.
|
- Consumes: every scoped store method from Tasks 4-8.
|
||||||
- Produces: `func NewService(g garmin.Client, db *store.DB, userID int64, cfg Config, now func() time.Time) *Service`. Every other `Service` method (`Backfill`, `IncrementalSync`, `FullSync`, `ResetAll`, `FillPendingDetails`, `ClassifyActivity`, `Progress`) keeps its exact current signature — the userID is baked into the `Service` instance at construction (one `Service` per logged-in user, per the design), so callers in `internal/api` (Tasks 13/15) never pass a userID to these methods directly, only to `NewService` itself.
|
- Produces: `func NewService(g garmin.Client, db *store.DB, userID int64, cfg Config, now func() time.Time) *Service`. Every other `Service` method (`Backfill`, `IncrementalSync`, `FullSync`, `ResetAll`, `FillPendingDetails`, `ClassifyActivity`, `Progress`) keeps its exact current signature — the userID is baked into the `Service` instance at construction (one `Service` per logged-in user, per the design), so callers in `internal/api` (Tasks 13/15) never pass a userID to these methods directly, only to `NewService` itself.
|
||||||
|
|
||||||
- [ ] **Step 1: Add the `userID` field and thread it through every store call in `backend/internal/sync/service.go`**
|
- [ ] **Step 1: Add the `userID` field and thread it through every store call in `../../../backend/internal/garmin/sync.go`**
|
||||||
|
|
||||||
Change the `Service` struct and `NewService`:
|
Change the `Service` struct and `NewService`:
|
||||||
|
|
||||||
@@ -2269,7 +2269,7 @@ Then, in every remaining method, prefix `s.userID` as the new argument to every
|
|||||||
- `fillActivityDetails`: `s.db.SetActivityWorkout(ctx, a.ID, ...)` → `s.db.SetActivityWorkout(ctx, s.userID, a.ID, ...)`; `s.db.ReplaceActivitySamples(ctx, a.ID, ...)` → `s.db.ReplaceActivitySamples(ctx, s.userID, a.ID, ...)`; `s.db.ReplaceLaps(ctx, a.ID, ...)` → `s.db.ReplaceLaps(ctx, s.userID, a.ID, ...)`; `s.db.SetActivityDetails(ctx, a.ID, ...)` → `s.db.SetActivityDetails(ctx, s.userID, a.ID, ...)`; `s.db.SetActivitySplitsFetched(ctx, a.ID)` → `s.db.SetActivitySplitsFetched(ctx, s.userID, a.ID)`.
|
- `fillActivityDetails`: `s.db.SetActivityWorkout(ctx, a.ID, ...)` → `s.db.SetActivityWorkout(ctx, s.userID, a.ID, ...)`; `s.db.ReplaceActivitySamples(ctx, a.ID, ...)` → `s.db.ReplaceActivitySamples(ctx, s.userID, a.ID, ...)`; `s.db.ReplaceLaps(ctx, a.ID, ...)` → `s.db.ReplaceLaps(ctx, s.userID, a.ID, ...)`; `s.db.SetActivityDetails(ctx, a.ID, ...)` → `s.db.SetActivityDetails(ctx, s.userID, a.ID, ...)`; `s.db.SetActivitySplitsFetched(ctx, a.ID)` → `s.db.SetActivitySplitsFetched(ctx, s.userID, a.ID)`.
|
||||||
- `ClassifyActivity`: `s.db.GetActivity(ctx, activityID)` → `s.db.GetActivity(ctx, s.userID, activityID)`; `s.db.LapsForActivity(ctx, activityID)` → `s.db.LapsForActivity(ctx, s.userID, activityID)`; `s.db.ListWorkoutKinds(ctx, true)` → `s.db.ListWorkoutKinds(ctx, s.userID, true)`; `s.db.GetProfile(ctx)` → `s.db.GetProfile(ctx, s.userID)`; `s.db.InsertKindAssignment(ctx, store.KindAssignment{...})` → `s.db.InsertKindAssignment(ctx, s.userID, store.KindAssignment{...})`.
|
- `ClassifyActivity`: `s.db.GetActivity(ctx, activityID)` → `s.db.GetActivity(ctx, s.userID, activityID)`; `s.db.LapsForActivity(ctx, activityID)` → `s.db.LapsForActivity(ctx, s.userID, activityID)`; `s.db.ListWorkoutKinds(ctx, true)` → `s.db.ListWorkoutKinds(ctx, s.userID, true)`; `s.db.GetProfile(ctx)` → `s.db.GetProfile(ctx, s.userID)`; `s.db.InsertKindAssignment(ctx, store.KindAssignment{...})` → `s.db.InsertKindAssignment(ctx, s.userID, store.KindAssignment{...})`.
|
||||||
|
|
||||||
- [ ] **Step 2: Fix `backend/internal/sync/service_test.go`'s `NewService` calls**
|
- [ ] **Step 2: Fix `../../../backend/internal/garmin/sync_test.go`'s `NewService` calls**
|
||||||
|
|
||||||
Every `NewService(m, db, Config{...}, fixedNow(...))` call in this file needs a `userID` inserted as the third argument. Add a shared helper near the top of the file (next to `openTestDB`/`fixedNow`):
|
Every `NewService(m, db, Config{...}, fixedNow(...))` call in this file needs a `userID` inserted as the third argument. Add a shared helper near the top of the file (next to `openTestDB`/`fixedNow`):
|
||||||
|
|
||||||
@@ -2355,7 +2355,7 @@ Run: `cd backend && gofmt -l internal/sync/`
|
|||||||
Expected: no output.
|
Expected: no output.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git add backend/internal/sync/service.go backend/internal/sync/service_test.go
|
git add backend/internal/sync/sync.go backend/internal/sync/sync_test.go
|
||||||
git commit -m "$(cat <<'EOF'
|
git commit -m "$(cat <<'EOF'
|
||||||
sync: scope Service to one user per instance
|
sync: scope Service to one user per instance
|
||||||
|
|
||||||
@@ -2372,12 +2372,12 @@ EOF
|
|||||||
## Task 11: `internal/config` additions
|
## Task 11: `internal/config` additions
|
||||||
|
|
||||||
**Files:**
|
**Files:**
|
||||||
- Modify: `backend/internal/config/config.go`
|
- Modify: `../../../backend/internal/config/envconfig.go`
|
||||||
|
|
||||||
**Interfaces:**
|
**Interfaces:**
|
||||||
- Produces: `Config.GarminTokenStoreRoot` (renamed from `GarminTokenStore`, same env var `GARMIN_TOKENSTORE`, now holds a root directory rather than a single path); `Config.LegacyOwnerOIDCSub` (new, optional, env var `GENIUSRUN_LEGACY_OWNER_OIDC_SUB`). Task 13's `main.go` wiring consumes both.
|
- Produces: `Config.GarminTokenStoreRoot` (renamed from `GarminTokenStore`, same env var `GARMIN_TOKENSTORE`, now holds a root directory rather than a single path); `Config.LegacyOwnerOIDCSub` (new, optional, env var `GENIUSRUN_LEGACY_OWNER_OIDC_SUB`). Task 13's `main.go` wiring consumes both.
|
||||||
|
|
||||||
- [ ] **Step 1: Rename `GarminTokenStore` → `GarminTokenStoreRoot` and add `LegacyOwnerOIDCSub`**
|
- [ ] **Step 1: Rename `GarminTokenStore` → `TokenStoreRoot` and add `LegacyOwnerOIDCSub`**
|
||||||
|
|
||||||
In the `Config` struct, change:
|
In the `Config` struct, change:
|
||||||
|
|
||||||
@@ -2427,7 +2427,7 @@ Run: `cd backend && gofmt -l internal/config/`
|
|||||||
Expected: no output.
|
Expected: no output.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git add backend/internal/config/config.go
|
git add backend/internal/config/envconfig.go
|
||||||
git commit -m "$(cat <<'EOF'
|
git commit -m "$(cat <<'EOF'
|
||||||
config: add per-user Garmin token store root and legacy-owner bootstrap var
|
config: add per-user Garmin token store root and legacy-owner bootstrap var
|
||||||
|
|
||||||
@@ -2442,17 +2442,17 @@ EOF
|
|||||||
## Task 12: User-resolution middleware + `POST /api/setup`
|
## Task 12: User-resolution middleware + `POST /api/setup`
|
||||||
|
|
||||||
**Files:**
|
**Files:**
|
||||||
- Create: `backend/internal/api/usercontext.go`
|
- Create: `../../../backend/internal/api/user.go`
|
||||||
- Create: `backend/internal/api/setup.go`
|
- Create: `backend/internal/api/setup.go`
|
||||||
- Modify: `backend/internal/api/session.go` (extend `sessionMeResponse`, `handleSessionMe`)
|
- Modify: `backend/internal/api/session.go` (extend `sessionMeResponse`, `handleSessionMe`)
|
||||||
- Create: `backend/internal/api/usercontext_test.go`
|
- Create: `../../../backend/internal/api/user_test.go`
|
||||||
- Create: `backend/internal/api/setup_test.go`
|
- Create: `backend/internal/api/setup_test.go`
|
||||||
|
|
||||||
**Interfaces:**
|
**Interfaces:**
|
||||||
- Consumes: `store.GetUserBySub`/`store.ProvisionUser` (Task 2), `auth.ClaimsFromContext` (existing).
|
- Consumes: `store.GetUserBySub`/`store.ProvisionUser` (Task 2), `auth.ClaimsFromContext` (existing).
|
||||||
- Produces: `func (s *Server) resolveUser(next http.Handler) http.Handler` (middleware); `func requireProvisionedUser(next http.Handler) http.Handler` (middleware); `func userFromContext(ctx context.Context) (resolvedUser, bool)`; `func userIDFromContext(ctx context.Context) int64`; `func (s *Server) handleSetup(w http.ResponseWriter, r *http.Request)`. Task 13 wires all of these into the router; every handler task (14/15) calls `userIDFromContext`.
|
- Produces: `func (s *Server) resolveUser(next http.Handler) http.Handler` (middleware); `func requireProvisionedUser(next http.Handler) http.Handler` (middleware); `func userFromContext(ctx context.Context) (resolvedUser, bool)`; `func userIDFromContext(ctx context.Context) int64`; `func (s *Server) handleSetup(w http.ResponseWriter, r *http.Request)`. Task 13 wires all of these into the router; every handler task (14/15) calls `userIDFromContext`.
|
||||||
|
|
||||||
- [ ] **Step 1: Write the failing tests in `backend/internal/api/usercontext_test.go`**
|
- [ ] **Step 1: Write the failing tests in `../../../backend/internal/api/user_test.go`**
|
||||||
|
|
||||||
Since `auth`'s session context key is unexported, this test drives the real chain (`auth.RequireSession` then `s.resolveUser`) via a minted session cookie — the same `doJSON` helper `api_test.go` already uses for every other handler test — rather than trying to poke the context directly.
|
Since `auth`'s session context key is unexported, this test drives the real chain (`auth.RequireSession` then `s.resolveUser`) via a minted session cookie — the same `doJSON` helper `api_test.go` already uses for every other handler test — rather than trying to poke the context directly.
|
||||||
|
|
||||||
@@ -2521,7 +2521,7 @@ func TestRequireProvisionedUser_RejectsWhenNoUserResolved(t *testing.T) {
|
|||||||
Run: `cd backend && go vet ./internal/api/...`
|
Run: `cd backend && go vet ./internal/api/...`
|
||||||
Expected: FAIL — `s.resolveUser`/`userFromContext`/`requireProvisionedUser` undefined.
|
Expected: FAIL — `s.resolveUser`/`userFromContext`/`requireProvisionedUser` undefined.
|
||||||
|
|
||||||
- [ ] **Step 3: Write `backend/internal/api/usercontext.go`**
|
- [ ] **Step 3: Write `../../../backend/internal/api/user.go`**
|
||||||
|
|
||||||
```go
|
```go
|
||||||
package api
|
package api
|
||||||
@@ -2790,7 +2790,7 @@ Run: `cd backend && gofmt -l internal/api/`
|
|||||||
Expected: no output.
|
Expected: no output.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git add backend/internal/api/usercontext.go backend/internal/api/usercontext_test.go backend/internal/api/setup.go backend/internal/api/setup_test.go backend/internal/api/session.go backend/internal/api/server.go
|
git add backend/internal/api/user.go backend/internal/api/user_test.go backend/internal/api/setup.go backend/internal/api/setup_test.go backend/internal/api/session.go backend/internal/api/server.go
|
||||||
git commit -m "$(cat <<'EOF'
|
git commit -m "$(cat <<'EOF'
|
||||||
api: add user-resolution middleware and POST /api/setup
|
api: add user-resolution middleware and POST /api/setup
|
||||||
|
|
||||||
@@ -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`, `auth.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,12 +3325,12 @@ EOF
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Task 14: Thread `userID` into `profile.go`, `kinds.go`, `auth.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`
|
||||||
- Modify: `backend/internal/api/kinds.go`
|
- Modify: `backend/internal/api/kinds.go`
|
||||||
- Modify: `backend/internal/api/auth.go`
|
- Modify: `../../../backend/internal/api/garmin.go`
|
||||||
- Modify: `backend/internal/api/progression.go`
|
- Modify: `backend/internal/api/progression.go`
|
||||||
- Modify: `backend/internal/api/api_test.go` (no new call-site changes expected beyond what Task 13 already made — this task only touches handler bodies, not test bodies, since the HTTP-level test assertions are unchanged)
|
- Modify: `backend/internal/api/api_test.go` (no new call-site changes expected beyond what Task 13 already made — this task only touches handler bodies, not test bodies, since the HTTP-level test assertions are unchanged)
|
||||||
|
|
||||||
@@ -3508,7 +3508,7 @@ func (s *Server) handleUpdateWorkoutKind(w http.ResponseWriter, r *http.Request)
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
- [ ] **Step 3: Update `backend/internal/api/auth.go`**
|
- [ ] **Step 3: Update `../../../backend/internal/api/garmin.go`**
|
||||||
|
|
||||||
Keep `authResponse`, `authStatusString` unchanged; replace `recordAuthResult` and the three handlers:
|
Keep `authResponse`, `authStatusString` unchanged; replace `recordAuthResult` and the three handlers:
|
||||||
|
|
||||||
@@ -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/auth.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
|
||||||
|
|
||||||
|
|||||||
822
docs/superpowers/plans/2026-07-26-improve-first-connection.md
Normal file
822
docs/superpowers/plans/2026-07-26-improve-first-connection.md
Normal file
@@ -0,0 +1,822 @@
|
|||||||
|
# Improve First Connection Page Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Make connecting a Garmin account a mandatory, persisted gate during onboarding (and on every subsequent login until it succeeds), instead of an optional step left to the Profile page.
|
||||||
|
|
||||||
|
**Architecture:** Add a nullable `garmin_connected_at` timestamp to `profile`, set once on the first successful Garmin authentication (`handleGarminAuthLogin`/`handleGarminAuthMFA`). Expose it via `GET /api/session/me`. `LoginGate.tsx` becomes a three-way fork (`!has_profile` → `CreateProfile`, `has_profile && !garmin_connected` → new `ConnectGarmin`, else → `App`), so a user who abandons the connect step re-enters at `ConnectGarmin` on their next login rather than skipping straight into the app.
|
||||||
|
|
||||||
|
**Tech Stack:** Go (`database/sql` via `modernc.org/sqlite`, chi router), React + TypeScript (Vite), no frontend test framework.
|
||||||
|
|
||||||
|
## Global Constraints
|
||||||
|
|
||||||
|
- `internal/store/schema.sql` is edited directly, never migrated (pre-production app, see `CLAUDE.md`).
|
||||||
|
- Every store/API method touching per-user data takes an explicit `userID` and filters by it.
|
||||||
|
- Cross-user isolation is tested adversarially (two real users, real IDs), not just checked for non-collision.
|
||||||
|
- After the `schema.sql` change, regenerate `docs/DATABASE.md` via `go run ./cmd/dumpschema` (from `backend/`).
|
||||||
|
- `gofmt -l .` must report nothing; `go vet ./...` and `go build ./...` must pass.
|
||||||
|
- No frontend test suite exists — frontend changes are verified via `npm run build`, `npm run lint`, and a manual browser check.
|
||||||
|
- This local dev DB predates schema changes made this session (`ON DELETE CASCADE`) but was already fixed up in place. A brand-new nullable column (`garmin_connected_at TEXT`) needs no such fix-up: SQLite's idempotent-skip `applySchema` never re-applies `schema.sql` to an existing DB file, but adding a nullable column to `profile`'s Go-side handling doesn't require the on-disk table to already have it selected correctly... **actually it does** — `GetProfile`'s `SELECT` will fail against the live dev DB once it lists `garmin_connected_at` in `profileColumns`, since that column doesn't exist there yet. Task 1 includes a real `ALTER TABLE` against the live dev DB (safe, additive, no rebuild needed this time) to keep manual testing working.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: Persist `garmin_connected_at` in the store layer
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `backend/internal/store/schema.sql` (add column to the `profile` table)
|
||||||
|
- 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`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Produces: `Profile.GarminConnectedAt *string` (nil until first successful Garmin auth); `func (db *DB) MarkGarminConnected(ctx context.Context, userID int64) error`.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing tests**
|
||||||
|
|
||||||
|
Add to `../../../backend/internal/store/profiles_test.go`:
|
||||||
|
|
||||||
|
```go
|
||||||
|
func TestMarkGarminConnected_SetsTimestampOnceOnFirstCall(t *testing.T) {
|
||||||
|
db := openTestDB(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
userID, err := db.ProvisionUser(ctx, "test-sub", "Test")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ProvisionUser: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
before, err := db.GetProfile(ctx, userID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetProfile: %v", err)
|
||||||
|
}
|
||||||
|
if before.GarminConnectedAt != nil {
|
||||||
|
t.Fatalf("expected a fresh profile to have nil GarminConnectedAt, got %v", *before.GarminConnectedAt)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := db.MarkGarminConnected(ctx, userID); err != nil {
|
||||||
|
t.Fatalf("MarkGarminConnected: %v", err)
|
||||||
|
}
|
||||||
|
afterFirst, err := db.GetProfile(ctx, userID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetProfile after first mark: %v", err)
|
||||||
|
}
|
||||||
|
if afterFirst.GarminConnectedAt == nil {
|
||||||
|
t.Fatal("expected GarminConnectedAt to be set after MarkGarminConnected")
|
||||||
|
}
|
||||||
|
firstValue := *afterFirst.GarminConnectedAt
|
||||||
|
|
||||||
|
// A second call must not change the recorded first-connection time.
|
||||||
|
if err := db.MarkGarminConnected(ctx, userID); err != nil {
|
||||||
|
t.Fatalf("MarkGarminConnected (second call): %v", err)
|
||||||
|
}
|
||||||
|
afterSecond, err := db.GetProfile(ctx, userID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetProfile after second mark: %v", err)
|
||||||
|
}
|
||||||
|
if afterSecond.GarminConnectedAt == nil || *afterSecond.GarminConnectedAt != firstValue {
|
||||||
|
t.Fatalf("GarminConnectedAt changed on second call: first=%q second=%v", firstValue, afterSecond.GarminConnectedAt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Add to `backend/internal/store/isolation_test.go`:
|
||||||
|
|
||||||
|
```go
|
||||||
|
// TestIsolation_MarkGarminConnectedNeverLeaksAcrossUsers confirms marking
|
||||||
|
// one user's Garmin connection never sets another user's flag.
|
||||||
|
func TestIsolation_MarkGarminConnectedNeverLeaksAcrossUsers(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)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := db.MarkGarminConnected(ctx, userA); err != nil {
|
||||||
|
t.Fatalf("MarkGarminConnected(a): %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
profileB, err := db.GetProfile(ctx, userB)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetProfile(b): %v", err)
|
||||||
|
}
|
||||||
|
if profileB.GarminConnectedAt != nil {
|
||||||
|
t.Fatal("userA's MarkGarminConnected call leaked into userB's profile")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run tests to verify they fail**
|
||||||
|
|
||||||
|
Run: `cd backend && go test ./internal/store/... -run 'TestMarkGarminConnected|TestIsolation_MarkGarminConnected' -v`
|
||||||
|
Expected: FAIL — `db.MarkGarminConnected undefined` / `Profile has no field GarminConnectedAt`.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Add the column to `schema.sql`**
|
||||||
|
|
||||||
|
In `backend/internal/store/schema.sql`, in the `profile` table (right after `garmin_password`), change:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
garmin_password TEXT NOT NULL DEFAULT '',
|
||||||
|
rolling_window_days INTEGER NOT NULL DEFAULT 90,
|
||||||
|
```
|
||||||
|
|
||||||
|
to:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
garmin_password TEXT NOT NULL DEFAULT '',
|
||||||
|
-- Set once, the first time this user successfully authenticates with
|
||||||
|
-- Garmin (handleAuthLogin/handleAuthMFA). Null means never connected --
|
||||||
|
-- the login gate uses this (not the in-memory auth status, which
|
||||||
|
-- resets on restart) to decide whether a returning user must
|
||||||
|
-- reconnect before entering the app.
|
||||||
|
garmin_connected_at TEXT,
|
||||||
|
rolling_window_days INTEGER NOT NULL DEFAULT 90,
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Update `Profile`, `profileColumns`, and `GetProfile` in `profiles.go`**
|
||||||
|
|
||||||
|
Change the struct (add the field right after `GarminPassword`):
|
||||||
|
|
||||||
|
```go
|
||||||
|
Name string
|
||||||
|
GarminEmail string
|
||||||
|
GarminPassword string
|
||||||
|
// GarminConnectedAt is nil until this user's first successful Garmin
|
||||||
|
// authentication (see store.MarkGarminConnected) -- the login gate uses
|
||||||
|
// it, not UpdateProfile, so it's deliberately excluded from
|
||||||
|
// UpdateProfile's SET clause below and can only ever move from nil to
|
||||||
|
// set, never reset by a normal profile save.
|
||||||
|
GarminConnectedAt *string
|
||||||
|
RollingWindowDays int
|
||||||
|
```
|
||||||
|
|
||||||
|
Change `profileColumns`:
|
||||||
|
|
||||||
|
```go
|
||||||
|
const profileColumns = `
|
||||||
|
name, 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,
|
||||||
|
warmup_minutes, cooldown_minutes,
|
||||||
|
min_representative_pace_sec_per_km, min_representative_time_seconds,
|
||||||
|
pace_color, heart_rate_color, warmup_color, effort_color, recovery_color, cooldown_color,
|
||||||
|
main_line_tint_pct, background_darken_pct, target_brighten_pct,
|
||||||
|
created_at, updated_at
|
||||||
|
`
|
||||||
|
```
|
||||||
|
|
||||||
|
Change `GetProfile`'s `Scan` call (add `&p.GarminConnectedAt` right after `&p.GarminPassword`):
|
||||||
|
|
||||||
|
```go
|
||||||
|
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,
|
||||||
|
&p.HRZone1MinPct, &p.HRZone1MaxPct, &p.HRZone2MinPct, &p.HRZone2MaxPct,
|
||||||
|
&p.HRZone3MinPct, &p.HRZone3MaxPct, &p.HRZone4MinPct, &p.HRZone4MaxPct,
|
||||||
|
&p.HRZone5MinPct, &p.HRZone5MaxPct,
|
||||||
|
&p.WarmupMinutes, &p.CooldownMinutes,
|
||||||
|
&p.MinRepresentativePaceSecPerKm, &p.MinRepresentativeTimeSeconds,
|
||||||
|
&p.PaceColor, &p.HeartRateColor, &p.WarmupColor, &p.EffortColor, &p.RecoveryColor, &p.CooldownColor,
|
||||||
|
&p.MainLineTintPct, &p.BackgroundDarkenPct, &p.TargetBrightenPct,
|
||||||
|
&p.CreatedAt, &p.UpdatedAt,
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
Do **not** touch `UpdateProfile` — `garmin_connected_at` must stay out of its `SET`/args list so a normal Profile-page save can never reset it.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Add `MarkGarminConnected`**
|
||||||
|
|
||||||
|
Append to `../../../backend/internal/store/profiles.go`:
|
||||||
|
|
||||||
|
```go
|
||||||
|
|
||||||
|
// MarkGarminConnected records the first time userID successfully
|
||||||
|
// 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)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("mark garmin connected for user %d: %w", userID, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 6: Run tests to verify they pass**
|
||||||
|
|
||||||
|
Run: `cd backend && go test ./internal/store/... -run 'TestMarkGarminConnected|TestIsolation_MarkGarminConnected' -v`
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 7: Run the full store test suite**
|
||||||
|
|
||||||
|
Run: `cd backend && go test ./internal/store/...`
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 8: Regenerate `docs/DATABASE.md`**
|
||||||
|
|
||||||
|
Run: `cd backend && go run ./cmd/dumpschema`
|
||||||
|
Expected: `docs/DATABASE.md` shows the new `garmin_connected_at` column in `profile`.
|
||||||
|
|
||||||
|
- [ ] **Step 9: Additively update the live local dev DB**
|
||||||
|
|
||||||
|
The real `backend/geniusrun.db` predates this column (schema.sql changes never retrofit an existing DB file — see `applySchema`'s idempotent skip in `db.go`). Unlike the earlier `ON DELETE CASCADE` fix, this is purely additive (a new nullable column), so a plain `ALTER TABLE` is sufficient — no rebuild-and-copy dance needed:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sqlite3 backend/geniusrun.db "ALTER TABLE profile ADD COLUMN garmin_connected_at TEXT;"
|
||||||
|
sqlite3 backend/geniusrun.db "SELECT sql FROM sqlite_master WHERE type='table' AND name='profile';" | grep garmin_connected_at
|
||||||
|
```
|
||||||
|
|
||||||
|
Confirm the backend isn't running before touching the file (`ps aux | grep geniusrund`), same care as before.
|
||||||
|
|
||||||
|
- [ ] **Step 10: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
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"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2: Wire `MarkGarminConnected` into the auth handlers and expose it via session/me
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `../../../backend/internal/api/garmin.go` (`recordAuthResult`, both call sites)
|
||||||
|
- Modify: `backend/internal/api/session.go` (`sessionMeResponse`, `handleSessionMe`)
|
||||||
|
- Modify: `backend/internal/api/api_test.go` (new tests)
|
||||||
|
- Modify: `backend/internal/api/isolation_test.go` (new adversarial test)
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: `store.DB.MarkGarminConnected(ctx, userID) error` (Task 1); `store.DB.GetProfile(ctx, userID) (store.Profile, error)` (existing).
|
||||||
|
- Produces: `sessionMeResponse.GarminConnected bool` (JSON `garmin_connected`).
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing tests**
|
||||||
|
|
||||||
|
Append to `backend/internal/api/api_test.go`:
|
||||||
|
|
||||||
|
```go
|
||||||
|
func TestSessionMe_ReportsGarminConnectedAfterSuccessfulAuth(t *testing.T) {
|
||||||
|
s, _, _ := newTestServer(t)
|
||||||
|
router := s.Router()
|
||||||
|
|
||||||
|
rec := doJSON(t, router, http.MethodGet, "/api/session/me", nil)
|
||||||
|
var before sessionMeResponse
|
||||||
|
unmarshalBody(t, rec, &before)
|
||||||
|
if before.GarminConnected {
|
||||||
|
t.Fatal("expected a fresh account to report garmin_connected=false")
|
||||||
|
}
|
||||||
|
|
||||||
|
rec = doJSON(t, router, http.MethodPost, "/api/auth/login", nil) // mock.Client defaults to AuthSuccess
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("auth/login status = %d, body = %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
rec = doJSON(t, router, http.MethodGet, "/api/session/me", nil)
|
||||||
|
var after sessionMeResponse
|
||||||
|
unmarshalBody(t, rec, &after)
|
||||||
|
if !after.GarminConnected {
|
||||||
|
t.Fatal("expected garmin_connected=true after a successful auth")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSessionMe_GarminConnectedStaysFalseOnMFARequiredOrFailed(t *testing.T) {
|
||||||
|
s, _, userID := newTestServer(t)
|
||||||
|
client, err := s.garminFor(context.Background(), userID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("garminFor: %v", err)
|
||||||
|
}
|
||||||
|
mockClient, ok := client.(*mock.Client)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("expected *mock.Client, got %T", client)
|
||||||
|
}
|
||||||
|
mockClient.AuthResults = []garmin.AuthResult{{Status: garmin.AuthMFARequired, Message: "MFA required"}}
|
||||||
|
|
||||||
|
router := s.Router()
|
||||||
|
rec := doJSON(t, router, http.MethodPost, "/api/auth/login", nil)
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("auth/login status = %d, body = %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
rec = doJSON(t, router, http.MethodGet, "/api/session/me", nil)
|
||||||
|
var me sessionMeResponse
|
||||||
|
unmarshalBody(t, rec, &me)
|
||||||
|
if me.GarminConnected {
|
||||||
|
t.Fatal("expected garmin_connected=false after mfa_required")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Append to `backend/internal/api/isolation_test.go`:
|
||||||
|
|
||||||
|
```go
|
||||||
|
// TestIsolation_GarminConnectedNeverLeaksAcrossUsers confirms one user's
|
||||||
|
// successful Garmin auth never flips garmin_connected for another user.
|
||||||
|
func TestIsolation_GarminConnectedNeverLeaksAcrossUsers(t *testing.T) {
|
||||||
|
s, db, _ := newTestServer(t) // provisions "test-user" (userA)
|
||||||
|
if _, err := db.ProvisionUser(newCtx(), "user-b", "B"); err != nil {
|
||||||
|
t.Fatalf("ProvisionUser(b): %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
router := s.Router()
|
||||||
|
rec := doJSON(t, router, http.MethodPost, "/api/auth/login", nil) // as userA
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("auth/login status = %d, body = %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
rec = doJSONAs(t, router, "user-b", http.MethodGet, "/api/session/me", nil)
|
||||||
|
var meB sessionMeResponse
|
||||||
|
unmarshalBody(t, rec, &meB)
|
||||||
|
if meB.GarminConnected {
|
||||||
|
t.Fatal("userA's successful Garmin auth leaked into userB's garmin_connected flag")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run tests to verify they fail**
|
||||||
|
|
||||||
|
Run: `cd backend && go test ./internal/api/... -run 'TestSessionMe_ReportsGarminConnected|TestSessionMe_GarminConnectedStaysFalse|TestIsolation_GarminConnected' -v`
|
||||||
|
Expected: FAIL — `sessionMeResponse` has no field `GarminConnected`.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Update `recordAuthResult` in `garmin.go`**
|
||||||
|
|
||||||
|
Change the imports:
|
||||||
|
|
||||||
|
```go
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"geniusrun/backend/internal/garmin"
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
Change `recordAuthResult` and both call sites:
|
||||||
|
|
||||||
|
```go
|
||||||
|
// recordAuthResult updates the in-memory auth status/message for userID,
|
||||||
|
// and -- on a successful authentication -- persists that this account has
|
||||||
|
// connected to Garmin at least once (store.MarkGarminConnected), which is
|
||||||
|
// what the login gate actually checks (session cookie's in-memory auth
|
||||||
|
// status resets on every backend restart; this doesn't).
|
||||||
|
func (s *Server) recordAuthResult(ctx context.Context, userID int64, res garmin.AuthResult) {
|
||||||
|
s.mu.Lock()
|
||||||
|
s.userAuthStatus[userID] = res.Status
|
||||||
|
s.userAuthMessage[userID] = res.Message
|
||||||
|
s.mu.Unlock()
|
||||||
|
|
||||||
|
if res.Status == garmin.AuthSuccess {
|
||||||
|
if err := s.DB.MarkGarminConnected(ctx, userID); err != nil {
|
||||||
|
log.Printf("api: mark garmin connected for user %d: %v", userID, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
In `handleGarminAuthLogin`, change `s.recordAuthResult(userID, res)` to `s.recordAuthResult(r.Context(), userID, res)`.
|
||||||
|
In `handleGarminAuthMFA`, change `s.recordAuthResult(userID, res)` to `s.recordAuthResult(r.Context(), userID, res)`.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Update `sessionMeResponse` and `handleSessionMe` in `session.go`**
|
||||||
|
|
||||||
|
Change:
|
||||||
|
|
||||||
|
```go
|
||||||
|
type sessionMeResponse struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Email string `json:"email"`
|
||||||
|
HasProfile bool `json:"has_profile"`
|
||||||
|
DisplayName string `json:"display_name,omitempty"`
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
to:
|
||||||
|
|
||||||
|
```go
|
||||||
|
type sessionMeResponse struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Email string `json:"email"`
|
||||||
|
HasProfile bool `json:"has_profile"`
|
||||||
|
DisplayName string `json:"display_name,omitempty"`
|
||||||
|
GarminConnected bool `json:"garmin_connected"`
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Change `handleSessionMe`:
|
||||||
|
|
||||||
|
```go
|
||||||
|
func (s *Server) handleSessionMe(w http.ResponseWriter, r *http.Request) {
|
||||||
|
claims, ok := auth.ClaimsFromContext(r.Context())
|
||||||
|
if !ok {
|
||||||
|
// Unreachable in practice -- RequireSession already 401s before this
|
||||||
|
// handler runs -- but fail closed rather than panic if that ever changes.
|
||||||
|
writeError(w, http.StatusUnauthorized, "not authenticated")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
resp := sessionMeResponse{Name: claims.Name, Email: claims.Email}
|
||||||
|
if u, found := userFromContext(r.Context()); found {
|
||||||
|
resp.HasProfile = true
|
||||||
|
resp.DisplayName = u.DisplayName
|
||||||
|
profile, err := s.DB.GetProfile(r.Context(), u.ID)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
resp.GarminConnected = profile.GarminConnectedAt != nil
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, resp)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: Run tests to verify they pass**
|
||||||
|
|
||||||
|
Run: `cd backend && go test ./internal/api/... -run 'TestSessionMe_ReportsGarminConnected|TestSessionMe_GarminConnectedStaysFalse|TestIsolation_GarminConnected' -v`
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 6: Run the full backend test suite**
|
||||||
|
|
||||||
|
Run: `cd backend && go build ./... && go vet ./... && gofmt -l . && go test ./...`
|
||||||
|
Expected: `gofmt -l .` empty; everything else passes.
|
||||||
|
|
||||||
|
- [ ] **Step 7: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add backend/internal/api/garmin.go backend/internal/api/session.go backend/internal/api/api_test.go backend/internal/api/isolation_test.go
|
||||||
|
git commit -m "feat(api): persist and expose garmin_connected on successful auth"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 3: Frontend — three-way login gate and the new ConnectGarmin screen
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `frontend/src/types/api.ts` (`SessionInfo`)
|
||||||
|
- Create: `frontend/src/ConnectGarmin.tsx`
|
||||||
|
- Modify: `frontend/src/CreateProfile.tsx` (add Log out link)
|
||||||
|
- Modify: `frontend/src/CreateProfile.css` (one new class, shared with `ConnectGarmin.tsx`)
|
||||||
|
- Modify: `frontend/src/LoginGate.tsx` (three-way fork)
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: `api.getProfile()`, `api.updateProfile(profile)`, `api.login()`, `api.submitMFA(code)` (all existing, unchanged signatures); `SessionInfo.garmin_connected: boolean` (new).
|
||||||
|
- Produces: `ConnectGarmin({ onConnected: () => void })`, a React component.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add `garmin_connected` to `SessionInfo`**
|
||||||
|
|
||||||
|
In `frontend/src/types/api.ts`, change:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export interface SessionInfo {
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
has_profile: boolean;
|
||||||
|
display_name?: string;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
to:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export interface SessionInfo {
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
has_profile: boolean;
|
||||||
|
display_name?: string;
|
||||||
|
garmin_connected: boolean;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Add a shared status-message class to `CreateProfile.css`**
|
||||||
|
|
||||||
|
Append to `frontend/src/CreateProfile.css`:
|
||||||
|
|
||||||
|
```css
|
||||||
|
.create-profile-message {
|
||||||
|
margin: 0;
|
||||||
|
color: #9ca3af;
|
||||||
|
}
|
||||||
|
|
||||||
|
.create-profile-mfa {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.75rem;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 320px;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Create `frontend/src/ConnectGarmin.tsx`**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { useState } from "react";
|
||||||
|
import { api, BASE_URL } from "./api/client";
|
||||||
|
import "./CreateProfile.css";
|
||||||
|
import type { AuthResponse } from "./types/api";
|
||||||
|
|
||||||
|
// Shown after CreateProfile (or on any later login, until it succeeds --
|
||||||
|
// see LoginGate) since connecting Garmin is mandatory: the account exists
|
||||||
|
// but this is the last gate before the main app. Deliberately not a reuse
|
||||||
|
// of GarminConnection.tsx (the Profile page's version), which also renders
|
||||||
|
// Sync now/Disconnect/Reset all -- none of which make sense here.
|
||||||
|
export function ConnectGarmin({ onConnected }: { onConnected: () => void }) {
|
||||||
|
const [email, setEmail] = useState("");
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
|
const [code, setCode] = useState("");
|
||||||
|
const [auth, setAuth] = useState<AuthResponse | null>(null);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
async function connect(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!email.trim() || !password) {
|
||||||
|
setError("Please enter your Garmin email and password.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setBusy(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const profile = await api.getProfile();
|
||||||
|
await api.updateProfile({ ...profile, GarminEmail: email.trim(), GarminPassword: password });
|
||||||
|
setAuth(await api.login());
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : "Something went wrong, please try again.");
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitMFA() {
|
||||||
|
if (!code.trim()) return;
|
||||||
|
setBusy(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
setAuth(await api.submitMFA(code.trim()));
|
||||||
|
setCode("");
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : "Something went wrong, please try again.");
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const status = auth?.status;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="create-profile">
|
||||||
|
<h1>🧞♀️ Connect your Garmin account</h1>
|
||||||
|
<p>geniusrun needs your Garmin credentials to sync your activities.</p>
|
||||||
|
|
||||||
|
{status !== "authenticated" && status !== "mfa_required" && (
|
||||||
|
<form onSubmit={connect}>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Garmin email"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
disabled={busy}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
placeholder="Garmin password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
disabled={busy}
|
||||||
|
/>
|
||||||
|
<button type="submit" disabled={busy}>
|
||||||
|
{busy ? "Connecting…" : "Connect"}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{status === "mfa_required" && (
|
||||||
|
<div className="create-profile-mfa">
|
||||||
|
<input
|
||||||
|
placeholder="MFA code"
|
||||||
|
value={code}
|
||||||
|
onChange={(e) => setCode(e.target.value)}
|
||||||
|
onKeyDown={(e) => e.key === "Enter" && submitMFA()}
|
||||||
|
disabled={busy}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<button type="button" disabled={busy} onClick={submitMFA}>
|
||||||
|
Submit code
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{status === "authenticated" && (
|
||||||
|
<>
|
||||||
|
<p className="create-profile-message">Connected to Garmin.</p>
|
||||||
|
<button type="button" onClick={onConnected}>
|
||||||
|
Continue to geniusrun
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{auth?.message && status !== "authenticated" && <p className="create-profile-message">{auth.message}</p>}
|
||||||
|
{error && <p className="create-profile-error">{error}</p>}
|
||||||
|
|
||||||
|
<form method="post" action={`${BASE_URL}/api/session/logout`}>
|
||||||
|
<button type="submit" className="logout-link">
|
||||||
|
Log out
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Add a Log out link to `CreateProfile.tsx`**
|
||||||
|
|
||||||
|
Change:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
{error && <p className="create-profile-error">{error}</p>}
|
||||||
|
<button type="submit" disabled={submitting}>
|
||||||
|
{submitting ? "Creating…" : "Continue"}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
to:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
{error && <p className="create-profile-error">{error}</p>}
|
||||||
|
<button type="submit" disabled={submitting}>
|
||||||
|
{submitting ? "Creating…" : "Continue"}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
<form method="post" action={`${BASE_URL}/api/session/logout`}>
|
||||||
|
<button type="submit" className="logout-link">
|
||||||
|
Log out
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
And change the import line at the top of `CreateProfile.tsx`:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { api } from "./api/client";
|
||||||
|
```
|
||||||
|
|
||||||
|
to:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { api, BASE_URL } from "./api/client";
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: Wire the three-way fork into `LoginGate.tsx`**
|
||||||
|
|
||||||
|
Change the imports:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { api, BASE_URL } from "./api/client";
|
||||||
|
import "./LoginGate.css";
|
||||||
|
import App from "./App";
|
||||||
|
import { CreateProfile } from "./CreateProfile";
|
||||||
|
import type { SessionInfo } from "./types/api";
|
||||||
|
```
|
||||||
|
|
||||||
|
to:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { api, BASE_URL } from "./api/client";
|
||||||
|
import "./LoginGate.css";
|
||||||
|
import App from "./App";
|
||||||
|
import { ConnectGarmin } from "./ConnectGarmin";
|
||||||
|
import { CreateProfile } from "./CreateProfile";
|
||||||
|
import type { SessionInfo } from "./types/api";
|
||||||
|
```
|
||||||
|
|
||||||
|
Change the final part of the component body:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
if (!session!.has_profile) {
|
||||||
|
return (
|
||||||
|
<CreateProfile
|
||||||
|
onCreated={(displayName) => setSession((s) => (s ? { ...s, has_profile: true, display_name: displayName } : s))}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return <App session={session!} />;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
to:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
if (!session!.has_profile) {
|
||||||
|
return (
|
||||||
|
<CreateProfile
|
||||||
|
onCreated={(displayName) => setSession((s) => (s ? { ...s, has_profile: true, display_name: displayName } : s))}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Connecting Garmin is mandatory, re-checked on every login (not just
|
||||||
|
// right after signup) -- see docs/superpowers/specs/2026-07-26-improve-first-connection-design.md
|
||||||
|
// for why this can't be a purely in-session check.
|
||||||
|
if (!session!.garmin_connected) {
|
||||||
|
return <ConnectGarmin onConnected={() => setSession((s) => (s ? { ...s, garmin_connected: true } : s))} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <App session={session!} />;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Also update the doc comment above the `LoginGate` function (currently describes only a two-way fork) -- change:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Wraps App: on mount, asks the backend whether this browser already has a
|
||||||
|
// valid session (GET /api/session/me). geniusrun has no anonymous view, so
|
||||||
|
// this is the first fork -- "show the login screen" vs "show the app." A
|
||||||
|
// second fork, once authenticated, is whether the session's account has a
|
||||||
|
// provisioned profile yet (session.has_profile) -- a brand-new OIDC login
|
||||||
|
// sees CreateProfile instead of App until it submits one.
|
||||||
|
```
|
||||||
|
|
||||||
|
to:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Wraps App: on mount, asks the backend whether this browser already has a
|
||||||
|
// valid session (GET /api/session/me). geniusrun has no anonymous view, so
|
||||||
|
// this is the first fork -- "show the login screen" vs "show the app." A
|
||||||
|
// second fork, once authenticated, is whether the session's account has a
|
||||||
|
// provisioned profile yet (session.has_profile) -- a brand-new OIDC login
|
||||||
|
// sees CreateProfile instead of App until it submits one. A third fork,
|
||||||
|
// once provisioned, is whether Garmin has ever been successfully connected
|
||||||
|
// (session.garmin_connected) -- mandatory, and re-checked on every login,
|
||||||
|
// not just immediately after signup.
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 6: Build and lint**
|
||||||
|
|
||||||
|
Run: `cd frontend && npm run build && npm run lint`
|
||||||
|
Expected: build succeeds; lint reports no new warnings beyond the 3 pre-existing `PaceField.tsx` ones.
|
||||||
|
|
||||||
|
- [ ] **Step 7: Manual browser verification**
|
||||||
|
|
||||||
|
With the backend (`cd backend && ./start.sh`) and frontend (`cd frontend && ./start.sh`) running:
|
||||||
|
1. Delete your profile (or use a fresh OIDC account) so you land on `CreateProfile`.
|
||||||
|
2. Submit a display name — confirm you're immediately routed to `ConnectGarmin`, not the main app.
|
||||||
|
3. Try an obviously wrong Garmin password — confirm the error shows and the form stays editable.
|
||||||
|
4. Enter correct credentials (resolve MFA if prompted) — confirm "Connected to Garmin." appears with a "Continue to geniusrun" button, and clicking it lands in the main app.
|
||||||
|
5. Log out, log back in with the same account — confirm you land straight in the app (not back through `ConnectGarmin`), proving `garmin_connected_at` persisted.
|
||||||
|
6. On the `ConnectGarmin` screen specifically, confirm the "Log out" link works.
|
||||||
|
|
||||||
|
- [ ] **Step 8: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add frontend/src/types/api.ts frontend/src/ConnectGarmin.tsx frontend/src/CreateProfile.tsx frontend/src/CreateProfile.css frontend/src/LoginGate.tsx
|
||||||
|
git commit -m "feat(onboarding): add mandatory ConnectGarmin gate to first login"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 4: Final verification
|
||||||
|
|
||||||
|
**Files:** none (verification + cleanup only)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Full backend check**
|
||||||
|
|
||||||
|
Run: `cd backend && gofmt -l . && go vet ./... && go build ./... && go test ./...`
|
||||||
|
Expected: `gofmt -l .` empty; everything else passes.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Full frontend check**
|
||||||
|
|
||||||
|
Run: `cd frontend && npm run build && npm run lint`
|
||||||
|
Expected: both succeed, no new lint warnings.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Confirm `docs/DATABASE.md` is current**
|
||||||
|
|
||||||
|
Run: `cd backend && go run ./cmd/dumpschema && git status --short docs/DATABASE.md`
|
||||||
|
Expected: no output from `git status` (already committed in Task 1, unchanged since).
|
||||||
|
|
||||||
|
- [ ] **Step 4: Update `docs/IDEAS.md`**
|
||||||
|
|
||||||
|
Remove the now-implemented line from the Backlog section:
|
||||||
|
|
||||||
|
```
|
||||||
|
- improve first connection page: in CreateProfile.tsx, in addition to the display name, we should directly ask garmin login/password and manage MFA from there, so the synchronization experience in profile page will be easier later on
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add docs/IDEAS.md
|
||||||
|
git commit -m "docs: remove improve-first-connection-page from IDEAS backlog (implemented)"
|
||||||
|
```
|
||||||
1743
docs/superpowers/plans/2026-07-26-improve-synchronization-plan.md
Normal file
1743
docs/superpowers/plans/2026-07-26-improve-synchronization-plan.md
Normal file
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -270,7 +270,7 @@ git commit -m "feat(store): add DeleteUser with cascading account deletion"
|
|||||||
### Task 2: `DELETE /api/profile` and Garmin client/tokenstore teardown
|
### Task 2: `DELETE /api/profile` and Garmin client/tokenstore teardown
|
||||||
|
|
||||||
**Files:**
|
**Files:**
|
||||||
- Modify: `backend/internal/api/server.go:5-21` (imports), `:95-117` (new `removeGarminClient` after `syncFor`), `:167-170` (route)
|
- Modify: `backend/internal/api/server.go:5-21` (imports), `:95-117` (new `removeUserClient` after `syncFor`), `:167-170` (route)
|
||||||
- Modify: `backend/internal/api/profile.go` (add `handleDeleteProfile`)
|
- Modify: `backend/internal/api/profile.go` (add `handleDeleteProfile`)
|
||||||
- Modify: `backend/internal/api/api_test.go` (imports + 3 new tests)
|
- Modify: `backend/internal/api/api_test.go` (imports + 3 new tests)
|
||||||
- Modify: `backend/internal/api/isolation_test.go` (1 new test)
|
- Modify: `backend/internal/api/isolation_test.go` (1 new test)
|
||||||
@@ -427,7 +427,7 @@ func TestIsolation_DeleteProfileOnlyDeletesOwnAccount(t *testing.T) {
|
|||||||
Run: `cd backend && go test ./internal/api/... -run 'TestDeleteProfile|TestIsolation_DeleteProfile' -v`
|
Run: `cd backend && go test ./internal/api/... -run 'TestDeleteProfile|TestIsolation_DeleteProfile' -v`
|
||||||
Expected: FAIL — 404s (no `DELETE /api/profile` route registered yet).
|
Expected: FAIL — 404s (no `DELETE /api/profile` route registered yet).
|
||||||
|
|
||||||
- [ ] **Step 3: Add the `os` import and `removeGarminClient` to `server.go`**
|
- [ ] **Step 3: Add the `os` import and `removeUserClient` to `server.go`**
|
||||||
|
|
||||||
Change the import block (add `"os"` between `"net/http"` and `"path/filepath"`):
|
Change the import block (add `"os"` between `"net/http"` and `"path/filepath"`):
|
||||||
|
|
||||||
@@ -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"
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
961
docs/superpowers/plans/2026-07-26-structured-json-logging.md
Normal file
961
docs/superpowers/plans/2026-07-26-structured-json-logging.md
Normal file
@@ -0,0 +1,961 @@
|
|||||||
|
# Structured JSON Logging Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Emit structured JSON logs on stdout for (1) every HTTP API request and (2) every Garmin wrapper subprocess call, correlated by a per-request id — without touching any existing `log.Printf` call site.
|
||||||
|
|
||||||
|
**Architecture:** A new `internal/applog` package wraps `log/slog` with a JSON handler plus context helpers (`WithLogger`/`FromContext`). `internal/api` gains a `loggingMiddleware` that logs one line per HTTP request and stashes a request-id-tagged logger into the request context. `internal/garmin`'s `client.go` reads that same logger back out of context (via the `ctx` every `Client` method already receives) inside `execute`, the single funnel point all six Garmin calls go through, logging one line per subprocess round-trip.
|
||||||
|
|
||||||
|
**Tech Stack:** Go stdlib `log/slog` (no new dependency), `github.com/go-chi/chi/v5/middleware` (already-available subpackage of the existing chi dependency).
|
||||||
|
|
||||||
|
## Global Constraints
|
||||||
|
|
||||||
|
- Existing `log.Printf`/`log.Fatalf` call sites are untouched — they keep going to stderr, unstructured, exactly as today.
|
||||||
|
- Garmin credentials must never be logged. Confirmed safe: `authenticate`/`complete_mfa`/`call` wire params never carry email/password (only env vars at subprocess spawn do) — logging `params` in full is safe everywhere in `execute`.
|
||||||
|
- `gofmt -l .` must report nothing; `go vet ./...` and `go build ./...` must pass.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: `internal/applog` — JSON logger + context helpers
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `backend/internal/applog/applog.go`
|
||||||
|
- Create: `../../../backend/internal/log/log_test.go`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Produces: `func NewLogger(level string, w io.Writer) *slog.Logger`, `func WithLogger(ctx context.Context, logger *slog.Logger) context.Context`, `func FromContext(ctx context.Context) *slog.Logger` (never nil — falls back to `slog.Default()`).
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing tests**
|
||||||
|
|
||||||
|
Create `backend/internal/applog/applog_test.go`:
|
||||||
|
|
||||||
|
```go
|
||||||
|
package applog
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"log/slog"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestNewLogger_FiltersBelowConfiguredLevel(t *testing.T) {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
logger := NewLogger("warn", &buf)
|
||||||
|
|
||||||
|
logger.Info("should be dropped")
|
||||||
|
if buf.Len() != 0 {
|
||||||
|
t.Fatalf("expected no output for an Info message under a warn-level logger, got %q", buf.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.Warn("should appear")
|
||||||
|
if !strings.Contains(buf.String(), "should appear") {
|
||||||
|
t.Fatalf("expected the Warn message in output, got %q", buf.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewLogger_WritesValidJSON(t *testing.T) {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
logger := NewLogger("info", &buf)
|
||||||
|
logger.Info("hello", "key", "value")
|
||||||
|
|
||||||
|
var decoded map[string]any
|
||||||
|
if err := json.Unmarshal(buf.Bytes(), &decoded); err != nil {
|
||||||
|
t.Fatalf("output is not valid JSON: %v (%q)", err, buf.String())
|
||||||
|
}
|
||||||
|
if decoded["msg"] != "hello" || decoded["key"] != "value" {
|
||||||
|
t.Errorf("decoded = %+v, want msg=hello key=value", decoded)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWithLogger_FromContext_RoundTrip(t *testing.T) {
|
||||||
|
logger := slog.New(slog.NewJSONHandler(&bytes.Buffer{}, nil))
|
||||||
|
ctx := WithLogger(context.Background(), logger)
|
||||||
|
|
||||||
|
if got := FromContext(ctx); got != logger {
|
||||||
|
t.Errorf("FromContext returned a different logger than what was stashed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFromContext_DefaultsWhenNoneSet(t *testing.T) {
|
||||||
|
if got := FromContext(context.Background()); got == nil {
|
||||||
|
t.Fatal("FromContext on a bare context returned nil, want slog.Default()")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run tests to verify they fail**
|
||||||
|
|
||||||
|
Run: `cd backend && go test ./internal/applog/... -v`
|
||||||
|
Expected: FAIL — package `internal/applog` doesn't exist yet (build failure).
|
||||||
|
|
||||||
|
- [ ] **Step 3: Create `../../../backend/internal/log/log.go`**
|
||||||
|
|
||||||
|
```go
|
||||||
|
// Package applog provides geniusrun's structured JSON logging: a
|
||||||
|
// log/slog-based logger writing to stdout, plus context helpers so a
|
||||||
|
// logger enriched in one layer (e.g. internal/api's HTTP middleware,
|
||||||
|
// attaching a request_id) is picked up by another (e.g. internal/garmin's
|
||||||
|
// wrapper-call logging) without either package depending on the other.
|
||||||
|
package applog
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// NewLogger builds a JSON-handler *slog.Logger writing to w at the given
|
||||||
|
// level ("debug"|"info"|"warn"|"error", case-insensitive; anything else
|
||||||
|
// defaults to info).
|
||||||
|
func NewLogger(level string, w io.Writer) *slog.Logger {
|
||||||
|
return slog.New(slog.NewJSONHandler(w, &slog.HandlerOptions{Level: parseLevel(level)}))
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseLevel(level string) slog.Level {
|
||||||
|
switch strings.ToLower(level) {
|
||||||
|
case "debug":
|
||||||
|
return slog.LevelDebug
|
||||||
|
case "warn":
|
||||||
|
return slog.LevelWarn
|
||||||
|
case "error":
|
||||||
|
return slog.LevelError
|
||||||
|
default:
|
||||||
|
return slog.LevelInfo
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type ctxKey struct{}
|
||||||
|
|
||||||
|
// WithLogger returns a context carrying logger, retrievable via FromContext.
|
||||||
|
func WithLogger(ctx context.Context, logger *slog.Logger) context.Context {
|
||||||
|
return context.WithValue(ctx, ctxKey{}, logger)
|
||||||
|
}
|
||||||
|
|
||||||
|
// FromContext returns the logger stashed by WithLogger, or slog.Default()
|
||||||
|
// if none was -- callers (e.g. the background incremental-sync loop, or
|
||||||
|
// tests that don't bother injecting one) always get a working logger,
|
||||||
|
// never nil.
|
||||||
|
func FromContext(ctx context.Context) *slog.Logger {
|
||||||
|
if logger, ok := ctx.Value(ctxKey{}).(*slog.Logger); ok {
|
||||||
|
return logger
|
||||||
|
}
|
||||||
|
return slog.Default()
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run tests to verify they pass**
|
||||||
|
|
||||||
|
Run: `cd backend && go test ./internal/applog/... -v`
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add backend/internal/applog
|
||||||
|
git commit -m "feat(applog): add JSON logger and context helpers"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2: HTTP access log middleware
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `../../../backend/internal/config/envconfig.go` (new `LogLevel` field)
|
||||||
|
- Modify: `../../../backend/internal/config/envconfig_test.go` (new tests)
|
||||||
|
- Modify: `backend/cmd/geniusrund/main.go` (wire up `slog.SetDefault`)
|
||||||
|
- Modify: `backend/internal/api/server.go` (`loggingMiddleware`, registered in `Router()`)
|
||||||
|
- Modify: `backend/internal/api/api_test.go` (new tests)
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: `applog.NewLogger`, `applog.WithLogger`, `applog.FromContext` (Task 1).
|
||||||
|
- Produces: `loggingMiddleware` (unexported chi middleware in `internal/api`), `config.Config.LogLevel string`.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing config tests**
|
||||||
|
|
||||||
|
Append to `../../../backend/internal/config/envconfig_test.go`:
|
||||||
|
|
||||||
|
```go
|
||||||
|
func TestLoad_LogLevelDefaultsToInfo(t *testing.T) {
|
||||||
|
setRequiredEnv(t)
|
||||||
|
t.Setenv("GENIUSRUN_LOG_LEVEL", "")
|
||||||
|
|
||||||
|
cfg, err := Load()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Load: %v", err)
|
||||||
|
}
|
||||||
|
if cfg.LogLevel != "info" {
|
||||||
|
t.Errorf("LogLevel = %q, want default %q", cfg.LogLevel, "info")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoad_LogLevelExplicitOverridesDefault(t *testing.T) {
|
||||||
|
setRequiredEnv(t)
|
||||||
|
t.Setenv("GENIUSRUN_LOG_LEVEL", "debug")
|
||||||
|
|
||||||
|
cfg, err := Load()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Load: %v", err)
|
||||||
|
}
|
||||||
|
if cfg.LogLevel != "debug" {
|
||||||
|
t.Errorf("LogLevel = %q, want debug", cfg.LogLevel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Write the failing API tests**
|
||||||
|
|
||||||
|
Add `"log/slog"` to `backend/internal/api/api_test.go`'s stdlib import group, and `"geniusrun/backend/internal/applog"` to its internal import group.
|
||||||
|
|
||||||
|
Append to `backend/internal/api/api_test.go`:
|
||||||
|
|
||||||
|
```go
|
||||||
|
func TestRequestLoggingMiddleware_LogsMethodPathStatusDuration(t *testing.T) {
|
||||||
|
s, _, _ := newTestServer(t)
|
||||||
|
var buf bytes.Buffer
|
||||||
|
logger := slog.New(slog.NewJSONHandler(&buf, nil))
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/health", nil)
|
||||||
|
req = req.WithContext(applog.WithLogger(req.Context(), logger))
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
s.Router().ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
var entry map[string]any
|
||||||
|
if err := json.Unmarshal(buf.Bytes(), &entry); err != nil {
|
||||||
|
t.Fatalf("log output is not valid JSON: %v (%q)", err, buf.String())
|
||||||
|
}
|
||||||
|
if entry["msg"] != "http request" {
|
||||||
|
t.Errorf("msg = %v, want \"http request\"", entry["msg"])
|
||||||
|
}
|
||||||
|
if entry["method"] != "GET" || entry["path"] != "/api/health" {
|
||||||
|
t.Errorf("method/path = %v/%v, want GET//api/health", entry["method"], entry["path"])
|
||||||
|
}
|
||||||
|
if entry["status"] != float64(http.StatusOK) {
|
||||||
|
t.Errorf("status = %v, want 200", entry["status"])
|
||||||
|
}
|
||||||
|
if _, ok := entry["duration_ms"]; !ok {
|
||||||
|
t.Error("expected a duration_ms field")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRequestLoggingMiddleware_5xxLogsAtWarnLevel(t *testing.T) {
|
||||||
|
s, db, _ := newTestServer(t)
|
||||||
|
db.Close() // force a downstream DB call to fail with a 500
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
logger := slog.New(slog.NewJSONHandler(&buf, nil))
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/profile", nil)
|
||||||
|
req = req.WithContext(applog.WithLogger(req.Context(), logger))
|
||||||
|
cookie, err := auth.MintSessionCookie(auth.Claims{Sub: "test-user", Name: "Test User", Email: "test@example.com"}, testSessionConfig.Secret, testSessionConfig.Duration, testSessionConfig.Secure)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("mint session cookie: %v", err)
|
||||||
|
}
|
||||||
|
req.AddCookie(cookie)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
s.Router().ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusInternalServerError {
|
||||||
|
t.Fatalf("expected the request itself to 500 after closing the DB, got %d", rec.Code)
|
||||||
|
}
|
||||||
|
var entry map[string]any
|
||||||
|
if err := json.Unmarshal(buf.Bytes(), &entry); err != nil {
|
||||||
|
t.Fatalf("log output is not valid JSON: %v (%q)", err, buf.String())
|
||||||
|
}
|
||||||
|
if entry["level"] != "WARN" {
|
||||||
|
t.Errorf("level = %v, want WARN for a 5xx response", entry["level"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Run tests to verify they fail**
|
||||||
|
|
||||||
|
Run: `cd backend && go test ./internal/config/... ./internal/api/... -run 'TestLoad_LogLevel|TestRequestLoggingMiddleware' -v`
|
||||||
|
Expected: FAIL — `LogLevel` field doesn't exist on `Config`; `applog` import unresolved; no log output produced (middleware doesn't exist).
|
||||||
|
|
||||||
|
- [ ] **Step 4: Add `LogLevel` to `envconfig.go`**
|
||||||
|
|
||||||
|
Change:
|
||||||
|
|
||||||
|
```go
|
||||||
|
MinConfidence float64
|
||||||
|
IncrementalSyncEvery time.Duration
|
||||||
|
```
|
||||||
|
|
||||||
|
to:
|
||||||
|
|
||||||
|
```go
|
||||||
|
MinConfidence float64
|
||||||
|
IncrementalSyncEvery time.Duration
|
||||||
|
// LogLevel controls internal/applog's JSON logger ("debug"|"info"|"warn"|"error").
|
||||||
|
LogLevel string
|
||||||
|
```
|
||||||
|
|
||||||
|
Change:
|
||||||
|
|
||||||
|
```go
|
||||||
|
IncrementalSyncEvery: getEnvDuration("GENIUSRUN_INCREMENTAL_SYNC_EVERY", 6*time.Hour),
|
||||||
|
```
|
||||||
|
|
||||||
|
to:
|
||||||
|
|
||||||
|
```go
|
||||||
|
IncrementalSyncEvery: getEnvDuration("GENIUSRUN_INCREMENTAL_SYNC_EVERY", 6*time.Hour),
|
||||||
|
LogLevel: getEnvDefault("GENIUSRUN_LOG_LEVEL", "info"),
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: Add the middleware to `server.go`**
|
||||||
|
|
||||||
|
Change the import block:
|
||||||
|
|
||||||
|
```go
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
"github.com/go-chi/chi/v5/middleware"
|
||||||
|
|
||||||
|
"geniusrun/backend/internal/applog"
|
||||||
|
"geniusrun/backend/internal/auth"
|
||||||
|
"geniusrun/backend/internal/garmin"
|
||||||
|
"geniusrun/backend/internal/store"
|
||||||
|
appsync "geniusrun/backend/internal/sync"
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
Insert this above `// Router builds the HTTP routes.`:
|
||||||
|
|
||||||
|
```go
|
||||||
|
var requestIDCounter atomic.Int64
|
||||||
|
|
||||||
|
// requestLoggingMiddleware logs one JSON line per HTTP request (method,
|
||||||
|
// path, status, duration) and attaches a per-request logger (tagged with a
|
||||||
|
// request_id) to the request context, so any downstream call this request
|
||||||
|
// triggers -- e.g. a Garmin wrapper round-trip -- logs with the same
|
||||||
|
// correlating id (see internal/applog, internal/garmin's roundTrip).
|
||||||
|
func requestLoggingMiddleware(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id := fmt.Sprintf("req-%d", requestIDCounter.Add(1))
|
||||||
|
logger := applog.FromContext(r.Context()).With("request_id", id)
|
||||||
|
r = r.WithContext(applog.WithLogger(r.Context(), logger))
|
||||||
|
|
||||||
|
ww := middleware.NewWrapResponseWriter(w, r.ProtoMajor)
|
||||||
|
start := time.Now()
|
||||||
|
next.ServeHTTP(ww, r)
|
||||||
|
|
||||||
|
level := slog.LevelInfo
|
||||||
|
if ww.Status() >= 500 {
|
||||||
|
level = slog.LevelWarn
|
||||||
|
}
|
||||||
|
logger.LogAttrs(r.Context(), level, "http request",
|
||||||
|
slog.String("method", r.Method),
|
||||||
|
slog.String("path", r.URL.Path),
|
||||||
|
slog.Int("status", ww.Status()),
|
||||||
|
slog.Int64("duration_ms", time.Since(start).Milliseconds()),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Change `Router()`'s opening lines:
|
||||||
|
|
||||||
|
```go
|
||||||
|
func (s *Server) Router() http.Handler {
|
||||||
|
r := chi.NewRouter()
|
||||||
|
r.Use(corsMiddleware)
|
||||||
|
```
|
||||||
|
|
||||||
|
to:
|
||||||
|
|
||||||
|
```go
|
||||||
|
func (s *Server) Router() http.Handler {
|
||||||
|
r := chi.NewRouter()
|
||||||
|
r.Use(requestLoggingMiddleware)
|
||||||
|
r.Use(corsMiddleware)
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 6: Wire `slog.SetDefault` into `main.go`**
|
||||||
|
|
||||||
|
Change the import block:
|
||||||
|
|
||||||
|
```go
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"os/signal"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"geniusrun/backend/internal/api"
|
||||||
|
"geniusrun/backend/internal/auth"
|
||||||
|
"geniusrun/backend/internal/config"
|
||||||
|
"geniusrun/backend/internal/garmin"
|
||||||
|
"geniusrun/backend/internal/store"
|
||||||
|
appsync "geniusrun/backend/internal/sync"
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
to:
|
||||||
|
|
||||||
|
```go
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"geniusrun/backend/internal/api"
|
||||||
|
"geniusrun/backend/internal/applog"
|
||||||
|
"geniusrun/backend/internal/auth"
|
||||||
|
"geniusrun/backend/internal/config"
|
||||||
|
"geniusrun/backend/internal/garmin"
|
||||||
|
"geniusrun/backend/internal/store"
|
||||||
|
appsync "geniusrun/backend/internal/sync"
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
Change:
|
||||||
|
|
||||||
|
```go
|
||||||
|
cfg, err := config.Load()
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
db, err := store.Open(cfg.DBPath)
|
||||||
|
```
|
||||||
|
|
||||||
|
to:
|
||||||
|
|
||||||
|
```go
|
||||||
|
cfg, err := config.Load()
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("config: %v", err)
|
||||||
|
}
|
||||||
|
slog.SetDefault(applog.NewLogger(cfg.LogLevel, os.Stdout))
|
||||||
|
|
||||||
|
db, err := store.Open(cfg.DBPath)
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 7: Run tests to verify they pass**
|
||||||
|
|
||||||
|
Run: `cd backend && go test ./internal/config/... ./internal/api/... -run 'TestLoad_LogLevel|TestRequestLoggingMiddleware' -v`
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 8: Run the full backend test suite**
|
||||||
|
|
||||||
|
Run: `cd backend && go build ./... && go vet ./... && gofmt -l . && go test ./...`
|
||||||
|
Expected: `gofmt -l .` empty; everything passes.
|
||||||
|
|
||||||
|
- [ ] **Step 9: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add backend/internal/config/envconfig.go backend/internal/config/envconfig_test.go backend/cmd/geniusrund/main.go backend/internal/api/server.go backend/internal/api/api_test.go
|
||||||
|
git commit -m "feat(api): add structured JSON access log with request-id correlation"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 3: Garmin wrapper call log
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `backend/internal/garmin/client.go` (`execute`, `ensureStarted`, all six call sites)
|
||||||
|
- Modify: `backend/internal/garmin/client_test.go` (update 3 direct-`execute` calls, add 2 new tests)
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: `applog.FromContext` (Task 1).
|
||||||
|
- Produces: `func (c *subprocessClient) roundTrip(ctx context.Context, cmdName string, params any) (result json.RawMessage, err error)` (signature change — `ctx` added as first param), `func (c *subprocessClient) ensureStarted(ctx context.Context) error` (signature change — `ctx` added).
|
||||||
|
|
||||||
|
- [ ] **Step 1: Update the 3 existing direct-`execute` tests and write the 2 new failing tests**
|
||||||
|
|
||||||
|
In `backend/internal/garmin/client_test.go`, add `"bytes"` and `"log/slog"` to the stdlib import block, and add `"geniusrun/backend/internal/applog"` as a new import group.
|
||||||
|
|
||||||
|
Change (in `TestSubprocessClient_RoundTrip_DetectsIDMismatch`):
|
||||||
|
|
||||||
|
```go
|
||||||
|
_, err := c.roundTrip("authenticate", nil)
|
||||||
|
```
|
||||||
|
|
||||||
|
to:
|
||||||
|
|
||||||
|
```go
|
||||||
|
_, err := c.roundTrip(context.Background(), "authenticate", nil)
|
||||||
|
```
|
||||||
|
|
||||||
|
Change (in `TestSubprocessClient_RoundTrip_SubprocessClosedIsError`):
|
||||||
|
|
||||||
|
```go
|
||||||
|
_, err := c.roundTrip("authenticate", nil)
|
||||||
|
```
|
||||||
|
|
||||||
|
to:
|
||||||
|
|
||||||
|
```go
|
||||||
|
_, err := c.roundTrip(context.Background(), "authenticate", nil)
|
||||||
|
```
|
||||||
|
|
||||||
|
Change (in `TestSubprocessClient_RoundTrip_WrapperErrorPropagates`):
|
||||||
|
|
||||||
|
```go
|
||||||
|
_, err := c.roundTrip("authenticate", nil)
|
||||||
|
```
|
||||||
|
|
||||||
|
to:
|
||||||
|
|
||||||
|
```go
|
||||||
|
_, err := c.roundTrip(context.Background(), "authenticate", nil)
|
||||||
|
```
|
||||||
|
|
||||||
|
Append two new tests:
|
||||||
|
|
||||||
|
```go
|
||||||
|
func TestSubprocessClient_RoundTrip_LogsCallWithResultPreview(t *testing.T) {
|
||||||
|
c := newFakeWrapperClient(t, func(cmd string, params json.RawMessage) wireResponsePayload {
|
||||||
|
return fakeResult(authResultWire{Status: "mfa_required", Message: "MFA required."})
|
||||||
|
})
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
logger := slog.New(slog.NewJSONHandler(&buf, nil))
|
||||||
|
ctx := applog.WithLogger(context.Background(), logger)
|
||||||
|
|
||||||
|
if _, err := c.Authenticate(ctx); err != nil {
|
||||||
|
t.Fatalf("Authenticate: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var entry map[string]any
|
||||||
|
if err := json.Unmarshal(buf.Bytes(), &entry); err != nil {
|
||||||
|
t.Fatalf("log output is not valid JSON: %v (%q)", err, buf.String())
|
||||||
|
}
|
||||||
|
if entry["msg"] != "garmin wrapper call" {
|
||||||
|
t.Errorf("msg = %v, want \"garmin wrapper call\"", entry["msg"])
|
||||||
|
}
|
||||||
|
if entry["cmd"] != "authenticate" {
|
||||||
|
t.Errorf("cmd = %v, want authenticate", entry["cmd"])
|
||||||
|
}
|
||||||
|
preview, _ := entry["result_preview"].(string)
|
||||||
|
if !strings.Contains(preview, "mfa_required") {
|
||||||
|
t.Errorf("result_preview = %q, want it to contain mfa_required", preview)
|
||||||
|
}
|
||||||
|
if _, hasError := entry["error"]; hasError {
|
||||||
|
t.Errorf("expected no error field on a successful call, got %v", entry["error"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSubprocessClient_RoundTrip_LogsErrorAtWarnLevel(t *testing.T) {
|
||||||
|
c := newFakeWrapperClient(t, func(cmd string, params json.RawMessage) wireResponsePayload {
|
||||||
|
return fakeError("boom")
|
||||||
|
})
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
logger := slog.New(slog.NewJSONHandler(&buf, nil))
|
||||||
|
ctx := applog.WithLogger(context.Background(), logger)
|
||||||
|
|
||||||
|
if _, err := c.Authenticate(ctx); err == nil {
|
||||||
|
t.Fatal("expected Authenticate to return an error")
|
||||||
|
}
|
||||||
|
|
||||||
|
var entry map[string]any
|
||||||
|
if err := json.Unmarshal(buf.Bytes(), &entry); err != nil {
|
||||||
|
t.Fatalf("log output is not valid JSON: %v (%q)", err, buf.String())
|
||||||
|
}
|
||||||
|
if entry["level"] != "WARN" {
|
||||||
|
t.Errorf("level = %v, want WARN for a failed call", entry["level"])
|
||||||
|
}
|
||||||
|
if errMsg, _ := entry["error"].(string); !strings.Contains(errMsg, "boom") {
|
||||||
|
t.Errorf("error field = %q, want it to mention \"boom\"", errMsg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run tests to verify they fail**
|
||||||
|
|
||||||
|
Run: `cd backend && go test ./internal/garmin/... -run 'TestSubprocessClient_RoundTrip' -v`
|
||||||
|
Expected: FAIL — `execute` still takes 2 args, not 3; `applog` import unresolved.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Update `client.go`**
|
||||||
|
|
||||||
|
Change the import block:
|
||||||
|
|
||||||
|
```go
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"context"
|
||||||
|
_ "embed"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"strconv"
|
||||||
|
"sync"
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
to:
|
||||||
|
|
||||||
|
```go
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"context"
|
||||||
|
_ "embed"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"strconv"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"geniusrun/backend/internal/applog"
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
Change `ensureStarted`'s signature and add the spawn log. From:
|
||||||
|
|
||||||
|
```go
|
||||||
|
// ensureStarted spawns the wrapper subprocess if it isn't already running.
|
||||||
|
// Callers must hold c.mu.
|
||||||
|
func (c *subprocessClient) ensureStarted() error {
|
||||||
|
if c.started {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
to:
|
||||||
|
|
||||||
|
```go
|
||||||
|
// ensureStarted spawns the wrapper subprocess if it isn't already running.
|
||||||
|
// Callers must hold c.mu.
|
||||||
|
func (c *subprocessClient) ensureStarted(ctx context.Context) error {
|
||||||
|
if c.started {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
And change its ending, from:
|
||||||
|
|
||||||
|
```go
|
||||||
|
c.cmd = cmd
|
||||||
|
c.stdin = stdin
|
||||||
|
c.enc = json.NewEncoder(stdin)
|
||||||
|
scanner := bufio.NewScanner(stdout)
|
||||||
|
scanner.Buffer(make([]byte, 0, 64*1024), maxWrapperLineBytes)
|
||||||
|
c.scanner = scanner
|
||||||
|
c.started = true
|
||||||
|
c.nextID = 0
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
to:
|
||||||
|
|
||||||
|
```go
|
||||||
|
c.cmd = cmd
|
||||||
|
c.stdin = stdin
|
||||||
|
c.enc = json.NewEncoder(stdin)
|
||||||
|
scanner := bufio.NewScanner(stdout)
|
||||||
|
scanner.Buffer(make([]byte, 0, 64*1024), maxWrapperLineBytes)
|
||||||
|
c.scanner = scanner
|
||||||
|
c.started = true
|
||||||
|
c.nextID = 0
|
||||||
|
|
||||||
|
applog.FromContext(ctx).Info("garmin wrapper spawning",
|
||||||
|
"python_path", pythonPath,
|
||||||
|
"token_store_configured", c.cfg.TokenStorePath != "",
|
||||||
|
)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Replace `execute` entirely. From:
|
||||||
|
|
||||||
|
```go
|
||||||
|
// roundTrip sends one request and returns its result payload, or an error
|
||||||
|
// if the wrapper reported one. Callers must hold c.mu and have already
|
||||||
|
// called ensureStarted.
|
||||||
|
func (c *subprocessClient) roundTrip(cmdName string, params any) (json.RawMessage, error) {
|
||||||
|
c.nextID++
|
||||||
|
id := c.nextID
|
||||||
|
|
||||||
|
if err := c.enc.Encode(wireRequest{ID: id, Cmd: cmdName, Params: params}); err != nil {
|
||||||
|
return nil, fmt.Errorf("write %s request: %w", cmdName, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !c.scanner.Scan() {
|
||||||
|
if err := c.scanner.Err(); err != nil {
|
||||||
|
return nil, fmt.Errorf("read %s response: %w", cmdName, err)
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("read %s response: subprocess closed its output", cmdName)
|
||||||
|
}
|
||||||
|
|
||||||
|
var resp wireResponse
|
||||||
|
if err := json.Unmarshal(c.scanner.Bytes(), &resp); err != nil {
|
||||||
|
return nil, fmt.Errorf("parse %s response: %w", cmdName, err)
|
||||||
|
}
|
||||||
|
if resp.ID != id {
|
||||||
|
return nil, fmt.Errorf("%s response id mismatch: got %d, want %d", cmdName, resp.ID, id)
|
||||||
|
}
|
||||||
|
if resp.Error != "" {
|
||||||
|
return nil, fmt.Errorf("%s: %s", cmdName, resp.Error)
|
||||||
|
}
|
||||||
|
return resp.Result, nil
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
to:
|
||||||
|
|
||||||
|
```go
|
||||||
|
// roundTrip sends one request and returns its result payload, or an error
|
||||||
|
// if the wrapper reported one. Callers must hold c.mu and have already
|
||||||
|
// called ensureStarted. Logs exactly one "garmin wrapper call" line
|
||||||
|
// regardless of outcome (see internal/applog) -- cmd/params are always
|
||||||
|
// safe to log in full here: Garmin credentials only ever reach the
|
||||||
|
// subprocess via env vars at spawn time (see ensureStarted), never through
|
||||||
|
// these wire params.
|
||||||
|
func (c *subprocessClient) roundTrip(ctx context.Context, cmdName string, params any) (result json.RawMessage, err error) {
|
||||||
|
start := time.Now()
|
||||||
|
defer func() {
|
||||||
|
attrs := []slog.Attr{
|
||||||
|
slog.String("cmd", cmdName),
|
||||||
|
slog.Any("params", params),
|
||||||
|
slog.Int64("duration_ms", time.Since(start).Milliseconds()),
|
||||||
|
}
|
||||||
|
level := slog.LevelInfo
|
||||||
|
if result != nil {
|
||||||
|
attrs = append(attrs, slog.String("result_preview", truncate(string(result), 500)))
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
level = slog.LevelWarn
|
||||||
|
attrs = append(attrs, slog.String("error", err.Error()))
|
||||||
|
}
|
||||||
|
applog.FromContext(ctx).LogAttrs(context.Background(), level, "garmin wrapper call", attrs...)
|
||||||
|
}()
|
||||||
|
|
||||||
|
c.nextID++
|
||||||
|
id := c.nextID
|
||||||
|
|
||||||
|
if err = c.enc.Encode(wireRequest{ID: id, Cmd: cmdName, Params: params}); err != nil {
|
||||||
|
err = fmt.Errorf("write %s request: %w", cmdName, err)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if !c.scanner.Scan() {
|
||||||
|
if serr := c.scanner.Err(); serr != nil {
|
||||||
|
err = fmt.Errorf("read %s response: %w", cmdName, serr)
|
||||||
|
} else {
|
||||||
|
err = fmt.Errorf("read %s response: subprocess closed its output", cmdName)
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var resp wireResponse
|
||||||
|
if uerr := json.Unmarshal(c.scanner.Bytes(), &resp); uerr != nil {
|
||||||
|
err = fmt.Errorf("parse %s response: %w", cmdName, uerr)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if resp.ID != id {
|
||||||
|
err = fmt.Errorf("%s response id mismatch: got %d, want %d", cmdName, resp.ID, id)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if resp.Error != "" {
|
||||||
|
err = fmt.Errorf("%s: %s", cmdName, resp.Error)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
result = resp.Result
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Update the six call sites. In `Authenticate`:
|
||||||
|
|
||||||
|
```go
|
||||||
|
if err := c.ensureStarted(); err != nil {
|
||||||
|
return AuthResult{}, err
|
||||||
|
}
|
||||||
|
raw, err := c.roundTrip("authenticate", nil)
|
||||||
|
```
|
||||||
|
|
||||||
|
to:
|
||||||
|
|
||||||
|
```go
|
||||||
|
if err := c.ensureStarted(ctx); err != nil {
|
||||||
|
return AuthResult{}, err
|
||||||
|
}
|
||||||
|
raw, err := c.roundTrip(ctx, "authenticate", nil)
|
||||||
|
```
|
||||||
|
|
||||||
|
In `CompleteMFA`:
|
||||||
|
|
||||||
|
```go
|
||||||
|
if err := c.ensureStarted(); err != nil {
|
||||||
|
return AuthResult{}, err
|
||||||
|
}
|
||||||
|
raw, err := c.roundTrip("complete_mfa", map[string]any{"code": code})
|
||||||
|
```
|
||||||
|
|
||||||
|
to:
|
||||||
|
|
||||||
|
```go
|
||||||
|
if err := c.ensureStarted(ctx); err != nil {
|
||||||
|
return AuthResult{}, err
|
||||||
|
}
|
||||||
|
raw, err := c.roundTrip(ctx, "complete_mfa", map[string]any{"code": code})
|
||||||
|
```
|
||||||
|
|
||||||
|
In `GetActivities`:
|
||||||
|
|
||||||
|
```go
|
||||||
|
if err := c.ensureStarted(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
raw, err := c.roundTrip("call", callParams{
|
||||||
|
Method: "get_activities_by_date",
|
||||||
|
Args: map[string]any{"startdate": startDate, "enddate": endDate},
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
to:
|
||||||
|
|
||||||
|
```go
|
||||||
|
if err := c.ensureStarted(ctx); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
raw, err := c.roundTrip(ctx, "call", callParams{
|
||||||
|
Method: "get_activities_by_date",
|
||||||
|
Args: map[string]any{"startdate": startDate, "enddate": endDate},
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
In `GetActivitySplits`:
|
||||||
|
|
||||||
|
```go
|
||||||
|
if err := c.ensureStarted(); err != nil {
|
||||||
|
return ActivitySplits{}, err
|
||||||
|
}
|
||||||
|
raw, err := c.roundTrip("call", callParams{
|
||||||
|
Method: "get_activity_splits",
|
||||||
|
Args: map[string]any{"activity_id": strconv.FormatInt(activityID, 10)},
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
to:
|
||||||
|
|
||||||
|
```go
|
||||||
|
if err := c.ensureStarted(ctx); err != nil {
|
||||||
|
return ActivitySplits{}, err
|
||||||
|
}
|
||||||
|
raw, err := c.roundTrip(ctx, "call", callParams{
|
||||||
|
Method: "get_activity_splits",
|
||||||
|
Args: map[string]any{"activity_id": strconv.FormatInt(activityID, 10)},
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
In `GetActivityDetails`:
|
||||||
|
|
||||||
|
```go
|
||||||
|
if err := c.ensureStarted(); err != nil {
|
||||||
|
return ActivityDetails{}, err
|
||||||
|
}
|
||||||
|
raw, err := c.roundTrip("call", callParams{
|
||||||
|
Method: "get_activity_details",
|
||||||
|
Args: map[string]any{"activity_id": strconv.FormatInt(activityID, 10)},
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
to:
|
||||||
|
|
||||||
|
```go
|
||||||
|
if err := c.ensureStarted(ctx); err != nil {
|
||||||
|
return ActivityDetails{}, err
|
||||||
|
}
|
||||||
|
raw, err := c.roundTrip(ctx, "call", callParams{
|
||||||
|
Method: "get_activity_details",
|
||||||
|
Args: map[string]any{"activity_id": strconv.FormatInt(activityID, 10)},
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
In `GetWorkoutByID`:
|
||||||
|
|
||||||
|
```go
|
||||||
|
if err := c.ensureStarted(); err != nil {
|
||||||
|
return Workout{}, err
|
||||||
|
}
|
||||||
|
raw, err := c.roundTrip("call", callParams{
|
||||||
|
Method: "get_workout_by_id",
|
||||||
|
Args: map[string]any{"workout_id": strconv.FormatInt(workoutID, 10)},
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
to:
|
||||||
|
|
||||||
|
```go
|
||||||
|
if err := c.ensureStarted(ctx); err != nil {
|
||||||
|
return Workout{}, err
|
||||||
|
}
|
||||||
|
raw, err := c.roundTrip(ctx, "call", callParams{
|
||||||
|
Method: "get_workout_by_id",
|
||||||
|
Args: map[string]any{"workout_id": strconv.FormatInt(workoutID, 10)},
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run tests to verify they pass**
|
||||||
|
|
||||||
|
Run: `cd backend && go test ./internal/garmin/... -v`
|
||||||
|
Expected: PASS (all existing tests still pass with the new `ctx` param threaded through; the 2 new logging tests pass).
|
||||||
|
|
||||||
|
- [ ] **Step 5: Run the full backend test suite**
|
||||||
|
|
||||||
|
Run: `cd backend && go build ./... && go vet ./... && gofmt -l . && go test ./...`
|
||||||
|
Expected: `gofmt -l .` empty; everything passes.
|
||||||
|
|
||||||
|
- [ ] **Step 6: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add backend/internal/garmin/client.go backend/internal/garmin/client_test.go
|
||||||
|
git commit -m "feat(garmin): log every wrapper subprocess call as structured JSON"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 4: Final verification
|
||||||
|
|
||||||
|
**Files:** none (verification only)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Full backend check**
|
||||||
|
|
||||||
|
Run: `cd backend && gofmt -l . && go vet ./... && go build ./... && go test ./...`
|
||||||
|
Expected: `gofmt -l .` empty; everything else passes.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Manual smoke check**
|
||||||
|
|
||||||
|
Run the real server briefly (`cd backend && ./start.sh`, or `go run ./cmd/geniusrund` with the required env vars set) and confirm stdout shows JSON lines for at least a health-check request (`curl localhost:8080/api/health`) — one `"msg":"http request"` line with a `request_id`. This doesn't require a real Garmin account; it only confirms the JSON handler is actually wired to stdout in the real binary (as opposed to only passing in tests).
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit (if anything drifted)**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add -A
|
||||||
|
git commit -m "fix: address final verification findings"
|
||||||
|
```
|
||||||
2027
docs/superpowers/plans/2026-07-27-modern-error-displays-plan.md
Normal file
2027
docs/superpowers/plans/2026-07-27-modern-error-displays-plan.md
Normal file
File diff suppressed because it is too large
Load Diff
844
docs/superpowers/plans/2026-07-27-workout-not-found-plan.md
Normal file
844
docs/superpowers/plans/2026-07-27-workout-not-found-plan.md
Normal file
@@ -0,0 +1,844 @@
|
|||||||
|
# Stop Retrying a Permanently-Missing Garmin Workout Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Stop `fillPendingWorkouts` from retrying a `get_workout_by_id` call forever when Garmin
|
||||||
|
returns a definitive 404 (the workout was deleted after being linked to an activity), per
|
||||||
|
`docs/superpowers/specs/2026-07-27-workout-not-found-design.md`.
|
||||||
|
|
||||||
|
**Architecture:** `garminconnect`'s own `GarminConnectNotFoundError` (raised for any HTTP 404) is
|
||||||
|
caught specifically in `wrapper.py`'s dispatch loop and marked with `"not_found": true` in the
|
||||||
|
JSON error response. `internal/garmin/client.go` turns that into a Go sentinel error
|
||||||
|
(`ErrNotFound`) any caller can `errors.Is` against. `internal/sync/service.go`'s
|
||||||
|
`fillPendingWorkouts` checks for it and, only for that specific case, calls a new store method
|
||||||
|
(`SetActivityWorkoutNotFound`) that permanently excludes the activity from
|
||||||
|
`ActivitiesMissingWorkout`/`CountActivitiesMissingWorkout` -- `workout_raw_json` stays `NULL`
|
||||||
|
forever (no fabricated data), but a new `workout_not_found_at` column distinguishes "confirmed
|
||||||
|
missing" from "not yet fetched."
|
||||||
|
|
||||||
|
**Tech Stack:** Go (backend, `internal/garmin`/`internal/store`/`internal/sync`), Python
|
||||||
|
(`internal/garmin/pyscript/wrapper.py`). No frontend changes.
|
||||||
|
|
||||||
|
## Global Constraints
|
||||||
|
|
||||||
|
- `gofmt -l .` must report nothing; `go build ./...`, `go vet ./...`, `go test ./...` must all
|
||||||
|
pass before any commit.
|
||||||
|
- Every store method takes an explicit `userID` and uses it in a real `WHERE`/`JOIN` clause (per
|
||||||
|
this repo's per-user isolation convention) -- `SetActivityWorkoutNotFound` follows the exact
|
||||||
|
pattern of the existing `SetActivitySplitsFetched`.
|
||||||
|
- No migration history: `internal/store/schema.sql` is edited directly, then
|
||||||
|
`go run ./cmd/dumpschema` regenerates `docs/DATABASE.md`.
|
||||||
|
- `fillActivityDetails`/`fillPendingActivityDetails`'s error handling (abort-the-batch-and-report)
|
||||||
|
is explicitly out of scope -- this fix only changes `fillPendingWorkouts`'s behavior.
|
||||||
|
- No frontend changes -- `workouts_pending` simply stops counting a confirmed-missing workout.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: `wrapper.py` marks a 404 with `not_found: true`
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `../../../backend/internal/garmin/wrapper/wrapper.py` (imports, `dispatch`)
|
||||||
|
- Modify: `../../../backend/internal/garmin/wrapper/tests/test_wrapper.py` (new test)
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: `garminconnect.GarminConnectNotFoundError` (already installed as a dependency).
|
||||||
|
- Produces: `dispatch()`'s returned error dict gains an additional `"not_found": true` key
|
||||||
|
whenever the caught exception is (or is a subclass of) `GarminConnectNotFoundError` -- Task 2
|
||||||
|
reads this field on the Go side.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing test**
|
||||||
|
|
||||||
|
Add to `../../../backend/internal/garmin/wrapper/tests/test_wrapper.py`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def test_call_marks_not_found_error_specifically():
|
||||||
|
from garminconnect import GarminConnectNotFoundError
|
||||||
|
|
||||||
|
wrapper._auth_state = "authenticated"
|
||||||
|
wrapper._client = MagicMock()
|
||||||
|
wrapper._client.get_workout_by_id.side_effect = GarminConnectNotFoundError("API Error 404")
|
||||||
|
resp = wrapper.dispatch({
|
||||||
|
"id": 11, "cmd": "call", "params": {"method": "get_workout_by_id", "args": {"workout_id": "999"}},
|
||||||
|
})
|
||||||
|
assert resp == {"id": 11, "error": "API Error 404", "not_found": True}
|
||||||
|
|
||||||
|
|
||||||
|
def test_call_does_not_mark_other_errors_as_not_found():
|
||||||
|
wrapper._auth_state = "authenticated"
|
||||||
|
wrapper._client = MagicMock()
|
||||||
|
wrapper._client.get_workout_by_id.side_effect = Exception("rate limited")
|
||||||
|
resp = wrapper.dispatch({
|
||||||
|
"id": 12, "cmd": "call", "params": {"method": "get_workout_by_id", "args": {"workout_id": "999"}},
|
||||||
|
})
|
||||||
|
assert resp == {"id": 12, "error": "rate limited"}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run it to verify it fails**
|
||||||
|
|
||||||
|
Run: `cd backend/internal/garmin/pyscript && python3 -m pytest tests/test_wrapper.py -k not_found -v`
|
||||||
|
(use whatever Python interpreter this repo's wrapper tests normally run under -- see
|
||||||
|
`../../../backend/internal/garmin/wrapper/.venv` if one exists, or the `GARMIN_WRAPPER_PYTHON` convention).
|
||||||
|
Expected: `test_call_marks_not_found_error_specifically` FAILS (`resp` has no `"not_found"` key
|
||||||
|
yet); `test_call_does_not_mark_other_errors_as_not_found` already passes (nothing to change for
|
||||||
|
that case).
|
||||||
|
|
||||||
|
- [ ] **Step 3: Update `dispatch()`**
|
||||||
|
|
||||||
|
In `../../../backend/internal/garmin/wrapper/wrapper.py`, replace:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from garminconnect import Garmin
|
||||||
|
```
|
||||||
|
|
||||||
|
with:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from garminconnect import Garmin, GarminConnectNotFoundError
|
||||||
|
```
|
||||||
|
|
||||||
|
Replace:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def dispatch(req):
|
||||||
|
handler = _HANDLERS.get(req.get("cmd"))
|
||||||
|
if handler is None:
|
||||||
|
return {"id": req.get("id"), "error": f"unknown cmd {req.get('cmd')!r}"}
|
||||||
|
try:
|
||||||
|
result = handler(req.get("params") or {})
|
||||||
|
return {"id": req["id"], "result": result}
|
||||||
|
except Exception as exc:
|
||||||
|
_debug(f"{req.get('cmd')} raised {type(exc).__name__}: {exc}")
|
||||||
|
_debug(traceback.format_exc())
|
||||||
|
return {"id": req.get("id"), "error": str(exc)}
|
||||||
|
```
|
||||||
|
|
||||||
|
with:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def dispatch(req):
|
||||||
|
handler = _HANDLERS.get(req.get("cmd"))
|
||||||
|
if handler is None:
|
||||||
|
return {"id": req.get("id"), "error": f"unknown cmd {req.get('cmd')!r}"}
|
||||||
|
try:
|
||||||
|
result = handler(req.get("params") or {})
|
||||||
|
return {"id": req["id"], "result": result}
|
||||||
|
except Exception as exc:
|
||||||
|
_debug(f"{req.get('cmd')} raised {type(exc).__name__}: {exc}")
|
||||||
|
_debug(traceback.format_exc())
|
||||||
|
resp = {"id": req.get("id"), "error": str(exc)}
|
||||||
|
# A 404 (e.g. get_workout_by_id for a workout deleted on Garmin's
|
||||||
|
# side after being linked to an activity) is definitive, not a
|
||||||
|
# transient failure worth retrying forever -- marked specifically so
|
||||||
|
# internal/garmin/client.go can tell the two apart (see
|
||||||
|
# docs/superpowers/specs/2026-07-27-workout-not-found-design.md).
|
||||||
|
if isinstance(exc, GarminConnectNotFoundError):
|
||||||
|
resp["not_found"] = True
|
||||||
|
return resp
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run the test to verify it passes**
|
||||||
|
|
||||||
|
Run: `cd backend/internal/garmin/pyscript && python3 -m pytest tests/test_wrapper.py -v`
|
||||||
|
Expected: all tests PASS, including both new ones and every existing test unchanged.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add backend/internal/garmin/wrapper/wrapper.py backend/internal/garmin/wrapper/tests/test_wrapper.py
|
||||||
|
git commit -m "$(cat <<'EOF'
|
||||||
|
feat(garmin): mark a wrapper 404 with not_found in the error response
|
||||||
|
|
||||||
|
garminconnect's own GarminConnectNotFoundError already exists
|
||||||
|
specifically for this (its docstring: "so callers can now catch a
|
||||||
|
missing resource specifically, e.g. deleting an already-deleted
|
||||||
|
workout"), raised by connectapi() for any real HTTP 404. dispatch()
|
||||||
|
now surfaces that distinction as an extra not_found: true field
|
||||||
|
alongside the existing error string, so internal/garmin/client.go
|
||||||
|
(next commit) can tell a definitive 404 apart from a transient
|
||||||
|
failure.
|
||||||
|
EOF
|
||||||
|
)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2: `internal/garmin/client.go` exposes `ErrNotFound`
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `backend/internal/garmin/client.go` (`wireResponse`, new `ErrNotFound`, `execute`)
|
||||||
|
- Modify: `backend/internal/garmin/client_test.go` (`wireResponsePayload`/harness, new test)
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: `wireResponse.NotFound` (wire field written by Task 1's `wrapper.py` change).
|
||||||
|
- Produces: `var ErrNotFound error` -- Task 4 (`fillPendingWorkouts`) checks
|
||||||
|
`errors.Is(err, garmin.ErrNotFound)` against errors returned by `GetWorkoutByID` (and, since
|
||||||
|
this is wired at the generic `execute` level, any other `Client` method too).
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing test**
|
||||||
|
|
||||||
|
Add to `backend/internal/garmin/client_test.go`, right after `fakeError`:
|
||||||
|
|
||||||
|
```go
|
||||||
|
func fakeNotFoundError(msg string) wireResponsePayload {
|
||||||
|
return wireResponsePayload{err: msg, notFound: true}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Update the `wireResponsePayload` struct (near the top of the file) from:
|
||||||
|
|
||||||
|
```go
|
||||||
|
type wireResponsePayload struct {
|
||||||
|
result json.RawMessage
|
||||||
|
err string
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
to:
|
||||||
|
|
||||||
|
```go
|
||||||
|
type wireResponsePayload struct {
|
||||||
|
result json.RawMessage
|
||||||
|
err string
|
||||||
|
notFound bool
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Update `newFakeWrapperClient`'s harness -- replace:
|
||||||
|
|
||||||
|
```go
|
||||||
|
payload := handle(req.Cmd, req.Params)
|
||||||
|
resp := wireResponse{ID: req.ID, Result: payload.result, Error: payload.err}
|
||||||
|
```
|
||||||
|
|
||||||
|
with:
|
||||||
|
|
||||||
|
```go
|
||||||
|
payload := handle(req.Cmd, req.Params)
|
||||||
|
resp := wireResponse{ID: req.ID, Result: payload.result, Error: payload.err, NotFound: payload.notFound}
|
||||||
|
```
|
||||||
|
|
||||||
|
Add a new test, right after `TestSubprocessClient_RoundTrip_WrapperErrorPropagates`:
|
||||||
|
|
||||||
|
```go
|
||||||
|
func TestSubprocessClient_RoundTrip_NotFoundWrapsErrNotFound(t *testing.T) {
|
||||||
|
c := newFakeWrapperClient(t, func(cmd string, params json.RawMessage) wireResponsePayload {
|
||||||
|
return fakeNotFoundError("API Error 404")
|
||||||
|
})
|
||||||
|
|
||||||
|
_, err := c.roundTrip(context.Background(), "call", nil)
|
||||||
|
if !errors.Is(err, ErrNotFound) {
|
||||||
|
t.Fatalf("roundTrip error = %v, want errors.Is(err, ErrNotFound)", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "API Error 404") {
|
||||||
|
t.Errorf("roundTrip error = %v, want it to still contain the original message", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSubprocessClient_RoundTrip_OrdinaryErrorDoesNotWrapErrNotFound(t *testing.T) {
|
||||||
|
c := newFakeWrapperClient(t, func(cmd string, params json.RawMessage) wireResponsePayload {
|
||||||
|
return fakeError("boom")
|
||||||
|
})
|
||||||
|
|
||||||
|
_, err := c.roundTrip(context.Background(), "call", nil)
|
||||||
|
if errors.Is(err, ErrNotFound) {
|
||||||
|
t.Fatalf("roundTrip error = %v, want errors.Is(err, ErrNotFound) to be false", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Add `"errors"` to this file's import block (alongside the existing `"strings"` etc.).
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run it to verify it fails**
|
||||||
|
|
||||||
|
Run: `cd backend && go test ./internal/garmin/... -run TestSubprocessClient_RoundTrip_NotFound -v`
|
||||||
|
Expected: FAIL to compile (`wireResponsePayload` has no field `notFound` yet -- wait, Step 1 above
|
||||||
|
already added it to the test file; the actual compile failure is `wireResponse` has no field
|
||||||
|
`NotFound` yet, and `ErrNotFound` is undefined) -- confirms the test exercises code that doesn't
|
||||||
|
exist yet.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Add `ErrNotFound` and wire it through `execute`**
|
||||||
|
|
||||||
|
In `backend/internal/garmin/client.go`, replace:
|
||||||
|
|
||||||
|
```go
|
||||||
|
// wireResponse is one line read from the wrapper subprocess's stdout.
|
||||||
|
type wireResponse struct {
|
||||||
|
ID int `json:"id"`
|
||||||
|
Result json.RawMessage `json:"result,omitempty"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
with:
|
||||||
|
|
||||||
|
```go
|
||||||
|
// wireResponse is one line read from the wrapper subprocess's stdout.
|
||||||
|
type wireResponse struct {
|
||||||
|
ID int `json:"id"`
|
||||||
|
Result json.RawMessage `json:"result,omitempty"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
NotFound bool `json:"not_found,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ErrNotFound wraps any error a Client method returns when the wrapper
|
||||||
|
// reported a definitive HTTP 404 (garminconnect's own
|
||||||
|
// GarminConnectNotFoundError) -- e.g. GetWorkoutByID for a workout deleted
|
||||||
|
// on Garmin's side after being linked to an activity. Callers use
|
||||||
|
// errors.Is(err, ErrNotFound) to distinguish this from a transient failure
|
||||||
|
// worth retrying.
|
||||||
|
var ErrNotFound = errors.New("garmin: resource not found")
|
||||||
|
```
|
||||||
|
|
||||||
|
Replace, in `execute`:
|
||||||
|
|
||||||
|
```go
|
||||||
|
if resp.Error != "" {
|
||||||
|
err = fmt.Errorf("%s: %s", cmdName, resp.Error)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
with:
|
||||||
|
|
||||||
|
```go
|
||||||
|
if resp.Error != "" {
|
||||||
|
if resp.NotFound {
|
||||||
|
err = fmt.Errorf("%s: %s: %w", cmdName, resp.Error, ErrNotFound)
|
||||||
|
} else {
|
||||||
|
err = fmt.Errorf("%s: %s", cmdName, resp.Error)
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Add `"errors"` to `client.go`'s import block.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run the test to verify it passes**
|
||||||
|
|
||||||
|
Run: `cd backend && go test ./internal/garmin/... -v`
|
||||||
|
Expected: all tests PASS, including the two new ones and every existing test unchanged.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Run the full backend suite**
|
||||||
|
|
||||||
|
Run: `cd backend && go build ./... && go vet ./... && gofmt -l . && go test ./...`
|
||||||
|
Expected: all pass, `gofmt -l .` prints nothing.
|
||||||
|
|
||||||
|
- [ ] **Step 6: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add backend/internal/garmin/client.go backend/internal/garmin/client_test.go
|
||||||
|
git commit -m "$(cat <<'EOF'
|
||||||
|
feat(garmin): expose ErrNotFound for a wrapper-reported 404
|
||||||
|
|
||||||
|
roundTrip now wraps the returned error with the new ErrNotFound
|
||||||
|
sentinel whenever the wrapper's response set not_found (Task 1),
|
||||||
|
letting any Client method's caller distinguish a definitive 404 from
|
||||||
|
a transient failure via errors.Is, regardless of which garminconnect
|
||||||
|
method was called.
|
||||||
|
EOF
|
||||||
|
)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 3: Store gains `workout_not_found_at`
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `backend/internal/store/schema.sql` (`activities` table)
|
||||||
|
- Modify: `backend/internal/store/activities.go` (`Activity` struct, `activityColumns`,
|
||||||
|
`scanActivity`, new `SetActivityWorkoutNotFound`, `ActivitiesMissingWorkout`/
|
||||||
|
`CountActivitiesMissingWorkout`)
|
||||||
|
- Modify: `backend/internal/store/store_test.go` (extend the existing test, add a new one)
|
||||||
|
- Regenerate: `docs/DATABASE.md` (via `go run ./cmd/dumpschema`)
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: nothing new.
|
||||||
|
- Produces: `db.SetActivityWorkoutNotFound(ctx, userID, activityID int64) error` -- Task 4
|
||||||
|
(`fillPendingWorkouts`) calls this. `Activity.WorkoutNotFoundAt *string` -- available to any
|
||||||
|
caller of `GetActivity`/`ListActivities`/etc. that wants to check it (none currently do, besides
|
||||||
|
this task's own test).
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing test**
|
||||||
|
|
||||||
|
Add to `backend/internal/store/store_test.go`, a new test (don't modify the existing
|
||||||
|
`TestActivitiesMissingWorkout_OnlyIncludesActivitiesWithDetailsAlreadyFetched` -- this is
|
||||||
|
additive):
|
||||||
|
|
||||||
|
```go
|
||||||
|
func TestActivitiesMissingWorkout_ExcludesConfirmedNotFound(t *testing.T) {
|
||||||
|
db := openTestDB(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
userID, err := db.ProvisionUser(ctx, "test-sub", "Test")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ProvisionUser: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
workoutID := int64(999)
|
||||||
|
// Details already fetched, workout confirmed 404 on Garmin -- must not
|
||||||
|
// be retried, so must not appear in ActivitiesMissingWorkout/Count.
|
||||||
|
notFound := Activity{
|
||||||
|
GarminActivityID: 1, WorkoutID: &workoutID,
|
||||||
|
StartTimeUTC: "2026-07-01 06:00:00", RawJSON: "{}",
|
||||||
|
}
|
||||||
|
idA, err := db.UpsertActivity(ctx, userID, notFound)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("UpsertActivity (a): %v", err)
|
||||||
|
}
|
||||||
|
if err := db.SetActivityDetails(ctx, userID, idA, "{}"); err != nil {
|
||||||
|
t.Fatalf("SetActivityDetails (a): %v", err)
|
||||||
|
}
|
||||||
|
if err := db.SetActivitySplitsFetched(ctx, userID, idA); err != nil {
|
||||||
|
t.Fatalf("SetActivitySplitsFetched (a): %v", err)
|
||||||
|
}
|
||||||
|
if err := db.SetActivityWorkoutNotFound(ctx, userID, idA); err != nil {
|
||||||
|
t.Fatalf("SetActivityWorkoutNotFound (a): %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A genuinely still-pending activity (details fetched, workout not yet
|
||||||
|
// attempted) must still be included, for contrast.
|
||||||
|
stillPending := Activity{
|
||||||
|
GarminActivityID: 2, WorkoutID: &workoutID,
|
||||||
|
StartTimeUTC: "2026-07-02 06:00:00", RawJSON: "{}",
|
||||||
|
}
|
||||||
|
idB, err := db.UpsertActivity(ctx, userID, stillPending)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("UpsertActivity (b): %v", err)
|
||||||
|
}
|
||||||
|
if err := db.SetActivityDetails(ctx, userID, idB, "{}"); err != nil {
|
||||||
|
t.Fatalf("SetActivityDetails (b): %v", err)
|
||||||
|
}
|
||||||
|
if err := db.SetActivitySplitsFetched(ctx, userID, idB); err != nil {
|
||||||
|
t.Fatalf("SetActivitySplitsFetched (b): %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
n, err := db.CountActivitiesMissingWorkout(ctx, userID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CountActivitiesMissingWorkout: %v", err)
|
||||||
|
}
|
||||||
|
if n != 1 {
|
||||||
|
t.Fatalf("CountActivitiesMissingWorkout = %d, want 1 (only activity b)", n)
|
||||||
|
}
|
||||||
|
|
||||||
|
pending, err := db.ActivitiesMissingWorkout(ctx, userID, 10)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ActivitiesMissingWorkout: %v", err)
|
||||||
|
}
|
||||||
|
if len(pending) != 1 || pending[0].ID != idB {
|
||||||
|
t.Fatalf("ActivitiesMissingWorkout = %+v, want only activity b (id %d)", pending, idB)
|
||||||
|
}
|
||||||
|
|
||||||
|
confirmed, _, err := db.GetActivity(ctx, userID, idA)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetActivity (a): %v", err)
|
||||||
|
}
|
||||||
|
if confirmed.WorkoutNotFoundAt == nil {
|
||||||
|
t.Error("activity a's WorkoutNotFoundAt is nil, want it set")
|
||||||
|
}
|
||||||
|
if confirmed.WorkoutRawJSON != nil {
|
||||||
|
t.Error("activity a's WorkoutRawJSON should stay nil -- not-found is recorded separately, not faked")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run it to verify it fails**
|
||||||
|
|
||||||
|
Run: `cd backend && go test ./internal/store/... -run TestActivitiesMissingWorkout_ExcludesConfirmedNotFound -v`
|
||||||
|
Expected: FAIL to compile (`db.SetActivityWorkoutNotFound` and `Activity.WorkoutNotFoundAt`
|
||||||
|
undefined).
|
||||||
|
|
||||||
|
- [ ] **Step 3: Add the schema column**
|
||||||
|
|
||||||
|
In `backend/internal/store/schema.sql`, replace:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- Genuine raw get_workout_by_id() response, the source used to compute
|
||||||
|
-- alignWorkoutTargets. Null when the activity has no workout_id.
|
||||||
|
workout_raw_json TEXT,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
```
|
||||||
|
|
||||||
|
with:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- Genuine raw get_workout_by_id() response, the source used to compute
|
||||||
|
-- alignWorkoutTargets. Null when the activity has no workout_id.
|
||||||
|
workout_raw_json TEXT,
|
||||||
|
-- Set when get_workout_by_id returned a definitive HTTP 404 (the
|
||||||
|
-- workout was deleted on Garmin's side after being linked to this
|
||||||
|
-- activity) -- distinct from workout_raw_json staying null for "not yet
|
||||||
|
-- fetched": this activity is excluded from ActivitiesMissingWorkout so
|
||||||
|
-- it stops being retried forever (see
|
||||||
|
-- docs/superpowers/specs/2026-07-27-workout-not-found-design.md).
|
||||||
|
-- workout_raw_json itself is never fabricated; it just stays null.
|
||||||
|
workout_not_found_at TEXT,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Update the Go struct, column list, and scan**
|
||||||
|
|
||||||
|
In `backend/internal/store/activities.go`, replace:
|
||||||
|
|
||||||
|
```go
|
||||||
|
// WorkoutRawJSON is the genuine raw get_workout_by_id() response for this
|
||||||
|
// activity's structured workout -- the source used to compute each lap's
|
||||||
|
// TargetPaceLowMps/HighMps and TargetHRLowBpm/HighBpm (see
|
||||||
|
// internal/sync/mapping.go's alignWorkoutTargets). Nil when the activity
|
||||||
|
// has no WorkoutID, or was synced before this column existed.
|
||||||
|
WorkoutRawJSON *string
|
||||||
|
CreatedAt string
|
||||||
|
UpdatedAt string
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
with:
|
||||||
|
|
||||||
|
```go
|
||||||
|
// WorkoutRawJSON is the genuine raw get_workout_by_id() response for this
|
||||||
|
// activity's structured workout -- the source used to compute each lap's
|
||||||
|
// TargetPaceLowMps/HighMps and TargetHRLowBpm/HighBpm (see
|
||||||
|
// internal/sync/mapping.go's alignWorkoutTargets). Nil when the activity
|
||||||
|
// has no WorkoutID, or was synced before this column existed.
|
||||||
|
WorkoutRawJSON *string
|
||||||
|
// WorkoutNotFoundAt is set when get_workout_by_id returned a definitive
|
||||||
|
// HTTP 404 for this activity's WorkoutID -- distinct from
|
||||||
|
// WorkoutRawJSON staying nil for "not yet fetched" (see
|
||||||
|
// SetActivityWorkoutNotFound).
|
||||||
|
WorkoutNotFoundAt *string
|
||||||
|
CreatedAt string
|
||||||
|
UpdatedAt string
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Replace:
|
||||||
|
|
||||||
|
```go
|
||||||
|
func scanActivity(row interface{ Scan(...any) error }) (Activity, error) {
|
||||||
|
var a Activity
|
||||||
|
err := row.Scan(
|
||||||
|
&a.ID, &a.GarminActivityID, &a.EventTypeKey, &a.WorkoutID, &a.StartTimeUTC,
|
||||||
|
&a.DurationSeconds, &a.DistanceMeters, &a.AvgHR, &a.MaxHR,
|
||||||
|
&a.AvgSpeedMps, &a.ElevationGainM,
|
||||||
|
&a.AerobicTrainingEffect, &a.AnaerobicTrainingEffect, &a.VO2MaxValue,
|
||||||
|
&a.RawJSON, &a.DetailsFetchedAt, &a.DetailsRawJSON, &a.SplitsFetchedAt, &a.WorkoutRawJSON,
|
||||||
|
&a.CreatedAt, &a.UpdatedAt,
|
||||||
|
)
|
||||||
|
return a, err
|
||||||
|
}
|
||||||
|
|
||||||
|
const activityColumns = `
|
||||||
|
id, garmin_activity_id, event_type_key, workout_id, start_time_utc,
|
||||||
|
duration_seconds, distance_meters, avg_hr, max_hr,
|
||||||
|
avg_speed_mps, elevation_gain_m,
|
||||||
|
aerobic_training_effect, anaerobic_training_effect, vo2max_value,
|
||||||
|
raw_json, details_fetched_at, details_raw_json, splits_fetched_at, workout_raw_json,
|
||||||
|
created_at, updated_at
|
||||||
|
`
|
||||||
|
```
|
||||||
|
|
||||||
|
with:
|
||||||
|
|
||||||
|
```go
|
||||||
|
func scanActivity(row interface{ Scan(...any) error }) (Activity, error) {
|
||||||
|
var a Activity
|
||||||
|
err := row.Scan(
|
||||||
|
&a.ID, &a.GarminActivityID, &a.EventTypeKey, &a.WorkoutID, &a.StartTimeUTC,
|
||||||
|
&a.DurationSeconds, &a.DistanceMeters, &a.AvgHR, &a.MaxHR,
|
||||||
|
&a.AvgSpeedMps, &a.ElevationGainM,
|
||||||
|
&a.AerobicTrainingEffect, &a.AnaerobicTrainingEffect, &a.VO2MaxValue,
|
||||||
|
&a.RawJSON, &a.DetailsFetchedAt, &a.DetailsRawJSON, &a.SplitsFetchedAt, &a.WorkoutRawJSON,
|
||||||
|
&a.WorkoutNotFoundAt, &a.CreatedAt, &a.UpdatedAt,
|
||||||
|
)
|
||||||
|
return a, err
|
||||||
|
}
|
||||||
|
|
||||||
|
const activityColumns = `
|
||||||
|
id, garmin_activity_id, event_type_key, workout_id, start_time_utc,
|
||||||
|
duration_seconds, distance_meters, avg_hr, max_hr,
|
||||||
|
avg_speed_mps, elevation_gain_m,
|
||||||
|
aerobic_training_effect, anaerobic_training_effect, vo2max_value,
|
||||||
|
raw_json, details_fetched_at, details_raw_json, splits_fetched_at, workout_raw_json,
|
||||||
|
workout_not_found_at, created_at, updated_at
|
||||||
|
`
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: Add `SetActivityWorkoutNotFound` and update the two query methods**
|
||||||
|
|
||||||
|
In `backend/internal/store/activities.go`, right after `SetActivityWorkout`, add:
|
||||||
|
|
||||||
|
```go
|
||||||
|
|
||||||
|
// SetActivityWorkoutNotFound records that get_workout_by_id returned a
|
||||||
|
// definitive HTTP 404 for this activity's WorkoutID -- the workout was
|
||||||
|
// deleted on Garmin's side after being linked to this activity.
|
||||||
|
// WorkoutRawJSON is deliberately left nil (never fabricated); this is a
|
||||||
|
// separate marker so ActivitiesMissingWorkout stops retrying it forever.
|
||||||
|
func (db *DB) SetActivityWorkoutNotFound(ctx context.Context, userID, activityID int64) error {
|
||||||
|
_, err := db.ExecContext(ctx, `
|
||||||
|
UPDATE activities SET workout_not_found_at = datetime('now'), updated_at = datetime('now')
|
||||||
|
WHERE id = ? AND user_id = ?`, activityID, userID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("set activity %d workout not found for user %d: %w", activityID, userID, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Replace:
|
||||||
|
|
||||||
|
```go
|
||||||
|
func (db *DB) ActivitiesMissingWorkout(ctx context.Context, userID int64, limit int) ([]Activity, error) {
|
||||||
|
rows, err := db.QueryContext(ctx, `SELECT `+activityColumns+` FROM activities
|
||||||
|
WHERE user_id = ? AND workout_id IS NOT NULL AND workout_raw_json IS NULL AND details_fetched_at IS NOT NULL
|
||||||
|
ORDER BY start_time_utc DESC LIMIT ?`, userID, limit)
|
||||||
|
```
|
||||||
|
|
||||||
|
with:
|
||||||
|
|
||||||
|
```go
|
||||||
|
func (db *DB) ActivitiesMissingWorkout(ctx context.Context, userID int64, limit int) ([]Activity, error) {
|
||||||
|
rows, err := db.QueryContext(ctx, `SELECT `+activityColumns+` FROM activities
|
||||||
|
WHERE user_id = ? AND workout_id IS NOT NULL AND workout_raw_json IS NULL
|
||||||
|
AND details_fetched_at IS NOT NULL AND workout_not_found_at IS NULL
|
||||||
|
ORDER BY start_time_utc DESC LIMIT ?`, userID, limit)
|
||||||
|
```
|
||||||
|
|
||||||
|
Replace:
|
||||||
|
|
||||||
|
```go
|
||||||
|
func (db *DB) CountActivitiesMissingWorkout(ctx context.Context, userID int64) (int, error) {
|
||||||
|
var n int
|
||||||
|
err := db.QueryRowContext(ctx, `SELECT COUNT(*) FROM activities
|
||||||
|
WHERE user_id = ? AND workout_id IS NOT NULL AND workout_raw_json IS NULL AND details_fetched_at IS NOT NULL`, userID).Scan(&n)
|
||||||
|
```
|
||||||
|
|
||||||
|
with:
|
||||||
|
|
||||||
|
```go
|
||||||
|
func (db *DB) CountActivitiesMissingWorkout(ctx context.Context, userID int64) (int, error) {
|
||||||
|
var n int
|
||||||
|
err := db.QueryRowContext(ctx, `SELECT COUNT(*) FROM activities
|
||||||
|
WHERE user_id = ? AND workout_id IS NOT NULL AND workout_raw_json IS NULL
|
||||||
|
AND details_fetched_at IS NOT NULL AND workout_not_found_at IS NULL`, userID).Scan(&n)
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 6: Run the test to verify it passes**
|
||||||
|
|
||||||
|
Run: `cd backend && go test ./internal/store/... -run TestActivitiesMissingWorkout -v`
|
||||||
|
Expected: PASS, including both the existing test and the new one.
|
||||||
|
|
||||||
|
- [ ] **Step 7: Regenerate the schema doc**
|
||||||
|
|
||||||
|
Run: `cd backend && go run ./cmd/dumpschema`
|
||||||
|
Expected: `docs/DATABASE.md` updates to include `workout_not_found_at` in the `activities` table.
|
||||||
|
|
||||||
|
- [ ] **Step 8: Run the full backend suite**
|
||||||
|
|
||||||
|
Run: `cd backend && go build ./... && go vet ./... && gofmt -l . && go test ./...`
|
||||||
|
Expected: all pass, `gofmt -l .` prints nothing.
|
||||||
|
|
||||||
|
- [ ] **Step 9: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add backend/internal/store/schema.sql backend/internal/store/activities.go backend/internal/store/store_test.go docs/DATABASE.md
|
||||||
|
git commit -m "$(cat <<'EOF'
|
||||||
|
feat(store): add workout_not_found_at, exclude it from missing-workout queries
|
||||||
|
|
||||||
|
A confirmed-404 workout must stop being retried forever, but
|
||||||
|
workout_raw_json should never be fabricated -- it stays null exactly
|
||||||
|
as it does for "not yet fetched." workout_not_found_at is the
|
||||||
|
separate marker ActivitiesMissingWorkout/CountActivitiesMissingWorkout
|
||||||
|
now check for, mirroring the existing pattern of the other _fetched_at
|
||||||
|
columns. UpsertActivity's ON CONFLICT clause already never touches
|
||||||
|
these columns, so the marker persists across every later re-sync.
|
||||||
|
EOF
|
||||||
|
)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 4: `fillPendingWorkouts` stops retrying a confirmed 404
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `../../../backend/internal/garmin/sync.go` (`fillPendingWorkouts`)
|
||||||
|
- Modify: `../../../backend/internal/garmin/sync_test.go` (new test)
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: `garmin.ErrNotFound` (Task 2), `db.SetActivityWorkoutNotFound` (Task 3).
|
||||||
|
- Produces: nothing new -- this is the final integration point for this fix.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing test**
|
||||||
|
|
||||||
|
Add to `../../../backend/internal/garmin/sync_test.go`, right after
|
||||||
|
`TestFillPendingDetails_OneWorkoutFetchFailureDoesNotAbortTheWorkoutsPass`:
|
||||||
|
|
||||||
|
```go
|
||||||
|
func TestFillPendingDetails_NotFoundWorkoutStopsBeingRetried(t *testing.T) {
|
||||||
|
db := openTestDB(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
userID := provisionTestUser(t, db)
|
||||||
|
|
||||||
|
const missingWorkoutID = 300
|
||||||
|
missingPtr := int64(missingWorkoutID)
|
||||||
|
m := &mock.Client{
|
||||||
|
Activities: []garmin.Activity{
|
||||||
|
{ActivityID: 1, ActivityType: garmin.ActivityType{TypeKey: "running"}, WorkoutID: &missingPtr,
|
||||||
|
StartTimeGMT: "2026-07-01 06:00:00", Distance: 5000, Duration: 1500},
|
||||||
|
},
|
||||||
|
Splits: map[int64]garmin.ActivitySplits{
|
||||||
|
1: {ActivityID: 1, Laps: []garmin.Lap{{LapIndex: 1, IntensityType: "ACTIVE"}}},
|
||||||
|
},
|
||||||
|
Details: map[int64]garmin.ActivityDetails{1: {ActivityID: 1}},
|
||||||
|
Workouts: map[int64]garmin.Workout{},
|
||||||
|
WorkoutErrByID: map[int64]error{missingWorkoutID: fmt.Errorf("API Error 404: %w", garmin.ErrNotFound)},
|
||||||
|
}
|
||||||
|
svc := NewService(m, db, userID, Config{InterCallDelay: time.Millisecond},
|
||||||
|
fixedNow(time.Date(2026, 7, 11, 0, 0, 0, 0, time.UTC)))
|
||||||
|
|
||||||
|
if _, err := svc.backfillCore(ctx); err != nil {
|
||||||
|
t.Fatalf("backfillCore: %v", err)
|
||||||
|
}
|
||||||
|
if err := svc.FillPendingDetails(ctx, 10); err != nil {
|
||||||
|
t.Fatalf("FillPendingDetails should not return an error for a confirmed-404 workout: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
remaining, err := db.CountActivitiesMissingWorkout(ctx, userID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CountActivitiesMissingWorkout: %v", err)
|
||||||
|
}
|
||||||
|
if remaining != 0 {
|
||||||
|
t.Fatalf("CountActivitiesMissingWorkout = %d, want 0 (confirmed-404 activity must not be retried)", remaining)
|
||||||
|
}
|
||||||
|
|
||||||
|
activities, err := db.ListActivities(ctx, userID, store.ActivityFilter{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListActivities: %v", err)
|
||||||
|
}
|
||||||
|
if len(activities) != 1 {
|
||||||
|
t.Fatalf("ListActivities returned %d activities, want 1", len(activities))
|
||||||
|
}
|
||||||
|
got, _, err := db.GetActivity(ctx, userID, activities[0].ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetActivity: %v", err)
|
||||||
|
}
|
||||||
|
if got.WorkoutNotFoundAt == nil {
|
||||||
|
t.Error("WorkoutNotFoundAt is nil, want it set")
|
||||||
|
}
|
||||||
|
if got.WorkoutRawJSON != nil {
|
||||||
|
t.Error("WorkoutRawJSON should stay nil -- not-found is recorded separately, not faked")
|
||||||
|
}
|
||||||
|
|
||||||
|
// A second FillPendingDetails call must not attempt this workout again
|
||||||
|
// (it's no longer in ActivitiesMissingWorkout's result set at all).
|
||||||
|
if err := svc.FillPendingDetails(ctx, 10); err != nil {
|
||||||
|
t.Fatalf("second FillPendingDetails: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run it to verify it fails**
|
||||||
|
|
||||||
|
Run: `cd backend && go test ./internal/sync/... -run TestFillPendingDetails_NotFoundWorkoutStopsBeingRetried -v`
|
||||||
|
Expected: FAIL -- `CountActivitiesMissingWorkout` still returns 1 (today's code retries forever
|
||||||
|
regardless of the error's nature).
|
||||||
|
|
||||||
|
- [ ] **Step 3: Update `fillPendingWorkouts`**
|
||||||
|
|
||||||
|
In `../../../backend/internal/garmin/sync.go`, replace:
|
||||||
|
|
||||||
|
```go
|
||||||
|
for i, a := range pending {
|
||||||
|
if i > 0 {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return ctx.Err()
|
||||||
|
case <-time.After(s.cfg.InterCallDelay):
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := s.fillActivityWorkout(ctx, a, profile); err != nil {
|
||||||
|
log.Printf("sync: fill workout for activity %d failed, will retry next sync: %v", a.GarminActivityID, err)
|
||||||
|
}
|
||||||
|
s.setProgress(PhaseWorkouts, i+1, len(pending))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
with:
|
||||||
|
|
||||||
|
```go
|
||||||
|
for i, a := range pending {
|
||||||
|
if i > 0 {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return ctx.Err()
|
||||||
|
case <-time.After(s.cfg.InterCallDelay):
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := s.fillActivityWorkout(ctx, a, profile); err != nil {
|
||||||
|
if errors.Is(err, garmin.ErrNotFound) {
|
||||||
|
// A definitive 404 (the workout was deleted on Garmin's side
|
||||||
|
// after being linked to this activity) will never succeed on
|
||||||
|
// retry -- mark it so ActivitiesMissingWorkout stops
|
||||||
|
// surfacing it, instead of retrying forever.
|
||||||
|
log.Printf("sync: workout for activity %d not found on Garmin, marking as such (will not retry): %v", a.GarminActivityID, err)
|
||||||
|
if serr := s.db.SetActivityWorkoutNotFound(ctx, s.userID, a.ID); serr != nil {
|
||||||
|
return fmt.Errorf("mark activity %d workout not found: %w", a.GarminActivityID, serr)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
log.Printf("sync: fill workout for activity %d failed, will retry next sync: %v", a.GarminActivityID, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
s.setProgress(PhaseWorkouts, i+1, len(pending))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Add `"errors"` to `sync.go`'s import block.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run the test to verify it passes**
|
||||||
|
|
||||||
|
Run: `cd backend && go test ./internal/sync/... -run TestFillPendingDetails_NotFoundWorkoutStopsBeingRetried -v`
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Run the full `internal/sync` test suite**
|
||||||
|
|
||||||
|
Run: `cd backend && go test ./internal/sync/... -v 2>&1 | tail -60`
|
||||||
|
Expected: every test passes, including
|
||||||
|
`TestFillPendingDetails_OneWorkoutFetchFailureDoesNotAbortTheWorkoutsPass` unchanged (its failure
|
||||||
|
is a plain `fmt.Errorf("garmin says no")`, not `ErrNotFound`-wrapped, so it still hits the
|
||||||
|
retry-next-sync branch exactly as before).
|
||||||
|
|
||||||
|
- [ ] **Step 6: Run the full backend suite**
|
||||||
|
|
||||||
|
Run: `cd backend && go build ./... && go vet ./... && gofmt -l . && go test ./...`
|
||||||
|
Expected: all pass, `gofmt -l .` prints nothing.
|
||||||
|
|
||||||
|
- [ ] **Step 7: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add backend/internal/sync/sync.go backend/internal/sync/sync_test.go
|
||||||
|
git commit -m "$(cat <<'EOF'
|
||||||
|
fix(sync): stop retrying a workout Garmin confirms is gone
|
||||||
|
|
||||||
|
fillPendingWorkouts previously treated every get_workout_by_id
|
||||||
|
failure identically -- log it, leave workout_raw_json null, retry
|
||||||
|
next sync -- which is correct for a transient failure but means a
|
||||||
|
definitive 404 (the workout deleted on Garmin's side after being
|
||||||
|
linked to an activity) got silently retried forever, with the only
|
||||||
|
symptom being an unexplained, permanent "1 more workout pending"
|
||||||
|
nudge. Now checks errors.Is(err, garmin.ErrNotFound) and, only for
|
||||||
|
that case, calls SetActivityWorkoutNotFound instead of retrying.
|
||||||
|
EOF
|
||||||
|
)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Final verification
|
||||||
|
|
||||||
|
- [ ] Run the full backend suite one more time: `cd backend && go build ./... && go vet ./... && gofmt -l . && go test ./...`
|
||||||
|
- [ ] Run the Python wrapper tests one more time: `cd backend/internal/garmin/pyscript && python3 -m pytest tests/ -v`
|
||||||
|
- [ ] Confirm `docs/DATABASE.md` reflects the new `workout_not_found_at` column.
|
||||||
|
- [ ] Use superpowers:finishing-a-development-branch to wrap up (tests green -> present the
|
||||||
|
merge/PR/keep-as-is menu).
|
||||||
1017
docs/superpowers/plans/2026-08-03-configuration.md
Normal file
1017
docs/superpowers/plans/2026-08-03-configuration.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -23,7 +23,7 @@ This replaces the MCP transport with a small custom Python subprocess wrapper ar
|
|||||||
- No change to what data geniusrun actually syncs/stores/classifies today — only the transport between Go and Python changes. `internal/sync`, `internal/classify`, `internal/store`, and the frontend are untouched.
|
- No change to what data geniusrun actually syncs/stores/classifies today — only the transport between Go and Python changes. `internal/sync`, `internal/classify`, `internal/store`, and the frontend are untouched.
|
||||||
- No security sandboxing of the generic dispatcher (see below) — the subprocess is spoken to only by geniusrun's own Go process over a private pipe it owns, not exposed to any other caller, so an open dispatch surface is an accepted risk, not a gap.
|
- No security sandboxing of the generic dispatcher (see below) — the subprocess is spoken to only by geniusrun's own Go process over a private pipe it owns, not exposed to any other caller, so an open dispatch surface is an accepted risk, not a gap.
|
||||||
- No auto-restart-on-crash logic for the subprocess. Matches today's behavior: a dead subprocess surfaces as a Go error on next use; only `UpdateCredentials` explicitly tears down and respawns.
|
- No auto-restart-on-crash logic for the subprocess. Matches today's behavior: a dead subprocess surfaces as a Go error on next use; only `UpdateCredentials` explicitly tears down and respawns.
|
||||||
- No change to the per-user token store layout (`{GarminTokenStoreRoot}/{userID}`) or the multi-tenant `garminFor` caching in `api.Server`.
|
- No change to the per-user token store layout (`{GarminTokenStoreRoot}/{userID}`) or the multi-tenant `clientFor` caching in `api.Server`.
|
||||||
|
|
||||||
## Wire protocol
|
## Wire protocol
|
||||||
|
|
||||||
@@ -99,5 +99,5 @@ Go's line reader uses a raised buffer size (well above the default 64KB `bufio.S
|
|||||||
## Rollout notes
|
## Rollout notes
|
||||||
|
|
||||||
- No data migration involved — this only changes how the Go backend talks to Garmin, not what's stored.
|
- No data migration involved — this only changes how the Go backend talks to Garmin, not what's stored.
|
||||||
- Existing per-user token stores under `GarminTokenStoreRoot` are unaffected (same `GARMIN_TOKENSTORE` env var name passed to the subprocess, same directory layout) — no re-login required for existing users after cutover.
|
- Existing per-user token stores under `TokenStoreRoot` are unaffected (same `GARMIN_TOKENSTORE` env var name passed to the subprocess, same directory layout) — no re-login required for existing users after cutover.
|
||||||
- Deploy order: ship the new wrapper + Go client together as one change (interface unchanged, so this is an internal swap, not a rolling/compat-sensitive migration).
|
- Deploy order: ship the new wrapper + Go client together as one change (interface unchanged, so this is an internal swap, not a rolling/compat-sensitive migration).
|
||||||
|
|||||||
@@ -85,7 +85,7 @@ A new middleware step runs immediately after the existing `RequireSession` (role
|
|||||||
`internal/garmin.Client` and `internal/sync.Service` already take their config/dependencies as constructor params with no hidden global state, so per-user instantiation is mechanical:
|
`internal/garmin.Client` and `internal/sync.Service` already take their config/dependencies as constructor params with no hidden global state, so per-user instantiation is mechanical:
|
||||||
|
|
||||||
- `api.Server`'s current single fixed `Garmin`/`Sync` fields and flat Garmin-auth-status fields (today: plain fields on `Server`, single-user assumption) become two mutex-guarded maps: `map[int64]*garmin.Client` and `map[int64]*sync.Service`, keyed by `user_id`, lazily constructed on first access for a given user.
|
- `api.Server`'s current single fixed `Garmin`/`Sync` fields and flat Garmin-auth-status fields (today: plain fields on `Server`, single-user assumption) become two mutex-guarded maps: `map[int64]*garmin.Client` and `map[int64]*sync.Service`, keyed by `user_id`, lazily constructed on first access for a given user.
|
||||||
- A per-user `garmin.Client` is built from that user's own `profile` row (`GarminEmail`/`GarminPassword`) and a per-user token store path: `filepath.Join(cfg.GarminTokenStoreRoot, strconv.FormatInt(userID, 10))`. `GarminTokenStore` in `internal/config` is renamed/repurposed to `GarminTokenStoreRoot` (a directory, not a single tokenstore path) to reflect this.
|
- A per-user `garmin.Client` is built from that user's own `profile` row (`GarminEmail`/`GarminPassword`) and a per-user token store path: `filepath.Join(cfg.GarminTokenStoreRoot, strconv.FormatInt(userID, 10))`. `GarminTokenStore` in `internal/config` is renamed/repurposed to `TokenStoreRoot` (a directory, not a single tokenstore path) to reflect this.
|
||||||
- `sync.Service.Progress()` becomes naturally per-user once each user has their own `Service` instance — no separate change needed there.
|
- `sync.Service.Progress()` becomes naturally per-user once each user has their own `Service` instance — no separate change needed there.
|
||||||
- `store.DB` stays a single shared connection against one SQLite file — this design keeps the single-shared-DB-with-`user_id`-columns approach, so no per-user DB file/connection is needed.
|
- `store.DB` stays a single shared connection against one SQLite file — this design keeps the single-shared-DB-with-`user_id`-columns approach, so no per-user DB file/connection is needed.
|
||||||
|
|
||||||
@@ -125,5 +125,5 @@ Every existing handler in `internal/api` that reads/writes `profile`, `activitie
|
|||||||
## Rollout notes
|
## Rollout notes
|
||||||
|
|
||||||
- Before deploying: log into the current single-tenant build once, note your `sub` from `GET /api/session/me`, and set `GENIUSRUN_LEGACY_OWNER_OIDC_SUB` to it for the first startup after upgrading.
|
- Before deploying: log into the current single-tenant build once, note your `sub` from `GET /api/session/me`, and set `GENIUSRUN_LEGACY_OWNER_OIDC_SUB` to it for the first startup after upgrading.
|
||||||
- `GARMIN_TOKENSTORE` env var / `GarminTokenStore` config field is repurposed as a root directory (`GarminTokenStoreRoot`) rather than a single tokenstore path — existing deployments need to point it at a directory rather than mcp-garmin's old single-file/dir location, and any already-cached Garmin session under the old path is effectively invalidated (that one user will need to log into Garmin again post-migration, once, under their new per-user subdirectory).
|
- `GARMIN_TOKENSTORE` env var / `GarminTokenStore` config field is repurposed as a root directory (`TokenStoreRoot`) rather than a single tokenstore path — existing deployments need to point it at a directory rather than mcp-garmin's old single-file/dir location, and any already-cached Garmin session under the old path is effectively invalidated (that one user will need to log into Garmin again post-migration, once, under their new per-user subdirectory).
|
||||||
- No frontend routing changes beyond the new setup screen — everything else (Dashboard, Review Queue, Plan stub, Profile) is unchanged in shape, just now reflecting whichever user is logged in.
|
- No frontend routing changes beyond the new setup screen — everything else (Dashboard, Review Queue, Plan stub, Profile) is unchanged in shape, just now reflecting whichever user is logged in.
|
||||||
|
|||||||
@@ -0,0 +1,163 @@
|
|||||||
|
# Improve the first connection page
|
||||||
|
|
||||||
|
Status: approved, not yet implemented.
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
`docs/IDEAS.md` backlog item: "improve first connection page: in
|
||||||
|
CreateProfile.tsx, in addition to the display name, we should directly ask
|
||||||
|
garmin login/password and manage MFA from there, so the synchronization
|
||||||
|
experience in profile page will be easier later on".
|
||||||
|
|
||||||
|
Today, `CreateProfile.tsx` only asks for a display name (`POST /api/setup`)
|
||||||
|
and drops the user straight into the main app -- Garmin credentials are
|
||||||
|
filled in later, whenever the user happens to visit the Profile page. This
|
||||||
|
means a brand-new account can sit around fully "set up" but never actually
|
||||||
|
synced, with no prompt nudging the user to connect Garmin.
|
||||||
|
|
||||||
|
## Design
|
||||||
|
|
||||||
|
### Scope decision: mandatory, persisted gate
|
||||||
|
|
||||||
|
Connecting Garmin becomes a **hard requirement** to enter the app, enforced
|
||||||
|
on every login, not just the first one -- confirmed explicitly, including
|
||||||
|
the corollary that this can't be a purely in-session check: account
|
||||||
|
provisioning (`POST /api/setup`) must happen before Garmin login can even be
|
||||||
|
attempted (the per-user Garmin client is keyed by an already-existing
|
||||||
|
account), so a user who abandons the flow mid-connect already has a
|
||||||
|
`users`/`profile` row. Enforcing the gate on *every subsequent login* (not
|
||||||
|
just immediately after signup) requires a persisted "has this account ever
|
||||||
|
successfully connected" flag -- the in-memory `Server.userAuthStatus` map
|
||||||
|
resets on every backend restart and can't be trusted for this.
|
||||||
|
|
||||||
|
Also confirmed: both `CreateProfile` and the new gate screen get a "Log out"
|
||||||
|
link (neither has one today), since a mandatory gate a user can get
|
||||||
|
genuinely stuck on (no Garmin credentials handy right now) needs an escape
|
||||||
|
hatch back to the login screen.
|
||||||
|
|
||||||
|
### Data model
|
||||||
|
|
||||||
|
`schema.sql`, `profile` table -- one new nullable column:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- Set once, the first time this user successfully authenticates with
|
||||||
|
-- Garmin (handleAuthLogin/handleAuthMFA). Null means never connected --
|
||||||
|
-- the login gate uses this (not the in-memory auth status, which
|
||||||
|
-- resets on restart) to decide whether a returning user must
|
||||||
|
-- reconnect before entering the app.
|
||||||
|
garmin_connected_at TEXT,
|
||||||
|
```
|
||||||
|
|
||||||
|
`store.Profile` gets a matching `GarminConnectedAt *string` field (same
|
||||||
|
nullable-TEXT-pointer pattern as `sync_state.EarliestSyncedDate`).
|
||||||
|
|
||||||
|
New store method (`internal/store/users.go` or a new small file, e.g.
|
||||||
|
`profiles.go` wherever `UpdateProfile`/`GetProfile` already live):
|
||||||
|
|
||||||
|
```go
|
||||||
|
// MarkGarminConnected records the first time userID successfully
|
||||||
|
// 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
|
||||||
|
```
|
||||||
|
|
||||||
|
Implemented as `UPDATE profile SET garmin_connected_at = datetime('now')
|
||||||
|
WHERE user_id = ? AND garmin_connected_at IS NULL`.
|
||||||
|
|
||||||
|
After the schema edit, regenerate `docs/DATABASE.md` via `go run
|
||||||
|
./cmd/dumpschema` (per project convention).
|
||||||
|
|
||||||
|
### Backend API
|
||||||
|
|
||||||
|
- **`handleGarminAuthLogin`/`handleGarminAuthMFA`** (`internal/api/auth.go`): right after
|
||||||
|
the existing `s.recordAuthResult(userID, res)` call, if `res.Status ==
|
||||||
|
garmin.AuthSuccess`, call `s.DB.MarkGarminConnected(r.Context(), userID)`.
|
||||||
|
A failure here is logged and does *not* fail the HTTP response -- the
|
||||||
|
Garmin authentication itself already succeeded; failing to persist the
|
||||||
|
flag is a non-fatal side effect (the next successful login attempt would
|
||||||
|
just set it then).
|
||||||
|
- **`sessionMeResponse`** (`internal/api/session.go`): add `GarminConnected
|
||||||
|
bool \`json:"garmin_connected"\``.
|
||||||
|
- **`handleSessionMe`**: when the session already has a profile, fetch it
|
||||||
|
(`s.DB.GetProfile`) and set `resp.GarminConnected = profile.GarminConnectedAt
|
||||||
|
!= nil`.
|
||||||
|
- No other endpoint changes. The new frontend screen calls the exact same
|
||||||
|
`PUT /api/profile`, `POST /api/auth/login`, `POST /api/auth/mfa` the
|
||||||
|
existing Profile page already uses.
|
||||||
|
|
||||||
|
### Frontend flow
|
||||||
|
|
||||||
|
- **`types/api.ts`**: `SessionInfo` gains `garmin_connected: boolean`.
|
||||||
|
- **`LoginGate.tsx`**: becomes a three-way fork instead of two:
|
||||||
|
```
|
||||||
|
!authenticated -> login screen
|
||||||
|
authenticated && !has_profile -> CreateProfile
|
||||||
|
authenticated && has_profile && !garmin_connected -> ConnectGarmin
|
||||||
|
authenticated && has_profile && garmin_connected -> App
|
||||||
|
```
|
||||||
|
`CreateProfile.onCreated` and the new `ConnectGarmin.onConnected` both just
|
||||||
|
flip the relevant boolean in the local `session` state object (same
|
||||||
|
pattern `has_profile` already uses).
|
||||||
|
- **`CreateProfile.tsx`**: behavior unchanged (display name ->
|
||||||
|
`POST /api/setup`); it's simply no longer the last screen. Gains a
|
||||||
|
"Log out" link (`<form method="post" action="{BASE_URL}/api/session/logout">`,
|
||||||
|
same pattern as `App.tsx`'s header).
|
||||||
|
- **New `frontend/src/ConnectGarmin.tsx`**: a dedicated, self-contained
|
||||||
|
onboarding screen (not a reuse of `GarminConnection.tsx`, to avoid
|
||||||
|
coupling that shared Profile-page component to an onboarding-only special
|
||||||
|
case):
|
||||||
|
- Email + password fields.
|
||||||
|
- "Connect" button: `PUT /api/profile` (persist credentials into the
|
||||||
|
profile row created by setup) then `POST /api/auth/login`.
|
||||||
|
- If the result is `mfa_required`, show an MFA code field -> `POST
|
||||||
|
/api/auth/mfa`.
|
||||||
|
- On `authenticated`, show a brief confirmation and a "Continue to
|
||||||
|
geniusrun" button that calls `onConnected()` -- no sync is
|
||||||
|
auto-triggered here; the existing background incremental-sync loop and
|
||||||
|
the Profile page's "Sync now" handle actual data fetching, keeping this
|
||||||
|
screen scoped to connecting only.
|
||||||
|
- On `failed` (wrong password, Garmin unreachable, rate-limited), show the
|
||||||
|
returned message and leave the fields editable for a retry -- never a
|
||||||
|
dead end.
|
||||||
|
- Also gains a "Log out" link.
|
||||||
|
|
||||||
|
### Error handling
|
||||||
|
|
||||||
|
- Wrong credentials / Garmin down / rate-limited: surfaced via the same
|
||||||
|
`authResponse.message` field `GarminConnection.tsx` already displays;
|
||||||
|
fields stay editable, retry is always available.
|
||||||
|
- `MarkGarminConnected` DB failure: logged server-side, never surfaced to
|
||||||
|
the user and never fails the auth response (see Backend API above).
|
||||||
|
- Abandoning the flow (closing the tab) leaves the account provisioned but
|
||||||
|
not connected -- the *next* login re-enters at `ConnectGarmin` (that's the
|
||||||
|
whole point of persisting the flag), not at `CreateProfile` again.
|
||||||
|
|
||||||
|
### Testing
|
||||||
|
|
||||||
|
- `internal/store`: `MarkGarminConnected` sets the timestamp exactly once
|
||||||
|
(a second call is a no-op, preserving the first-connection time);
|
||||||
|
adversarial isolation test that marking user A connected never affects
|
||||||
|
user B's `GarminConnectedAt`.
|
||||||
|
- `internal/api`: `POST /api/auth/login` (via `mock.Client` returning
|
||||||
|
`AuthSuccess`) followed by `GET /api/session/me` reports `garmin_connected:
|
||||||
|
true`; an `mfa_required` or `failed` result does *not* set it; a freshly
|
||||||
|
provisioned user (via `newTestServer`, no Garmin attempt yet) reports
|
||||||
|
`garmin_connected: false`.
|
||||||
|
- No frontend test suite exists yet (per `CLAUDE.md`) -- manual smoke test:
|
||||||
|
fresh account -> display name -> forced into `ConnectGarmin` -> wrong
|
||||||
|
password shows an error and allows retry -> correct password (or MFA)
|
||||||
|
resolved -> lands in `<App>` -> log out -> log back in with the same
|
||||||
|
account -> confirm it goes straight to `<App>` (not back through
|
||||||
|
`ConnectGarmin`), proving `garmin_connected_at` actually persisted.
|
||||||
|
|
||||||
|
## Out of scope
|
||||||
|
|
||||||
|
- Auto-triggering a first sync from `ConnectGarmin` -- deferred to the
|
||||||
|
existing "Sync now" / background incremental-sync mechanisms.
|
||||||
|
- Any "reconnect required" re-gating if Garmin credentials later go stale
|
||||||
|
(e.g. password changed on Garmin's side) -- this feature only covers the
|
||||||
|
*first-ever* successful connection; subsequent reconnects still go through
|
||||||
|
the normal Profile page flow.
|
||||||
|
- Combining the display-name and Garmin-credential steps into a single
|
||||||
|
form/request -- kept as two sequential screens since Garmin login
|
||||||
|
requires an already-provisioned account (see Scope decision above).
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
# Improve Synchronization UX — Design
|
||||||
|
|
||||||
|
**Source idea:** `docs/IDEAS.md` — "improve synchronization: make it modal, split downloads of
|
||||||
|
activities and workouts (as workouts are currently downloaded from their ID in related
|
||||||
|
activities), add progress bar (which requires to know in advance how many activities or
|
||||||
|
workouts will have to be downloaded)."
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Replace the Profile page's single inline "syncing: N/M activities" text line with a blocking
|
||||||
|
modal that shows real progress through sync, broken into named phases the user can actually
|
||||||
|
follow: discovering new activities, fetching activity details, then fetching workouts.
|
||||||
|
|
||||||
|
## Cleanup folded into this work: dead `Backfill`/`IncrementalSync` wrappers
|
||||||
|
|
||||||
|
`Service.Backfill(ctx)` and `Service.IncrementalSync(ctx)` are exported methods that each record
|
||||||
|
their own `SyncRun`, wrapping `backfillCore`/`incrementalSyncCore`. Their doc comments say
|
||||||
|
they're used "by the periodic background loop" -- but that loop was removed in an earlier
|
||||||
|
session (`4d2cbe4 refactor: remove automatic background incremental sync`), and grepping
|
||||||
|
`internal/api/` and `cmd/` confirms nothing in production calls them anymore. `FullSync` (the
|
||||||
|
only production caller of the core logic) calls `backfillCore`/`incrementalSyncCore` directly.
|
||||||
|
Only `internal/sync/service_test.go` still calls `Backfill`/`IncrementalSync`, which is the only
|
||||||
|
reason they're not already flagged as unused by the compiler.
|
||||||
|
|
||||||
|
Since this plan already restructures `sync.go`'s progress model, remove these two dead
|
||||||
|
exported methods (and their now-inaccurate doc comments) as an early task, rewriting the tests
|
||||||
|
that called them to exercise the same behavior through `FullSync` or the `*Core` functions
|
||||||
|
directly (same package, so unexported functions are still directly testable) -- before any of
|
||||||
|
the progress-model changes below, so later tasks aren't touching code that's about to be
|
||||||
|
deleted.
|
||||||
|
|
||||||
|
## Current state (for reference)
|
||||||
|
|
||||||
|
- `sync.Service.Progress()` returns a flat `{Done, Total}`, written only by
|
||||||
|
`FillPendingDetails` — `Backfill`/`IncrementalSync` (the "discover which activities exist"
|
||||||
|
phase) report no progress at all today.
|
||||||
|
- `FillPendingDetails` fetches, per activity, in one pass: `GetActivitySplits`,
|
||||||
|
`GetActivityDetails`, and (if the activity has a `workout_id`) `GetWorkoutByID` — then writes
|
||||||
|
samples, laps (with workout-derived target pace/HR bands baked in), activity details, and
|
||||||
|
marks splits fetched.
|
||||||
|
- `GET /api/sync/status` returns `{in_progress, detail_fill_progress: {Done, Total},
|
||||||
|
activities_pending_details, last_run?}`. The frontend (`GarminConnection.tsx`) polls this and
|
||||||
|
renders an inline `<p>` line, both while syncing and once idle (a static "last sync: ..."
|
||||||
|
summary).
|
||||||
|
- `sync_runs` stores one row per `Backfill`/`IncrementalSync`/`FullSync` call
|
||||||
|
(`kind`/`started_at`/`finished_at`/`activities_fetched`/`status`/`error_message`).
|
||||||
|
`GET /api/sync/runs` exists but nothing in the frontend calls it.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
**In scope:** "Sync now" (`FullSync`) gets the new modal and phase-aware progress. The static
|
||||||
|
idle "last sync" summary on the Profile page is removed — the modal becomes the only place
|
||||||
|
sync status/results are shown; closing it means nothing is shown again until the next sync.
|
||||||
|
|
||||||
|
**Out of scope:** "Reset all" keeps its current behavior unchanged (confirm dialog, fire, poll
|
||||||
|
via a blocking `while` loop, reload the page) — no modal, no progress UI. The "modern error
|
||||||
|
displays" idea (ephemeral banners, backend-down handling) from the same backlog section is a
|
||||||
|
separate, later feature — this one only makes sync's own errors visible via the modal's final
|
||||||
|
state, reusing the `sync_runs.error_message` field that's already fetched but never rendered
|
||||||
|
today.
|
||||||
|
|
||||||
|
## Backend design
|
||||||
|
|
||||||
|
### Phase-aware progress
|
||||||
|
|
||||||
|
```go
|
||||||
|
type Progress struct {
|
||||||
|
Phase string // "idle" | "discovering" | "activities" | "workouts"
|
||||||
|
Done int
|
||||||
|
Total int
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`FullSync` drives the phase transitions:
|
||||||
|
|
||||||
|
1. **`discovering`** — set (Done=0, Total=0, indeterminate) while `Backfill`/`IncrementalSync`
|
||||||
|
run. These still report no count; the modal shows a spinner, not a bar, during this phase.
|
||||||
|
2. **`activities`** — `FillPendingDetails` is split into two sequential passes. The first pass
|
||||||
|
fetches `GetActivitySplits`+`GetActivityDetails`+samples for every activity missing details
|
||||||
|
(`ActivitiesMissingDetails`/`CountActivitiesMissingDetails`, unchanged queries), writing laps
|
||||||
|
*without* workout-target alignment yet. `Total` is the pending-details count taken once at
|
||||||
|
the start of this pass; `Done` advances per activity.
|
||||||
|
3. **`workouts`** — a new second pass. `workout_id` is decoded from Garmin's activity summary
|
||||||
|
and stored on the `activities` row at upsert time, well before any detail fetch — so
|
||||||
|
"activities needing a workout fetched" is an independently queryable condition
|
||||||
|
(`workout_id IS NOT NULL AND workout_raw_json IS NULL`), needing no live Garmin call to
|
||||||
|
count. Two new store methods, `ActivitiesMissingWorkout`/`CountActivitiesMissingWorkout`
|
||||||
|
(mirroring the naming of the existing `ActivitiesMissingDetails`/
|
||||||
|
`CountActivitiesMissingDetails`), list/count these. For each: fetch `GetWorkoutByID`,
|
||||||
|
re-derive lap target bands from the just-stored lap data (`alignWorkoutTargets`), update
|
||||||
|
the laps and set `workout_raw_json`. This condition also picks up any older activity that
|
||||||
|
has a `workout_id` but never got its workout aligned in some prior run (e.g. a past
|
||||||
|
transient failure) — not just ones touched in this run's `activities` pass — a small
|
||||||
|
latent-bug fix as a side effect.
|
||||||
|
4. **`idle`** — reset to `{Phase: "idle", Done: 0, Total: 0}` once `FullSync` returns, same
|
||||||
|
`defer`-based reset as today.
|
||||||
|
|
||||||
|
Restructuring `fillActivityDetails` to decouple lap-writing from workout-target alignment
|
||||||
|
means an activity with a workout gets its laps written twice (once plain in the `activities`
|
||||||
|
pass, once updated with target bands in the `workouts` pass) — this is a second local SQL
|
||||||
|
delete+insert, not a second Garmin API call, so it costs nothing against rate limits.
|
||||||
|
|
||||||
|
### API
|
||||||
|
|
||||||
|
`GET /api/sync/status` changes shape:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"in_progress": true,
|
||||||
|
"progress": { "phase": "workouts", "done": 3, "total": 8 },
|
||||||
|
"activities_pending_details": 0,
|
||||||
|
"last_run": { "...": "SyncRun, unchanged fields" }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
This replaces `detail_fill_progress`/`DetailFillProgress` outright (no other consumer exists).
|
||||||
|
`SyncRun.Kind`'s frontend type also gains its missing `"full"` value (backend has always been
|
||||||
|
able to report it; the frontend type was just never updated to match).
|
||||||
|
|
||||||
|
## Frontend design
|
||||||
|
|
||||||
|
`GarminConnection.tsx` keeps its connect/MFA/Reset-all logic and buttons untouched, but loses
|
||||||
|
its `garminSyncStatus` polling, `syncProgressLabel` helper, and all inline sync-progress/last-sync
|
||||||
|
JSX.
|
||||||
|
|
||||||
|
A new `SyncModal.tsx`:
|
||||||
|
|
||||||
|
- Renders as a blocking overlay (page behind it non-interactive) the moment `api.syncRun()`
|
||||||
|
resolves successfully.
|
||||||
|
- Polls `api.syncStatus()` every 1500ms while open (same cadence as today's polling).
|
||||||
|
- Renders by `progress.phase`:
|
||||||
|
- `discovering` → spinner + "Discovering activities…"
|
||||||
|
- `activities` → progress bar + "Activities: {done}/{total}"
|
||||||
|
- `workouts` → progress bar + "Workouts: {done}/{total}"
|
||||||
|
- Once `in_progress` becomes `false`: shows `last_run.Status`, `ActivitiesFetched`, and
|
||||||
|
`ErrorMessage` (if the run errored) plus the existing "N more pending — sync again" nudge if
|
||||||
|
`activities_pending_details > 0`, and a **Close** button. No auto-close, no timeout — the
|
||||||
|
user decides when to dismiss it (this is the one place sync errors are visible, so nothing
|
||||||
|
should hide it automatically).
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
- `internal/sync`: new/updated tests for the two-phase `FillPendingDetails` split (an activity
|
||||||
|
with a workout gets its laps written in both passes, target bands only present after the
|
||||||
|
`workouts` pass; an activity without a workout is untouched by the `workouts` pass), and for
|
||||||
|
phase-aware `Progress()` transitions.
|
||||||
|
- `internal/api`: `sync_test.go` updated for the new `/api/sync/status` JSON shape.
|
||||||
|
- Frontend: no test suite exists (per CLAUDE.md) — manual smoke test against `seedsample` data,
|
||||||
|
watching the modal move through all four phases.
|
||||||
@@ -0,0 +1,232 @@
|
|||||||
|
# Onboarding wizard with deferred DB commit
|
||||||
|
|
||||||
|
Status: approved, not yet implemented.
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
Feedback on the just-shipped mandatory Garmin-connect onboarding
|
||||||
|
(`docs/superpowers/specs/2026-07-26-improve-first-connection-design.md`):
|
||||||
|
|
||||||
|
1. UX: a wizard-style Previous/Next pair reads better than the current
|
||||||
|
Continue/Log out pairing across `CreateProfile` → `ConnectGarmin`.
|
||||||
|
2. If the user quits mid-flow without logging out, the account is left in a
|
||||||
|
provisioned-but-not-Garmin-connected state -- confirmed this should
|
||||||
|
instead mean **nothing is written to the database at all** until Garmin
|
||||||
|
authentication actually succeeds, at which point display name + Garmin
|
||||||
|
credentials + the connected flag are all committed together, atomically.
|
||||||
|
|
||||||
|
(A third piece of feedback, structured JSON logging, is unrelated and gets
|
||||||
|
its own design afterward.)
|
||||||
|
|
||||||
|
## Design
|
||||||
|
|
||||||
|
### Consequence: the three-way LoginGate fork collapses
|
||||||
|
|
||||||
|
Since account provisioning and Garmin-connection now happen together in one
|
||||||
|
atomic step, `has_profile=true && garmin_connected=false` becomes
|
||||||
|
structurally unreachable -- confirmed to collapse `LoginGate` back to a
|
||||||
|
two-way fork (`has_profile ? App : OnboardingWizard`). `profile.garmin_connected_at`
|
||||||
|
stays as an informational timestamp (still written by the new atomic
|
||||||
|
commit); it just no longer gates anything on its own.
|
||||||
|
|
||||||
|
### The core problem: Garmin auth needs an account today
|
||||||
|
|
||||||
|
`clientFor` builds a per-user `garmin.Client` keyed by a DB user id and
|
||||||
|
reads Garmin credentials from that user's `profile` row. To authenticate
|
||||||
|
with Garmin *before* an account exists, onboarding needs a **temporary,
|
||||||
|
not-yet-persisted** Garmin session -- keyed by the OIDC subject (already
|
||||||
|
known from the session cookie, before any `users` row exists) instead of a
|
||||||
|
user id. A long-held-open SQL transaction across the wizard's multiple
|
||||||
|
HTTP round trips is not an option: SQLite's single connection
|
||||||
|
(`SetMaxOpenConns(1)`, see `db.go`) means one held-open transaction would
|
||||||
|
block every other request in the app for as long as the user takes to
|
||||||
|
finish onboarding.
|
||||||
|
|
||||||
|
### Backend: ephemeral pre-account Garmin sessions
|
||||||
|
|
||||||
|
New type and `Server` field (`server.go`, alongside the existing
|
||||||
|
`userClient`/`userSync`/etc. per-user maps):
|
||||||
|
|
||||||
|
```go
|
||||||
|
// 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)
|
||||||
|
// rather than a user id. Promoted into Server.userGarmin once
|
||||||
|
// /api/setup/complete actually creates the account; evicted lazily (the
|
||||||
|
// next setup-endpoint touch for that subject checks staleness first) once
|
||||||
|
// idle past setupSessionIdleTimeout.
|
||||||
|
type setupSession struct {
|
||||||
|
Client garmin.Client
|
||||||
|
Email, Password string
|
||||||
|
Status garmin.AuthStatus
|
||||||
|
Message string
|
||||||
|
LastUsed time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
const setupSessionIdleTimeout = 15 * time.Minute
|
||||||
|
```
|
||||||
|
|
||||||
|
`Server` gains `setupGarmin map[string]*setupSession` (keyed by OIDC
|
||||||
|
subject string), initialized alongside the other maps in `NewServer`.
|
||||||
|
|
||||||
|
Helper methods on `Server` (`server.go`):
|
||||||
|
|
||||||
|
- `setupSessionFor(sub string) (*setupSession, bool)` -- returns the
|
||||||
|
session if present and not stale; a stale one is closed and evicted
|
||||||
|
first (lazy cleanup, no background sweep goroutine -- an abandoned
|
||||||
|
session otherwise leaks one idle subprocess until the backend restarts,
|
||||||
|
an accepted trade-off for a single-operator app).
|
||||||
|
- `replaceSetupSession(sub, email, password string) *setupSession` --
|
||||||
|
closes and replaces any existing session for `sub` (same "close old,
|
||||||
|
spawn new" semantics as `garmin.Client.UpdateCredentials`), builds a
|
||||||
|
fresh ephemeral client whose `TokenStorePath` is namespaced under
|
||||||
|
`{root}/setup/{sub}` (distinct from the permanent `{root}/{userID}`
|
||||||
|
namespacing `clientFor` uses, so two people onboarding concurrently never
|
||||||
|
collide, and so the ephemeral session's persisted Garmin tokens have
|
||||||
|
*some* home even though no user id exists yet).
|
||||||
|
- `recordSetupAuthResult(sub string, res garmin.AuthResult)` -- updates a
|
||||||
|
session's `Status`/`Message`/`LastUsed` after a login or MFA attempt.
|
||||||
|
- `removeSetupSession(sub string)` -- closes and drops `sub`'s session
|
||||||
|
(best-effort removes its `{root}/setup/{sub}` token-store directory too).
|
||||||
|
Called both when `/api/setup/complete` promotes a session into the
|
||||||
|
permanent cache, and from `handleSessionLogout` so abandoning mid-wizard
|
||||||
|
via logout doesn't linger until the idle timeout.
|
||||||
|
|
||||||
|
### Backend: three new endpoints replace `/api/setup`
|
||||||
|
|
||||||
|
Registered where `r.Post("/setup", s.handleSetup)` used to be (same
|
||||||
|
middleware group: `RequireSession` + `resolveUser`, but *not*
|
||||||
|
`requireProvisionedUser` -- these must work before a profile exists):
|
||||||
|
|
||||||
|
```go
|
||||||
|
r.Route("/setup", func(r chi.Router) {
|
||||||
|
r.Post("/complete", s.handleSetupComplete)
|
||||||
|
r.Route("/garmin", func(r chi.Router) {
|
||||||
|
r.Post("/login", s.handleSetupGarminLogin)
|
||||||
|
r.Post("/mfa", s.handleSetupGarminMFA)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
- **`POST /api/setup/garmin/login`** `{garmin_email, garmin_password}`:
|
||||||
|
401 if unauthenticated; 409 if a profile already exists for this subject
|
||||||
|
(mirrors old `handleSetup`'s existing check); 400 on missing fields.
|
||||||
|
Otherwise `replaceSetupSession` + `Authenticate()` + `recordSetupAuthResult`,
|
||||||
|
responding with the same `{status, message}` shape `/api/auth/login`
|
||||||
|
already uses.
|
||||||
|
- **`POST /api/setup/garmin/mfa`** `{code}`: 409 if no session exists for
|
||||||
|
this subject (never started, or expired) -- frontend resets to the
|
||||||
|
Garmin step and asks the user to reconnect. Otherwise
|
||||||
|
`CompleteMFA()` + `recordSetupAuthResult` on the *same* session (never
|
||||||
|
replaces it -- MFA continues an in-progress attempt on the same
|
||||||
|
subprocess).
|
||||||
|
- **`POST /api/setup/complete`** `{display_name}`: 409 if already
|
||||||
|
provisioned; 409 if no session exists for this subject or its `Status !=
|
||||||
|
garmin.AuthSuccess` ("Garmin isn't connected yet"). Otherwise, in order:
|
||||||
|
1. `ProvisionUser(sub, displayName)` (unchanged).
|
||||||
|
2. `GetProfile` + set `GarminEmail`/`GarminPassword` from the session +
|
||||||
|
`UpdateProfile` (unchanged method, just called here instead of from
|
||||||
|
the Profile page).
|
||||||
|
3. `MarkGarminConnected(userID)`.
|
||||||
|
4. Promote: move the session's already-authenticated `garmin.Client`
|
||||||
|
into `s.userGarmin[userID]` and its recorded status into
|
||||||
|
`s.userAuthStatus[userID]`/`userAuthMessage[userID]` (avoids a
|
||||||
|
redundant re-authentication -- and possibly a repeat MFA prompt --
|
||||||
|
immediately after signup), then `delete(s.setupGarmin, sub)` without
|
||||||
|
closing the client (ownership transferred, not discarded).
|
||||||
|
5. Best-effort `os.Rename` the ephemeral token-store directory
|
||||||
|
(`{root}/setup/{sub}`) to the permanent one (`{root}/{userID}`), so a
|
||||||
|
later respawn of this user's subprocess (e.g. after a server restart)
|
||||||
|
resumes the already-established Garmin session instead of requiring a
|
||||||
|
fresh login. Logged, not fatal, on error.
|
||||||
|
|
||||||
|
`internal/api/auth.go`'s existing `handleGarminAuthLogin`/`handleGarminAuthMFA`/`handleGarminAuthStatus`
|
||||||
|
(used by the Profile page's `GarminConnection.tsx` for reconnecting an
|
||||||
|
already-provisioned user) are untouched.
|
||||||
|
|
||||||
|
### Frontend: one wizard component instead of two screens
|
||||||
|
|
||||||
|
`CreateProfile.tsx` and `ConnectGarmin.tsx` are deleted, replaced by
|
||||||
|
`frontend/src/OnboardingWizard.tsx` (and `OnboardingWizard.css`, replacing
|
||||||
|
`CreateProfile.css`). Two top-level steps (`"name" | "garmin"`); the
|
||||||
|
in-progress-MFA and already-authenticated states are just conditions on
|
||||||
|
`auth?.status` *within* the `"garmin"` step, not a third step value --
|
||||||
|
simpler than threading a separate `"mfa"` step through, and matches how
|
||||||
|
`GarminConnection.tsx` already branches on `status` today:
|
||||||
|
|
||||||
|
- **`name` step**: display name field, held in local state only -- no API
|
||||||
|
call. "Next" just validates non-empty and advances `step` to `"garmin"`.
|
||||||
|
- **`garmin` step**, branching on `auth?.status`:
|
||||||
|
- `undefined` / `"failed"`: email/password fields. "Previous" returns to
|
||||||
|
the `name` step (pure local state, nothing to undo server-side).
|
||||||
|
"Next" calls `api.setupGarminLogin(email, password)`.
|
||||||
|
- `"mfa_required"`: MFA code field. "Previous" clears `auth` back to
|
||||||
|
`null`, which re-shows the credentials form (submitting it again just
|
||||||
|
replaces the ephemeral session, same as the backend's "close old,
|
||||||
|
spawn new" semantics). "Submit code" calls `api.setupGarminMFA(code)`.
|
||||||
|
- `"authenticated"`: a confirmation message and "Continue to geniusrun",
|
||||||
|
which calls `api.setupComplete(displayName)` and then `onCreated(...)`.
|
||||||
|
- "Log out" (`<form method="post" action=".../session/logout">`) stays
|
||||||
|
visible throughout, the same escape hatch as before.
|
||||||
|
|
||||||
|
`frontend/src/api/client.ts`: remove `setup`; add `setupGarminLogin`,
|
||||||
|
`setupGarminMFA`, `setupComplete` (bodies/shapes as described above).
|
||||||
|
|
||||||
|
`frontend/src/LoginGate.tsx`: collapse back to
|
||||||
|
`!session.has_profile ? <OnboardingWizard onCreated={...}> : <App>` --
|
||||||
|
`garmin_connected` is no longer read here at all (still present on
|
||||||
|
`SessionInfo`/`session/me`, just unused by the gate).
|
||||||
|
|
||||||
|
### Error handling
|
||||||
|
|
||||||
|
- Garmin login/MFA failure: same as today -- message shown, fields stay
|
||||||
|
editable, retry always available, nothing persisted regardless of
|
||||||
|
outcome.
|
||||||
|
- `/api/setup/complete` with no session, or session not yet `authenticated`:
|
||||||
|
`409`, frontend falls back to the `garmin` step (credentials form) so the
|
||||||
|
user can (re)connect.
|
||||||
|
- `/api/setup/complete`/`/api/setup/garmin/login` when already provisioned
|
||||||
|
(double-submit, duplicate tab): `409` "profile already exists"; frontend
|
||||||
|
treats this as success and refreshes session info rather than showing an
|
||||||
|
error.
|
||||||
|
- Idle sessions (15 minutes unused) are evicted lazily on next touch --
|
||||||
|
`/api/setup/garmin/mfa` or `/api/setup/complete` against an expired
|
||||||
|
session behaves identically to "never connected" (409), prompting a
|
||||||
|
fresh `/api/setup/garmin/login`.
|
||||||
|
- Logging out mid-wizard closes and drops that subject's ephemeral session
|
||||||
|
and its token-store directory.
|
||||||
|
|
||||||
|
### Testing
|
||||||
|
|
||||||
|
- `internal/api`: `POST /api/setup/garmin/login` (mock `AuthSuccess`)
|
||||||
|
creates no DB user yet (`GetUserBySub` still not found); the
|
||||||
|
`mfa_required` path via `/api/setup/garmin/mfa` on the same session;
|
||||||
|
`/api/setup/complete` after a successful session creates the user with
|
||||||
|
`GarminEmail`/`GarminPassword`/`GarminConnectedAt` all set, and promotes
|
||||||
|
the *same* `*mock.Client` instance into `s.userGarmin[userID]` (asserted
|
||||||
|
via `clientFor` returning that exact pointer, and `ClosedCalled` still
|
||||||
|
false -- proving no redundant re-authentication happened);
|
||||||
|
`/api/setup/complete` with no prior login attempt → 409;
|
||||||
|
already-provisioned subject hitting either endpoint → 409; two different
|
||||||
|
OIDC subjects' ephemeral sessions never interfere (isolation test,
|
||||||
|
matching this repo's adversarial-isolation convention). A token-store
|
||||||
|
rename test mirroring the existing `TestDeleteProfile_RemovesTokenStoreDirectory`
|
||||||
|
pattern (set `GarminBase.TokenStorePath`, confirm the directory ends up
|
||||||
|
under the new user id's path after `/api/setup/complete`, not under
|
||||||
|
`setup/{sub}`).
|
||||||
|
- No frontend test suite -- manual smoke test: fresh account → name step →
|
||||||
|
Previous/Next between name and Garmin steps preserves typed input →
|
||||||
|
wrong password shows an error and stays editable → MFA prompt → Previous
|
||||||
|
from MFA returns to credentials → correct credentials (or MFA resolved)
|
||||||
|
→ "Continue to geniusrun" → lands in the app → confirm `GetUserBySub`
|
||||||
|
now exists with everything set. Separately: start the wizard, enter
|
||||||
|
Garmin credentials, then just close the tab without finishing --
|
||||||
|
confirm no `users`/`profile` row was created at all.
|
||||||
|
|
||||||
|
## Out of scope
|
||||||
|
|
||||||
|
- A background sweep goroutine for abandoned ephemeral sessions -- accepted
|
||||||
|
as a known, low-stakes trade-off (see Error handling).
|
||||||
|
- Structured JSON logging (the third piece of feedback) -- separate design.
|
||||||
|
- Any change to the Profile page's own reconnect flow
|
||||||
|
(`GarminConnection.tsx`, `/api/auth/*`) -- untouched.
|
||||||
@@ -74,7 +74,7 @@ the `requireProvisionedUser` group (`server.go`). `userID` comes from
|
|||||||
`client.UpdateCredentials` outside `s.mu`).
|
`client.UpdateCredentials` outside `s.mu`).
|
||||||
- Best-effort `os.RemoveAll` on `filepath.Join(s.GarminBase.TokenStorePath,
|
- Best-effort `os.RemoveAll` on `filepath.Join(s.GarminBase.TokenStorePath,
|
||||||
strconv.FormatInt(userID, 10))` when `TokenStorePath` is configured --
|
strconv.FormatInt(userID, 10))` when `TokenStorePath` is configured --
|
||||||
the same path `garminFor` computes when building a client. Log on
|
the same path `clientFor` computes when building a client. Log on
|
||||||
error; this is cleanup of an already-orphaned directory, not something
|
error; this is cleanup of an already-orphaned directory, not something
|
||||||
that should fail the request that already deleted the DB row.
|
that should fail the request that already deleted the DB row.
|
||||||
4. Respond `204 No Content`.
|
4. Respond `204 No Content`.
|
||||||
|
|||||||
@@ -0,0 +1,173 @@
|
|||||||
|
# Structured JSON logging (API calls + Garmin wrapper calls)
|
||||||
|
|
||||||
|
Status: approved, not yet implemented.
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
Motivated by a real debugging need: after implementing the deferred-commit
|
||||||
|
onboarding wizard, a login attempt reported `mfa_required` but no MFA code
|
||||||
|
ever arrived by email. There is currently no visibility into what actually
|
||||||
|
happened between geniusrun and the Garmin wrapper subprocess beyond raw,
|
||||||
|
unstructured stderr output.
|
||||||
|
|
||||||
|
**Likely root cause of that specific incident** (found while surveying the
|
||||||
|
code for this design, worth recording even though this task doesn't fix
|
||||||
|
it): `internal/garmin/pyscript/wrapper.py`'s `authenticate` handler blocks
|
||||||
|
on a 10-second queue read and, on timeout, unconditionally returns
|
||||||
|
`{"status": "mfa_required"}` -- regardless of whether `garminconnect` ever
|
||||||
|
actually called `prompt_mfa()`. A login that's merely slow (Garmin/Cloudflare
|
||||||
|
rate-limiting, already a documented trap in this codebase) looks identical
|
||||||
|
to a real MFA challenge. The only current way to tell them apart is a raw
|
||||||
|
stderr line (`"garminconnect invoked prompt_mfa()..."`, only emitted when
|
||||||
|
MFA is real) that's easy to miss. This design doesn't change that timeout
|
||||||
|
logic -- it makes the distinction visible in structured logs instead of
|
||||||
|
buried in stderr, which is the requested first step.
|
||||||
|
|
||||||
|
The user asked, specifically: JSON logs on stdout (for an existing
|
||||||
|
observability stack), for now covering only two things -- calls made to
|
||||||
|
geniusrun's own APIs, and calls made to external components (the Python
|
||||||
|
wrapper subprocess).
|
||||||
|
|
||||||
|
## Design
|
||||||
|
|
||||||
|
### Scope
|
||||||
|
|
||||||
|
- Two new log categories: an HTTP access log (one line per API request) and
|
||||||
|
a Garmin-wrapper call log (one line per subprocess round-trip).
|
||||||
|
- Existing ad-hoc `log.Printf` calls throughout the codebase are **not**
|
||||||
|
touched or migrated -- they keep going to stderr, unstructured, exactly as
|
||||||
|
today. This is deliberate, matching "for now" -- a wholesale migration is
|
||||||
|
a separate, later concern.
|
||||||
|
- `log/slog` (stdlib, available with no new dependency given this repo's Go
|
||||||
|
version) with a JSON handler writing to stdout -- a separate stream from
|
||||||
|
the existing stderr output, matching "JSON logs on stdout" literally.
|
||||||
|
|
||||||
|
### New package: `internal/applog`
|
||||||
|
|
||||||
|
```go
|
||||||
|
package applog
|
||||||
|
|
||||||
|
// NewLogger builds a JSON-handler *slog.Logger writing to w at the given
|
||||||
|
// level ("debug"|"info"|"warn"|"error", case-insensitive, defaulting to
|
||||||
|
// info for anything unrecognized).
|
||||||
|
func NewLogger(level string, w io.Writer) *slog.Logger
|
||||||
|
|
||||||
|
// WithLogger/FromContext thread a *slog.Logger through request-scoped
|
||||||
|
// context.Context, so a logger enriched with (e.g.) a request_id in one
|
||||||
|
// layer is picked up by another (e.g. internal/garmin, downstream of
|
||||||
|
// internal/api) without either package depending on the other.
|
||||||
|
// FromContext never returns nil -- it falls back to slog.Default() so
|
||||||
|
// callers with no request context (the background incremental-sync loop,
|
||||||
|
// tests that don't bother injecting one) still get a working logger.
|
||||||
|
func WithLogger(ctx context.Context, logger *slog.Logger) context.Context
|
||||||
|
func FromContext(ctx context.Context) *slog.Logger
|
||||||
|
```
|
||||||
|
|
||||||
|
`internal/config`: new `LogLevel string` field, `getEnvDefault("GENIUSRUN_LOG_LEVEL", "info")`,
|
||||||
|
same pattern as every other optional config value.
|
||||||
|
|
||||||
|
`cmd/geniusrund/main.go`: once at startup,
|
||||||
|
```go
|
||||||
|
slog.SetDefault(applog.NewLogger(cfg.LogLevel, os.Stdout))
|
||||||
|
```
|
||||||
|
No other wiring needed -- every consumer reads via `applog.FromContext`,
|
||||||
|
which falls back to this default.
|
||||||
|
|
||||||
|
### HTTP access log (`internal/api`)
|
||||||
|
|
||||||
|
A new `loggingMiddleware`, registered as the **first** `r.Use(...)`
|
||||||
|
in `Router()` (ahead of `corsMiddleware`), so it wraps every request
|
||||||
|
including unauthenticated ones (login redirect, health check) and OPTIONS
|
||||||
|
preflights:
|
||||||
|
|
||||||
|
- Generates a per-request id from an in-process monotonic counter (e.g.
|
||||||
|
`req-42`) -- simple, no new randomness dependency, resets on restart
|
||||||
|
(acceptable; log aggregation timestamps disambiguate across restarts).
|
||||||
|
- Builds `logger := applog.FromContext(r.Context()).With("request_id", id)`
|
||||||
|
and re-stashes it via `r = r.WithContext(applog.WithLogger(...))` *before*
|
||||||
|
calling `next.ServeHTTP` -- this is what lets every downstream layer
|
||||||
|
(including a Garmin wrapper call the handler triggers) log with the same
|
||||||
|
`request_id`.
|
||||||
|
- Wraps the `http.ResponseWriter` with chi's own
|
||||||
|
`middleware.NewWrapResponseWriter` (already available -- `go-chi/chi/v5`
|
||||||
|
is already a dependency, this is just a different subpackage of it, no
|
||||||
|
new module) to capture the status code.
|
||||||
|
- After `next.ServeHTTP` returns, logs one line:
|
||||||
|
`msg: "http request"`, fields `request_id`, `method`, `path` (`r.URL.Path`
|
||||||
|
only, deliberately excluding the query string, to stay conservative about
|
||||||
|
anything unexpected ending up in a log line), `status`, `duration_ms`.
|
||||||
|
Level `Info`, or `Warn` if `status >= 500`.
|
||||||
|
|
||||||
|
### Garmin wrapper call log (`internal/garmin`)
|
||||||
|
|
||||||
|
`client.go`'s `execute` is the single funnel point every one of the six
|
||||||
|
`Client` methods already goes through (confirmed while surveying: none of
|
||||||
|
them ever put Garmin credentials in the wire `params` -- email/password
|
||||||
|
only ever reach the subprocess via env vars at spawn time, so logging
|
||||||
|
`params` in full, for every command, is safe). Change its signature to
|
||||||
|
`roundTrip(ctx context.Context, cmdName string, params any) (result json.RawMessage, err error)`
|
||||||
|
(named returns), and wrap the whole body in a single `defer` that logs
|
||||||
|
exactly once regardless of which branch returned:
|
||||||
|
|
||||||
|
```go
|
||||||
|
defer func() {
|
||||||
|
attrs := []slog.Attr{
|
||||||
|
slog.String("cmd", cmdName),
|
||||||
|
slog.Any("params", params),
|
||||||
|
slog.Int64("duration_ms", time.Since(start).Milliseconds()),
|
||||||
|
}
|
||||||
|
level := slog.LevelInfo
|
||||||
|
if result != nil {
|
||||||
|
attrs = append(attrs, slog.String("result_preview", truncate(string(result), 500)))
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
level = slog.LevelWarn
|
||||||
|
attrs = append(attrs, slog.String("error", err.Error()))
|
||||||
|
}
|
||||||
|
applog.FromContext(ctx).LogAttrs(context.Background(), level, "garmin wrapper call", attrs...)
|
||||||
|
}()
|
||||||
|
```
|
||||||
|
|
||||||
|
Reusing the existing `truncate()` helper for `result_preview` (500 chars) is
|
||||||
|
what keeps this safe for the huge per-second-telemetry responses
|
||||||
|
(`get_activity_details` can run to several MB, per `maxWrapperLineBytes`)
|
||||||
|
while still showing tiny auth results (`{"status":"mfa_required",...}`) in
|
||||||
|
full -- exactly the detail needed for the motivating incident.
|
||||||
|
|
||||||
|
All six `Client` methods pass their own `ctx` through to `execute`
|
||||||
|
(currently they call it without one). `ensureStarted` also gains a `ctx
|
||||||
|
context.Context` param and logs one `Info` line on actual spawn
|
||||||
|
(`python_path`, whether a token store is configured) -- spawning the
|
||||||
|
subprocess is itself a call to the external component.
|
||||||
|
|
||||||
|
### Out of scope
|
||||||
|
|
||||||
|
- Migrating existing `log.Printf` call sites to `slog`/JSON.
|
||||||
|
- Fixing `wrapper.py`'s 10-second MFA-timeout ambiguity itself (see
|
||||||
|
Problem section) -- this design only makes the distinction visible.
|
||||||
|
- Logging request/response bodies for HTTP calls, or full (non-preview)
|
||||||
|
Garmin call results.
|
||||||
|
- `user_id` on the HTTP access log line (would need threading it out of
|
||||||
|
`resolveUser`'s context back to the outermost middleware, a bigger change
|
||||||
|
than "for now" calls for) -- `request_id` correlation covers the
|
||||||
|
motivating need (one login attempt <-> its Garmin calls) without it.
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
- `internal/applog`: level filtering (a below-threshold message doesn't
|
||||||
|
appear in the output writer); `WithLogger`/`FromContext` round-trip
|
||||||
|
(same logger comes back out); `FromContext` on a bare `context.Background()`
|
||||||
|
returns a non-nil logger.
|
||||||
|
- `internal/garmin`: reuse the existing in-process fake-wrapper harness
|
||||||
|
(`newFakeWrapperClient`) -- inject a captor logger via
|
||||||
|
`applog.WithLogger`, call `Authenticate`/`CompleteMFA`, assert the emitted
|
||||||
|
JSON line's `cmd`/`duration_ms`/`result_preview` fields, and that a
|
||||||
|
wrapper-reported error surfaces at `Warn` with an `error` field. Update
|
||||||
|
the three existing tests that call `execute` directly
|
||||||
|
(`TestSubprocessClient_RoundTrip_DetectsIDMismatch`,
|
||||||
|
`_SubprocessClosedIsError`, `_WrapperErrorPropagates`) to pass
|
||||||
|
`context.Background()` as the new first argument.
|
||||||
|
- `internal/api`: one focused test building a request with a pre-seeded
|
||||||
|
context logger (writing to a `bytes.Buffer`), confirming the access-log
|
||||||
|
line's fields, and that a handler returning a 5xx bumps the log level to
|
||||||
|
`Warn`.
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
# Modern Error Displays — Design
|
||||||
|
|
||||||
|
**Status:** Approved, ready for implementation planning.
|
||||||
|
**Origin:** `docs/IDEAS.md`'s "modern error displays" backlog item.
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Replace every page/component's ad-hoc inline `error`-state paragraph
|
||||||
|
(`<p className="error">{error}</p>` and its several near-duplicates) with a
|
||||||
|
single, consistent banner system: ephemeral, color-coded, full-width bars
|
||||||
|
(red for error, orange for warning, blue for notice, green for success)
|
||||||
|
that stack, auto-dismiss, and can be closed manually.
|
||||||
|
|
||||||
|
This is a full replacement, not an addition alongside the old pattern —
|
||||||
|
every existing inline error display in the frontend is migrated to the new
|
||||||
|
system, with one deliberate exception (OnboardingWizard's field-validation
|
||||||
|
messages — see below).
|
||||||
|
|
||||||
|
## Non-goals
|
||||||
|
|
||||||
|
- No backend changes. This is purely a frontend presentation change; no API
|
||||||
|
shape changes anything about how errors are already communicated to the
|
||||||
|
frontend.
|
||||||
|
- No new "logout failed" error path. `handleSessionLogout` on the backend
|
||||||
|
has no failure mode today (unconditional cookie-clear + redirect) — the
|
||||||
|
banner system will be *capable* of showing a logout error if one is ever
|
||||||
|
added, but nothing is invented here just to exercise it.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### `frontend/src/banner.ts` — the store
|
||||||
|
|
||||||
|
A plain module, not a React Context/Provider. The codebase has no existing
|
||||||
|
`createContext`/`useContext` usage anywhere, and — critically —
|
||||||
|
`frontend/src/api/client.ts` is a plain module (not a component), so a
|
||||||
|
Context wouldn't be reachable from it without extra plumbing. A
|
||||||
|
module-level singleton store is simpler and callable from anywhere:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
type Severity = "error" | "warning" | "notice" | "success";
|
||||||
|
type Banner = { id: number; severity: Severity; message: string };
|
||||||
|
|
||||||
|
// newest-on-top; auto-dismisses after 8000ms; manually dismissible early.
|
||||||
|
export function showError(message: string): void;
|
||||||
|
export function showWarning(message: string): void;
|
||||||
|
export function showNotice(message: string): void;
|
||||||
|
export function showSuccess(message: string): void;
|
||||||
|
export function dismiss(id: number): void;
|
||||||
|
|
||||||
|
// useSyncExternalStore plumbing for the render side.
|
||||||
|
export function subscribe(listener: () => void): () => void;
|
||||||
|
export function getSnapshot(): Banner[];
|
||||||
|
```
|
||||||
|
|
||||||
|
Multiple banners stack (newest on top); each dismisses independently on its
|
||||||
|
own timer. A banner's `message` may contain embedded newlines (`\n`) for
|
||||||
|
the sync-result-plus-nudges case (see below) — the renderer treats them as
|
||||||
|
line breaks within one banner, not separate banners.
|
||||||
|
|
||||||
|
### `frontend/src/components/BannerStack.tsx` + `BannerStack.css` — the renderer
|
||||||
|
|
||||||
|
- `useSyncExternalStore(subscribe, getSnapshot)` to read the current list.
|
||||||
|
- Renders one `<div className="banner banner-{severity}">` per active
|
||||||
|
banner: the message text (respecting embedded newlines) plus a `×` close
|
||||||
|
button that calls `dismiss(id)`.
|
||||||
|
- Four CSS variants — `banner-error` (red bg), `banner-warning` (orange
|
||||||
|
bg), `banner-notice` (blue bg), `banner-success` (green bg) — each with
|
||||||
|
readable foreground text/icon color against its background.
|
||||||
|
- **Mounted exactly once**, in `frontend/src/main.tsx`, as a sibling above
|
||||||
|
`<LoginGate />`:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
createRoot(document.getElementById("root")!).render(
|
||||||
|
<StrictMode>
|
||||||
|
<BannerStack />
|
||||||
|
<LoginGate />
|
||||||
|
</StrictMode>,
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
Rendered in normal document flow (not `position: fixed`), so it
|
||||||
|
naturally pushes down whatever's currently showing below it — the login
|
||||||
|
screen, the onboarding wizard, or the full app (header + tabs + content)
|
||||||
|
— without needing a separate mount point wired into each of those three
|
||||||
|
top-level layouts individually. When no banners are active, it renders
|
||||||
|
nothing (no reserved empty space).
|
||||||
|
|
||||||
|
### `frontend/src/api/client.ts` — friendlier network-failure messages
|
||||||
|
|
||||||
|
`request()`'s `fetch()` call is wrapped so a network-level failure (the
|
||||||
|
`fetch()` promise itself rejecting — offline, connection refused, CORS,
|
||||||
|
etc.) throws a friendly, fixed message instead of the raw browser error
|
||||||
|
text:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
let res: Response;
|
||||||
|
try {
|
||||||
|
res = await fetch(`${BASE_URL}${path}`, { ... });
|
||||||
|
} catch {
|
||||||
|
throw new Error("Can't reach the server — check your connection.");
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
A real HTTP response that just happens to be non-2xx (4xx/5xx) is
|
||||||
|
unaffected — that branch keeps its existing `${status} ${body}` message,
|
||||||
|
since that's a legitimate API-level error, not a "the backend isn't there"
|
||||||
|
condition.
|
||||||
|
|
||||||
|
This means every call site's existing pattern —
|
||||||
|
`.catch((e) => setError(String(e)))` — becomes
|
||||||
|
`.catch((e) => showError(String(e)))`, and the friendly message for a truly
|
||||||
|
unreachable backend falls out for free, with no per-call-site
|
||||||
|
network-error special-casing needed. This is what satisfies IDEAS.md's
|
||||||
|
"used on all pages/tabs in case of backend is not here": every page's
|
||||||
|
existing catch-and-display path already covers this, once it displays via
|
||||||
|
`showError` instead of local state.
|
||||||
|
|
||||||
|
## Migration inventory
|
||||||
|
|
||||||
|
Every current inline-error site, and what changes:
|
||||||
|
|
||||||
|
| File | Before | After |
|
||||||
|
|---|---|---|
|
||||||
|
| `components/GarminConnection.tsx` | Local `error` state, all catch blocks `setError(String(e))`, inline `{error && <p className="error">{error}</p>}` | Remove the state and the paragraph; every catch block calls `showError(String(e))` directly. |
|
||||||
|
| `components/SyncModal.tsx` | Never auto-closes; on completion renders a "final result" block inline (success/error line + optional pending-activities/pending-workouts nudges) with a manual Close button; a status-poll fetch failure renders inline too. | The "final result" JSX block is deleted entirely — **the modal now purely shows the progress bar/spinner and nothing else**. On the poll tick where `status.in_progress` is first observed to be `false`, build one message: the success/error line, followed by a newline-separated line for each applicable pending nudge (`"N more activities pending — click Sync now again"` / `"N more workouts pending — click Sync now again"`), call `showSuccess(...)` or `showError(...)` with that combined message, then call `onClose()` immediately — the modal disappears the instant sync finishes, and the banner carries the result. A transient status-poll fetch failure (modal still open, sync still presumably running) calls `showError(String(e))` but does **not** close the modal — polling continues as it does today. |
|
||||||
|
| `pages/Profile.tsx` | Local `error` state (profile load/save failures), inline paragraph. | Same pattern as GarminConnection — state and paragraph removed, catch blocks call `showError`. |
|
||||||
|
| `pages/Activities.tsx` | Local `error` state (multiple load/action failures), inline paragraph. | Same. |
|
||||||
|
| `pages/Analysis.tsx` | Local `error` state, inline paragraph. | Same. |
|
||||||
|
| `components/TrainingTypesCard.tsx` | Local `error` state, inline paragraph. | Same. |
|
||||||
|
| `LoginGate.tsx` | Reads `?auth_error=` from the URL synchronously during render, shows `<p className="login-gate-error">` with a mapped message. | A mount effect reads `?auth_error=` once and calls `showError(mappedMessage)` (same `AUTH_ERROR_MESSAGES` mapping as today); the inline paragraph and its CSS class are removed. |
|
||||||
|
| `OnboardingWizard.tsx` | Single `error` state used for both field validation ("Please enter a display name.", "Please enter your Garmin email and password.") **and** real failures (Garmin login rejected, MFA rejected, `complete()` failing after a successful Garmin auth) — all rendered via the same `onboarding-wizard-error` paragraph, in 4 places. | **Split.** Field-validation messages stay exactly as they are today (local state, inline paragraph, tied to a specific input the user needs to fix — a transient top-of-page banner is the wrong medium for "you left a required field blank"). Real failures (`submitGarmin`'s catch, `garminMFA`'s catch, `complete()`'s catch) call `showError(...)` instead of `setError(...)`. The `complete()`-failure Retry button, which today is conditionally rendered on `error &&`, switches to a new local boolean `completeFailed` (set `true` in that catch block) — the button's visibility no longer depends on the error text still being present, since that text now lives only in the (transient, auto-dismissing) banner. |
|
||||||
|
|
||||||
|
**Dead CSS removal:** once no `.tsx` file references them, delete the
|
||||||
|
`.error`, `.onboarding-wizard-error`, and `.login-gate-error` rules from
|
||||||
|
their respective CSS files.
|
||||||
|
|
||||||
|
## Data flow example (sync completion)
|
||||||
|
|
||||||
|
1. User clicks "Sync now" → `GarminConnection.sync()` calls
|
||||||
|
`api.syncRun()`, sets `showSyncModal(true)`.
|
||||||
|
2. `SyncModal` polls `/api/sync/status` every 1.5s, rendering the
|
||||||
|
spinner/progress bar per `progress.Phase` (unchanged from the existing
|
||||||
|
implementation).
|
||||||
|
3. A poll response shows `in_progress: false` for the first time.
|
||||||
|
`SyncModal` builds a message from `last_run.Status`/`ErrorMessage`/
|
||||||
|
`ActivitiesFetched` plus `activities_pending_details`/
|
||||||
|
`workouts_pending`, calls `showSuccess(message)` or `showError(message)`
|
||||||
|
accordingly, then calls `onClose()`.
|
||||||
|
4. `GarminConnection` unmounts `SyncModal` (its normal `showSyncModal`
|
||||||
|
toggle, unchanged). The banner, now living in the shared store
|
||||||
|
independent of any unmounted component, continues to display and
|
||||||
|
auto-dismiss on its own schedule.
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
No frontend test suite exists in this repo (established convention —
|
||||||
|
verification is `tsc -b`/`vite build`, `oxlint`, and manual browser
|
||||||
|
checks). Verification for this feature is:
|
||||||
|
|
||||||
|
- `npm run build` / `npm run lint` clean.
|
||||||
|
- Manual smoke test covering each severity: trigger a real API error (e.g.
|
||||||
|
an invalid save), a network failure (stop the backend, attempt any
|
||||||
|
action), a successful sync, a sync ending in error, a sync with pending
|
||||||
|
activities/workouts remaining, and the login screen's `auth_error`
|
||||||
|
redirect — confirming banners stack, auto-dismiss, and can be closed
|
||||||
|
manually, and that `SyncModal` now closes itself the instant sync
|
||||||
|
finishes rather than waiting for a manual Close click.
|
||||||
108
docs/superpowers/specs/2026-07-27-workout-not-found-design.md
Normal file
108
docs/superpowers/specs/2026-07-27-workout-not-found-design.md
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
# Stop retrying a permanently-missing Garmin workout forever — Design
|
||||||
|
|
||||||
|
**Status:** Approved, ready for implementation planning.
|
||||||
|
**Origin:** user report -- `/api/sync/status`'s `workouts_pending` count stuck at 1 forever, no
|
||||||
|
matter how many times "Sync now" is clicked.
|
||||||
|
|
||||||
|
## Root cause
|
||||||
|
|
||||||
|
`fillPendingWorkouts`/`fillActivityWorkout` (`backend/internal/sync/service.go:339-371`) treats
|
||||||
|
every `get_workout_by_id` failure identically: log it, leave `workout_raw_json` `NULL`, and let
|
||||||
|
`ActivitiesMissingWorkout` naturally retry it on the next sync. This is the right behavior for a
|
||||||
|
*transient* failure (network blip, rate limiting), but wrong for a genuine HTTP 404 -- the
|
||||||
|
activity's `workout_id` points at a workout that was deleted on Garmin's side after the activity
|
||||||
|
was recorded (confirmed via the user's own backend log: a 404 for a specific `workout_id`). A 404
|
||||||
|
is definitive and will never succeed on retry, yet the current code retries it every single sync,
|
||||||
|
forever, with the failure only ever logged server-side (`log.Printf`) -- never surfaced to the
|
||||||
|
user beyond a perpetual, unexplained "1 more workout pending" nudge.
|
||||||
|
|
||||||
|
## Fix
|
||||||
|
|
||||||
|
An activity can legitimately end up with `workout_id` set but `workout_raw_json` permanently
|
||||||
|
`NULL` when the workout can't be found on Garmin -- `workout_raw_json` stays `NULL` forever (no
|
||||||
|
fabricated data), but a new, separate marker records "confirmed missing" distinctly from "not yet
|
||||||
|
fetched," so `ActivitiesMissingWorkout`/`CountActivitiesMissingWorkout` stop counting it.
|
||||||
|
|
||||||
|
### `internal/garmin/pyscript/wrapper.py`
|
||||||
|
|
||||||
|
`garminconnect` already defines `GarminConnectNotFoundError` (a subclass of
|
||||||
|
`GarminConnectConnectionError`) specifically for this case -- its own docstring says "so callers
|
||||||
|
can now catch a missing resource specifically (e.g. deleting an already-deleted workout)." It's
|
||||||
|
raised by `connectapi()` (used by `get_workout_by_id` and other lookups) whenever the underlying
|
||||||
|
HTTP response is a 404.
|
||||||
|
|
||||||
|
`dispatch()`'s generic exception handler (used by every `cmd`, including the generic `call`
|
||||||
|
dispatcher that `get_workout_by_id` goes through) gains a check: if the caught exception is a
|
||||||
|
`GarminConnectNotFoundError`, the returned JSON error response gets an additional
|
||||||
|
`"not_found": true` field alongside the existing `"error"` string. This is deliberately generic
|
||||||
|
(any Garmin API call that 404s gets this marker, not just workouts) -- the decision about what to
|
||||||
|
*do* with a not-found error stays method-specific in the Go/sync layer.
|
||||||
|
|
||||||
|
### `internal/garmin/client.go`
|
||||||
|
|
||||||
|
- `wireResponse` gains `NotFound bool `json:"not_found,omitempty"``.
|
||||||
|
- A new sentinel: `var ErrNotFound = errors.New("garmin: resource not found")`.
|
||||||
|
- `execute`: when `resp.Error != "" && resp.NotFound`, the returned error wraps `ErrNotFound`
|
||||||
|
(`fmt.Errorf("%s: %s: %w", cmdName, resp.Error, ErrNotFound)`), so callers can
|
||||||
|
`errors.Is(err, garmin.ErrNotFound)` regardless of which method was called.
|
||||||
|
|
||||||
|
### `internal/store` (schema + queries)
|
||||||
|
|
||||||
|
- `schema.sql`: `activities` gains `workout_not_found_at TEXT` (nullable), placed next to
|
||||||
|
`workout_raw_json`, following the exact same style as the existing `details_fetched_at`/
|
||||||
|
`splits_fetched_at` timestamp markers.
|
||||||
|
- New method `SetActivityWorkoutNotFound(ctx, userID, activityID) error`, mirroring
|
||||||
|
`SetActivitySplitsFetched`'s exact shape (`UPDATE activities SET workout_not_found_at =
|
||||||
|
datetime('now'), updated_at = datetime('now') WHERE id = ? AND user_id = ?`).
|
||||||
|
- `ActivitiesMissingWorkout`/`CountActivitiesMissingWorkout` both gain
|
||||||
|
`AND workout_not_found_at IS NULL` in their `WHERE` clause.
|
||||||
|
- `UpsertActivity`'s `ON CONFLICT DO UPDATE` already never touches `details_fetched_at`/
|
||||||
|
`details_raw_json`/`splits_fetched_at`/`workout_raw_json` -- `workout_not_found_at` follows the
|
||||||
|
same pattern, so it's untouched by a later re-sync and the "confirmed missing" marker persists
|
||||||
|
indefinitely once set.
|
||||||
|
- `docs/DATABASE.md` regenerated via `go run ./cmd/dumpschema` after the schema edit.
|
||||||
|
|
||||||
|
### `internal/sync/service.go`
|
||||||
|
|
||||||
|
In `fillPendingWorkouts`'s loop, when `fillActivityWorkout` returns an error:
|
||||||
|
- If `errors.Is(err, garmin.ErrNotFound)`: call `db.SetActivityWorkoutNotFound(ctx, s.userID,
|
||||||
|
a.ID)` and log at Info level ("workout not found on Garmin, likely deleted -- marking as such,
|
||||||
|
will not retry"). No further retry.
|
||||||
|
- Otherwise: keep the existing behavior exactly as it is today (log a warning, leave
|
||||||
|
`workout_raw_json` `NULL`, naturally retried on the next sync).
|
||||||
|
|
||||||
|
### `internal/garmin/mock`
|
||||||
|
|
||||||
|
`mock.Client`'s existing `WorkoutErrByID map[int64]error` field (added in a previous plan) needs
|
||||||
|
no shape change -- a test simulating a 404 just sets the mapped error to
|
||||||
|
`fmt.Errorf("...: %w", garmin.ErrNotFound)` (or `garmin.ErrNotFound` directly), which
|
||||||
|
`errors.Is` picks up the same way a real wrapped error would.
|
||||||
|
|
||||||
|
## Non-goals
|
||||||
|
|
||||||
|
- No change to `fillActivityDetails`/`fillPendingActivityDetails`'s error handling (a
|
||||||
|
details/splits fetch failure still aborts the batch and surfaces as a sync error, as it does
|
||||||
|
today) -- this fix is scoped to workouts specifically, matching the confirmed root cause. If a
|
||||||
|
details/splits 404 ever turns out to need the same treatment, that's a separate, unconfirmed
|
||||||
|
problem to investigate on its own evidence.
|
||||||
|
- No frontend change -- `workouts_pending` simply stops counting a confirmed-missing workout, so
|
||||||
|
the existing SyncModal/banner UI needs nothing new.
|
||||||
|
- No handling for the (very unlikely) edge case of Garmin re-linking a *different* `workout_id`
|
||||||
|
to the same activity after `workout_not_found_at` was set for an earlier one -- accepted as an
|
||||||
|
edge case not worth the added complexity.
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
- `internal/garmin/pyscript/tests/test_wrapper.py`: a new test mirroring the existing
|
||||||
|
`test_call_propagates_garminconnect_exception_as_error`, but raising
|
||||||
|
`GarminConnectNotFoundError` and asserting the response includes `"not_found": true`.
|
||||||
|
- `internal/garmin` (Go): a test asserting `execute` wraps the error with `ErrNotFound` when
|
||||||
|
the wire response sets `not_found: true`.
|
||||||
|
- `internal/store`: extend the existing `ActivitiesMissingWorkout` test (or add a new one) with a
|
||||||
|
fixture that has `workout_not_found_at` set, asserting it's excluded from both
|
||||||
|
`ActivitiesMissingWorkout` and `CountActivitiesMissingWorkout`.
|
||||||
|
- `internal/sync`: a test using `mock.Client.WorkoutErrByID` set to a `garmin.ErrNotFound`-wrapped
|
||||||
|
error, asserting `fillPendingWorkouts`/`FillPendingDetails` completes without error, the
|
||||||
|
activity's `workout_not_found_at` gets set, and it's excluded from a subsequent
|
||||||
|
`CountActivitiesMissingWorkout` call (i.e., confirming it does NOT get retried on a second call,
|
||||||
|
unlike today's forever-retry behavior for this exact scenario).
|
||||||
132
docs/superpowers/specs/2026-08-03-configuration-design.md
Normal file
132
docs/superpowers/specs/2026-08-03-configuration-design.md
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
# Configuration: application vs environment — design
|
||||||
|
|
||||||
|
Date: 2026-08-03
|
||||||
|
Status: approved
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
All runtime tuning today is either an env var (`internal/config`, process-level)
|
||||||
|
or a per-user `profile` column. There is no home for instance-global,
|
||||||
|
behavior-affecting settings shared by all users, and some env vars are
|
||||||
|
misnamed or shouldn't be env vars at all. From `docs/IDEAS.md` ("configuration").
|
||||||
|
|
||||||
|
## Concepts
|
||||||
|
|
||||||
|
- **Application configuration**: key/value pairs, stored in the database,
|
||||||
|
common to all users, cold (read once at startup; a change takes effect on
|
||||||
|
the next backend restart — no hot reload). Unrelated to the execution
|
||||||
|
environment.
|
||||||
|
- **Environment configuration**: env vars, tied to the execution environment
|
||||||
|
(paths, URLs, credentials for infrastructure). Unchanged mechanism.
|
||||||
|
|
||||||
|
## Data model
|
||||||
|
|
||||||
|
New table in `schema.sql`:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE config (
|
||||||
|
key TEXT PRIMARY KEY,
|
||||||
|
value TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
- Deliberately **not** user-scoped — the single exception to the per-user
|
||||||
|
convention, because application configuration is instance-global by
|
||||||
|
definition. A schema comment records this.
|
||||||
|
- The table stores **overrides only**; defaults live in code. Absent key =
|
||||||
|
default in effect.
|
||||||
|
- Regenerate `docs/DATABASE.md` (`go run ./cmd/dumpschema`) after the schema
|
||||||
|
change. Breaking schema change: delete the local DB and let `Open()`
|
||||||
|
recreate it (normal pre-production remedy).
|
||||||
|
|
||||||
|
## Application-config registry
|
||||||
|
|
||||||
|
`internal/config` gains a registry of known keys — name, default, validator,
|
||||||
|
description. Launch set (one key):
|
||||||
|
|
||||||
|
| key | type | default | validation |
|
||||||
|
|--------------------|---------------|---------|----------------------|
|
||||||
|
| `session.duration` | integer hours | `720` | integer, > 0 |
|
||||||
|
|
||||||
|
- `config.Load()` (env) stays as-is: fail-fast, runs before the DB opens.
|
||||||
|
- New `config.LoadApp(getter)` reads overrides through a small store-backed
|
||||||
|
getter, merges defaults, validates, and returns a typed `AppConfig` struct
|
||||||
|
(`SessionDuration time.Duration`). `main.go` calls it after `store.Open`
|
||||||
|
and wires the result into `api.SessionConfig.Duration`.
|
||||||
|
- Writes validate against the registry: unknown keys and invalid values are
|
||||||
|
rejected, so the `config` table cannot accumulate junk.
|
||||||
|
|
||||||
|
## Environment-variable changes (all breaking; pre-production, no shims)
|
||||||
|
|
||||||
|
| before | after |
|
||||||
|
|----------------------------|----------------------------|
|
||||||
|
| `GENIUSRUN_ADDR` | `GENIUSRUN_BACKEND_ADDR` |
|
||||||
|
| `GARMIN_WRAPPER_PYTHON` | `GENIUSRUN_PYTHON_PATH` |
|
||||||
|
| `GARMIN_TOKENSTORE` | `GENIUSRUN_TOKENSTORE_PATH`|
|
||||||
|
| `GENIUSRUN_MIN_CONFIDENCE` | removed — hard-coded to `classify.DefaultMinConfidence` (0.6) |
|
||||||
|
| `GENIUSRUN_SESSION_DURATION` | removed — becomes app-config key `session.duration` |
|
||||||
|
| `GENIUSRUN_SESSION_SECRET` | unchanged (stays env; still required, >= 32 chars) |
|
||||||
|
|
||||||
|
Also update: `backend/.env` (local), `CLAUDE.md`, config tests, and any doc
|
||||||
|
references.
|
||||||
|
|
||||||
|
## API
|
||||||
|
|
||||||
|
Both routes sit behind the usual session + `requireProvisionedUser` gates.
|
||||||
|
Any provisioned user may read and write (single-operator app in practice).
|
||||||
|
|
||||||
|
- `GET /api/config` →
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"application": [
|
||||||
|
{"key": "session.duration", "value": "720", "default": "720",
|
||||||
|
"overridden": false, "description": "Session cookie lifetime in hours"}
|
||||||
|
],
|
||||||
|
"environment": [
|
||||||
|
{"name": "GENIUSRUN_BACKEND_ADDR", "value": ":8080"},
|
||||||
|
{"name": "GENIUSRUN_OIDC_CLIENT_SECRET", "value": "•••• (set)"}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
- Secrets (`GENIUSRUN_OIDC_CLIENT_SECRET`, `GENIUSRUN_SESSION_SECRET`) are
|
||||||
|
masked as `•••• (set)` / `(unset)`; values never leave the process.
|
||||||
|
- `main.go` builds the display-safe environment snapshot once (from
|
||||||
|
`config.Config`, masking applied) and passes it to `api.NewServer` —
|
||||||
|
handlers never call `os.Getenv`.
|
||||||
|
- `PUT /api/config` with `{"session.duration": "168"}`:
|
||||||
|
validates every pair against the registry (reject unknown key / invalid
|
||||||
|
value with 400 naming the offender; all-or-nothing), upserts, returns the
|
||||||
|
same shape as GET. Cold semantics: response carries no magic — the UI
|
||||||
|
states that changes apply after a backend restart.
|
||||||
|
|
||||||
|
## Frontend
|
||||||
|
|
||||||
|
- **Config page** at browser path `/config` — `App.tsx` derives the view from
|
||||||
|
`window.location.pathname` (no router library; `history.pushState` on
|
||||||
|
navigation so the URL is shareable/deep-linkable, and Vite's SPA fallback
|
||||||
|
serves it in dev; a production reverse proxy needs SPA fallback for the
|
||||||
|
path).
|
||||||
|
- Page content: an "Application configuration" card (editable fields per
|
||||||
|
registry entry, Save button, a visible note "changes take effect after the
|
||||||
|
backend restarts"), and a read-only "Environment configuration" card
|
||||||
|
listing name/value pairs, secrets masked by the backend.
|
||||||
|
- **Header changes** (`App.tsx`), left to right after the tabs:
|
||||||
|
1. Profile button: icon-only, user-profile glyph (👤). The profile name
|
||||||
|
moves into `title`/`aria-label` — no visible text.
|
||||||
|
2. New settings button: icon-only gear (⚙), navigates to `/config`.
|
||||||
|
3. Log out: icon-only cross (✕), still the POST `<form>` (OIDC logout
|
||||||
|
needs a real navigation). `title`/`aria-label` "Log out".
|
||||||
|
- No visible text labels on any of the three; same emoji/glyph style as
|
||||||
|
the existing tabs, no icon library added.
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
- `internal/store`: config upsert/read round-trip; overrides-only semantics.
|
||||||
|
- `internal/config`: table-driven registry tests — default when absent,
|
||||||
|
override applied, invalid value rejected, unknown key rejected.
|
||||||
|
- `internal/api`: httptest — GET shape + secret masking; PUT happy path,
|
||||||
|
unknown key 400, invalid value 400; 403 until provisioned (inherited gate,
|
||||||
|
asserted once).
|
||||||
|
- Frontend: live click-through — header icons, navigate to `/config`, edit
|
||||||
|
`session.duration`, save, verify persisted via GET and after restart.
|
||||||
12
docs/workouts.yaml
Normal file
12
docs/workouts.yaml
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
version: v1
|
||||||
|
name: geniusrun
|
||||||
|
templates:
|
||||||
|
- name: 'Recovery 30m'
|
||||||
|
difficulty: 1
|
||||||
|
kind: recovery
|
||||||
|
blocks:
|
||||||
|
- index: 1
|
||||||
|
reps: 1
|
||||||
|
block:
|
||||||
|
- kind: run
|
||||||
|
duration: 30m
|
||||||
@@ -52,40 +52,6 @@ body {
|
|||||||
color: white;
|
color: white;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Pill-shaped and icon-led, deliberately unlike the rectangular .tab
|
|
||||||
buttons: this opens account/profile settings, not an application view
|
|
||||||
alongside Activities/Progression/Training plan, so it reads more like an
|
|
||||||
account chip (à la Slack/GitHub's corner avatar) than another nav tab. */
|
|
||||||
.profile-name {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.4rem;
|
|
||||||
background: #1a1d24;
|
|
||||||
border: 1px solid #2a2d35;
|
|
||||||
color: #9aa0ab;
|
|
||||||
padding: 0.4rem 0.9rem 0.4rem 0.7rem;
|
|
||||||
border-radius: 999px;
|
|
||||||
cursor: pointer;
|
|
||||||
font-weight: 600;
|
|
||||||
font-size: 0.85rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.profile-name::before {
|
|
||||||
content: "⚙️";
|
|
||||||
font-size: 0.8rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.profile-name:hover:not(.active) {
|
|
||||||
border-color: #3b82f6;
|
|
||||||
color: #e6e6e6;
|
|
||||||
}
|
|
||||||
|
|
||||||
.profile-name.active {
|
|
||||||
background: #3b82f6;
|
|
||||||
border-color: #3b82f6;
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.header-actions {
|
.header-actions {
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -93,19 +59,51 @@ body {
|
|||||||
gap: 0.6rem;
|
gap: 0.6rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.logout-link {
|
/* Icon-only header buttons (profile / settings / log out): circular chips,
|
||||||
background: none;
|
deliberately unlike the rectangular .tab buttons -- these are account/
|
||||||
border: none;
|
instance actions, not application views. No visible text: the glyph is
|
||||||
padding: 0;
|
the label (full text lives in title/aria-label). */
|
||||||
font: inherit;
|
.icon-button {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 2.2rem;
|
||||||
|
height: 2.2rem;
|
||||||
|
background: #1a1d24;
|
||||||
|
border: 1px solid #2a2d35;
|
||||||
color: #9aa0ab;
|
color: #9aa0ab;
|
||||||
font-size: 0.85rem;
|
border-radius: 999px;
|
||||||
text-decoration: none;
|
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
font-size: 0.95rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.logout-link:hover {
|
.icon-button:hover:not(.active) {
|
||||||
color: #3b82f6;
|
border-color: #3b82f6;
|
||||||
|
color: #e6e6e6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-button.active {
|
||||||
|
background: #3b82f6;
|
||||||
|
border-color: #3b82f6;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.env-config div {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 0.25rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.env-config dt {
|
||||||
|
min-width: 18rem;
|
||||||
|
color: #9aa0ab;
|
||||||
|
font-family: monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.env-config dd {
|
||||||
|
margin: 0;
|
||||||
|
font-family: monospace;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
}
|
}
|
||||||
|
|
||||||
.garmin-connection {
|
.garmin-connection {
|
||||||
@@ -211,6 +209,11 @@ input[type="color"] {
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.field-hint {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: #6b7280;
|
||||||
|
}
|
||||||
|
|
||||||
button:hover:not(:disabled) {
|
button:hover:not(:disabled) {
|
||||||
border-color: #3b82f6;
|
border-color: #3b82f6;
|
||||||
}
|
}
|
||||||
@@ -269,10 +272,6 @@ button:disabled {
|
|||||||
color: #9aa0ab;
|
color: #9aa0ab;
|
||||||
}
|
}
|
||||||
|
|
||||||
.error {
|
|
||||||
color: #f87171;
|
|
||||||
}
|
|
||||||
|
|
||||||
.review-list {
|
.review-list {
|
||||||
list-style: none;
|
list-style: none;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
@@ -485,6 +484,74 @@ button:disabled {
|
|||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.sync-modal-content {
|
||||||
|
width: min(420px, 90vw);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sync-modal-body {
|
||||||
|
padding: 1.5rem 1rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sync-modal-progress {
|
||||||
|
width: 100%;
|
||||||
|
height: 0.6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sync-modal-label {
|
||||||
|
margin: 0;
|
||||||
|
color: #9aa0ab;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sync-modal-spinner {
|
||||||
|
width: 2rem;
|
||||||
|
height: 2rem;
|
||||||
|
border: 3px solid #2a2d35;
|
||||||
|
border-top-color: #3b82f6;
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: sync-modal-spin 0.8s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes sync-modal-spin {
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.sync-modal-actions {
|
||||||
|
justify-content: flex-end;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
border-top: 1px solid #2a2d35;
|
||||||
|
}
|
||||||
|
|
||||||
|
.garmin-mfa-modal-content {
|
||||||
|
width: min(420px, 90vw);
|
||||||
|
}
|
||||||
|
|
||||||
|
.garmin-mfa-modal-body {
|
||||||
|
padding: 1.5rem 1rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.garmin-mfa-modal-message {
|
||||||
|
margin: 0;
|
||||||
|
color: #9aa0ab;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.garmin-mfa-modal-actions {
|
||||||
|
justify-content: flex-end;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
border-top: 1px solid #2a2d35;
|
||||||
|
}
|
||||||
|
|
||||||
.json-toggle {
|
.json-toggle {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
width: 1rem;
|
width: 1rem;
|
||||||
@@ -541,12 +608,19 @@ button:disabled {
|
|||||||
max-width: 580px;
|
max-width: 580px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.profile-page {
|
.profile-page,
|
||||||
|
.config-page {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 1.75rem;
|
gap: 1.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Wider than the default .kind-editor cap: the environment card lists
|
||||||
|
URLs/paths as single-line monospace values that would otherwise wrap. */
|
||||||
|
.config-page .kind-editor {
|
||||||
|
max-width: 900px;
|
||||||
|
}
|
||||||
|
|
||||||
.training-type-list {
|
.training-type-list {
|
||||||
list-style: none;
|
list-style: none;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { api, BASE_URL } from "./api/client";
|
import { api, BASE_URL } from "./api/client";
|
||||||
import "./App.css";
|
import "./App.css";
|
||||||
|
import { BannerStack } from "./components/BannerStack";
|
||||||
import { Activities } from "./pages/Activities";
|
import { Activities } from "./pages/Activities";
|
||||||
import { Analysis } from "./pages/Analysis";
|
import { Analysis } from "./pages/Analysis";
|
||||||
|
import { Config } from "./pages/Config";
|
||||||
import { Plan } from "./pages/Plan";
|
import { Plan } from "./pages/Plan";
|
||||||
import { Profile } from "./pages/Profile";
|
import { Profile } from "./pages/Profile";
|
||||||
import type { SessionInfo } from "./types/api";
|
import type { SessionInfo } from "./types/api";
|
||||||
@@ -13,21 +15,64 @@ const TABS = [
|
|||||||
{ key: "plan", label: "Training plan", icon: "✎", Component: Plan },
|
{ key: "plan", label: "Training plan", icon: "✎", Component: Plan },
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
type TabKey = (typeof TABS)[number]["key"];
|
// Every view is pathname-addressable (/activities, /analysis, /plan,
|
||||||
|
// /profile, /config) so any of them can be deep-linked, bookmarked, and
|
||||||
|
// survives a reload -- no router library, just pushState + popstate with
|
||||||
|
// the URL as the single source of truth. "/" and anything unrecognized
|
||||||
|
// fall back to the default activities view.
|
||||||
|
const VIEWS = ["activities", "analysis", "plan", "profile", "config"] as const;
|
||||||
|
|
||||||
|
type View = (typeof VIEWS)[number];
|
||||||
|
|
||||||
|
function viewFromPathname(pathname: string): View {
|
||||||
|
const candidate = pathname.replace(/^\/+/, "").replace(/\/+$/, "");
|
||||||
|
return (VIEWS as readonly string[]).includes(candidate) ? (candidate as View) : "activities";
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST_LOGIN_PATH_KEY is where LoginGate stashes the path a logged-out
|
||||||
|
// visitor was heading to: the OIDC flow always lands back on "/", so the
|
||||||
|
// deep link would otherwise be lost across the Keycloak round-trip.
|
||||||
|
export const POST_LOGIN_PATH_KEY = "geniusrun_post_login_path";
|
||||||
|
|
||||||
|
// restoreDeepLink resolves the initial view: normally straight from the
|
||||||
|
// URL, but right after a login round-trip (we're on "/" and LoginGate
|
||||||
|
// stashed a path) the stashed destination wins and the URL is rewritten
|
||||||
|
// to it -- replaceState, not pushState, so Back doesn't bounce through
|
||||||
|
// an intermediate "/" entry. The stash is consumed either way; a stale
|
||||||
|
// one must never redirect some later, unrelated visit to "/".
|
||||||
|
function restoreDeepLink(): View {
|
||||||
|
const stashed = sessionStorage.getItem(POST_LOGIN_PATH_KEY);
|
||||||
|
if (stashed === null) return viewFromPathname(window.location.pathname);
|
||||||
|
sessionStorage.removeItem(POST_LOGIN_PATH_KEY);
|
||||||
|
if (window.location.pathname !== "/") return viewFromPathname(window.location.pathname);
|
||||||
|
history.replaceState({}, "", stashed);
|
||||||
|
return viewFromPathname(stashed);
|
||||||
|
}
|
||||||
|
|
||||||
function App({ session }: { session: SessionInfo }) {
|
function App({ session }: { session: SessionInfo }) {
|
||||||
const [tab, setTab] = useState<TabKey>("activities");
|
const [view, setView] = useState<View>(restoreDeepLink);
|
||||||
// Profile isn't a tab: it's reached via the profile name in the top-right
|
|
||||||
// corner instead, since (for now, single-profile) it's account settings,
|
|
||||||
// not a content view alongside Activities/Analysis/Training plan.
|
|
||||||
const [showProfile, setShowProfile] = useState(false);
|
|
||||||
const [profileName, setProfileName] = useState<string | null>(null);
|
const [profileName, setProfileName] = useState<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
api.getProfile().then((p) => setProfileName(p.Name)).catch(() => {});
|
api.getProfile().then((p) => setProfileName(p.Name)).catch(() => {});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const Active = TABS.find((t) => t.key === tab)!.Component;
|
useEffect(() => {
|
||||||
|
const onPop = () => setView(viewFromPathname(window.location.pathname));
|
||||||
|
window.addEventListener("popstate", onPop);
|
||||||
|
return () => window.removeEventListener("popstate", onPop);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
function navigate(next: View) {
|
||||||
|
// No history entry when the URL already means this view (e.g. "/"
|
||||||
|
// already renders activities) -- avoids junk Back-button stops.
|
||||||
|
if (viewFromPathname(window.location.pathname) !== next) {
|
||||||
|
history.pushState({}, "", "/" + next);
|
||||||
|
}
|
||||||
|
setView(next);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ActiveTab = TABS.find((t) => t.key === view)?.Component;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="app">
|
<div className="app">
|
||||||
@@ -37,11 +82,8 @@ function App({ session }: { session: SessionInfo }) {
|
|||||||
{TABS.map((t) => (
|
{TABS.map((t) => (
|
||||||
<button
|
<button
|
||||||
key={t.key}
|
key={t.key}
|
||||||
className={!showProfile && t.key === tab ? "tab active" : "tab"}
|
className={view === t.key ? "tab active" : "tab"}
|
||||||
onClick={() => {
|
onClick={() => navigate(t.key)}
|
||||||
setShowProfile(false);
|
|
||||||
setTab(t.key);
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<span className="tab-icon">{t.icon}</span>
|
<span className="tab-icon">{t.icon}</span>
|
||||||
{t.label}
|
{t.label}
|
||||||
@@ -51,19 +93,39 @@ function App({ session }: { session: SessionInfo }) {
|
|||||||
<div className="header-actions">
|
<div className="header-actions">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={showProfile ? "profile-name active" : "profile-name"}
|
className={view === "profile" ? "icon-button active" : "icon-button"}
|
||||||
onClick={() => setShowProfile(true)}
|
title={profileName ?? "Profile"}
|
||||||
|
aria-label={profileName ?? "Profile"}
|
||||||
|
onClick={() => navigate("profile")}
|
||||||
>
|
>
|
||||||
{profileName ?? "Profile"}
|
👤
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={view === "config" ? "icon-button active" : "icon-button"}
|
||||||
|
title="Settings"
|
||||||
|
aria-label="Settings"
|
||||||
|
onClick={() => navigate("config")}
|
||||||
|
>
|
||||||
|
⚙️
|
||||||
</button>
|
</button>
|
||||||
<form method="post" action={`${BASE_URL}/api/session/logout`} title={session.email}>
|
<form method="post" action={`${BASE_URL}/api/session/logout`} title={session.email}>
|
||||||
<button type="submit" className="logout-link">
|
<button type="submit" className="icon-button" aria-label="Log out" title="Log out">
|
||||||
Log out
|
✕
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<main>{showProfile ? <Profile onSaved={(p) => setProfileName(p.Name)} /> : <Active />}</main>
|
<BannerStack />
|
||||||
|
<main>
|
||||||
|
{view === "config" ? (
|
||||||
|
<Config />
|
||||||
|
) : view === "profile" ? (
|
||||||
|
<Profile onSaved={(p) => setProfileName(p.Name)} />
|
||||||
|
) : (
|
||||||
|
ActiveTab && <ActiveTab />
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,43 +0,0 @@
|
|||||||
.create-profile {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
height: 100vh;
|
|
||||||
gap: 1rem;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.create-profile form {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.75rem;
|
|
||||||
width: 100%;
|
|
||||||
max-width: 320px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.create-profile input {
|
|
||||||
padding: 0.6rem 0.8rem;
|
|
||||||
font-size: 1rem;
|
|
||||||
border-radius: 0.5rem;
|
|
||||||
border: 1px solid #ccc;
|
|
||||||
}
|
|
||||||
|
|
||||||
.create-profile button {
|
|
||||||
padding: 0.6rem 0.8rem;
|
|
||||||
font-size: 1rem;
|
|
||||||
border-radius: 0.5rem;
|
|
||||||
border: none;
|
|
||||||
background: #3b82f6;
|
|
||||||
color: white;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.create-profile button:disabled {
|
|
||||||
opacity: 0.6;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
|
|
||||||
.create-profile-error {
|
|
||||||
color: #ef4444;
|
|
||||||
}
|
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
import { useState } from "react";
|
|
||||||
import { api } from "./api/client";
|
|
||||||
import "./CreateProfile.css";
|
|
||||||
|
|
||||||
// Shown once, right after a brand-new OIDC login, before the account has a
|
|
||||||
// geniusrun profile at all. The only thing asked for is a display name --
|
|
||||||
// Garmin credentials and every other tunable are filled in afterward via
|
|
||||||
// the existing Profile screen, same as a fresh single-user install today.
|
|
||||||
export function CreateProfile({ onCreated }: { onCreated: (displayName: string) => void }) {
|
|
||||||
const [displayName, setDisplayName] = useState("");
|
|
||||||
const [submitting, setSubmitting] = useState(false);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
const trimmed = displayName.trim();
|
|
||||||
if (!trimmed) {
|
|
||||||
setError("Please enter a display name.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setSubmitting(true);
|
|
||||||
setError(null);
|
|
||||||
try {
|
|
||||||
const result = await api.setup(trimmed);
|
|
||||||
onCreated(result.display_name);
|
|
||||||
} catch (err) {
|
|
||||||
setError(err instanceof Error ? err.message : "Something went wrong, please try again.");
|
|
||||||
setSubmitting(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="create-profile">
|
|
||||||
<h1>🧞♀️ Welcome to geniusrun</h1>
|
|
||||||
<p>Let's set up your profile. You can add your Garmin account afterward.</p>
|
|
||||||
<form onSubmit={handleSubmit}>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
placeholder="Display name"
|
|
||||||
value={displayName}
|
|
||||||
onChange={(e) => setDisplayName(e.target.value)}
|
|
||||||
disabled={submitting}
|
|
||||||
autoFocus
|
|
||||||
/>
|
|
||||||
{error && <p className="create-profile-error">{error}</p>}
|
|
||||||
<button type="submit" disabled={submitting}>
|
|
||||||
{submitting ? "Creating…" : "Continue"}
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -11,10 +11,6 @@
|
|||||||
color: #e6e6e6;
|
color: #e6e6e6;
|
||||||
}
|
}
|
||||||
|
|
||||||
.login-gate-error {
|
|
||||||
color: #f87171;
|
|
||||||
}
|
|
||||||
|
|
||||||
.login-gate-button {
|
.login-gate-button {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
margin-top: 1rem;
|
margin-top: 1rem;
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { api, BASE_URL } from "./api/client";
|
import { api, BASE_URL, NetworkError } from "./api/client";
|
||||||
|
import { showError } from "./banner";
|
||||||
|
import { BannerStack } from "./components/BannerStack";
|
||||||
import "./LoginGate.css";
|
import "./LoginGate.css";
|
||||||
import App from "./App";
|
import App, { POST_LOGIN_PATH_KEY } from "./App";
|
||||||
import { CreateProfile } from "./CreateProfile";
|
import { OnboardingWizard } from "./OnboardingWizard";
|
||||||
import type { SessionInfo } from "./types/api";
|
import type { SessionInfo } from "./types/api";
|
||||||
|
|
||||||
type Status = "loading" | "authenticated" | "unauthenticated";
|
type Status = "loading" | "authenticated" | "unauthenticated";
|
||||||
@@ -17,7 +19,11 @@ const AUTH_ERROR_MESSAGES: Record<string, string> = {
|
|||||||
// this is the first fork -- "show the login screen" vs "show the app." A
|
// this is the first fork -- "show the login screen" vs "show the app." A
|
||||||
// second fork, once authenticated, is whether the session's account has a
|
// second fork, once authenticated, is whether the session's account has a
|
||||||
// provisioned profile yet (session.has_profile) -- a brand-new OIDC login
|
// provisioned profile yet (session.has_profile) -- a brand-new OIDC login
|
||||||
// sees CreateProfile instead of App until it submits one.
|
// sees OnboardingWizard instead of App until it completes one. Nothing is
|
||||||
|
// persisted until the wizard's Garmin-connect step actually succeeds (see
|
||||||
|
// docs/superpowers/specs/2026-07-26-onboarding-wizard-deferred-commit-design.md),
|
||||||
|
// so has_profile alone is always sufficient here -- there's no separate
|
||||||
|
// "provisioned but not connected" state to gate on.
|
||||||
export function LoginGate() {
|
export function LoginGate() {
|
||||||
const [status, setStatus] = useState<Status>("loading");
|
const [status, setStatus] = useState<Status>("loading");
|
||||||
const [session, setSession] = useState<SessionInfo | null>(null);
|
const [session, setSession] = useState<SessionInfo | null>(null);
|
||||||
@@ -29,29 +35,63 @@ export function LoginGate() {
|
|||||||
setSession(s);
|
setSession(s);
|
||||||
setStatus("authenticated");
|
setStatus("authenticated");
|
||||||
})
|
})
|
||||||
.catch(() => setStatus("unauthenticated"));
|
.catch((e) => {
|
||||||
|
// A NetworkError here means the backend itself isn't reachable, not
|
||||||
|
// that this browser is genuinely logged out -- worth a banner, since
|
||||||
|
// otherwise this is the one place in the app where "backend not
|
||||||
|
// here" would show no feedback at all. Either way the screen still
|
||||||
|
// falls through to "unauthenticated" (showing the Log in button),
|
||||||
|
// since there's no session to trust regardless of why the check
|
||||||
|
// failed.
|
||||||
|
if (e instanceof NetworkError) showError(e.message);
|
||||||
|
setStatus("unauthenticated");
|
||||||
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Keycloak redirects back here with ?auth_error=<code> when the
|
||||||
|
// login-gate's own role check (not Keycloak's own authentication) rejects
|
||||||
|
// a session -- surfaced once, as a banner, the moment this screen is
|
||||||
|
// reached.
|
||||||
|
useEffect(() => {
|
||||||
|
if (status !== "unauthenticated") return;
|
||||||
|
const authError = new URLSearchParams(window.location.search).get("auth_error");
|
||||||
|
if (authError) showError(AUTH_ERROR_MESSAGES[authError] ?? "Login failed, please try again.");
|
||||||
|
// Deep link survival: the OIDC flow always lands back on "/" (the
|
||||||
|
// callback's redirect), so remember where the user was actually
|
||||||
|
// heading -- App pops this stash on its first render after login and
|
||||||
|
// restores the path (see restoreDeepLink in App.tsx).
|
||||||
|
if (window.location.pathname !== "/") {
|
||||||
|
sessionStorage.setItem(POST_LOGIN_PATH_KEY, window.location.pathname);
|
||||||
|
}
|
||||||
|
}, [status]);
|
||||||
|
|
||||||
if (status === "loading") {
|
if (status === "loading") {
|
||||||
return <div className="login-gate-loading">Loading…</div>;
|
return (
|
||||||
|
<>
|
||||||
|
<BannerStack />
|
||||||
|
<div className="login-gate-loading">Loading…</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (status === "unauthenticated") {
|
if (status === "unauthenticated") {
|
||||||
const authError = new URLSearchParams(window.location.search).get("auth_error");
|
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
|
<BannerStack />
|
||||||
<div className="login-gate">
|
<div className="login-gate">
|
||||||
<h1>🧞♀️ geniusrun</h1>
|
<h1>🧞♀️ geniusrun</h1>
|
||||||
{authError && <p className="login-gate-error">{AUTH_ERROR_MESSAGES[authError] ?? "Login failed, please try again."}</p>}
|
|
||||||
<a className="login-gate-button" href={`${BASE_URL}/api/session/login`}>
|
<a className="login-gate-button" href={`${BASE_URL}/api/session/login`}>
|
||||||
Log in
|
Log in
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!session!.has_profile) {
|
if (!session!.has_profile) {
|
||||||
return (
|
return (
|
||||||
<CreateProfile
|
<OnboardingWizard
|
||||||
|
defaultName={session!.name}
|
||||||
onCreated={(displayName) => setSession((s) => (s ? { ...s, has_profile: true, display_name: displayName } : s))}
|
onCreated={(displayName) => setSession((s) => (s ? { ...s, has_profile: true, display_name: displayName } : s))}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
74
frontend/src/OnboardingWizard.css
Normal file
74
frontend/src/OnboardingWizard.css
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
.onboarding-wizard {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 100vh;
|
||||||
|
gap: 1rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.onboarding-wizard form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.75rem;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 320px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.onboarding-wizard input {
|
||||||
|
padding: 0.6rem 0.8rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
border: 1px solid #ccc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.onboarding-wizard button {
|
||||||
|
padding: 0.6rem 0.8rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
border: none;
|
||||||
|
background: #3b82f6;
|
||||||
|
color: white;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.onboarding-wizard button:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.onboarding-wizard-error {
|
||||||
|
color: #ef4444;
|
||||||
|
}
|
||||||
|
|
||||||
|
.onboarding-wizard-message {
|
||||||
|
margin: 0;
|
||||||
|
color: #9ca3af;
|
||||||
|
}
|
||||||
|
|
||||||
|
.onboarding-wizard-mfa {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.75rem;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 320px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.onboarding-wizard-name-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.onboarding-wizard-name-row input {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.onboarding-wizard-icon-button {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
padding: 0.6rem 0.9rem;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
193
frontend/src/OnboardingWizard.tsx
Normal file
193
frontend/src/OnboardingWizard.tsx
Normal file
@@ -0,0 +1,193 @@
|
|||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import type { FormEvent } from "react";
|
||||||
|
import { api } from "./api/client";
|
||||||
|
import { showError } from "./banner";
|
||||||
|
import { BannerStack } from "./components/BannerStack";
|
||||||
|
import "./OnboardingWizard.css";
|
||||||
|
import type { AuthResponse } from "./types/api";
|
||||||
|
|
||||||
|
type Step = "name" | "garmin";
|
||||||
|
|
||||||
|
// Replaces the old CreateProfile.tsx + ConnectGarmin.tsx: a single wizard,
|
||||||
|
// since nothing is persisted to the database until Garmin actually
|
||||||
|
// connects (see
|
||||||
|
// docs/superpowers/specs/2026-07-26-onboarding-wizard-deferred-commit-design.md).
|
||||||
|
// Quitting partway through (closing the tab) leaves no trace in the
|
||||||
|
// database -- there's no half-created account to clean up or re-prompt
|
||||||
|
// for later, so there's deliberately no "Log out" escape hatch anywhere in
|
||||||
|
// this flow: closing the tab *is* the escape hatch.
|
||||||
|
// defaultName pre-fills the display-name field with the OIDC claim's name --
|
||||||
|
// most people keep it, and a single ✓ click through is nicer than retyping.
|
||||||
|
export function OnboardingWizard({
|
||||||
|
onCreated,
|
||||||
|
defaultName = "",
|
||||||
|
}: {
|
||||||
|
onCreated: (displayName: string) => void;
|
||||||
|
defaultName?: string;
|
||||||
|
}) {
|
||||||
|
const [step, setStep] = useState<Step>("name");
|
||||||
|
const [displayName, setDisplayName] = useState(defaultName);
|
||||||
|
const [email, setEmail] = useState("");
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
|
const [code, setCode] = useState("");
|
||||||
|
const [auth, setAuth] = useState<AuthResponse | null>(null);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
// Field-validation messages only ("please fill this in") -- real
|
||||||
|
// API/Garmin failures go through the shared banner system instead (see
|
||||||
|
// completeFailed below for why the Retry button no longer keys off this).
|
||||||
|
const [validationError, setValidationError] = useState<string | null>(null);
|
||||||
|
const [completeFailed, setCompleteFailed] = useState(false);
|
||||||
|
const completeTriggered = useRef(false);
|
||||||
|
|
||||||
|
function submitName(e: FormEvent<HTMLFormElement>) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!displayName.trim()) {
|
||||||
|
setValidationError("Please enter a display name.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setValidationError(null);
|
||||||
|
setStep("garmin");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitGarmin(e: FormEvent<HTMLFormElement>) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!email.trim() || !password) {
|
||||||
|
setValidationError("Please enter your Garmin email and password.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setBusy(true);
|
||||||
|
setValidationError(null);
|
||||||
|
try {
|
||||||
|
setAuth(await api.setupGarminLogin(email.trim(), password));
|
||||||
|
} catch (err) {
|
||||||
|
showError(err instanceof Error ? err.message : "Something went wrong, please try again.");
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitMFA() {
|
||||||
|
if (!code.trim()) return;
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
setAuth(await api.setupGarminMFA(code.trim()));
|
||||||
|
setCode("");
|
||||||
|
} catch (err) {
|
||||||
|
showError(err instanceof Error ? err.message : "Something went wrong, please try again.");
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function complete() {
|
||||||
|
setBusy(true);
|
||||||
|
setCompleteFailed(false);
|
||||||
|
try {
|
||||||
|
const result = await api.setupComplete(displayName.trim());
|
||||||
|
onCreated(result.display_name);
|
||||||
|
} catch (err) {
|
||||||
|
showError(err instanceof Error ? err.message : "Something went wrong, please try again.");
|
||||||
|
setCompleteFailed(true);
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Once Garmin reports success (whether MFA was needed or not), finish
|
||||||
|
// setup immediately -- no "Continue to geniusrun" click required. Guarded
|
||||||
|
// by a ref (not just the effect's dependency array) so this can never
|
||||||
|
// fire twice, e.g. under React StrictMode's double-invoked effects in
|
||||||
|
// development.
|
||||||
|
useEffect(() => {
|
||||||
|
if (auth?.status === "authenticated" && !completeTriggered.current) {
|
||||||
|
completeTriggered.current = true;
|
||||||
|
complete();
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [auth?.status]);
|
||||||
|
|
||||||
|
if (step === "name") {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<BannerStack />
|
||||||
|
<div className="onboarding-wizard">
|
||||||
|
<h1>🧞♀️ Welcome to geniusrun</h1>
|
||||||
|
<p>Let's set up your profile. You'll connect your Garmin account next.</p>
|
||||||
|
<form onSubmit={submitName}>
|
||||||
|
<div className="onboarding-wizard-name-row">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Display name"
|
||||||
|
value={displayName}
|
||||||
|
onChange={(e) => setDisplayName(e.target.value)}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<button type="submit" className="onboarding-wizard-icon-button" title="Continue" aria-label="Continue">
|
||||||
|
✓
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{validationError && <p className="onboarding-wizard-error">{validationError}</p>}
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<BannerStack />
|
||||||
|
<div className="onboarding-wizard">
|
||||||
|
<h1>🧞♀️ Connect your Garmin account</h1>
|
||||||
|
<p>geniusrun needs your Garmin credentials to sync your activities.</p>
|
||||||
|
|
||||||
|
{auth?.status === "authenticated" ? (
|
||||||
|
<>
|
||||||
|
<p className="onboarding-wizard-message">Connected to Garmin — finishing setup…</p>
|
||||||
|
{completeFailed && (
|
||||||
|
<button type="button" disabled={busy} onClick={complete}>
|
||||||
|
{busy ? "Finishing…" : "Retry"}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : auth?.status === "mfa_required" ? (
|
||||||
|
<div className="onboarding-wizard-mfa">
|
||||||
|
<input
|
||||||
|
placeholder="MFA code"
|
||||||
|
value={code}
|
||||||
|
onChange={(e) => setCode(e.target.value)}
|
||||||
|
onKeyDown={(e) => e.key === "Enter" && submitMFA()}
|
||||||
|
disabled={busy}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
{auth.message && <p className="onboarding-wizard-message">{auth.message}</p>}
|
||||||
|
<button type="button" disabled={busy} onClick={submitMFA}>
|
||||||
|
Submit code
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<form onSubmit={submitGarmin}>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Garmin email"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
disabled={busy}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
placeholder="Garmin password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
disabled={busy}
|
||||||
|
/>
|
||||||
|
{auth?.message && <p className="onboarding-wizard-message">{auth.message}</p>}
|
||||||
|
{validationError && <p className="onboarding-wizard-error">{validationError}</p>}
|
||||||
|
<button type="submit" disabled={busy}>
|
||||||
|
{busy ? "Connecting…" : "Login"}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,12 +1,10 @@
|
|||||||
import type {
|
import type {
|
||||||
Activity,
|
ActivitiesPage,
|
||||||
ActivityListItem,
|
|
||||||
AuthResponse,
|
AuthResponse,
|
||||||
KindAssignment,
|
ConfigResponse,
|
||||||
Profile,
|
Profile,
|
||||||
ProgressionMetric,
|
ProgressionMetric,
|
||||||
ProgressionPoint,
|
ProgressionPoint,
|
||||||
ReviewQueuePage,
|
|
||||||
SessionInfo,
|
SessionInfo,
|
||||||
SyncRun,
|
SyncRun,
|
||||||
SyncStatus,
|
SyncStatus,
|
||||||
@@ -15,23 +13,41 @@ import type {
|
|||||||
|
|
||||||
export const BASE_URL = import.meta.env.VITE_API_BASE_URL ?? "http://localhost:8080";
|
export const BASE_URL = import.meta.env.VITE_API_BASE_URL ?? "http://localhost:8080";
|
||||||
|
|
||||||
|
// Thrown when fetch() itself rejects (offline, connection refused, CORS --
|
||||||
|
// the backend genuinely isn't reachable), as opposed to a normal non-2xx
|
||||||
|
// HTTP response. A distinct type, not just a distinguishable message
|
||||||
|
// string, so a caller that needs to tell the two apart (LoginGate.tsx's
|
||||||
|
// initial session check, which must not treat "backend unreachable" the
|
||||||
|
// same as "genuinely not logged in") can check `instanceof` rather than
|
||||||
|
// matching on text that's free to change.
|
||||||
|
export class NetworkError extends Error {}
|
||||||
|
|
||||||
async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
||||||
const res = await fetch(`${BASE_URL}${path}`, {
|
let res: Response;
|
||||||
|
try {
|
||||||
|
res = await fetch(`${BASE_URL}${path}`, {
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
credentials: "include",
|
credentials: "include",
|
||||||
...init,
|
...init,
|
||||||
});
|
});
|
||||||
|
} catch {
|
||||||
|
// The raw browser error text here (e.g. "TypeError: Failed to fetch")
|
||||||
|
// is not something to show a user; every caller's catch-and-showError
|
||||||
|
// already displays whatever this throws, so the friendly text only
|
||||||
|
// needs to be written once, here.
|
||||||
|
throw new NetworkError("Can't reach the server.");
|
||||||
|
}
|
||||||
if (res.status === 401 && path !== "/api/session/me") {
|
if (res.status === 401 && path !== "/api/session/me") {
|
||||||
// An established session expired mid-use (not the initial "am I logged
|
// An established session expired mid-use (not the initial "am I logged
|
||||||
// in" check, which LoginGate itself interprets) -- reload so LoginGate
|
// in" check, which LoginGate itself interprets) -- reload so LoginGate
|
||||||
// re-checks and shows the login screen instead of leaving the SPA in a
|
// re-checks and shows the login screen instead of leaving the SPA in a
|
||||||
// half-authenticated state.
|
// half-authenticated state.
|
||||||
window.location.href = "/";
|
window.location.href = "/";
|
||||||
throw new Error(`${init?.method ?? "GET"} ${path} failed: 401 (session expired)`);
|
throw new Error(`${init?.method ?? "GET"} ${path} failed: HTTP 401 (session expired)`);
|
||||||
}
|
}
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const body = await res.text();
|
const body = await res.text();
|
||||||
throw new Error(`${init?.method ?? "GET"} ${path} failed: ${res.status} ${body}`);
|
throw new Error(`${init?.method ?? "GET"} ${path} failed: HTTP ${res.status} ${body}`);
|
||||||
}
|
}
|
||||||
if (res.status === 204) return undefined as T;
|
if (res.status === 204) return undefined as T;
|
||||||
return res.json() as Promise<T>;
|
return res.json() as Promise<T>;
|
||||||
@@ -46,38 +62,38 @@ export const api = {
|
|||||||
// browser navigation. Logout must POST (see server.go's route), which an
|
// browser navigation. Logout must POST (see server.go's route), which an
|
||||||
// <a href> can't do, hence the form.
|
// <a href> can't do, hence the form.
|
||||||
getSessionInfo: () => request<SessionInfo>("/api/session/me"),
|
getSessionInfo: () => request<SessionInfo>("/api/session/me"),
|
||||||
setup: (displayName: string) =>
|
// Onboarding (pre-account) -- distinct from the Profile page's
|
||||||
request<{ user_id: number; display_name: string }>("/api/setup", {
|
// auth.login()/submitMFA() below, since these run before any account
|
||||||
|
// exists: the Garmin session they build is ephemeral (keyed by OIDC
|
||||||
|
// subject, not a user id) until setupComplete() actually creates the
|
||||||
|
// account. See docs/superpowers/specs/2026-07-26-onboarding-wizard-deferred-commit-design.md.
|
||||||
|
setupGarminLogin: (garminEmail: string, garminPassword: string) =>
|
||||||
|
request<AuthResponse>("/api/setup/garmin/login", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ garmin_email: garminEmail, garmin_password: garminPassword }),
|
||||||
|
}),
|
||||||
|
setupGarminMFA: (code: string) =>
|
||||||
|
request<AuthResponse>("/api/setup/garmin/mfa", { method: "POST", body: JSON.stringify({ code }) }),
|
||||||
|
setupComplete: (displayName: string) =>
|
||||||
|
request<{ user_id: number; display_name: string }>("/api/setup/complete", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify({ display_name: displayName }),
|
body: JSON.stringify({ display_name: displayName }),
|
||||||
}),
|
}),
|
||||||
|
|
||||||
// Auth
|
// Garmin Auth
|
||||||
login: () => request<AuthResponse>("/api/auth/login", { method: "POST" }),
|
garminLogin: () => request<AuthResponse>("/api/garmin/auth/login", { method: "POST" }),
|
||||||
submitMFA: (code: string) =>
|
garminMFA: (code: string) =>
|
||||||
request<AuthResponse>("/api/auth/mfa", { method: "POST", body: JSON.stringify({ code }) }),
|
request<AuthResponse>("/api/garmin/auth/mfa", { method: "POST", body: JSON.stringify({ code }) }),
|
||||||
authStatus: () => request<AuthResponse>("/api/auth/status"),
|
garminStatus: () => request<AuthResponse>("/api/garmin/auth/status"),
|
||||||
|
|
||||||
// Sync
|
// Garmin Sync
|
||||||
syncRun: () => request<{ status: string }>("/api/sync/run", { method: "POST" }),
|
garminSyncRun: () => request<{ status: string }>("/api/garmin/sync/run", { method: "POST" }),
|
||||||
// Deletes every synced activity (and its laps/samples/kind assignments)
|
// Deletes every synced activity (and its laps/samples/kind assignments)
|
||||||
// and rewinds the backfill watermark -- destructive, gated by a
|
// and rewinds the backfill watermark -- destructive, gated by a
|
||||||
// confirmation in the UI.
|
// confirmation in the UI.
|
||||||
resetSync: () => request<{ status: string }>("/api/sync/reset", { method: "POST" }),
|
garminSyncReset: () => request<{ status: string }>("/api/garmin/sync/reset", { method: "POST" }),
|
||||||
syncRuns: () => request<SyncRun[]>("/api/sync/runs"),
|
garminSyncRuns: () => request<SyncRun[]>("/api/garmin/sync/runs"),
|
||||||
syncStatus: () => request<SyncStatus>("/api/sync/status"),
|
garminSyncStatus: () => request<SyncStatus>("/api/garmin/sync/status"),
|
||||||
|
|
||||||
// Activities
|
|
||||||
listActivities: (params?: { from?: string; to?: string; limit?: number }) => {
|
|
||||||
const q = new URLSearchParams();
|
|
||||||
if (params?.from) q.set("from", params.from);
|
|
||||||
if (params?.to) q.set("to", params.to);
|
|
||||||
if (params?.limit) q.set("limit", String(params.limit));
|
|
||||||
const qs = q.toString();
|
|
||||||
return request<ActivityListItem[]>(`/api/activities/${qs ? `?${qs}` : ""}`);
|
|
||||||
},
|
|
||||||
getActivity: (id: number) =>
|
|
||||||
request<{ activity: Activity; laps: unknown[]; assignment?: KindAssignment }>(`/api/activities/${id}`),
|
|
||||||
|
|
||||||
// Training types -- fixed taxonomy, no create/delete
|
// Training types -- fixed taxonomy, no create/delete
|
||||||
listWorkoutKinds: (includeInactive = false) =>
|
listWorkoutKinds: (includeInactive = false) =>
|
||||||
@@ -111,34 +127,40 @@ export const api = {
|
|||||||
// navigation -- this does not touch the session cookie (see Profile.tsx).
|
// navigation -- this does not touch the session cookie (see Profile.tsx).
|
||||||
deleteProfile: () => request<void>("/api/profile", { method: "DELETE" }),
|
deleteProfile: () => request<void>("/api/profile", { method: "DELETE" }),
|
||||||
|
|
||||||
// Review queue -- cursor-paginated: pass the previous page's next_cursor
|
// Activities -- cursor-paginated: pass the previous page's next_cursor
|
||||||
// as `before` to fetch the next one. Fetching every activity's laps and
|
// as `before` to fetch the next one. Fetching every activity's laps and
|
||||||
// per-second samples up front gets expensive once there are many, so the
|
// per-second samples up front gets expensive once there are many, so the
|
||||||
// frontend loads it incrementally (infinite scroll) instead of all at once
|
// frontend loads it incrementally (infinite scroll) instead of all at once
|
||||||
// -- kindId/unclassified filter server-side so a filtered view stays
|
// -- kindId/unclassified filter server-side so a filtered view stays
|
||||||
// paginated too, instead of having to load the whole matching backlog.
|
// paginated too, instead of having to load the whole matching backlog.
|
||||||
reviewQueue: (params?: { limit?: number; before?: string; kindId?: number; unclassified?: boolean }) => {
|
listActivities: (params?: { limit?: number; before?: string; kindId?: number; unclassified?: boolean }) => {
|
||||||
const q = new URLSearchParams();
|
const q = new URLSearchParams();
|
||||||
if (params?.limit) q.set("limit", String(params.limit));
|
if (params?.limit) q.set("limit", String(params.limit));
|
||||||
if (params?.before) q.set("before", params.before);
|
if (params?.before) q.set("before", params.before);
|
||||||
if (params?.kindId != null) q.set("kind_id", String(params.kindId));
|
if (params?.kindId != null) q.set("kind_id", String(params.kindId));
|
||||||
if (params?.unclassified) q.set("unclassified", "true");
|
if (params?.unclassified) q.set("unclassified", "true");
|
||||||
const qs = q.toString();
|
const qs = q.toString();
|
||||||
return request<ReviewQueuePage>(`/api/review-queue/${qs ? `?${qs}` : ""}`);
|
return request<ActivitiesPage>(`/api/activities/${qs ? `?${qs}` : ""}`);
|
||||||
},
|
},
|
||||||
resolveReview: (activityId: number, workoutKindId: number) =>
|
assignActivity: (activityId: number, workoutKindId: number) =>
|
||||||
request<{ status: string }>(`/api/review-queue/${activityId}/resolve`, {
|
request<{ status: string }>(`/api/activities/${activityId}/assign`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify({ workout_kind_id: workoutKindId }),
|
body: JSON.stringify({ workout_kind_id: workoutKindId }),
|
||||||
}),
|
}),
|
||||||
// Reverts a manual assignment back to rule_engine sourcing (same kind),
|
// Reverts a manual assignment back to rule_engine sourcing (same kind),
|
||||||
// so a later reclassify pass is free to change it again.
|
// so a later reclassify pass is free to change it again.
|
||||||
unlockReview: (activityId: number) =>
|
unlockActivity: (activityId: number) =>
|
||||||
request<{ status: string }>(`/api/review-queue/${activityId}/unlock`, { method: "POST" }),
|
request<{ status: string }>(`/api/activities/${activityId}/unlock`, { method: "POST" }),
|
||||||
// Manually clears an activity's kind back to Unclassified (locks that
|
// Manually clears an activity's kind back to Unclassified (locks that
|
||||||
// decision, same as resolveReview locks a specific kind).
|
// decision, same as assignActivity locks a specific kind).
|
||||||
unassignReview: (activityId: number) =>
|
unassignActivity: (activityId: number) =>
|
||||||
request<{ status: string }>(`/api/review-queue/${activityId}/unassign`, { method: "POST" }),
|
request<{ status: string }>(`/api/activities/${activityId}/unassign`, { method: "POST" }),
|
||||||
|
|
||||||
|
// Application/environment configuration (the /config page). Cold: saved
|
||||||
|
// values take effect after the backend restarts.
|
||||||
|
getConfig: () => request<ConfigResponse>("/api/config"),
|
||||||
|
updateConfig: (values: Record<string, string>) =>
|
||||||
|
request<ConfigResponse>("/api/config", { method: "PUT", body: JSON.stringify(values) }),
|
||||||
|
|
||||||
// Progression
|
// Progression
|
||||||
progression: (kindId: number, metric: ProgressionMetric = "pace", from?: string, to?: string) => {
|
progression: (kindId: number, metric: ProgressionMetric = "pace", from?: string, to?: string) => {
|
||||||
|
|||||||
63
frontend/src/banner.ts
Normal file
63
frontend/src/banner.ts
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
export type BannerSeverity = "error" | "warning" | "notice" | "success";
|
||||||
|
|
||||||
|
export interface BannerEntry {
|
||||||
|
id: number;
|
||||||
|
severity: BannerSeverity;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Non-error banners clean themselves up; errors stay until the user
|
||||||
|
// dismisses them, so a failure can't silently vanish before it's read.
|
||||||
|
const DISMISS_AFTER_MS = 8000;
|
||||||
|
|
||||||
|
let banners: BannerEntry[] = [];
|
||||||
|
let nextId = 1;
|
||||||
|
const listeners = new Set<() => void>();
|
||||||
|
|
||||||
|
function notify() {
|
||||||
|
for (const listener of listeners) listener();
|
||||||
|
}
|
||||||
|
|
||||||
|
function show(severity: BannerSeverity, message: string): void {
|
||||||
|
const isDuplicate = banners.some(
|
||||||
|
(b) => b.severity === severity && b.message === message,
|
||||||
|
);
|
||||||
|
if (isDuplicate) return;
|
||||||
|
|
||||||
|
const id = nextId++;
|
||||||
|
banners = [{ id, severity, message }, ...banners];
|
||||||
|
notify();
|
||||||
|
if (severity !== "error") {
|
||||||
|
setTimeout(() => dismiss(id), DISMISS_AFTER_MS);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function showError(message: string): void {
|
||||||
|
show("error", message);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function showWarning(message: string): void {
|
||||||
|
show("warning", message);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function showNotice(message: string): void {
|
||||||
|
show("notice", message);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function showSuccess(message: string): void {
|
||||||
|
show("success", message);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function dismiss(id: number): void {
|
||||||
|
banners = banners.filter((b) => b.id !== id);
|
||||||
|
notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function subscribe(listener: () => void): () => void {
|
||||||
|
listeners.add(listener);
|
||||||
|
return () => listeners.delete(listener);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getSnapshot(): BannerEntry[] {
|
||||||
|
return banners;
|
||||||
|
}
|
||||||
51
frontend/src/components/BannerStack.css
Normal file
51
frontend/src/components/BannerStack.css
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
.banner-stack {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.banner {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 0.75rem 1.5rem;
|
||||||
|
white-space: pre-line;
|
||||||
|
}
|
||||||
|
|
||||||
|
.banner-message {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.banner-close {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: inherit;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
line-height: 1;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.banner-error {
|
||||||
|
background: #7f1d1d;
|
||||||
|
border-bottom: 1px solid #f87171;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.banner-warning {
|
||||||
|
background: #7c2d12;
|
||||||
|
border-bottom: 1px solid #fb923c;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.banner-notice {
|
||||||
|
background: #1e3a5f;
|
||||||
|
border-bottom: 1px solid #3b82f6;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.banner-success {
|
||||||
|
background: #14532d;
|
||||||
|
border-bottom: 1px solid #22c55e;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
26
frontend/src/components/BannerStack.tsx
Normal file
26
frontend/src/components/BannerStack.tsx
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import { useSyncExternalStore } from "react";
|
||||||
|
import "./BannerStack.css";
|
||||||
|
import { dismiss, getSnapshot, subscribe } from "../banner";
|
||||||
|
|
||||||
|
// Mounted exactly once, in main.tsx, above <LoginGate /> -- see
|
||||||
|
// docs/superpowers/specs/2026-07-27-modern-error-displays-design.md. Renders
|
||||||
|
// in normal document flow (not position: fixed) so it pushes down whatever's
|
||||||
|
// currently showing (login screen, onboarding wizard, or the full app),
|
||||||
|
// without needing a separate mount point in each of those three layouts.
|
||||||
|
export function BannerStack() {
|
||||||
|
const banners = useSyncExternalStore(subscribe, getSnapshot);
|
||||||
|
if (banners.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="banner-stack">
|
||||||
|
{banners.map((b) => (
|
||||||
|
<div key={b.id} className={`banner banner-${b.severity}`}>
|
||||||
|
<span className="banner-message">{b.message}</span>
|
||||||
|
<button type="button" className="banner-close" aria-label="Dismiss" onClick={() => dismiss(b.id)}>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,60 +1,40 @@
|
|||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { api } from "../api/client";
|
import { api } from "../api/client";
|
||||||
import type { AuthResponse, SyncStatus } from "../types/api";
|
import { showError, showSuccess } from "../banner";
|
||||||
|
import type { AuthResponse } from "../types/api";
|
||||||
function syncProgressLabel(status: SyncStatus): string {
|
import { GarminMFAModal } from "./GarminMFAModal";
|
||||||
const { detail_fill_progress: fill, activities_pending_details: pending } = status;
|
import { SyncModal } from "./SyncModal";
|
||||||
if (fill.Total > 0) {
|
|
||||||
// pending includes the current batch, so subtract what's already
|
|
||||||
// counted in this batch's Total to avoid double-counting the "beyond
|
|
||||||
// this batch" remainder.
|
|
||||||
const beyondBatch = Math.max(0, pending - (fill.Total - fill.Done));
|
|
||||||
return `syncing: ${fill.Done}/${fill.Total} activities${beyondBatch > 0 ? ` (+${beyondBatch} more queued)` : ""}`;
|
|
||||||
}
|
|
||||||
return "syncing...";
|
|
||||||
}
|
|
||||||
|
|
||||||
export function GarminConnection({ onBeforeConnect }: { onBeforeConnect?: () => Promise<void> }) {
|
export function GarminConnection({ onBeforeConnect }: { onBeforeConnect?: () => Promise<void> }) {
|
||||||
const [auth, setAuth] = useState<AuthResponse | null>(null);
|
const [auth, setAuth] = useState<AuthResponse | null>(null);
|
||||||
const [code, setCode] = useState("");
|
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
const [syncStatus, setSyncStatus] = useState<SyncStatus | null>(null);
|
|
||||||
// There's no real Garmin logout -- the backend session just sits idle.
|
// There's no real Garmin logout -- the backend session just sits idle.
|
||||||
// "Disconnect" only hides that and shows Connect again locally; a real
|
// "Disconnect" only hides that and shows Connect again locally; a real
|
||||||
// login (connect()) clears it.
|
// login (connect()) clears it.
|
||||||
const [disconnected, setDisconnected] = useState(false);
|
const [disconnected, setDisconnected] = useState(false);
|
||||||
|
const [showSyncModal, setShowSyncModal] = useState(false);
|
||||||
|
|
||||||
function refreshStatus() {
|
// Auth status alone -- sync status is polled by SyncModal itself while
|
||||||
api.authStatus().then(setAuth).catch((e) => setError(String(e)));
|
// it's open, so this no longer needs the "poll faster while syncing"
|
||||||
api.syncStatus().then(setSyncStatus).catch(() => {});
|
// dynamic interval it used to have.
|
||||||
}
|
|
||||||
|
|
||||||
// Poll faster while a sync is actually running, so progress feels live;
|
|
||||||
// back off to a relaxed interval the rest of the time.
|
|
||||||
const syncStatusRef = useRef(syncStatus);
|
|
||||||
syncStatusRef.current = syncStatus;
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
refreshStatus();
|
api.garminStatus().then(setAuth).catch((e) => showError(String(e)));
|
||||||
let timeout: ReturnType<typeof setTimeout>;
|
const interval = setInterval(() => {
|
||||||
const tick = () => {
|
api.garminStatus().then(setAuth).catch(() => {});
|
||||||
refreshStatus();
|
}, 6000);
|
||||||
timeout = setTimeout(tick, syncStatusRef.current?.in_progress ? 1500 : 6000);
|
return () => clearInterval(interval);
|
||||||
};
|
|
||||||
timeout = setTimeout(tick, 1500);
|
|
||||||
return () => clearTimeout(timeout);
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
async function connect() {
|
async function connect() {
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
setError(null);
|
|
||||||
try {
|
try {
|
||||||
await onBeforeConnect?.();
|
await onBeforeConnect?.();
|
||||||
setAuth(await api.login());
|
const result = await api.garminLogin();
|
||||||
|
setAuth(result);
|
||||||
setDisconnected(false);
|
setDisconnected(false);
|
||||||
|
if (result.status === "authenticated") showSuccess("Connected to Garmin successfully.");
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(String(e));
|
showError(String(e));
|
||||||
} finally {
|
} finally {
|
||||||
setBusy(false);
|
setBusy(false);
|
||||||
}
|
}
|
||||||
@@ -64,15 +44,15 @@ export function GarminConnection({ onBeforeConnect }: { onBeforeConnect?: () =>
|
|||||||
setDisconnected(true);
|
setDisconnected(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function submitMFA() {
|
async function submitMFA(code: string) {
|
||||||
if (!code.trim()) return;
|
if (!code.trim()) return;
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
setError(null);
|
|
||||||
try {
|
try {
|
||||||
setAuth(await api.submitMFA(code.trim()));
|
const result = await api.garminMFA(code.trim());
|
||||||
setCode("");
|
setAuth(result);
|
||||||
|
if (result.status === "authenticated") showSuccess("Connected to Garmin successfully.");
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(String(e));
|
showError(String(e));
|
||||||
} finally {
|
} finally {
|
||||||
setBusy(false);
|
setBusy(false);
|
||||||
}
|
}
|
||||||
@@ -80,12 +60,11 @@ export function GarminConnection({ onBeforeConnect }: { onBeforeConnect?: () =>
|
|||||||
|
|
||||||
async function sync() {
|
async function sync() {
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
setError(null);
|
|
||||||
try {
|
try {
|
||||||
await api.syncRun();
|
await api.garminSyncRun();
|
||||||
refreshStatus();
|
setShowSyncModal(true);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(String(e));
|
showError(String(e));
|
||||||
} finally {
|
} finally {
|
||||||
setBusy(false);
|
setBusy(false);
|
||||||
}
|
}
|
||||||
@@ -96,20 +75,19 @@ export function GarminConnection({ onBeforeConnect }: { onBeforeConnect?: () =>
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
setError(null);
|
|
||||||
try {
|
try {
|
||||||
await api.resetSync();
|
await api.garminSyncReset();
|
||||||
// Reset runs as a background sync job; wait for it to actually finish
|
// Reset runs as a background sync job; wait for it to actually finish
|
||||||
// before reloading, otherwise other pages (e.g. Activities) would
|
// before reloading, otherwise other pages (e.g. Activities) would
|
||||||
// still show the just-deleted activities from their own stale state.
|
// still show the just-deleted activities from their own stale state.
|
||||||
let status = await api.syncStatus();
|
let status = await api.garminSyncStatus();
|
||||||
while (status.in_progress) {
|
while (status.in_progress) {
|
||||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||||
status = await api.syncStatus();
|
status = await api.garminSyncStatus();
|
||||||
}
|
}
|
||||||
window.location.reload();
|
window.location.reload();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(String(e));
|
showError(String(e));
|
||||||
setBusy(false);
|
setBusy(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -122,7 +100,7 @@ export function GarminConnection({ onBeforeConnect }: { onBeforeConnect?: () =>
|
|||||||
<div className="garmin-connection-actions">
|
<div className="garmin-connection-actions">
|
||||||
{status !== "authenticated" && status !== "mfa_required" && (
|
{status !== "authenticated" && status !== "mfa_required" && (
|
||||||
<button disabled={busy} onClick={connect}>
|
<button disabled={busy} onClick={connect}>
|
||||||
Connect to Garmin
|
Connect
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -159,35 +137,14 @@ export function GarminConnection({ onBeforeConnect }: { onBeforeConnect?: () =>
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{status === "mfa_required" && (
|
{status === "mfa_required" && (
|
||||||
<div className="garmin-connection-row">
|
<GarminMFAModal message={auth?.message} busy={busy} onSubmit={submitMFA} onCancel={disconnect} />
|
||||||
<input
|
|
||||||
placeholder="MFA code"
|
|
||||||
value={code}
|
|
||||||
onChange={(e) => setCode(e.target.value)}
|
|
||||||
onKeyDown={(e) => e.key === "Enter" && submitMFA()}
|
|
||||||
/>
|
|
||||||
<button disabled={busy} onClick={submitMFA}>
|
|
||||||
Submit code
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!disconnected && auth?.message && <p className="garmin-connection-message">{auth.message}</p>}
|
{!disconnected && auth?.message && status !== "authenticated" && (
|
||||||
|
<p className="garmin-connection-message">{auth.message}</p>
|
||||||
{/* Full-width so a long "last sync"/pending-details message has room
|
|
||||||
to breathe, instead of being squeezed next to the status dot. */}
|
|
||||||
{status === "authenticated" && syncStatus?.in_progress && (
|
|
||||||
<p className="garmin-connection-message">{syncProgressLabel(syncStatus)}</p>
|
|
||||||
)}
|
|
||||||
{status === "authenticated" && syncStatus?.last_run && !syncStatus.in_progress && (
|
|
||||||
<p className="garmin-connection-message">
|
|
||||||
last sync: {syncStatus.last_run.Status} ({syncStatus.last_run.ActivitiesFetched} activities)
|
|
||||||
{syncStatus.activities_pending_details > 0 &&
|
|
||||||
` — ${syncStatus.activities_pending_details} still need details, click Sync now again`}
|
|
||||||
</p>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{error && <p className="error">{error}</p>}
|
{showSyncModal && <SyncModal onClose={() => setShowSyncModal(false)} />}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
60
frontend/src/components/GarminMFAModal.tsx
Normal file
60
frontend/src/components/GarminMFAModal.tsx
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
// Shown in place of the old inline MFA row whenever GarminConnection reports
|
||||||
|
// mfa_required -- MFA is a required step to finish authenticating, not a
|
||||||
|
// minor detail, so it gets the same focused-dialog treatment as SyncModal/
|
||||||
|
// RawDataModal rather than a cramped row squeezed between the connect
|
||||||
|
// buttons. Cancelling (Escape, backdrop click, or the Cancel button) calls
|
||||||
|
// onCancel, which GarminConnection wires to its existing local-only
|
||||||
|
// disconnect() -- there's no real way to abort a pending Garmin MFA
|
||||||
|
// challenge server-side, so this only hides the prompt and lets the user
|
||||||
|
// start over with a fresh "Connect" click, same as "Disconnect" already
|
||||||
|
// does for an established session.
|
||||||
|
export function GarminMFAModal({
|
||||||
|
message,
|
||||||
|
busy,
|
||||||
|
onSubmit,
|
||||||
|
onCancel,
|
||||||
|
}: {
|
||||||
|
message?: string;
|
||||||
|
busy: boolean;
|
||||||
|
onSubmit: (code: string) => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
}) {
|
||||||
|
const [code, setCode] = useState("");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onKeyDown = (e: KeyboardEvent) => e.key === "Escape" && onCancel();
|
||||||
|
window.addEventListener("keydown", onKeyDown);
|
||||||
|
return () => window.removeEventListener("keydown", onKeyDown);
|
||||||
|
}, [onCancel]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="modal-backdrop" onClick={onCancel}>
|
||||||
|
<div className="modal-content garmin-mfa-modal-content" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<div className="modal-header">
|
||||||
|
<span>Garmin verification code</span>
|
||||||
|
</div>
|
||||||
|
<div className="garmin-mfa-modal-body">
|
||||||
|
{message && <p className="garmin-mfa-modal-message">{message}</p>}
|
||||||
|
<input
|
||||||
|
placeholder="MFA code"
|
||||||
|
value={code}
|
||||||
|
onChange={(e) => setCode(e.target.value)}
|
||||||
|
onKeyDown={(e) => e.key === "Enter" && onSubmit(code)}
|
||||||
|
disabled={busy}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="modal-header-actions garmin-mfa-modal-actions">
|
||||||
|
<button type="button" disabled={busy} onClick={onCancel}>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button type="button" disabled={busy || !code.trim()} onClick={() => onSubmit(code)}>
|
||||||
|
{busy ? "Submitting…" : "Submit code"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user