Copied into this worktree so subagent-driven-development can operate on it; the plan was written and reviewed on main before this branch existed.
4698 lines
178 KiB
Markdown
4698 lines
178 KiB
Markdown
# Per-user profile & data isolation 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:** Give every OIDC-authenticated user their own profile, Garmin credentials, and fully isolated synced/classified dataset, reversing the "single shared profile" design from `docs/superpowers/specs/2026-07-24-oidc-authentication-design.md`.
|
||
|
||
**Architecture:** A new `users` table (keyed by OIDC `sub`) anchors per-user rows added to `profile`, `workout_kinds`, `activities`, `sync_state`, and `sync_runs` in the existing single SQLite database; `laps`/`activity_samples`/`kind_assignments`/`workout_type_paces` stay unscoped in schema but are always accessed via an ownership-checked join to their parent. `garmin.Client`/`sync.Service` become per-user instances built lazily and cached in `api.Server`. Every handler derives `user_id` only from the signed session cookie (never from client input) via a new context-based middleware.
|
||
|
||
**Tech Stack:** Go (chi router, `database/sql` + `modernc.org/sqlite`), React/TypeScript frontend, existing OIDC session auth (`internal/auth`) reused unchanged.
|
||
|
||
**Design reference:** `docs/superpowers/specs/2026-07-25-per-user-profile-design.md` — read it first for the full rationale; this plan implements it task-by-task.
|
||
|
||
## Global Constraints
|
||
|
||
- Every store method that touches `profile`/`workout_kinds`/`activities`/`sync_state`/`sync_runs` (or anything joined off them) takes an explicit `userID int64` parameter — never infer it from anything but the caller-supplied value, and every HTTP handler must obtain that value only from `userIDFromContext(r.Context())`, never a URL param, query string, or request body field. This is the entire security boundary of this feature.
|
||
- `gofmt -l .` must report nothing; `go build ./...` and `go vet ./...` must be clean before any task is considered done.
|
||
- Migrations are added as new numbered files in `backend/internal/store/migrations/`, never editing an already-applied one.
|
||
- No admin/impersonation path is introduced anywhere. No frontend profile switcher.
|
||
- `sync.Service` and `garmin.Client` are constructed per-user (one instance per logged-in user, cached), not per-request.
|
||
|
||
---
|
||
|
||
## Task 1: Schema migration — `users` table + `user_id` scoping
|
||
|
||
**Files:**
|
||
- Create: `backend/internal/store/migrations/0021_users_table.sql`
|
||
- Create: `backend/internal/store/migrations/0022_activities_and_syncruns_user_id.sql`
|
||
- Create: `backend/internal/store/migrations/0023_profile_user_scoped.sql`
|
||
- Create: `backend/internal/store/migrations/0024_workout_kinds_user_scoped.sql`
|
||
- Create: `backend/internal/store/migrations/0025_sync_state_user_scoped.sql`
|
||
- Test: `backend/internal/store/store_test.go` (extend `TestMigrateIsIdempotent`, add a new schema-shape test)
|
||
|
||
**Interfaces:**
|
||
- Produces: a `users` table (`id`, `oidc_sub` UNIQUE, `display_name`, `created_at`); `profile.user_id` (nullable, `UNIQUE(user_id)`, no more `CHECK(id=1)`); `workout_kinds.user_id` (nullable, `UNIQUE(user_id, name)` replacing the old global `UNIQUE(name)`); `activities.user_id` (nullable) plus a new `UNIQUE(user_id, garmin_activity_id)` index; `sync_state.user_id` (nullable, `UNIQUE(user_id)`, no more `CHECK(id=1)`); `sync_runs.user_id` (nullable).
|
||
- `user_id` columns are deliberately nullable at the SQL level — migrations can't take runtime parameters, so the real owner of pre-existing rows isn't known yet. Task 3's Go-level bootstrap step fills them in. Every store method added in later tasks requires a non-nil `userID` parameter regardless; application code should never itself write or rely on a NULL `user_id`.
|
||
|
||
This task rebuilds `profile`, `workout_kinds`, and `sync_state` (SQLite has no `DROP CONSTRAINT`, so dropping `CHECK(id=1)` or changing a `UNIQUE` column constraint requires the documented create-new-table/copy/drop-old/rename-into-place recipe). `activities` and `sync_runs` only need a plain `ADD COLUMN` since nothing about their existing constraints blocks multi-tenancy. The rebuild recipe below was verified directly against a real SQLite database (via `sqlite3` CLI, matching this project's actual FK/index behavior) before being written here — in particular, always create the replacement table under a **different** name and `RENAME` it into the final name at the end (never rename the *existing* table away first) so that other tables' foreign keys (e.g. `kind_assignments.workout_kind_id REFERENCES workout_kinds(id)`) are never left dangling, and always drop the old table (not rename it away) — `DROP TABLE` on a table with existing inbound foreign key references only succeeds once the transaction's FK checks are deferred.
|
||
|
||
- [ ] **Step 1: Write `0021_users_table.sql`**
|
||
|
||
```sql
|
||
CREATE TABLE users (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
oidc_sub TEXT NOT NULL UNIQUE,
|
||
display_name TEXT NOT NULL,
|
||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||
);
|
||
```
|
||
|
||
- [ ] **Step 2: Write `0022_activities_and_syncruns_user_id.sql`**
|
||
|
||
```sql
|
||
-- user_id is nullable here even though every row will eventually need one:
|
||
-- migrations can't take runtime parameters, so the actual owner isn't known
|
||
-- yet. A one-time Go bootstrap step (store.ClaimLegacyOwner, run at
|
||
-- geniusrund startup) backfills every existing row to one user once given
|
||
-- that user's OIDC subject; from then on every store method requires a
|
||
-- non-nil userID and this column is never NULL again in practice.
|
||
ALTER TABLE activities ADD COLUMN user_id INTEGER REFERENCES users(id);
|
||
ALTER TABLE sync_runs ADD COLUMN user_id INTEGER REFERENCES users(id);
|
||
|
||
-- Used by UpsertActivity's ON CONFLICT target going forward. The original
|
||
-- UNIQUE(garmin_activity_id) constraint (migration 0001) stays in place too
|
||
-- -- Garmin's own activity ids are already globally unique in practice, so
|
||
-- the stricter constraint is harmless, and SQLite can't drop a column-level
|
||
-- constraint without a full table rebuild, which isn't worth the risk here.
|
||
CREATE UNIQUE INDEX ux_activities_user_garmin_id ON activities(user_id, garmin_activity_id);
|
||
```
|
||
|
||
- [ ] **Step 3: Write `0023_profile_user_scoped.sql`**
|
||
|
||
```sql
|
||
-- profile.id's CHECK(id = 1) singleton constraint (migration 0003) can't be
|
||
-- dropped via ALTER TABLE -- SQLite has no DROP CONSTRAINT -- so this
|
||
-- rebuilds the table via SQLite's documented rename/recreate/copy/drop
|
||
-- pattern instead. Always create the replacement under a different name and
|
||
-- RENAME it into place at the end (never rename the live table away first)
|
||
-- -- verified directly against SQLite that this ordering is what keeps
|
||
-- other tables' foreign keys intact when they exist (not the case for
|
||
-- profile, but kept consistent with migrations 0024/0025 for the same
|
||
-- pattern). user_id is nullable for the same not-yet-known-owner reason as
|
||
-- migration 0022 -- see its comment.
|
||
PRAGMA defer_foreign_keys = ON;
|
||
|
||
CREATE TABLE profile_new (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
user_id INTEGER REFERENCES users(id),
|
||
name TEXT NOT NULL DEFAULT 'Default',
|
||
garmin_email TEXT NOT NULL DEFAULT '',
|
||
garmin_password TEXT NOT NULL DEFAULT '',
|
||
rolling_window_days INTEGER NOT NULL DEFAULT 90,
|
||
backfill_horizon_days INTEGER NOT NULL DEFAULT 1095,
|
||
max_heart_rate REAL,
|
||
resting_heart_rate REAL,
|
||
hr_zone1_min_pct REAL NOT NULL DEFAULT 50,
|
||
hr_zone1_max_pct REAL NOT NULL DEFAULT 60,
|
||
hr_zone2_min_pct REAL NOT NULL DEFAULT 60,
|
||
hr_zone2_max_pct REAL NOT NULL DEFAULT 70,
|
||
hr_zone3_min_pct REAL NOT NULL DEFAULT 70,
|
||
hr_zone3_max_pct REAL NOT NULL DEFAULT 80,
|
||
hr_zone4_min_pct REAL NOT NULL DEFAULT 80,
|
||
hr_zone4_max_pct REAL NOT NULL DEFAULT 90,
|
||
hr_zone5_min_pct REAL NOT NULL DEFAULT 90,
|
||
hr_zone5_max_pct REAL NOT NULL DEFAULT 100,
|
||
warmup_minutes REAL NOT NULL DEFAULT 10,
|
||
cooldown_minutes REAL NOT NULL DEFAULT 5,
|
||
min_representative_pace_sec_per_km REAL NOT NULL DEFAULT 720,
|
||
min_representative_time_seconds REAL NOT NULL DEFAULT 3,
|
||
pace_color TEXT NOT NULL DEFAULT '#3b82f6',
|
||
heart_rate_color TEXT NOT NULL DEFAULT '#ef4444',
|
||
warmup_color TEXT NOT NULL DEFAULT '#c2410c',
|
||
effort_color TEXT NOT NULL DEFAULT '#7c3aed',
|
||
recovery_color TEXT NOT NULL DEFAULT '#15803d',
|
||
cooldown_color TEXT NOT NULL DEFAULT '#fb923c',
|
||
main_line_tint_pct REAL NOT NULL DEFAULT 20,
|
||
background_darken_pct REAL NOT NULL DEFAULT 35,
|
||
target_brighten_pct REAL NOT NULL DEFAULT 20,
|
||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||
UNIQUE(user_id)
|
||
);
|
||
|
||
INSERT INTO profile_new (
|
||
id, user_id, name, 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_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
|
||
)
|
||
SELECT
|
||
id, NULL, name, 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_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
|
||
FROM profile;
|
||
|
||
DROP TABLE profile;
|
||
|
||
ALTER TABLE profile_new RENAME TO profile;
|
||
```
|
||
|
||
- [ ] **Step 4: Write `0024_workout_kinds_user_scoped.sql`**
|
||
|
||
```sql
|
||
-- workout_kinds.name's UNIQUE(name) constraint (migration 0001) must become
|
||
-- per-user (UNIQUE(user_id, name)) now that every user gets their own copy
|
||
-- of the same 8-kind taxonomy -- otherwise a second user could never be
|
||
-- provisioned (inserting the same seeded name would collide). kind_assignments
|
||
-- and workout_type_paces hold foreign keys into this table
|
||
-- (workout_kind_id REFERENCES workout_kinds(id)) -- the create-new-name/
|
||
-- copy/drop-old/rename-into-place order below (verified against a real
|
||
-- SQLite database) leaves those foreign keys' schema text untouched
|
||
-- throughout, so they resolve correctly again the instant the final
|
||
-- `ALTER TABLE workout_kinds_new RENAME TO workout_kinds` completes.
|
||
PRAGMA defer_foreign_keys = ON;
|
||
|
||
CREATE TABLE workout_kinds_new (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
user_id INTEGER REFERENCES users(id),
|
||
name TEXT NOT NULL,
|
||
description TEXT NOT NULL DEFAULT '',
|
||
color TEXT NOT NULL DEFAULT '',
|
||
rule_json TEXT NOT NULL,
|
||
priority INTEGER NOT NULL DEFAULT 0,
|
||
is_active INTEGER NOT NULL DEFAULT 1,
|
||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||
UNIQUE(user_id, name)
|
||
);
|
||
|
||
INSERT INTO workout_kinds_new (id, user_id, name, description, color, rule_json, priority, is_active, created_at, updated_at)
|
||
SELECT id, NULL, name, description, color, rule_json, priority, is_active, created_at, updated_at
|
||
FROM workout_kinds;
|
||
|
||
DROP TABLE workout_kinds;
|
||
|
||
ALTER TABLE workout_kinds_new RENAME TO workout_kinds;
|
||
```
|
||
|
||
- [ ] **Step 5: Write `0025_sync_state_user_scoped.sql`**
|
||
|
||
```sql
|
||
-- Same CHECK(id = 1) rebuild reasoning as migration 0023's profile rebuild.
|
||
PRAGMA defer_foreign_keys = ON;
|
||
|
||
CREATE TABLE sync_state_new (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
user_id INTEGER REFERENCES users(id),
|
||
earliest_synced_date TEXT,
|
||
backfill_complete INTEGER NOT NULL DEFAULT 0,
|
||
UNIQUE(user_id)
|
||
);
|
||
|
||
INSERT INTO sync_state_new (id, user_id, earliest_synced_date, backfill_complete)
|
||
SELECT id, NULL, earliest_synced_date, backfill_complete
|
||
FROM sync_state;
|
||
|
||
DROP TABLE sync_state;
|
||
|
||
ALTER TABLE sync_state_new RENAME TO sync_state;
|
||
```
|
||
|
||
- [ ] **Step 6: Run the existing migration test to confirm it still applies cleanly**
|
||
|
||
Run: `cd backend && go test ./internal/store/... -run TestMigrateIsIdempotent -v`
|
||
Expected: PASS (this only checks migrations apply without error on a fresh DB and re-apply as a no-op on an existing one; it does not yet assert the new schema shape).
|
||
|
||
- [ ] **Step 7: Add a schema-shape test to `backend/internal/store/store_test.go`**
|
||
|
||
```go
|
||
func TestUsersAndOwnershipSchema_AppliesCleanly(t *testing.T) {
|
||
db := openTestDB(t)
|
||
ctx := context.Background()
|
||
|
||
// A fresh DB has no legacy singleton rows, so every user_id column
|
||
// should already be backfilled to nothing (fresh install, no rows at
|
||
// all yet in these tables besides the migration-seeded workout_kinds --
|
||
// which do have NULL user_id until a real user is provisioned).
|
||
var nullableCount int
|
||
if err := db.QueryRowContext(ctx, `SELECT COUNT(*) FROM workout_kinds WHERE user_id IS NULL`).Scan(&nullableCount); err != nil {
|
||
t.Fatalf("count workout_kinds: %v", err)
|
||
}
|
||
if nullableCount != 8 {
|
||
t.Fatalf("expected 8 migration-seeded workout_kinds with NULL user_id on a fresh DB, got %d", nullableCount)
|
||
}
|
||
|
||
// 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 {
|
||
t.Fatalf("insert users: %v", err)
|
||
}
|
||
if _, err := db.ExecContext(ctx, `
|
||
INSERT INTO workout_kinds (user_id, name, rule_json)
|
||
VALUES ((SELECT id FROM users WHERE oidc_sub='sub-a'), 'Custom Kind', '{}'),
|
||
((SELECT id FROM users WHERE oidc_sub='sub-b'), 'Custom Kind', '{}')`); err != nil {
|
||
t.Fatalf("expected same workout_kind name to be allowed across two different users, got: %v", err)
|
||
}
|
||
|
||
// 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 {
|
||
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 {
|
||
t.Fatal("expected a second profile row for the same user_id to violate UNIQUE(user_id)")
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 8: Run the new test to verify it fails first, then passes after Steps 1-5**
|
||
|
||
Run: `cd backend && go test ./internal/store/... -run TestUsersAndOwnershipSchema_AppliesCleanly -v`
|
||
Expected: PASS (the migrations from Steps 1-5 are already in place, so this confirms them; if you're following TDD strictly, temporarily comment out Steps 1-5's files first to see it fail, then restore them).
|
||
|
||
- [ ] **Step 9: Run the full store test suite and `gofmt`**
|
||
|
||
Run: `cd backend && gofmt -l . && go vet ./... && go test ./internal/store/... -v`
|
||
Expected: `gofmt -l .` prints nothing; all tests PASS (existing store tests still work unchanged since no store method signatures have changed yet — that's Tasks 4-8).
|
||
|
||
- [ ] **Step 10: Commit**
|
||
|
||
```bash
|
||
git add backend/internal/store/migrations/0021_users_table.sql backend/internal/store/migrations/0022_activities_and_syncruns_user_id.sql backend/internal/store/migrations/0023_profile_user_scoped.sql backend/internal/store/migrations/0024_workout_kinds_user_scoped.sql backend/internal/store/migrations/0025_sync_state_user_scoped.sql backend/internal/store/store_test.go
|
||
git commit -m "$(cat <<'EOF'
|
||
store: add users table and user_id scoping to migrations
|
||
|
||
Schema-only step toward per-user profile isolation: profile/workout_kinds/
|
||
sync_state are rebuilt to drop their singleton constraints, activities/
|
||
sync_runs gain a nullable user_id column. Store methods are scoped in
|
||
later tasks.
|
||
EOF
|
||
)"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 2: `store.User` + `ProvisionUser`
|
||
|
||
**Files:**
|
||
- Create: `backend/internal/store/users.go`
|
||
- Test: `backend/internal/store/users_test.go`
|
||
|
||
**Interfaces:**
|
||
- Consumes: the `users`/`profile`/`workout_kinds`/`workout_type_paces`/`sync_state` schema from Task 1.
|
||
- Produces: `type User struct { ID int64; OIDCSub string; DisplayName string; CreatedAt string }`; `func (db *DB) CreateUser(ctx, oidcSub, displayName string) (int64, error)`; `func (db *DB) GetUserBySub(ctx, oidcSub string) (User, bool, error)`; `func (db *DB) ListUsers(ctx) ([]User, error)`; `func (db *DB) ProvisionUser(ctx, oidcSub, displayName string) (int64, error)`. Task 11's middleware and Task 3's legacy-claim bootstrap both depend on these exact signatures.
|
||
|
||
- [ ] **Step 1: Write the failing test**
|
||
|
||
```go
|
||
package store
|
||
|
||
import (
|
||
"context"
|
||
"testing"
|
||
)
|
||
|
||
func TestGetUserBySub_NotFoundReturnsFalseNotError(t *testing.T) {
|
||
db := openTestDB(t)
|
||
_, found, err := db.GetUserBySub(context.Background(), "no-such-sub")
|
||
if err != nil {
|
||
t.Fatalf("GetUserBySub: %v", err)
|
||
}
|
||
if found {
|
||
t.Fatal("expected found=false for an unknown sub")
|
||
}
|
||
}
|
||
|
||
func TestProvisionUser_SeedsProfileTaxonomyAndSyncState(t *testing.T) {
|
||
db := openTestDB(t)
|
||
ctx := context.Background()
|
||
|
||
userID, err := db.ProvisionUser(ctx, "sub-123", "Lucie")
|
||
if err != nil {
|
||
t.Fatalf("ProvisionUser: %v", err)
|
||
}
|
||
|
||
u, found, err := db.GetUserBySub(ctx, "sub-123")
|
||
if err != nil || !found {
|
||
t.Fatalf("GetUserBySub: found=%v err=%v", found, err)
|
||
}
|
||
if u.ID != userID || u.DisplayName != "Lucie" {
|
||
t.Fatalf("got %+v, want ID=%d DisplayName=Lucie", u, userID)
|
||
}
|
||
|
||
profile, err := db.GetProfile(ctx, userID)
|
||
if err != nil {
|
||
t.Fatalf("GetProfile: %v", err)
|
||
}
|
||
if profile.Name != "Lucie" {
|
||
t.Errorf("profile.Name = %q, want %q", profile.Name, "Lucie")
|
||
}
|
||
if profile.RollingWindowDays != 90 {
|
||
t.Errorf("profile.RollingWindowDays = %d, want 90 (default)", profile.RollingWindowDays)
|
||
}
|
||
|
||
kinds, err := db.ListWorkoutKinds(ctx, userID, false)
|
||
if err != nil {
|
||
t.Fatalf("ListWorkoutKinds: %v", err)
|
||
}
|
||
if len(kinds) != 8 {
|
||
t.Fatalf("expected 8 seeded workout kinds, got %d", len(kinds))
|
||
}
|
||
|
||
state, err := db.GetSyncState(ctx, userID)
|
||
if err != nil {
|
||
t.Fatalf("GetSyncState: %v", err)
|
||
}
|
||
if state.EarliestSyncedDate != nil || state.BackfillComplete {
|
||
t.Errorf("expected fresh sync state, got %+v", state)
|
||
}
|
||
}
|
||
|
||
func TestProvisionUser_TwoUsersGetIndependentTaxonomies(t *testing.T) {
|
||
db := openTestDB(t)
|
||
ctx := context.Background()
|
||
|
||
userA, err := db.ProvisionUser(ctx, "sub-a", "A")
|
||
if err != nil {
|
||
t.Fatalf("ProvisionUser(a): %v", err)
|
||
}
|
||
userB, err := db.ProvisionUser(ctx, "sub-b", "B")
|
||
if err != nil {
|
||
t.Fatalf("ProvisionUser(b): %v", err)
|
||
}
|
||
|
||
kindsA, err := db.ListWorkoutKinds(ctx, userA, false)
|
||
if err != nil {
|
||
t.Fatalf("ListWorkoutKinds(a): %v", err)
|
||
}
|
||
kindsB, err := db.ListWorkoutKinds(ctx, userB, false)
|
||
if err != nil {
|
||
t.Fatalf("ListWorkoutKinds(b): %v", err)
|
||
}
|
||
if len(kindsA) != 8 || len(kindsB) != 8 {
|
||
t.Fatalf("expected 8 kinds each, got a=%d b=%d", len(kindsA), len(kindsB))
|
||
}
|
||
if kindsA[0].ID == kindsB[0].ID {
|
||
t.Fatal("expected each user's seeded kinds to be distinct rows")
|
||
}
|
||
}
|
||
```
|
||
|
||
Note: this test file depends on `GetProfile`/`ListWorkoutKinds`/`GetSyncState` already taking a `userID` parameter — write it now (it won't compile until Tasks 4/5/8 land), or write it as part of Task 8 instead if executing tasks strictly in order. Since this plan is meant to be executed top-to-bottom, add this test file now but expect it to fail to *compile* until Task 8 is done — track that as expected, not a regression, when running Step 2 below.
|
||
|
||
- [ ] **Step 2: Confirm the test doesn't compile yet (expected)**
|
||
|
||
Run: `cd backend && go vet ./internal/store/...`
|
||
Expected: FAIL to compile — `GetProfile`/`ListWorkoutKinds`/`GetSyncState` don't take a `userID` argument yet. This is expected; the test will compile and pass once Tasks 4/5/8 are done. Continue with Step 3 below regardless.
|
||
|
||
- [ ] **Step 3: Write `backend/internal/store/users.go`**
|
||
|
||
```go
|
||
package store
|
||
|
||
import (
|
||
"context"
|
||
"database/sql"
|
||
"fmt"
|
||
)
|
||
|
||
// User is one geniusrun account, bound 1:1 to an OIDC subject. Every
|
||
// synced/classified dataset (profile, workout kinds, activities, sync
|
||
// state) is scoped to exactly one User -- see
|
||
// docs/superpowers/specs/2026-07-25-per-user-profile-design.md.
|
||
type User struct {
|
||
ID int64
|
||
OIDCSub string
|
||
DisplayName string
|
||
CreatedAt string
|
||
}
|
||
|
||
// CreateUser inserts a bare users row. Most callers want ProvisionUser
|
||
// instead, which also seeds the profile/taxonomy/sync-state a fresh account
|
||
// needs; CreateUser alone exists for ClaimLegacyOwner (Task 3), which
|
||
// attaches an *existing* profile/taxonomy rather than seeding new ones.
|
||
func (db *DB) CreateUser(ctx context.Context, oidcSub, displayName string) (int64, error) {
|
||
res, err := db.ExecContext(ctx, `INSERT INTO users (oidc_sub, display_name) VALUES (?, ?)`, oidcSub, displayName)
|
||
if err != nil {
|
||
return 0, fmt.Errorf("create user %q: %w", oidcSub, err)
|
||
}
|
||
return res.LastInsertId()
|
||
}
|
||
|
||
// GetUserBySub looks up a user by their OIDC subject -- the only lookup key
|
||
// the session-resolution middleware (Task 11) ever uses.
|
||
func (db *DB) GetUserBySub(ctx context.Context, oidcSub string) (User, bool, error) {
|
||
var u User
|
||
err := db.QueryRowContext(ctx, `SELECT id, oidc_sub, display_name, created_at FROM users WHERE oidc_sub = ?`, oidcSub).
|
||
Scan(&u.ID, &u.OIDCSub, &u.DisplayName, &u.CreatedAt)
|
||
if err == sql.ErrNoRows {
|
||
return User{}, false, nil
|
||
}
|
||
if err != nil {
|
||
return User{}, false, fmt.Errorf("get user by sub: %w", err)
|
||
}
|
||
return u, true, nil
|
||
}
|
||
|
||
// ListUsers returns every provisioned user, for the background incremental
|
||
// sync loop (Task 13) to iterate.
|
||
func (db *DB) ListUsers(ctx context.Context) ([]User, error) {
|
||
rows, err := db.QueryContext(ctx, `SELECT id, oidc_sub, display_name, created_at FROM users ORDER BY id`)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("list users: %w", err)
|
||
}
|
||
defer rows.Close()
|
||
|
||
users := []User{}
|
||
for rows.Next() {
|
||
var u User
|
||
if err := rows.Scan(&u.ID, &u.OIDCSub, &u.DisplayName, &u.CreatedAt); err != nil {
|
||
return nil, fmt.Errorf("scan user row: %w", err)
|
||
}
|
||
users = append(users, u)
|
||
}
|
||
return users, rows.Err()
|
||
}
|
||
|
||
// neverMatchRule is the same placeholder every fresh install's rule-engine
|
||
// kinds start with (migration 0004) -- every activity lands in needs_review
|
||
// until the user tunes real rules.
|
||
const neverMatchRule = `{"match":"all","conditions":[{"metric":"distance_meters","op":"<","value":0}]}`
|
||
|
||
// defaultWorkoutKindSeeds mirrors the 8 kinds migrations 0004/0007 seed for
|
||
// a fresh install, so a newly-provisioned user starts with the same
|
||
// taxonomy instead of an empty one.
|
||
var defaultWorkoutKindSeeds = []WorkoutKind{
|
||
{Name: "Easy", Color: "#22c55e", RuleJSON: neverMatchRule, Priority: 80, IsActive: true},
|
||
{Name: "Long", Color: "#3b82f6", RuleJSON: neverMatchRule, Priority: 70, IsActive: true},
|
||
{Name: "60' Threshold", Color: "#f59e0b", RuleJSON: neverMatchRule, Priority: 60, IsActive: true},
|
||
{Name: "30' Threshold", Color: "#f59e0b", RuleJSON: neverMatchRule, Priority: 50, IsActive: true},
|
||
{Name: "Tempo", Color: "#eab308", RuleJSON: neverMatchRule, Priority: 40, IsActive: true},
|
||
{Name: "Intervals", Color: "#ef4444", RuleJSON: neverMatchRule, Priority: 30, IsActive: true},
|
||
{Name: "MAS Test", Color: "#a855f7", RuleJSON: neverMatchRule, Priority: 20, IsActive: true},
|
||
{Name: "Race", Color: "#dc2626", RuleJSON: `{"match":"all","conditions":[{"metric":"is_race","op":"==","value":true}]}`, Priority: 10, IsActive: true},
|
||
}
|
||
|
||
// ProvisionUser creates a brand-new geniusrun account for an OIDC subject:
|
||
// the users row, a default profile (name defaulted to displayName), the 8
|
||
// default workout kinds (with their paired workout_type_paces rows), and an
|
||
// initial sync_state row -- all in one transaction, so a partially
|
||
// provisioned user is never observable.
|
||
func (db *DB) ProvisionUser(ctx context.Context, oidcSub, displayName string) (int64, error) {
|
||
tx, err := db.BeginTx(ctx, nil)
|
||
if err != nil {
|
||
return 0, fmt.Errorf("begin provision user tx: %w", err)
|
||
}
|
||
defer tx.Rollback()
|
||
|
||
res, err := tx.ExecContext(ctx, `INSERT INTO users (oidc_sub, display_name) VALUES (?, ?)`, oidcSub, displayName)
|
||
if err != nil {
|
||
return 0, fmt.Errorf("create user %q: %w", oidcSub, err)
|
||
}
|
||
userID, err := res.LastInsertId()
|
||
if err != nil {
|
||
return 0, err
|
||
}
|
||
|
||
if _, err := tx.ExecContext(ctx, `INSERT INTO profile (user_id, name) VALUES (?, ?)`, userID, displayName); err != nil {
|
||
return 0, fmt.Errorf("create profile for user %d: %w", userID, err)
|
||
}
|
||
|
||
for _, k := range defaultWorkoutKindSeeds {
|
||
res, err := tx.ExecContext(ctx, `
|
||
INSERT INTO workout_kinds (user_id, name, description, color, rule_json, priority, is_active)
|
||
VALUES (?,?,?,?,?,?,?)`,
|
||
userID, k.Name, k.Description, k.Color, k.RuleJSON, k.Priority, k.IsActive)
|
||
if err != nil {
|
||
return 0, fmt.Errorf("seed workout kind %q for user %d: %w", k.Name, userID, err)
|
||
}
|
||
kindID, err := res.LastInsertId()
|
||
if err != nil {
|
||
return 0, err
|
||
}
|
||
if _, err := tx.ExecContext(ctx, `INSERT INTO workout_type_paces (workout_kind_id) VALUES (?)`, kindID); err != nil {
|
||
return 0, fmt.Errorf("seed workout type pace for kind %d: %w", kindID, err)
|
||
}
|
||
}
|
||
|
||
if _, err := tx.ExecContext(ctx, `INSERT INTO sync_state (user_id, earliest_synced_date, backfill_complete) VALUES (?, NULL, 0)`, userID); err != nil {
|
||
return 0, fmt.Errorf("create sync state for user %d: %w", userID, err)
|
||
}
|
||
|
||
return userID, tx.Commit()
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Run the store package tests**
|
||
|
||
Run: `cd backend && go test ./internal/store/... -run 'TestGetUserBySub|TestProvisionUser' -v`
|
||
Expected: still FAIL to compile (Tasks 4/5/8 haven't landed) — confirm the *only* compile errors reference `GetProfile`/`ListWorkoutKinds`/`GetSyncState` missing a `userID` argument, not anything in `users.go` itself. If `users.go` itself has an error, fix it now.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add backend/internal/store/users.go backend/internal/store/users_test.go
|
||
git commit -m "$(cat <<'EOF'
|
||
store: add User type and ProvisionUser
|
||
|
||
Lays the groundwork for per-user accounts: CreateUser/GetUserBySub/
|
||
ListUsers plus ProvisionUser, which seeds a brand-new user's profile,
|
||
default workout-kind taxonomy, and sync state in one transaction. Depends
|
||
on later tasks scoping GetProfile/ListWorkoutKinds/GetSyncState to compile
|
||
and pass -- expected or committing to a shared branch.
|
||
EOF
|
||
)"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 3: Legacy data claim bootstrap
|
||
|
||
**Files:**
|
||
- Create: `backend/internal/store/legacy_claim.go`
|
||
- Test: `backend/internal/store/legacy_claim_test.go`
|
||
|
||
**Interfaces:**
|
||
- Consumes: the nullable `user_id` columns from Task 1.
|
||
- Produces: `func (db *DB) ClaimLegacyOwner(ctx context.Context, oidcSub string) error`. Task 11's `main.go` wiring calls this once at startup.
|
||
|
||
- [ ] **Step 1: Write the failing test**
|
||
|
||
```go
|
||
package store
|
||
|
||
import (
|
||
"context"
|
||
"testing"
|
||
)
|
||
|
||
func TestClaimLegacyOwner_BindsExistingSingletonRowsToOneNewUser(t *testing.T) {
|
||
db := openTestDB(t)
|
||
ctx := context.Background()
|
||
|
||
// Simulate the pre-migration state: a fresh DB already has one
|
||
// migration-seeded profile row (id=1, user_id=NULL) and 8 workout_kinds
|
||
// rows (user_id=NULL) -- exactly what a real upgraded deployment looks
|
||
// like right after Task 1's migrations run, before any user exists.
|
||
if _, err := db.ExecContext(ctx, `UPDATE profile SET name = 'Kriss', garmin_email = 'kriss@example.com' WHERE user_id IS NULL`); err != nil {
|
||
t.Fatalf("seed legacy profile: %v", err)
|
||
}
|
||
if _, err := db.ExecContext(ctx, `INSERT INTO activities (user_id, garmin_activity_id, start_time_utc, duration_seconds, distance_meters, raw_json) VALUES (NULL, 1, '2026-01-01 00:00:00', 1800, 5000, '{}')`); err != nil {
|
||
t.Fatalf("seed legacy activity: %v", err)
|
||
}
|
||
|
||
if err := db.ClaimLegacyOwner(ctx, "kriss-sub"); err != nil {
|
||
t.Fatalf("ClaimLegacyOwner: %v", err)
|
||
}
|
||
|
||
u, found, err := db.GetUserBySub(ctx, "kriss-sub")
|
||
if err != nil || !found {
|
||
t.Fatalf("GetUserBySub: found=%v err=%v", found, err)
|
||
}
|
||
if u.DisplayName != "Kriss" {
|
||
t.Errorf("DisplayName = %q, want %q (from the legacy profile's name column)", u.DisplayName, "Kriss")
|
||
}
|
||
|
||
profile, err := db.GetProfile(ctx, u.ID)
|
||
if err != nil {
|
||
t.Fatalf("GetProfile(claimed user): %v", err)
|
||
}
|
||
if profile.GarminEmail != "kriss@example.com" {
|
||
t.Errorf("claimed profile GarminEmail = %q, want kriss@example.com", profile.GarminEmail)
|
||
}
|
||
|
||
kinds, err := db.ListWorkoutKinds(ctx, u.ID, false)
|
||
if err != nil {
|
||
t.Fatalf("ListWorkoutKinds(claimed user): %v", err)
|
||
}
|
||
if len(kinds) != 8 {
|
||
t.Fatalf("expected the 8 legacy workout_kinds rows to be claimed, got %d", len(kinds))
|
||
}
|
||
|
||
activities, err := db.ListActivities(ctx, u.ID, ActivityFilter{})
|
||
if err != nil {
|
||
t.Fatalf("ListActivities(claimed user): %v", err)
|
||
}
|
||
if len(activities) != 1 {
|
||
t.Fatalf("expected the 1 legacy activity to be claimed, got %d", len(activities))
|
||
}
|
||
|
||
var remainingNullUserIDRows int
|
||
for _, table := range []string{"profile", "workout_kinds", "activities", "sync_state"} {
|
||
var n int
|
||
if err := db.QueryRowContext(ctx, `SELECT COUNT(*) FROM `+table+` WHERE user_id IS NULL`).Scan(&n); err != nil {
|
||
t.Fatalf("count NULL user_id in %s: %v", table, err)
|
||
}
|
||
remainingNullUserIDRows += n
|
||
}
|
||
if remainingNullUserIDRows != 0 {
|
||
t.Errorf("expected every legacy row to be claimed, %d rows still have NULL user_id", remainingNullUserIDRows)
|
||
}
|
||
}
|
||
|
||
func TestClaimLegacyOwner_NoOpOnceAUserAlreadyExists(t *testing.T) {
|
||
db := openTestDB(t)
|
||
ctx := context.Background()
|
||
|
||
firstUserID, err := db.ProvisionUser(ctx, "already-here", "Someone")
|
||
if err != nil {
|
||
t.Fatalf("ProvisionUser: %v", err)
|
||
}
|
||
|
||
// A second call (simulating a later restart with the env var still set)
|
||
// must not create a second user or touch anything.
|
||
if err := db.ClaimLegacyOwner(ctx, "kriss-sub"); err != nil {
|
||
t.Fatalf("ClaimLegacyOwner (should be a no-op): %v", err)
|
||
}
|
||
|
||
if _, found, err := db.GetUserBySub(ctx, "kriss-sub"); err != nil || found {
|
||
t.Fatalf("expected no user created for kriss-sub, found=%v err=%v", found, err)
|
||
}
|
||
users, err := db.ListUsers(ctx)
|
||
if err != nil {
|
||
t.Fatalf("ListUsers: %v", err)
|
||
}
|
||
if len(users) != 1 || users[0].ID != firstUserID {
|
||
t.Fatalf("expected exactly the 1 pre-existing user to remain, got %+v", users)
|
||
}
|
||
}
|
||
|
||
func TestClaimLegacyOwner_NoOpOnGenuinelyFreshInstall(t *testing.T) {
|
||
db := openTestDB(t)
|
||
ctx := context.Background()
|
||
|
||
if err := db.ClaimLegacyOwner(ctx, "kriss-sub"); err != nil {
|
||
t.Fatalf("ClaimLegacyOwner on fresh install: %v", err)
|
||
}
|
||
if _, found, err := db.GetUserBySub(ctx, "kriss-sub"); err != nil || found {
|
||
t.Fatalf("expected no user created on a fresh install with no legacy profile, found=%v err=%v", found, err)
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Run to verify it fails to compile (no `ClaimLegacyOwner` yet)**
|
||
|
||
Run: `cd backend && go vet ./internal/store/...`
|
||
Expected: FAIL — `db.ClaimLegacyOwner` undefined.
|
||
|
||
- [ ] **Step 3: Write `backend/internal/store/legacy_claim.go`**
|
||
|
||
```go
|
||
package store
|
||
|
||
import (
|
||
"context"
|
||
"database/sql"
|
||
"fmt"
|
||
)
|
||
|
||
// ClaimLegacyOwner is a one-time upgrade step, not a normal runtime
|
||
// operation: it binds every pre-multi-tenancy row (user_id IS NULL, left
|
||
// that way by migrations that can't take runtime parameters -- see
|
||
// docs/superpowers/specs/2026-07-25-per-user-profile-design.md) to a single
|
||
// new user identified by oidcSub. Safe to call on every startup: once the
|
||
// users table is non-empty, it's a no-op, so leaving
|
||
// GENIUSRUN_LEGACY_OWNER_OIDC_SUB set after the first successful run causes
|
||
// no harm. Also a no-op on a genuinely fresh install (no legacy profile row
|
||
// to claim at all).
|
||
func (db *DB) ClaimLegacyOwner(ctx context.Context, oidcSub string) error {
|
||
var userCount int
|
||
if err := db.QueryRowContext(ctx, `SELECT COUNT(*) FROM users`).Scan(&userCount); err != nil {
|
||
return fmt.Errorf("count users: %w", err)
|
||
}
|
||
if userCount > 0 {
|
||
return nil // already bootstrapped (either claimed already, or real signups exist)
|
||
}
|
||
|
||
var displayName string
|
||
err := db.QueryRowContext(ctx, `SELECT name FROM profile WHERE user_id IS NULL LIMIT 1`).Scan(&displayName)
|
||
if err == sql.ErrNoRows {
|
||
return nil // fresh install, no pre-existing singleton profile to claim
|
||
}
|
||
if err != nil {
|
||
return fmt.Errorf("find legacy profile: %w", err)
|
||
}
|
||
|
||
tx, err := db.BeginTx(ctx, nil)
|
||
if err != nil {
|
||
return fmt.Errorf("begin claim legacy owner tx: %w", err)
|
||
}
|
||
defer tx.Rollback()
|
||
|
||
res, err := tx.ExecContext(ctx, `INSERT INTO users (oidc_sub, display_name) VALUES (?, ?)`, oidcSub, displayName)
|
||
if err != nil {
|
||
return fmt.Errorf("create legacy owner user: %w", err)
|
||
}
|
||
userID, err := res.LastInsertId()
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
// table is always one of the fixed literals below, never user input.
|
||
for _, table := range []string{"profile", "workout_kinds", "activities", "sync_state", "sync_runs"} {
|
||
if _, err := tx.ExecContext(ctx, `UPDATE `+table+` SET user_id = ? WHERE user_id IS NULL`, userID); err != nil {
|
||
return fmt.Errorf("claim legacy %s rows: %w", table, err)
|
||
}
|
||
}
|
||
|
||
return tx.Commit()
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Run the tests**
|
||
|
||
Run: `cd backend && go test ./internal/store/... -run TestClaimLegacyOwner -v`
|
||
Expected: still FAIL to compile until Tasks 4/5/6/8 land (`GetProfile`/`ListWorkoutKinds`/`ListActivities` need their `userID` parameter). Confirm the only failures are in those dependencies, not in `legacy_claim.go` itself.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add backend/internal/store/legacy_claim.go backend/internal/store/legacy_claim_test.go
|
||
git commit -m "$(cat <<'EOF'
|
||
store: add ClaimLegacyOwner one-time upgrade bootstrap
|
||
|
||
Binds pre-multi-tenancy singleton rows to one named OIDC subject, given at
|
||
startup via an env var (wired in Task 11). No-ops once any user exists or
|
||
on a genuinely fresh install.
|
||
EOF
|
||
)"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 4: Scope `profile.go` to `user_id`
|
||
|
||
**Files:**
|
||
- Modify: `backend/internal/store/profile.go`
|
||
- Modify: `backend/internal/store/profile_test.go`
|
||
|
||
**Interfaces:**
|
||
- Produces: `func (db *DB) GetProfile(ctx context.Context, userID int64) (Profile, error)`; `func (db *DB) UpdateProfile(ctx context.Context, userID int64, p Profile) error`. Every other task/caller of these two methods must pass `userID` as the second positional argument from here on.
|
||
|
||
- [ ] **Step 1: Update `GetProfile`/`UpdateProfile` in `backend/internal/store/profile.go`**
|
||
|
||
Replace the two function bodies (the `Profile` struct and `profileColumns` constant are unchanged):
|
||
|
||
```go
|
||
// GetProfile returns the profile row for userID.
|
||
func (db *DB) GetProfile(ctx context.Context, userID int64) (Profile, error) {
|
||
var p Profile
|
||
err := db.QueryRowContext(ctx, `SELECT `+profileColumns+` FROM profile WHERE user_id = ?`, userID).Scan(
|
||
&p.Name, &p.GarminEmail, &p.GarminPassword, &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,
|
||
)
|
||
if err != nil {
|
||
return Profile{}, fmt.Errorf("get profile for user %d: %w", userID, err)
|
||
}
|
||
return p, nil
|
||
}
|
||
|
||
// UpdateProfile overwrites userID's profile row. Callers should read via
|
||
// GetProfile first and modify the fields they intend to change, since this
|
||
// replaces every column.
|
||
func (db *DB) UpdateProfile(ctx context.Context, userID int64, p Profile) error {
|
||
_, err := db.ExecContext(ctx, `
|
||
UPDATE profile SET
|
||
name=?, 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_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=?,
|
||
updated_at=datetime('now')
|
||
WHERE user_id = ?`,
|
||
p.Name, p.GarminEmail, p.GarminPassword, 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,
|
||
userID,
|
||
)
|
||
if err != nil {
|
||
return fmt.Errorf("update profile for user %d: %w", userID, err)
|
||
}
|
||
return nil
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Fix `backend/internal/store/profile_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:
|
||
|
||
```go
|
||
func TestProfile_DefaultsThenUpdate(t *testing.T) {
|
||
db := openTestDB(t)
|
||
ctx := context.Background()
|
||
userID, err := db.ProvisionUser(ctx, "test-sub", "Default")
|
||
if err != nil {
|
||
t.Fatalf("ProvisionUser: %v", err)
|
||
}
|
||
|
||
p, err := db.GetProfile(ctx, userID)
|
||
```
|
||
|
||
Then update every remaining `db.GetProfile(ctx)` → `db.GetProfile(ctx, userID)` and `db.UpdateProfile(ctx, p)` → `db.UpdateProfile(ctx, userID, p)` in the rest of that test function (there are 2 more `GetProfile` calls and 1 `UpdateProfile` call further down). Also change the assertion `if p.Name != "Default"` to expect `"Default"` still (ProvisionUser was called with displayName `"Default"` above, matching the original migration-seeded default, so no assertion values need to change) — but the later `p.Name = "Kriss"` assignment and `got.Name != "Kriss"` assertion stay as-is (that's the test uprating the name via UpdateProfile, unrelated to the account's own display name).
|
||
|
||
- [ ] **Step 3: Run the store tests, fix any remaining call sites via compiler errors**
|
||
|
||
Run: `cd backend && go build ./... && go vet ./...`
|
||
Expected: compile errors pointing at every remaining `GetProfile`/`UpdateProfile` call site missing the new `userID` argument (e.g. in `internal/sync/service.go`, `internal/api/profile.go`, `cmd/seedsample/main.go` — those are fixed in Tasks 10/14/17, so it's expected they still fail here; only fix sites inside `backend/internal/store/` in this task). Fix any remaining `internal/store` call sites the same way, then re-run until `go vet ./internal/store/...` is clean.
|
||
|
||
- [ ] **Step 4: Run the profile store tests**
|
||
|
||
Run: `cd backend && go test ./internal/store/... -run TestProfile -v`
|
||
Expected: PASS.
|
||
|
||
- [ ] **Step 5: `gofmt` and commit**
|
||
|
||
Run: `cd backend && gofmt -l internal/store/`
|
||
Expected: no output.
|
||
|
||
```bash
|
||
git add backend/internal/store/profile.go backend/internal/store/profile_test.go
|
||
git commit -m "$(cat <<'EOF'
|
||
store: scope GetProfile/UpdateProfile to a user_id
|
||
|
||
Part of per-user profile isolation: profile rows are no longer a global
|
||
singleton, so every read/write requires the caller's userID.
|
||
EOF
|
||
)"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 5: Scope `workoutkinds.go` + `workoutpaces.go` to `user_id`
|
||
|
||
**Files:**
|
||
- Modify: `backend/internal/store/workoutkinds.go`
|
||
- Modify: `backend/internal/store/workoutpaces.go`
|
||
- Modify: `backend/internal/store/workoutkinds_taxonomy_test.go`
|
||
- Modify: `backend/internal/store/workoutpaces_test.go`
|
||
|
||
**Interfaces:**
|
||
- Produces: `func (db *DB) CreateWorkoutKind(ctx, userID int64, k WorkoutKind) (int64, error)`; `func (db *DB) UpdateWorkoutKind(ctx, userID int64, k WorkoutKind) error`; `func (db *DB) GetWorkoutKind(ctx, userID, id int64) (WorkoutKind, bool, error)`; `func (db *DB) GetWorkoutKindByName(ctx, userID int64, name string) (WorkoutKind, bool, error)`; `func (db *DB) ListWorkoutKinds(ctx, userID int64, activeOnly bool) ([]WorkoutKind, error)`; `func (db *DB) SoftDeleteWorkoutKind(ctx, userID, id int64) error`; `func (db *DB) GetWorkoutTypePace(ctx, userID, workoutKindID int64) (WorkoutTypePace, error)`; `func (db *DB) UpdateWorkoutTypePace(ctx, userID int64, p WorkoutTypePace) error`; `func (db *DB) ListWorkoutTypePaces(ctx, userID int64) ([]WorkoutTypePace, error)`.
|
||
- `workout_type_paces` has no `user_id` column of its own (see the design doc) — ownership is checked transitively via a join to `workout_kinds.user_id`.
|
||
|
||
- [ ] **Step 1: Rewrite `backend/internal/store/workoutkinds.go`**
|
||
|
||
```go
|
||
package store
|
||
|
||
import (
|
||
"context"
|
||
"database/sql"
|
||
"fmt"
|
||
)
|
||
|
||
// WorkoutKind is a user-editable classification rule ("Easy", "Tempo", ...).
|
||
// RuleJSON holds the condition tree evaluated by internal/classify.
|
||
type WorkoutKind struct {
|
||
ID int64
|
||
Name string
|
||
Description string
|
||
Color string
|
||
RuleJSON string
|
||
Priority int
|
||
IsActive bool
|
||
CreatedAt string
|
||
UpdatedAt string
|
||
}
|
||
|
||
func scanWorkoutKind(row interface{ Scan(...any) error }) (WorkoutKind, error) {
|
||
var k WorkoutKind
|
||
err := row.Scan(&k.ID, &k.Name, &k.Description, &k.Color, &k.RuleJSON, &k.Priority, &k.IsActive, &k.CreatedAt, &k.UpdatedAt)
|
||
return k, err
|
||
}
|
||
|
||
const workoutKindColumns = `id, name, description, color, rule_json, priority, is_active, created_at, updated_at`
|
||
|
||
// CreateWorkoutKind inserts a new workout kind for userID and returns its id.
|
||
func (db *DB) CreateWorkoutKind(ctx context.Context, userID int64, k WorkoutKind) (int64, error) {
|
||
res, err := db.ExecContext(ctx, `
|
||
INSERT INTO workout_kinds (user_id, name, description, color, rule_json, priority, is_active)
|
||
VALUES (?,?,?,?,?,?,?)`,
|
||
userID, k.Name, k.Description, k.Color, k.RuleJSON, k.Priority, k.IsActive)
|
||
if err != nil {
|
||
return 0, fmt.Errorf("create workout kind %q for user %d: %w", k.Name, userID, err)
|
||
}
|
||
return res.LastInsertId()
|
||
}
|
||
|
||
// UpdateWorkoutKind updates an existing workout kind's editable fields,
|
||
// scoped so it can only ever affect a row owned by userID.
|
||
func (db *DB) UpdateWorkoutKind(ctx context.Context, userID int64, k WorkoutKind) error {
|
||
_, err := db.ExecContext(ctx, `
|
||
UPDATE workout_kinds SET name=?, description=?, color=?, rule_json=?, priority=?, is_active=?, updated_at=datetime('now')
|
||
WHERE id=? AND user_id=?`,
|
||
k.Name, k.Description, k.Color, k.RuleJSON, k.Priority, k.IsActive, k.ID, userID)
|
||
if err != nil {
|
||
return fmt.Errorf("update workout kind %d for user %d: %w", k.ID, userID, err)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// GetWorkoutKind fetches one workout kind by id, scoped to userID.
|
||
func (db *DB) GetWorkoutKind(ctx context.Context, userID, id int64) (WorkoutKind, bool, error) {
|
||
row := db.QueryRowContext(ctx, `SELECT `+workoutKindColumns+` FROM workout_kinds WHERE id = ? AND user_id = ?`, id, userID)
|
||
k, err := scanWorkoutKind(row)
|
||
if err == sql.ErrNoRows {
|
||
return WorkoutKind{}, false, nil
|
||
}
|
||
if err != nil {
|
||
return WorkoutKind{}, false, fmt.Errorf("get workout kind %d for user %d: %w", id, userID, err)
|
||
}
|
||
return k, true, nil
|
||
}
|
||
|
||
// GetWorkoutKindByName fetches one workout kind by name, scoped to userID
|
||
// (the same name can exist for different users -- see UNIQUE(user_id, name)).
|
||
func (db *DB) GetWorkoutKindByName(ctx context.Context, userID int64, name string) (WorkoutKind, bool, error) {
|
||
row := db.QueryRowContext(ctx, `SELECT `+workoutKindColumns+` FROM workout_kinds WHERE name = ? AND user_id = ?`, name, userID)
|
||
k, err := scanWorkoutKind(row)
|
||
if err == sql.ErrNoRows {
|
||
return WorkoutKind{}, false, nil
|
||
}
|
||
if err != nil {
|
||
return WorkoutKind{}, false, fmt.Errorf("get workout kind %q for user %d: %w", name, userID, err)
|
||
}
|
||
return k, true, nil
|
||
}
|
||
|
||
// ListWorkoutKinds returns userID's workout kinds. If activeOnly,
|
||
// soft-deleted (is_active=0) kinds are excluded.
|
||
func (db *DB) ListWorkoutKinds(ctx context.Context, userID int64, activeOnly bool) ([]WorkoutKind, error) {
|
||
query := `SELECT ` + workoutKindColumns + ` FROM workout_kinds WHERE user_id = ?`
|
||
if activeOnly {
|
||
query += ` AND is_active = 1`
|
||
}
|
||
query += ` ORDER BY priority DESC, name`
|
||
|
||
rows, err := db.QueryContext(ctx, query, userID)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("list workout kinds for user %d: %w", userID, err)
|
||
}
|
||
defer rows.Close()
|
||
|
||
kinds := []WorkoutKind{}
|
||
for rows.Next() {
|
||
k, err := scanWorkoutKind(rows)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("scan workout kind row: %w", err)
|
||
}
|
||
kinds = append(kinds, k)
|
||
}
|
||
return kinds, rows.Err()
|
||
}
|
||
|
||
// SoftDeleteWorkoutKind sets is_active=0, keeping history (kind_assignments
|
||
// referencing it) intact. Scoped to userID.
|
||
func (db *DB) SoftDeleteWorkoutKind(ctx context.Context, userID, id int64) error {
|
||
_, err := db.ExecContext(ctx, `UPDATE workout_kinds SET is_active=0, updated_at=datetime('now') WHERE id=? AND user_id=?`, id, userID)
|
||
if err != nil {
|
||
return fmt.Errorf("soft delete workout kind %d for user %d: %w", id, userID, err)
|
||
}
|
||
return nil
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Rewrite `backend/internal/store/workoutpaces.go`**
|
||
|
||
```go
|
||
package store
|
||
|
||
import (
|
||
"context"
|
||
"database/sql"
|
||
"fmt"
|
||
)
|
||
|
||
// WorkoutTypePace is a workout kind's user-declared target pace range and HR
|
||
// range (percent of heart rate reserve). Informational only -- never read by
|
||
// the classification rule engine. No history: fields are overwritten in
|
||
// place. Has no user_id column of its own -- ownership is checked via a
|
||
// join to workout_kinds.user_id, since it's always accessed 1:1 through a
|
||
// specific workout kind.
|
||
type WorkoutTypePace struct {
|
||
WorkoutKindID int64
|
||
PaceMinSecPerKm *float64
|
||
PaceMaxSecPerKm *float64
|
||
HRMinPctHRR *float64
|
||
HRMaxPctHRR *float64
|
||
}
|
||
|
||
func scanWorkoutTypePace(row interface{ Scan(...any) error }) (WorkoutTypePace, error) {
|
||
var p WorkoutTypePace
|
||
err := row.Scan(&p.WorkoutKindID, &p.PaceMinSecPerKm, &p.PaceMaxSecPerKm, &p.HRMinPctHRR, &p.HRMaxPctHRR)
|
||
return p, err
|
||
}
|
||
|
||
const workoutTypePaceColumns = `wtp.workout_kind_id, wtp.pace_min_sec_per_km, wtp.pace_max_sec_per_km, wtp.hr_min_pct_hrr, wtp.hr_max_pct_hrr`
|
||
|
||
// GetWorkoutTypePace fetches the pace/zone row for one workout kind, scoped
|
||
// to userID via a join to workout_kinds.
|
||
func (db *DB) GetWorkoutTypePace(ctx context.Context, userID, workoutKindID int64) (WorkoutTypePace, error) {
|
||
row := db.QueryRowContext(ctx, `
|
||
SELECT `+workoutTypePaceColumns+`
|
||
FROM workout_type_paces wtp
|
||
JOIN workout_kinds wk ON wk.id = wtp.workout_kind_id
|
||
WHERE wtp.workout_kind_id = ? AND wk.user_id = ?`, workoutKindID, userID)
|
||
p, err := scanWorkoutTypePace(row)
|
||
if err == sql.ErrNoRows {
|
||
return WorkoutTypePace{WorkoutKindID: workoutKindID}, nil
|
||
}
|
||
if err != nil {
|
||
return WorkoutTypePace{}, fmt.Errorf("get workout type pace for kind %d (user %d): %w", workoutKindID, userID, err)
|
||
}
|
||
return p, nil
|
||
}
|
||
|
||
// UpdateWorkoutTypePace overwrites the pace/zone row for one workout kind,
|
||
// scoped so it can only ever affect a kind owned by userID.
|
||
func (db *DB) UpdateWorkoutTypePace(ctx context.Context, userID int64, p WorkoutTypePace) error {
|
||
_, err := db.ExecContext(ctx, `
|
||
UPDATE workout_type_paces SET pace_min_sec_per_km=?, pace_max_sec_per_km=?, hr_min_pct_hrr=?, hr_max_pct_hrr=?
|
||
WHERE workout_kind_id=? AND workout_kind_id IN (SELECT id FROM workout_kinds WHERE user_id=?)`,
|
||
p.PaceMinSecPerKm, p.PaceMaxSecPerKm, p.HRMinPctHRR, p.HRMaxPctHRR, p.WorkoutKindID, userID)
|
||
if err != nil {
|
||
return fmt.Errorf("update workout type pace for kind %d (user %d): %w", p.WorkoutKindID, userID, err)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// ListWorkoutTypePaces returns every one of userID's workout kinds' pace/zone rows.
|
||
func (db *DB) ListWorkoutTypePaces(ctx context.Context, userID int64) ([]WorkoutTypePace, error) {
|
||
rows, err := db.QueryContext(ctx, `
|
||
SELECT `+workoutTypePaceColumns+`
|
||
FROM workout_type_paces wtp
|
||
JOIN workout_kinds wk ON wk.id = wtp.workout_kind_id
|
||
WHERE wk.user_id = ?
|
||
ORDER BY wtp.workout_kind_id`, userID)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("list workout type paces for user %d: %w", userID, err)
|
||
}
|
||
defer rows.Close()
|
||
|
||
paces := []WorkoutTypePace{}
|
||
for rows.Next() {
|
||
p, err := scanWorkoutTypePace(rows)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("scan workout type pace row: %w", err)
|
||
}
|
||
paces = append(paces, p)
|
||
}
|
||
return paces, rows.Err()
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Fix `workoutkinds_taxonomy_test.go` and `workoutpaces_test.go`**
|
||
|
||
Both files currently call things like `db.ListWorkoutKinds(ctx, false)` or `db.CreateWorkoutKind(ctx, WorkoutKind{...})` against a bare `openTestDB(t)`. For each test function in both files: add `userID, err := db.ProvisionUser(ctx, "test-sub", "Test")` (checking the error) right after `db := openTestDB(t)`, then insert `userID` as the new second argument (right after `ctx`) into every `CreateWorkoutKind`/`UpdateWorkoutKind`/`GetWorkoutKind`/`GetWorkoutKindByName`/`ListWorkoutKinds`/`SoftDeleteWorkoutKind`/`GetWorkoutTypePace`/`UpdateWorkoutTypePace`/`ListWorkoutTypePaces` call in that function. Note `ListWorkoutKinds`/`ListWorkoutTypePaces` will now also return `ProvisionUser`'s 8 seeded kinds in addition to whatever the test itself creates — check whether any test asserts an exact count and adjust that expected count to include the 8 seeded ones (e.g. a test creating 2 more kinds and asserting `len(kinds) == 2` needs to become `len(kinds) == 10`, or better, filter to just the kind(s) the test created by name/id before asserting on count).
|
||
|
||
- [ ] **Step 3: Build, fix remaining call sites, run tests**
|
||
|
||
Run: `cd backend && go build ./... && go vet ./internal/store/...`
|
||
Expected: fix every remaining `internal/store/*_test.go` compile error the same way (only within `internal/store` for this task — `internal/sync`, `internal/api`, `cmd/seedsample` are handled in later tasks).
|
||
|
||
Run: `cd backend && go test ./internal/store/... -run 'TestWorkoutKind|TestWorkoutTypePace' -v`
|
||
Expected: PASS.
|
||
|
||
- [ ] **Step 4: `gofmt` and commit**
|
||
|
||
Run: `cd backend && gofmt -l internal/store/`
|
||
Expected: no output.
|
||
|
||
```bash
|
||
git add backend/internal/store/workoutkinds.go backend/internal/store/workoutpaces.go backend/internal/store/workoutkinds_taxonomy_test.go backend/internal/store/workoutpaces_test.go
|
||
git commit -m "$(cat <<'EOF'
|
||
store: scope workout kinds and their pace/HR ranges to a user_id
|
||
|
||
workout_kinds gains a real user_id column (Task 1); workout_type_paces has
|
||
none of its own and is scoped via a join to workout_kinds instead, since
|
||
it's always accessed through a specific kind.
|
||
EOF
|
||
)"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 6: Scope `activities.go` to `user_id`
|
||
|
||
**Files:**
|
||
- Modify: `backend/internal/store/activities.go`
|
||
- Modify: `backend/internal/store/store_test.go` (fixes `TestUpsertActivity_InsertThenUpdateIsIdempotent`; other tests in this file are fixed in Task 7)
|
||
|
||
**Interfaces:**
|
||
- Produces: `func (db *DB) UpsertActivity(ctx, userID int64, a Activity) (int64, error)`; `func (db *DB) GetActivity(ctx, userID, id int64) (Activity, bool, error)`; `func (db *DB) ListActivities(ctx, userID int64, f ActivityFilter) ([]Activity, error)`; `func (db *DB) ActivityExists(ctx, userID, garminActivityID int64) (bool, error)`; `func (db *DB) LatestActivityStartTime(ctx, userID int64) (string, bool, error)`; `func (db *DB) SetActivityDetails(ctx, userID, activityID int64, rawJSON string) error`; `func (db *DB) SetActivityWorkout(ctx, userID, activityID int64, rawJSON string) error`; `func (db *DB) SetActivitySplitsFetched(ctx, userID, activityID int64) error`; `func (db *DB) ActivitiesMissingDetails(ctx, userID int64, limit int) ([]Activity, error)`; `func (db *DB) CountActivitiesMissingDetails(ctx, userID int64) (int, error)`.
|
||
|
||
- [ ] **Step 1: Rewrite `backend/internal/store/activities.go`**
|
||
|
||
Keep the `Activity` struct exactly as-is; replace every function below it:
|
||
|
||
```go
|
||
// UpsertActivity inserts a new activity for userID or updates the existing
|
||
// row for the same (userID, garmin_activity_id) pair (idempotent, safe to
|
||
// call on every sync pass), and returns its internal id.
|
||
func (db *DB) UpsertActivity(ctx context.Context, userID int64, a Activity) (int64, error) {
|
||
_, err := db.ExecContext(ctx, `
|
||
INSERT INTO activities (
|
||
user_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, updated_at
|
||
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?, datetime('now'))
|
||
ON CONFLICT(user_id, garmin_activity_id) DO UPDATE SET
|
||
event_type_key=excluded.event_type_key,
|
||
workout_id=excluded.workout_id,
|
||
start_time_utc=excluded.start_time_utc,
|
||
duration_seconds=excluded.duration_seconds,
|
||
distance_meters=excluded.distance_meters,
|
||
avg_hr=excluded.avg_hr,
|
||
max_hr=excluded.max_hr,
|
||
avg_speed_mps=excluded.avg_speed_mps,
|
||
elevation_gain_m=excluded.elevation_gain_m,
|
||
aerobic_training_effect=excluded.aerobic_training_effect,
|
||
anaerobic_training_effect=excluded.anaerobic_training_effect,
|
||
vo2max_value=excluded.vo2max_value,
|
||
raw_json=excluded.raw_json,
|
||
updated_at=datetime('now')
|
||
`,
|
||
userID, 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,
|
||
)
|
||
if err != nil {
|
||
return 0, fmt.Errorf("upsert activity %d for user %d: %w", a.GarminActivityID, userID, err)
|
||
}
|
||
|
||
var id int64
|
||
if err := db.QueryRowContext(ctx, `SELECT id FROM activities WHERE user_id = ? AND garmin_activity_id = ?`, userID, a.GarminActivityID).Scan(&id); err != nil {
|
||
return 0, fmt.Errorf("fetch id for activity %d (user %d): %w", a.GarminActivityID, userID, err)
|
||
}
|
||
return id, nil
|
||
}
|
||
|
||
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
|
||
`
|
||
|
||
// GetActivity fetches one activity by its internal id, scoped to userID.
|
||
func (db *DB) GetActivity(ctx context.Context, userID, id int64) (Activity, bool, error) {
|
||
row := db.QueryRowContext(ctx, `SELECT `+activityColumns+` FROM activities WHERE id = ? AND user_id = ?`, id, userID)
|
||
a, err := scanActivity(row)
|
||
if err == sql.ErrNoRows {
|
||
return Activity{}, false, nil
|
||
}
|
||
if err != nil {
|
||
return Activity{}, false, fmt.Errorf("get activity %d for user %d: %w", id, userID, err)
|
||
}
|
||
return a, true, nil
|
||
}
|
||
|
||
// ActivityFilter narrows ListActivities results. Zero values mean "no filter".
|
||
type ActivityFilter struct {
|
||
FromDate string // inclusive, "YYYY-MM-DD"
|
||
ToDate string // inclusive, "YYYY-MM-DD"
|
||
Limit int
|
||
Offset int
|
||
}
|
||
|
||
// ListActivities returns userID's activities newest-first, optionally
|
||
// filtered by date range.
|
||
func (db *DB) ListActivities(ctx context.Context, userID int64, f ActivityFilter) ([]Activity, error) {
|
||
query := `SELECT ` + activityColumns + ` FROM activities WHERE user_id = ?`
|
||
args := []any{userID}
|
||
if f.FromDate != "" {
|
||
query += ` AND start_time_utc >= ?`
|
||
args = append(args, f.FromDate)
|
||
}
|
||
if f.ToDate != "" {
|
||
query += ` AND start_time_utc <= ?`
|
||
args = append(args, f.ToDate+" 23:59:59")
|
||
}
|
||
query += ` ORDER BY start_time_utc DESC`
|
||
if f.Limit > 0 {
|
||
query += ` LIMIT ? OFFSET ?`
|
||
args = append(args, f.Limit, f.Offset)
|
||
}
|
||
|
||
rows, err := db.QueryContext(ctx, query, args...)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("list activities 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()
|
||
}
|
||
|
||
// ActivityExists reports whether userID already has an activity with this
|
||
// garmin_activity_id stored.
|
||
func (db *DB) ActivityExists(ctx context.Context, userID, garminActivityID int64) (bool, error) {
|
||
var id int64
|
||
err := db.QueryRowContext(ctx, `SELECT id FROM activities WHERE user_id = ? AND garmin_activity_id = ?`, userID, garminActivityID).Scan(&id)
|
||
if err == sql.ErrNoRows {
|
||
return false, nil
|
||
}
|
||
if err != nil {
|
||
return false, fmt.Errorf("check activity %d exists for user %d: %w", garminActivityID, userID, err)
|
||
}
|
||
return true, nil
|
||
}
|
||
|
||
// LatestActivityStartTime returns userID's most recently started activity's
|
||
// start_time_utc, used to compute the incremental sync window.
|
||
func (db *DB) LatestActivityStartTime(ctx context.Context, userID int64) (string, bool, error) {
|
||
var t string
|
||
err := db.QueryRowContext(ctx, `SELECT start_time_utc FROM activities WHERE user_id = ? ORDER BY start_time_utc DESC LIMIT 1`, userID).Scan(&t)
|
||
if err == sql.ErrNoRows {
|
||
return "", false, nil
|
||
}
|
||
if err != nil {
|
||
return "", false, fmt.Errorf("latest activity start time for user %d: %w", userID, err)
|
||
}
|
||
return t, true, nil
|
||
}
|
||
|
||
// SetActivityDetails records that get_activity_details has been fetched for
|
||
// this activity, storing the raw response for future reprocessing.
|
||
func (db *DB) SetActivityDetails(ctx context.Context, userID, activityID int64, rawJSON string) error {
|
||
_, err := db.ExecContext(ctx, `
|
||
UPDATE activities SET details_fetched_at = datetime('now'), details_raw_json = ?, updated_at = datetime('now')
|
||
WHERE id = ? AND user_id = ?`, rawJSON, activityID, userID)
|
||
if err != nil {
|
||
return fmt.Errorf("set activity %d details for user %d: %w", activityID, userID, err)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// SetActivityWorkout stores the raw get_workout_by_id() response used to
|
||
// compute this activity's laps' target pace/HR bands.
|
||
func (db *DB) SetActivityWorkout(ctx context.Context, userID, activityID int64, rawJSON string) error {
|
||
_, err := db.ExecContext(ctx, `
|
||
UPDATE activities SET workout_raw_json = ?, updated_at = datetime('now')
|
||
WHERE id = ? AND user_id = ?`, rawJSON, activityID, userID)
|
||
if err != nil {
|
||
return fmt.Errorf("set activity %d workout for user %d: %w", activityID, userID, err)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// SetActivitySplitsFetched records that get_activity_splits has been fetched
|
||
// for this activity.
|
||
func (db *DB) SetActivitySplitsFetched(ctx context.Context, userID, activityID int64) error {
|
||
_, err := db.ExecContext(ctx, `
|
||
UPDATE activities SET splits_fetched_at = datetime('now'), updated_at = datetime('now')
|
||
WHERE id = ? AND user_id = ?`, activityID, userID)
|
||
if err != nil {
|
||
return fmt.Errorf("set activity %d splits fetched for user %d: %w", activityID, userID, err)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// ActivitiesMissingDetails returns userID's activities that haven't had
|
||
// get_activity_details/get_activity_splits fetched yet, for the lazy
|
||
// background detail-fill pass.
|
||
func (db *DB) ActivitiesMissingDetails(ctx context.Context, userID int64, limit int) ([]Activity, error) {
|
||
rows, err := db.QueryContext(ctx, `SELECT `+activityColumns+` FROM activities
|
||
WHERE user_id = ? AND (details_fetched_at IS NULL OR splits_fetched_at IS NULL)
|
||
ORDER BY start_time_utc DESC LIMIT ?`, userID, limit)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("list activities missing details 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()
|
||
}
|
||
|
||
// CountActivitiesMissingDetails returns how many of userID's activities
|
||
// still need get_activity_details/get_activity_splits fetched, regardless
|
||
// of any per-call batch limit -- used to report overall remaining work.
|
||
func (db *DB) CountActivitiesMissingDetails(ctx context.Context, userID int64) (int, error) {
|
||
var n int
|
||
err := db.QueryRowContext(ctx, `SELECT COUNT(*) FROM activities
|
||
WHERE user_id = ? AND (details_fetched_at IS NULL OR splits_fetched_at IS NULL)`, userID).Scan(&n)
|
||
if err != nil {
|
||
return 0, fmt.Errorf("count activities missing details for user %d: %w", userID, err)
|
||
}
|
||
return n, nil
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Fix `TestUpsertActivity_InsertThenUpdateIsIdempotent` in `store_test.go`**
|
||
|
||
```go
|
||
func TestUpsertActivity_InsertThenUpdateIsIdempotent(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)
|
||
}
|
||
|
||
a := Activity{
|
||
GarminActivityID: 23554066504,
|
||
StartTimeUTC: "2026-07-11 05:02:35",
|
||
DurationSeconds: 1800,
|
||
DistanceMeters: 6858,
|
||
AvgHR: f(148),
|
||
RawJSON: `{"activityId":23554066504,"activityName":"Auriol - W2-5-Base Endurance","activityType":{"typeKey":"running"}}`,
|
||
}
|
||
|
||
id, err := db.UpsertActivity(ctx, userID, a)
|
||
if err != nil {
|
||
t.Fatalf("UpsertActivity (insert): %v", err)
|
||
}
|
||
|
||
a.AvgHR = f(150) // simulate a re-sync with a corrected value
|
||
id2, err := db.UpsertActivity(ctx, userID, a)
|
||
if err != nil {
|
||
t.Fatalf("UpsertActivity (update): %v", err)
|
||
}
|
||
if id != id2 {
|
||
t.Fatalf("expected same internal id across upserts, got %d then %d", id, id2)
|
||
}
|
||
|
||
got, ok, err := db.GetActivity(ctx, userID, id)
|
||
if err != nil || !ok {
|
||
t.Fatalf("GetActivity: ok=%v err=%v", ok, err)
|
||
}
|
||
if *got.AvgHR != 150 {
|
||
t.Errorf("AvgHR = %v, want 150 (update should have applied)", *got.AvgHR)
|
||
}
|
||
|
||
all, err := db.ListActivities(ctx, userID, ActivityFilter{})
|
||
if err != nil {
|
||
t.Fatalf("ListActivities: %v", err)
|
||
}
|
||
if len(all) != 1 {
|
||
t.Fatalf("expected exactly 1 activity after upsert-update, got %d", len(all))
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3: Add a cross-user activity-uniqueness test to `store_test.go`**
|
||
|
||
```go
|
||
func TestUpsertActivity_SameGarminActivityIDAllowedAcrossDifferentUsers(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)
|
||
}
|
||
|
||
activity := Activity{GarminActivityID: 999, StartTimeUTC: "2026-07-11 05:00:00", RawJSON: "{}"}
|
||
if _, err := db.UpsertActivity(ctx, userA, activity); err != nil {
|
||
t.Fatalf("UpsertActivity(a): %v", err)
|
||
}
|
||
if _, err := db.UpsertActivity(ctx, userB, activity); err != nil {
|
||
t.Fatalf("expected the same garmin_activity_id to be allowed for a different user, got: %v", err)
|
||
}
|
||
|
||
aActivities, err := db.ListActivities(ctx, userA, ActivityFilter{})
|
||
if err != nil || len(aActivities) != 1 {
|
||
t.Fatalf("ListActivities(a): got %d, err=%v", len(aActivities), err)
|
||
}
|
||
bActivities, err := db.ListActivities(ctx, userB, ActivityFilter{})
|
||
if err != nil || len(bActivities) != 1 {
|
||
t.Fatalf("ListActivities(b): got %d, err=%v", len(bActivities), err)
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Build, fix remaining `internal/store` call sites, run tests**
|
||
|
||
Run: `cd backend && go build ./... && go vet ./internal/store/...`
|
||
Expected: fix any remaining compile errors within `internal/store/` the same way (Task 7's tests still reference `UpsertActivity`/`GetActivity` without `userID` too — fix those now if `go vet` flags them here, since Task 7 assumes activities.go is already scoped).
|
||
|
||
Run: `cd backend && go test ./internal/store/... -run 'TestUpsertActivity' -v`
|
||
Expected: PASS.
|
||
|
||
- [ ] **Step 5: `gofmt` and commit**
|
||
|
||
Run: `cd backend && gofmt -l internal/store/`
|
||
Expected: no output.
|
||
|
||
```bash
|
||
git add backend/internal/store/activities.go backend/internal/store/store_test.go
|
||
git commit -m "$(cat <<'EOF'
|
||
store: scope activities to a user_id
|
||
|
||
garmin_activity_id uniqueness becomes per-user (UNIQUE(user_id,
|
||
garmin_activity_id), added in Task 1) so two users' Garmin accounts can
|
||
never collide even in the unlikely event their activity ids coincide.
|
||
EOF
|
||
)"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 7: Scope `assignments.go` + `laps.go` + `samples.go` via activity ownership
|
||
|
||
**Files:**
|
||
- Modify: `backend/internal/store/assignments.go`
|
||
- Modify: `backend/internal/store/laps.go`
|
||
- Modify: `backend/internal/store/samples.go`
|
||
- Modify: `backend/internal/store/store_test.go` (remaining tests: `TestKindAssignment_AppendOnlyHistoryAndCurrentView`, `TestReplaceLapsIsIdempotent`)
|
||
|
||
**Interfaces:**
|
||
- Consumes: `activities.user_id` from Task 6.
|
||
- Produces: `func (db *DB) InsertKindAssignment(ctx, userID int64, a KindAssignment) (int64, error)`; `func (db *DB) CurrentAssignment(ctx, userID, activityID int64) (KindAssignment, bool, error)`; `func (db *DB) ReviewQueue(ctx, userID int64) ([]KindAssignment, error)`; `func (db *DB) AllCurrentAssignments(ctx, userID int64) ([]KindAssignment, error)`; `func (db *DB) AssignmentsForKind(ctx, userID, workoutKindID int64) ([]KindAssignment, error)`; `func (db *DB) ReplaceLaps(ctx, userID, activityID int64, laps []Lap) error`; `func (db *DB) LapsForActivity(ctx, userID, activityID int64) ([]Lap, error)`; `func (db *DB) ReplaceActivitySamples(ctx, userID, activityID int64, samples []Sample) error`; `func (db *DB) SamplesForActivity(ctx, userID, activityID int64) ([]Sample, error)`.
|
||
- None of `kind_assignments`/`laps`/`activity_samples` gained a `user_id` column in Task 1 — ownership is always checked via a join/subquery against `activities.user_id`, since these tables are only ever accessed through a specific activity.
|
||
|
||
- [ ] **Step 1: Rewrite `backend/internal/store/assignments.go`**
|
||
|
||
Keep the `KindAssignment` struct and the `AssignmentSource*`/`AssignmentStatus*` constants exactly as-is; replace everything below them:
|
||
|
||
```go
|
||
// InsertKindAssignment appends a new assignment row for an activity owned
|
||
// by userID.
|
||
func (db *DB) InsertKindAssignment(ctx context.Context, userID int64, a KindAssignment) (int64, error) {
|
||
var exists int
|
||
err := db.QueryRowContext(ctx, `SELECT 1 FROM activities WHERE id = ? AND user_id = ?`, a.ActivityID, userID).Scan(&exists)
|
||
if err == sql.ErrNoRows {
|
||
return 0, fmt.Errorf("insert kind assignment: activity %d not found for user %d", a.ActivityID, userID)
|
||
}
|
||
if err != nil {
|
||
return 0, fmt.Errorf("insert kind assignment for activity %d (user %d): %w", a.ActivityID, userID, err)
|
||
}
|
||
|
||
res, err := db.ExecContext(ctx, `
|
||
INSERT INTO kind_assignments (activity_id, workout_kind_id, assignment_source, status, confidence, candidate_kinds_json)
|
||
VALUES (?,?,?,?,?,?)`,
|
||
a.ActivityID, a.WorkoutKindID, a.AssignmentSource, a.Status, a.Confidence, a.CandidateKindsJSON)
|
||
if err != nil {
|
||
return 0, fmt.Errorf("insert kind assignment for activity %d: %w", a.ActivityID, err)
|
||
}
|
||
return res.LastInsertId()
|
||
}
|
||
|
||
func scanKindAssignment(row interface{ Scan(...any) error }) (KindAssignment, error) {
|
||
var a KindAssignment
|
||
err := row.Scan(&a.ID, &a.ActivityID, &a.WorkoutKindID, &a.AssignmentSource, &a.Status, &a.Confidence, &a.CandidateKindsJSON, &a.CreatedAt)
|
||
return a, err
|
||
}
|
||
|
||
const kindAssignmentColumns = `a.id, a.activity_id, a.workout_kind_id, a.assignment_source, a.status, a.confidence, a.candidate_kinds_json, a.created_at`
|
||
|
||
// CurrentAssignment returns the latest assignment for an activity owned by
|
||
// userID, if any.
|
||
func (db *DB) CurrentAssignment(ctx context.Context, userID, activityID int64) (KindAssignment, bool, error) {
|
||
row := db.QueryRowContext(ctx, `
|
||
SELECT `+kindAssignmentColumns+`
|
||
FROM current_kind_assignment a
|
||
JOIN activities ON activities.id = a.activity_id
|
||
WHERE a.activity_id = ? AND activities.user_id = ?`, activityID, userID)
|
||
a, err := scanKindAssignment(row)
|
||
if err == sql.ErrNoRows {
|
||
return KindAssignment{}, false, nil
|
||
}
|
||
if err != nil {
|
||
return KindAssignment{}, false, fmt.Errorf("current assignment for activity %d (user %d): %w", activityID, userID, err)
|
||
}
|
||
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
|
||
// userID's activities that has one, regardless of status or source -- the
|
||
// basis for deciding which activities a global reclassify pass may touch.
|
||
func (db *DB) AllCurrentAssignments(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 = ?`, userID)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("all current assignments 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()
|
||
}
|
||
|
||
// AssignmentsForKind returns every historical assignment (for userID's
|
||
// activities) where the given workout kind was the resolved kind (regardless
|
||
// of source), oldest first -- the basis for progression-over-time charts.
|
||
func (db *DB) AssignmentsForKind(ctx context.Context, userID, workoutKindID 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.workout_kind_id = ? AND a.status = ?
|
||
ORDER BY a.created_at ASC`,
|
||
userID, workoutKindID, AssignmentStatusAssigned)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("assignments for kind %d (user %d): %w", workoutKindID, 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()
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Rewrite `backend/internal/store/laps.go`**
|
||
|
||
Keep the `Lap` struct exactly as-is; replace the two functions below it:
|
||
|
||
```go
|
||
// ReplaceLaps deletes any existing laps for activityID (owned by userID)
|
||
// and inserts the given set, so re-syncing an activity's splits is
|
||
// idempotent.
|
||
func (db *DB) ReplaceLaps(ctx context.Context, userID, activityID int64, laps []Lap) error {
|
||
var exists int
|
||
err := db.QueryRowContext(ctx, `SELECT 1 FROM activities WHERE id = ? AND user_id = ?`, activityID, userID).Scan(&exists)
|
||
if err == sql.ErrNoRows {
|
||
return fmt.Errorf("replace laps: activity %d not found for user %d", activityID, userID)
|
||
}
|
||
if err != nil {
|
||
return fmt.Errorf("replace laps for activity %d (user %d): %w", activityID, userID, err)
|
||
}
|
||
|
||
tx, err := db.BeginTx(ctx, nil)
|
||
if err != nil {
|
||
return fmt.Errorf("begin replace laps tx: %w", err)
|
||
}
|
||
defer tx.Rollback()
|
||
|
||
if _, err := tx.ExecContext(ctx, `DELETE FROM laps WHERE activity_id = ?`, activityID); err != nil {
|
||
return fmt.Errorf("delete existing laps for activity %d: %w", activityID, err)
|
||
}
|
||
|
||
for _, l := range laps {
|
||
_, err := tx.ExecContext(ctx, `
|
||
INSERT INTO laps (
|
||
activity_id, lap_index, avg_speed_mps,
|
||
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
|
||
) VALUES (?,?,?,?,?,?,?,?,?,?,?)`,
|
||
activityID, l.LapIndex, l.AvgSpeedMps,
|
||
l.IntensityType, l.HRDriftBpmPerMin, l.HRRecoveryBpmPerMin,
|
||
l.TargetPaceLowMps, l.TargetPaceHighMps, l.TargetHRLowBpm, l.TargetHRHighBpm, l.RawJSON,
|
||
)
|
||
if err != nil {
|
||
return fmt.Errorf("insert lap %d for activity %d: %w", l.LapIndex, activityID, err)
|
||
}
|
||
}
|
||
return tx.Commit()
|
||
}
|
||
|
||
// LapsForActivity returns all laps for an activity owned by userID,
|
||
// ordered by lap_index.
|
||
func (db *DB) LapsForActivity(ctx context.Context, userID, activityID int64) ([]Lap, error) {
|
||
rows, err := db.QueryContext(ctx, `
|
||
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.target_pace_low_mps, laps.target_pace_high_mps, laps.target_hr_low_bpm, laps.target_hr_high_bpm, laps.raw_json
|
||
FROM laps
|
||
JOIN activities ON activities.id = laps.activity_id
|
||
WHERE laps.activity_id = ? AND activities.user_id = ?
|
||
ORDER BY laps.lap_index`, activityID, userID)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("laps for activity %d (user %d): %w", activityID, userID, err)
|
||
}
|
||
defer rows.Close()
|
||
|
||
laps := []Lap{}
|
||
for rows.Next() {
|
||
var l Lap
|
||
if err := rows.Scan(
|
||
&l.ID, &l.ActivityID, &l.LapIndex, &l.AvgSpeedMps,
|
||
&l.IntensityType, &l.HRDriftBpmPerMin, &l.HRRecoveryBpmPerMin,
|
||
&l.TargetPaceLowMps, &l.TargetPaceHighMps, &l.TargetHRLowBpm, &l.TargetHRHighBpm, &l.RawJSON,
|
||
); err != nil {
|
||
return nil, fmt.Errorf("scan lap row: %w", err)
|
||
}
|
||
laps = append(laps, l)
|
||
}
|
||
return laps, rows.Err()
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3: Rewrite `backend/internal/store/samples.go`**
|
||
|
||
Keep the `Sample` struct exactly as-is; replace the two functions below it:
|
||
|
||
```go
|
||
// ReplaceActivitySamples deletes any existing samples for activityID (owned
|
||
// by userID) and bulk-inserts the given set, so re-syncing an activity's
|
||
// details is idempotent.
|
||
func (db *DB) ReplaceActivitySamples(ctx context.Context, userID, activityID int64, samples []Sample) error {
|
||
var exists int
|
||
err := db.QueryRowContext(ctx, `SELECT 1 FROM activities WHERE id = ? AND user_id = ?`, activityID, userID).Scan(&exists)
|
||
if err == sql.ErrNoRows {
|
||
return fmt.Errorf("replace activity samples: activity %d not found for user %d", activityID, userID)
|
||
}
|
||
if err != nil {
|
||
return fmt.Errorf("replace activity samples for activity %d (user %d): %w", activityID, userID, err)
|
||
}
|
||
|
||
tx, err := db.BeginTx(ctx, nil)
|
||
if err != nil {
|
||
return fmt.Errorf("begin replace samples tx: %w", err)
|
||
}
|
||
defer tx.Rollback()
|
||
|
||
if _, err := tx.ExecContext(ctx, `DELETE FROM activity_samples WHERE activity_id = ?`, activityID); err != nil {
|
||
return fmt.Errorf("delete existing samples for activity %d: %w", activityID, err)
|
||
}
|
||
|
||
stmt, err := tx.PrepareContext(ctx, `
|
||
INSERT INTO activity_samples (activity_id, elapsed_seconds, timestamp_ms, heart_rate, speed_mps, distance_m, elevation_m)
|
||
VALUES (?,?,?,?,?,?,?)`)
|
||
if err != nil {
|
||
return fmt.Errorf("prepare insert sample: %w", err)
|
||
}
|
||
defer stmt.Close()
|
||
|
||
for _, s := range samples {
|
||
if _, err := stmt.ExecContext(ctx, activityID, s.ElapsedSeconds, s.TimestampMs, s.HeartRate, s.SpeedMps, s.DistanceM, s.ElevationM); err != nil {
|
||
return fmt.Errorf("insert sample for activity %d: %w", activityID, err)
|
||
}
|
||
}
|
||
return tx.Commit()
|
||
}
|
||
|
||
// SamplesForActivity returns all samples for an activity owned by userID,
|
||
// ordered by elapsed_seconds.
|
||
func (db *DB) SamplesForActivity(ctx context.Context, userID, activityID int64) ([]Sample, error) {
|
||
rows, err := db.QueryContext(ctx, `
|
||
SELECT activity_samples.elapsed_seconds, activity_samples.timestamp_ms, activity_samples.heart_rate,
|
||
activity_samples.speed_mps, activity_samples.distance_m, activity_samples.elevation_m
|
||
FROM activity_samples
|
||
JOIN activities ON activities.id = activity_samples.activity_id
|
||
WHERE activity_samples.activity_id = ? AND activities.user_id = ?
|
||
ORDER BY activity_samples.elapsed_seconds`, activityID, userID)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("samples for activity %d (user %d): %w", activityID, userID, err)
|
||
}
|
||
defer rows.Close()
|
||
|
||
samples := []Sample{}
|
||
for rows.Next() {
|
||
var s Sample
|
||
if err := rows.Scan(&s.ElapsedSeconds, &s.TimestampMs, &s.HeartRate, &s.SpeedMps, &s.DistanceM, &s.ElevationM); err != nil {
|
||
return nil, fmt.Errorf("scan sample row: %w", err)
|
||
}
|
||
samples = append(samples, s)
|
||
}
|
||
return samples, rows.Err()
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Fix `TestKindAssignment_AppendOnlyHistoryAndCurrentView` and `TestReplaceLapsIsIdempotent` in `store_test.go`**
|
||
|
||
For each, add `userID, err := db.ProvisionUser(ctx, "test-sub", "Test")` right after `db := openTestDB(t)` (checking the error), then thread `userID` as the new argument (right after `ctx`) into every `UpsertActivity`, `CreateWorkoutKind`, `InsertKindAssignment`, `ReviewQueue`, `CurrentAssignment`, `AssignmentsForKind`, `ReplaceLaps`, `LapsForActivity` call in those two functions. `CreateWorkoutKind` was already scoped in Task 5, so this task only needs to add `userID` to the assignment/lap-specific calls.
|
||
|
||
- [ ] **Step 5: Add a cross-user activity-ownership test to `store_test.go`**
|
||
|
||
```go
|
||
func TestCurrentAssignment_ScopedToOwningUser(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)
|
||
}
|
||
|
||
activityID, err := db.UpsertActivity(ctx, userA, Activity{GarminActivityID: 1, StartTimeUTC: "2026-07-11 05:00:00", RawJSON: "{}"})
|
||
if err != nil {
|
||
t.Fatalf("UpsertActivity: %v", err)
|
||
}
|
||
if _, err := db.InsertKindAssignment(ctx, userA, KindAssignment{
|
||
ActivityID: activityID, AssignmentSource: AssignmentSourceRuleEngine, Status: AssignmentStatusNeedsReview, CandidateKindsJSON: "[]",
|
||
}); err != nil {
|
||
t.Fatalf("InsertKindAssignment: %v", err)
|
||
}
|
||
|
||
if _, found, err := db.CurrentAssignment(ctx, userB, activityID); err != nil || found {
|
||
t.Fatalf("expected userB to not see userA's activity assignment, found=%v err=%v", found, err)
|
||
}
|
||
if _, err := db.InsertKindAssignment(ctx, userB, KindAssignment{
|
||
ActivityID: activityID, AssignmentSource: AssignmentSourceManual, Status: AssignmentStatusAssigned, CandidateKindsJSON: "[]",
|
||
}); err == nil {
|
||
t.Fatal("expected InsertKindAssignment to reject an activity that doesn't belong to userB")
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 6: Build, fix remaining `internal/store` call sites, run tests**
|
||
|
||
Run: `cd backend && go build ./... && go vet ./internal/store/...`
|
||
Expected: fix any remaining compile errors within `internal/store/` the same way.
|
||
|
||
Run: `cd backend && go test ./internal/store/... -v`
|
||
Expected: every test in `internal/store` PASSes (this is the last store-layer scoping task; the whole package should compile and pass cleanly now, aside from anything intentionally deferred to Task 9's cross-user tests, which don't exist as failures — they're new).
|
||
|
||
- [ ] **Step 7: `gofmt` and commit**
|
||
|
||
Run: `cd backend && gofmt -l internal/store/`
|
||
Expected: no output.
|
||
|
||
```bash
|
||
git add backend/internal/store/assignments.go backend/internal/store/laps.go backend/internal/store/samples.go backend/internal/store/store_test.go
|
||
git commit -m "$(cat <<'EOF'
|
||
store: scope kind assignments, laps, and samples via activity ownership
|
||
|
||
None of these three tables gained their own user_id column -- they're
|
||
always accessed through a specific activity, so ownership is checked via a
|
||
join/subquery against activities.user_id instead.
|
||
EOF
|
||
)"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 8: Scope `syncstate.go` + `syncruns.go` + `reset.go` to `user_id`
|
||
|
||
**Files:**
|
||
- Modify: `backend/internal/store/syncstate.go`
|
||
- Modify: `backend/internal/store/syncruns.go`
|
||
- Modify: `backend/internal/store/reset.go`
|
||
- Modify: `backend/internal/store/syncstate_test.go`
|
||
- Modify: `backend/internal/store/reset_test.go`
|
||
|
||
**Interfaces:**
|
||
- Produces: `func (db *DB) GetSyncState(ctx, userID int64) (SyncState, error)`; `func (db *DB) UpdateSyncState(ctx, userID int64, earliestSyncedDate string, complete bool) error`; `func (db *DB) StartSyncRun(ctx, userID int64, kind string) (int64, error)`; `func (db *DB) FinishSyncRun(ctx, userID, id int64, activitiesFetched int, errMsg *string) error`; `func (db *DB) LatestSyncRun(ctx, userID int64) (SyncRun, bool, error)`; `func (db *DB) ListSyncRuns(ctx, userID int64, limit int) ([]SyncRun, error)`; `func (db *DB) ResetAllSyncedData(ctx, userID int64) error`.
|
||
- This is the last store-package scoping task — after it, every method Task 10 (`internal/sync`) and Task 14/15 (`internal/api`) depend on already has its final signature.
|
||
|
||
- [ ] **Step 1: Rewrite `backend/internal/store/syncstate.go`**
|
||
|
||
Keep the `SyncState` struct as-is; replace the two functions:
|
||
|
||
```go
|
||
// GetSyncState returns userID's current backfill watermark.
|
||
func (db *DB) GetSyncState(ctx context.Context, userID int64) (SyncState, error) {
|
||
var s SyncState
|
||
err := db.QueryRowContext(ctx, `SELECT earliest_synced_date, backfill_complete FROM sync_state WHERE user_id = ?`, userID).
|
||
Scan(&s.EarliestSyncedDate, &s.BackfillComplete)
|
||
if err != nil {
|
||
return SyncState{}, fmt.Errorf("get sync state for user %d: %w", userID, err)
|
||
}
|
||
return s, nil
|
||
}
|
||
|
||
// UpdateSyncState records progress of a backfill run for userID.
|
||
func (db *DB) UpdateSyncState(ctx context.Context, userID int64, earliestSyncedDate string, complete bool) error {
|
||
_, err := db.ExecContext(ctx, `
|
||
UPDATE sync_state SET earliest_synced_date = ?, backfill_complete = ? WHERE user_id = ?`,
|
||
earliestSyncedDate, complete, userID)
|
||
if err != nil {
|
||
return fmt.Errorf("update sync state for user %d: %w", userID, err)
|
||
}
|
||
return nil
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Rewrite `backend/internal/store/syncruns.go`**
|
||
|
||
Keep the `SyncKind*`/`SyncStatus*` constants and `SyncRun` struct as-is; replace the four functions:
|
||
|
||
```go
|
||
// StartSyncRun records a new in-progress sync run for userID and returns its id.
|
||
func (db *DB) StartSyncRun(ctx context.Context, userID int64, kind string) (int64, error) {
|
||
res, err := db.ExecContext(ctx, `
|
||
INSERT INTO sync_runs (user_id, kind, started_at, status) VALUES (?, ?, datetime('now'), ?)`,
|
||
userID, kind, SyncStatusRunning)
|
||
if err != nil {
|
||
return 0, fmt.Errorf("start sync run for user %d: %w", userID, err)
|
||
}
|
||
return res.LastInsertId()
|
||
}
|
||
|
||
// FinishSyncRun marks a sync run (owned by userID) as finished, recording
|
||
// how many activities were fetched and whether it succeeded.
|
||
func (db *DB) FinishSyncRun(ctx context.Context, userID, id int64, activitiesFetched int, errMsg *string) error {
|
||
status := SyncStatusSuccess
|
||
if errMsg != nil {
|
||
status = SyncStatusError
|
||
}
|
||
_, err := db.ExecContext(ctx, `
|
||
UPDATE sync_runs SET finished_at = datetime('now'), activities_fetched = ?, status = ?, error_message = ?
|
||
WHERE id = ? AND user_id = ?`, activitiesFetched, status, errMsg, id, userID)
|
||
if err != nil {
|
||
return fmt.Errorf("finish sync run %d for user %d: %w", id, userID, err)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// LatestSyncRun returns userID's most recent sync run, if any.
|
||
func (db *DB) LatestSyncRun(ctx context.Context, userID int64) (SyncRun, bool, error) {
|
||
row := db.QueryRowContext(ctx, `
|
||
SELECT id, kind, started_at, finished_at, activities_fetched, status, error_message
|
||
FROM sync_runs WHERE user_id = ? ORDER BY id DESC LIMIT 1`, userID)
|
||
var r SyncRun
|
||
err := row.Scan(&r.ID, &r.Kind, &r.StartedAt, &r.FinishedAt, &r.ActivitiesFetched, &r.Status, &r.ErrorMessage)
|
||
if err == sql.ErrNoRows {
|
||
return SyncRun{}, false, nil
|
||
}
|
||
if err != nil {
|
||
return SyncRun{}, false, fmt.Errorf("latest sync run for user %d: %w", userID, err)
|
||
}
|
||
return r, true, nil
|
||
}
|
||
|
||
// ListSyncRuns returns userID's recent sync runs, newest first.
|
||
func (db *DB) ListSyncRuns(ctx context.Context, userID int64, limit int) ([]SyncRun, error) {
|
||
rows, err := db.QueryContext(ctx, `
|
||
SELECT id, kind, started_at, finished_at, activities_fetched, status, error_message
|
||
FROM sync_runs WHERE user_id = ? ORDER BY id DESC LIMIT ?`, userID, limit)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("list sync runs for user %d: %w", userID, err)
|
||
}
|
||
defer rows.Close()
|
||
|
||
runs := []SyncRun{}
|
||
for rows.Next() {
|
||
var r SyncRun
|
||
if err := rows.Scan(&r.ID, &r.Kind, &r.StartedAt, &r.FinishedAt, &r.ActivitiesFetched, &r.Status, &r.ErrorMessage); err != nil {
|
||
return nil, fmt.Errorf("scan sync run row: %w", err)
|
||
}
|
||
runs = append(runs, r)
|
||
}
|
||
return runs, rows.Err()
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3: Rewrite `backend/internal/store/reset.go`**
|
||
|
||
```go
|
||
package store
|
||
|
||
import (
|
||
"context"
|
||
"fmt"
|
||
)
|
||
|
||
// ResetAllSyncedData deletes every synced activity belonging to userID
|
||
// (cascading to its laps, activity_samples, and kind_assignments via ON
|
||
// DELETE CASCADE) and rewinds userID's backfill watermark, so a subsequent
|
||
// Backfill starts a genuinely fresh pull instead of thinking history is
|
||
// already covered. userID's workout kinds (their taxonomy) are left
|
||
// untouched.
|
||
func (db *DB) ResetAllSyncedData(ctx context.Context, userID int64) error {
|
||
tx, err := db.BeginTx(ctx, nil)
|
||
if err != nil {
|
||
return fmt.Errorf("begin reset tx: %w", err)
|
||
}
|
||
defer tx.Rollback()
|
||
|
||
if _, err := tx.ExecContext(ctx, `DELETE FROM activities WHERE user_id = ?`, userID); err != nil {
|
||
return fmt.Errorf("delete activities for user %d: %w", userID, err)
|
||
}
|
||
if _, err := tx.ExecContext(ctx, `UPDATE sync_state SET earliest_synced_date = NULL, backfill_complete = 0 WHERE user_id = ?`, userID); err != nil {
|
||
return fmt.Errorf("reset sync state for user %d: %w", userID, err)
|
||
}
|
||
return tx.Commit()
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Fix `syncstate_test.go` and `reset_test.go`**
|
||
|
||
Both files call `db.GetSyncState(ctx)`/`db.UpdateSyncState(ctx, ...)`/`db.ResetAllSyncedData(ctx)` against a bare `openTestDB(t)`. In each test function, add `userID, err := db.ProvisionUser(ctx, "test-sub", "Test")` right after `db := openTestDB(t)` (checking the error), then add `userID` as the new argument (right after `ctx`) to every call.
|
||
|
||
- [ ] **Step 5: Build, fix any remaining `internal/store` call sites, run the full store suite**
|
||
|
||
Run: `cd backend && go build ./internal/store/... && go vet ./internal/store/...`
|
||
Expected: clean (this is the last store-scoping task, so `internal/store` itself should now fully compile — remaining errors elsewhere in `go build ./...`, e.g. `internal/sync`, `internal/api`, `cmd/seedsample`, are expected and fixed in Tasks 10/14/15/17).
|
||
|
||
Run: `cd backend && go test ./internal/store/... -v`
|
||
Expected: every test in the package PASSes.
|
||
|
||
- [ ] **Step 6: `gofmt` and commit**
|
||
|
||
Run: `cd backend && gofmt -l internal/store/`
|
||
Expected: no output.
|
||
|
||
```bash
|
||
git add backend/internal/store/syncstate.go backend/internal/store/syncruns.go backend/internal/store/reset.go backend/internal/store/syncstate_test.go backend/internal/store/reset_test.go
|
||
git commit -m "$(cat <<'EOF'
|
||
store: scope sync state, sync runs, and reset to a user_id
|
||
|
||
Completes the store-layer scoping pass -- every store method touching
|
||
per-user data now requires an explicit userID.
|
||
EOF
|
||
)"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 9: Store-layer cross-user isolation tests
|
||
|
||
**Files:**
|
||
- Create: `backend/internal/store/isolation_test.go`
|
||
|
||
**Interfaces:**
|
||
- Consumes: every scoped store method from Tasks 4-8. This task adds no new production code — it's a dedicated adversarial verification pass for the core security property (Tasks 6/7 already added a couple of these inline; this task rounds out coverage for `profile`, `workout_kinds`/`workout_type_paces`, and `sync_state`/`sync_runs`, which didn't get one yet).
|
||
|
||
- [ ] **Step 1: Write `backend/internal/store/isolation_test.go`**
|
||
|
||
```go
|
||
package store
|
||
|
||
import (
|
||
"context"
|
||
"testing"
|
||
)
|
||
|
||
// TestIsolation_ProfileNeverLeaksAcrossUsers confirms GetProfile only ever
|
||
// returns the row matching the given userID, and that two users' profiles
|
||
// can diverge independently.
|
||
func TestIsolation_ProfileNeverLeaksAcrossUsers(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)
|
||
}
|
||
|
||
profileA, err := db.GetProfile(ctx, userA)
|
||
if err != nil {
|
||
t.Fatalf("GetProfile(a): %v", err)
|
||
}
|
||
profileA.GarminEmail = "a@example.com"
|
||
if err := db.UpdateProfile(ctx, userA, profileA); err != nil {
|
||
t.Fatalf("UpdateProfile(a): %v", err)
|
||
}
|
||
|
||
profileB, err := db.GetProfile(ctx, userB)
|
||
if err != nil {
|
||
t.Fatalf("GetProfile(b): %v", err)
|
||
}
|
||
if profileB.GarminEmail == "a@example.com" {
|
||
t.Fatal("userB's profile picked up userA's GarminEmail update")
|
||
}
|
||
|
||
// Attempting to update B's profile "as A" (i.e. calling UpdateProfile
|
||
// with userA but a struct that happens to describe B's desired state)
|
||
// only ever touches the row WHERE user_id = userA -- confirm B is
|
||
// unaffected by any such call.
|
||
if err := db.UpdateProfile(ctx, userA, Profile{GarminEmail: "still-a-only@example.com"}); err != nil {
|
||
t.Fatalf("UpdateProfile(a) second call: %v", err)
|
||
}
|
||
profileB2, err := db.GetProfile(ctx, userB)
|
||
if err != nil {
|
||
t.Fatalf("GetProfile(b) after A's update: %v", err)
|
||
}
|
||
if profileB2.GarminEmail == "still-a-only@example.com" {
|
||
t.Fatal("userA's UpdateProfile call leaked into userB's row")
|
||
}
|
||
}
|
||
|
||
// TestIsolation_WorkoutKindsAndPacesNeverLeakAcrossUsers confirms
|
||
// GetWorkoutKind/GetWorkoutTypePace return not-found for a kind id that
|
||
// exists but belongs to a different user, and that UpdateWorkoutKind /
|
||
// UpdateWorkoutTypePace can never mutate another user's row even if handed
|
||
// that row's real id.
|
||
func TestIsolation_WorkoutKindsAndPacesNeverLeakAcrossUsers(t *testing.T) {
|
||
db := openTestDB(t)
|
||
ctx := context.Background()
|
||
|
||
userA, err := db.ProvisionUser(ctx, "sub-a", "A")
|
||
if err != nil {
|
||
t.Fatalf("ProvisionUser(a): %v", err)
|
||
}
|
||
userB, err := db.ProvisionUser(ctx, "sub-b", "B")
|
||
if err != nil {
|
||
t.Fatalf("ProvisionUser(b): %v", err)
|
||
}
|
||
|
||
kindsA, err := db.ListWorkoutKinds(ctx, userA, false)
|
||
if err != nil || len(kindsA) == 0 {
|
||
t.Fatalf("ListWorkoutKinds(a): len=%d err=%v", len(kindsA), err)
|
||
}
|
||
targetKindID := kindsA[0].ID
|
||
|
||
if _, found, err := db.GetWorkoutKind(ctx, userB, targetKindID); err != nil || found {
|
||
t.Fatalf("expected userB not to see userA's kind %d, found=%v err=%v", targetKindID, found, err)
|
||
}
|
||
|
||
// Attempt to update A's kind "as B" -- must silently affect zero rows,
|
||
// not A's real row.
|
||
if err := db.UpdateWorkoutKind(ctx, userB, WorkoutKind{ID: targetKindID, Name: "Hijacked", RuleJSON: "{}"}); err != nil {
|
||
t.Fatalf("UpdateWorkoutKind(as b): %v", err)
|
||
}
|
||
stillA, found, err := db.GetWorkoutKind(ctx, userA, targetKindID)
|
||
if err != nil || !found {
|
||
t.Fatalf("GetWorkoutKind(a) after B's attempted update: found=%v err=%v", found, err)
|
||
}
|
||
if stillA.Name == "Hijacked" {
|
||
t.Fatal("userB's UpdateWorkoutKind call was able to mutate userA's kind")
|
||
}
|
||
|
||
if _, err := db.GetWorkoutTypePace(ctx, userB, targetKindID); err != nil {
|
||
t.Fatalf("GetWorkoutTypePace(as b) should return a zero-value pace, not an error, got: %v", err)
|
||
}
|
||
minPace := 300.0
|
||
if err := db.UpdateWorkoutTypePace(ctx, userB, WorkoutTypePace{WorkoutKindID: targetKindID, PaceMinSecPerKm: &minPace}); err != nil {
|
||
t.Fatalf("UpdateWorkoutTypePace(as b): %v", err)
|
||
}
|
||
paceA, err := db.GetWorkoutTypePace(ctx, userA, targetKindID)
|
||
if err != nil {
|
||
t.Fatalf("GetWorkoutTypePace(a): %v", err)
|
||
}
|
||
if paceA.PaceMinSecPerKm != nil {
|
||
t.Fatal("userB's UpdateWorkoutTypePace call was able to mutate userA's pace row")
|
||
}
|
||
}
|
||
|
||
// TestIsolation_SyncStateAndRunsNeverLeakAcrossUsers confirms each user's
|
||
// backfill watermark and sync run history are independent.
|
||
func TestIsolation_SyncStateAndRunsNeverLeakAcrossUsers(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.UpdateSyncState(ctx, userA, "2020-01-01", true); err != nil {
|
||
t.Fatalf("UpdateSyncState(a): %v", err)
|
||
}
|
||
stateB, err := db.GetSyncState(ctx, userB)
|
||
if err != nil {
|
||
t.Fatalf("GetSyncState(b): %v", err)
|
||
}
|
||
if stateB.BackfillComplete || stateB.EarliestSyncedDate != nil {
|
||
t.Fatalf("userA's UpdateSyncState leaked into userB's sync_state: %+v", stateB)
|
||
}
|
||
|
||
runID, err := db.StartSyncRun(ctx, userA, SyncKindBackfill)
|
||
if err != nil {
|
||
t.Fatalf("StartSyncRun(a): %v", err)
|
||
}
|
||
runsB, err := db.ListSyncRuns(ctx, userB, 10)
|
||
if err != nil {
|
||
t.Fatalf("ListSyncRuns(b): %v", err)
|
||
}
|
||
if len(runsB) != 0 {
|
||
t.Fatalf("expected userB to have 0 sync runs, got %d (userA's run id=%d)", len(runsB), runID)
|
||
}
|
||
|
||
// Finishing A's run "as B" must not succeed against A's row.
|
||
if err := db.FinishSyncRun(ctx, userB, runID, 5, nil); err != nil {
|
||
t.Fatalf("FinishSyncRun(as b): %v", err)
|
||
}
|
||
latestA, found, err := db.LatestSyncRun(ctx, userA)
|
||
if err != nil || !found {
|
||
t.Fatalf("LatestSyncRun(a): found=%v err=%v", found, err)
|
||
}
|
||
if latestA.Status == SyncStatusSuccess {
|
||
t.Fatal("userB's FinishSyncRun call was able to mutate userA's sync run")
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Run the new isolation tests**
|
||
|
||
Run: `cd backend && go test ./internal/store/... -run TestIsolation -v`
|
||
Expected: PASS. If any test fails, it points at a real cross-user data leak in a Task 4-8 query — fix the query (it's missing a `WHERE user_id = ?` or the join condition is wrong), not the test.
|
||
|
||
- [ ] **Step 3: Run the full store suite one more time**
|
||
|
||
Run: `cd backend && gofmt -l internal/store/ && go vet ./internal/store/... && go test ./internal/store/... -v`
|
||
Expected: `gofmt` prints nothing; all PASS.
|
||
|
||
- [ ] **Step 4: Commit**
|
||
|
||
```bash
|
||
git add backend/internal/store/isolation_test.go
|
||
git commit -m "$(cat <<'EOF'
|
||
store: add cross-user isolation tests
|
||
|
||
Dedicated adversarial coverage for the core security property: no store
|
||
method can read or mutate another user's profile, workout kinds/paces, or
|
||
sync state/runs, even when handed that other user's real row id.
|
||
EOF
|
||
)"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 10: `internal/sync.Service` becomes per-user
|
||
|
||
**Files:**
|
||
- Modify: `backend/internal/sync/service.go`
|
||
- Modify: `backend/internal/sync/service_test.go`
|
||
|
||
**Interfaces:**
|
||
- 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.
|
||
|
||
- [ ] **Step 1: Add the `userID` field and thread it through every store call in `backend/internal/sync/service.go`**
|
||
|
||
Change the `Service` struct and `NewService`:
|
||
|
||
```go
|
||
// Service is the sync orchestrator, scoped to one user -- every store call
|
||
// it makes is for userID's data only.
|
||
type Service struct {
|
||
garmin garmin.Client
|
||
db *store.DB
|
||
userID int64
|
||
cfg Config
|
||
now func() time.Time
|
||
|
||
progressMu sync.Mutex
|
||
progress Progress
|
||
}
|
||
|
||
// NewService builds a Service scoped to userID. now defaults to time.Now if
|
||
// 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 {
|
||
if now == nil {
|
||
now = time.Now
|
||
}
|
||
return &Service{garmin: g, db: db, userID: userID, cfg: cfg.withDefaults(), now: now}
|
||
}
|
||
```
|
||
|
||
Then, in every remaining method, prefix `s.userID` as the new argument to every `s.db.*` call. The full list of call sites to update (unchanged method signatures, only their bodies change):
|
||
|
||
- `backfillCore`: `s.db.GetProfile(ctx)` → `s.db.GetProfile(ctx, s.userID)`; `s.db.GetSyncState(ctx)` → `s.db.GetSyncState(ctx, s.userID)`; both `s.db.UpdateSyncState(ctx, dateStr(...), ...)` calls → `s.db.UpdateSyncState(ctx, s.userID, dateStr(...), ...)`.
|
||
- `Backfill`/`IncrementalSync`/`FullSync`: every `s.db.StartSyncRun(ctx, store.SyncKind...)` → `s.db.StartSyncRun(ctx, s.userID, store.SyncKind...)`; every `s.db.FinishSyncRun(ctx, runID, ...)` → `s.db.FinishSyncRun(ctx, s.userID, runID, ...)`.
|
||
- `incrementalSyncCore`: `s.db.LatestActivityStartTime(ctx)` → `s.db.LatestActivityStartTime(ctx, s.userID)`.
|
||
- `ResetAll`: `s.db.ResetAllSyncedData(ctx)` → `s.db.ResetAllSyncedData(ctx, s.userID)`.
|
||
- `fetchAndStoreWindow`: `s.db.ActivityExists(ctx, a.ActivityID)` → `s.db.ActivityExists(ctx, s.userID, a.ActivityID)`; `s.db.UpsertActivity(ctx, toActivityRow(a))` → `s.db.UpsertActivity(ctx, s.userID, toActivityRow(a))`.
|
||
- `FillPendingDetails`: `s.db.ActivitiesMissingDetails(ctx, limit)` → `s.db.ActivitiesMissingDetails(ctx, s.userID, limit)`; `s.db.GetProfile(ctx)` → `s.db.GetProfile(ctx, s.userID)`.
|
||
- `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{...})`.
|
||
|
||
- [ ] **Step 2: Fix `backend/internal/sync/service_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`):
|
||
|
||
```go
|
||
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
|
||
}
|
||
```
|
||
|
||
Then in each test, right after `db := openTestDB(t)`, add `userID := provisionTestUser(t, db)`, and change `NewService(m, db, Config{}, fixedNow(...))` → `NewService(m, db, userID, Config{}, fixedNow(...))` (and similarly wherever `setBackfillHorizon(t, db, days)` is called, since that helper calls `db.GetProfile`/`db.UpdateProfile` directly — update its signature to `setBackfillHorizon(t *testing.T, db *store.DB, userID int64, days int)` and thread `userID` into its two calls, then update every call site to pass the test's `userID`).
|
||
|
||
- [ ] **Step 3: Build and fix remaining call sites**
|
||
|
||
Run: `cd backend && go build ./internal/sync/... && go vet ./internal/sync/...`
|
||
Expected: fix any remaining compile errors in `internal/sync/service_test.go` the same way (there may be more `NewService`/`setBackfillHorizon` calls further down the file than the ones shown above — use the compiler to find every one).
|
||
|
||
- [ ] **Step 4: Run the sync package tests**
|
||
|
||
Run: `cd backend && go test ./internal/sync/... -v`
|
||
Expected: PASS.
|
||
|
||
- [ ] **Step 5: Add a cross-user sync isolation test**
|
||
|
||
```go
|
||
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)
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 6: Run it**
|
||
|
||
Run: `cd backend && go test ./internal/sync/... -run TestService_TwoUsersSyncIndependently -v`
|
||
Expected: PASS.
|
||
|
||
- [ ] **Step 7: `gofmt` and commit**
|
||
|
||
Run: `cd backend && gofmt -l internal/sync/`
|
||
Expected: no output.
|
||
|
||
```bash
|
||
git add backend/internal/sync/service.go backend/internal/sync/service_test.go
|
||
git commit -m "$(cat <<'EOF'
|
||
sync: scope Service to one user per instance
|
||
|
||
NewService now takes a userID, baked into the instance rather than passed
|
||
per-call -- matches internal/api's one-Service-per-logged-in-user model
|
||
(Task 13), so ClassifyActivity/Backfill/etc. keep their existing call
|
||
signatures unchanged everywhere they're already used.
|
||
EOF
|
||
)"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 11: `internal/config` additions
|
||
|
||
**Files:**
|
||
- Modify: `backend/internal/config/config.go`
|
||
|
||
**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.
|
||
|
||
- [ ] **Step 1: Rename `GarminTokenStore` → `GarminTokenStoreRoot` and add `LegacyOwnerOIDCSub`**
|
||
|
||
In the `Config` struct, change:
|
||
|
||
```go
|
||
// GarminTokenStoreRoot, if set, is the root directory under which each
|
||
// user's mcp-garmin session cache lives (one subdirectory per user id,
|
||
// e.g. "<root>/3"), overriding mcp-garmin's default ~/.garth. Read from
|
||
// the same GARMIN_TOKENSTORE env var as before Task 1's per-user
|
||
// scoping -- only its meaning changed (a root directory rather than a
|
||
// single path).
|
||
GarminTokenStoreRoot string
|
||
```
|
||
|
||
And in `Load()`, change the field name in the struct literal:
|
||
|
||
```go
|
||
GarminTokenStoreRoot: os.Getenv("GARMIN_TOKENSTORE"),
|
||
```
|
||
|
||
Add, in the `Config` struct near the OIDC fields:
|
||
|
||
```go
|
||
// LegacyOwnerOIDCSub, if set, is used exactly once at startup (via
|
||
// store.ClaimLegacyOwner) to bind this deployment's pre-existing
|
||
// single-tenant data to one named OIDC subject after upgrading to
|
||
// per-user profiles. Safe to leave set indefinitely -- ClaimLegacyOwner
|
||
// no-ops once any user already exists.
|
||
LegacyOwnerOIDCSub string
|
||
```
|
||
|
||
And in `Load()`'s struct literal:
|
||
|
||
```go
|
||
LegacyOwnerOIDCSub: os.Getenv("GENIUSRUN_LEGACY_OWNER_OIDC_SUB"),
|
||
```
|
||
|
||
This field is optional (no required-var check added) — a fresh install or an already-claimed deployment simply never uses it.
|
||
|
||
- [ ] **Step 2: Build**
|
||
|
||
Run: `cd backend && go build ./internal/config/...`
|
||
Expected: compiles clean (nothing else references `cfg.GarminTokenStore` yet inside this package). Other packages referencing the old field name (`cmd/geniusrund/main.go`) are fixed in Task 13.
|
||
|
||
- [ ] **Step 3: `gofmt` and commit**
|
||
|
||
Run: `cd backend && gofmt -l internal/config/`
|
||
Expected: no output.
|
||
|
||
```bash
|
||
git add backend/internal/config/config.go
|
||
git commit -m "$(cat <<'EOF'
|
||
config: add per-user Garmin token store root and legacy-owner bootstrap var
|
||
|
||
GarminTokenStore is renamed GarminTokenStoreRoot to reflect that it now
|
||
roots one subdirectory per user rather than a single session cache path.
|
||
EOF
|
||
)"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 12: User-resolution middleware + `POST /api/setup`
|
||
|
||
**Files:**
|
||
- Create: `backend/internal/api/usercontext.go`
|
||
- Create: `backend/internal/api/setup.go`
|
||
- Modify: `backend/internal/api/session.go` (extend `sessionMeResponse`, `handleSessionMe`)
|
||
- Create: `backend/internal/api/usercontext_test.go`
|
||
- Create: `backend/internal/api/setup_test.go`
|
||
|
||
**Interfaces:**
|
||
- 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`.
|
||
|
||
- [ ] **Step 1: Write the failing tests in `backend/internal/api/usercontext_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.
|
||
|
||
```go
|
||
package api
|
||
|
||
import (
|
||
"context"
|
||
"net/http"
|
||
"net/http/httptest"
|
||
"testing"
|
||
|
||
"geniusrun/backend/internal/auth"
|
||
)
|
||
|
||
func TestResolveUser_AttachesResolvedUserWhenProvisioned(t *testing.T) {
|
||
s, db := newTestServer(t)
|
||
userID, err := db.ProvisionUser(context.Background(), "test-user", "Test User")
|
||
if err != nil {
|
||
t.Fatalf("ProvisionUser: %v", err)
|
||
}
|
||
|
||
var gotUserID int64
|
||
var gotOK bool
|
||
handler := auth.RequireSession(testSessionConfig.Secret)(s.resolveUser(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
u, ok := userFromContext(r.Context())
|
||
gotUserID, gotOK = u.ID, ok
|
||
})))
|
||
|
||
rec := doJSON(t, handler, http.MethodGet, "/", nil) // doJSON's test cookie carries Sub: "test-user"
|
||
_ = rec
|
||
if !gotOK || gotUserID != userID {
|
||
t.Fatalf("resolveUser: ok=%v userID=%d, want ok=true userID=%d", gotOK, gotUserID, userID)
|
||
}
|
||
}
|
||
|
||
func TestResolveUser_LeavesContextEmptyWhenNotProvisioned(t *testing.T) {
|
||
s, _ := newTestServer(t)
|
||
|
||
var gotOK bool
|
||
handler := auth.RequireSession(testSessionConfig.Secret)(s.resolveUser(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
_, gotOK = userFromContext(r.Context())
|
||
})))
|
||
|
||
doJSON(t, handler, http.MethodGet, "/", nil) // "test-user" sub, never provisioned in this test
|
||
if gotOK {
|
||
t.Fatal("expected userFromContext to report not-found for an unprovisioned sub")
|
||
}
|
||
}
|
||
|
||
func TestRequireProvisionedUser_RejectsWhenNoUserResolved(t *testing.T) {
|
||
handler := requireProvisionedUser(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
t.Fatal("handler should not be reached")
|
||
}))
|
||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||
rec := httptest.NewRecorder()
|
||
handler.ServeHTTP(rec, req)
|
||
if rec.Code != http.StatusForbidden {
|
||
t.Fatalf("status = %d, want 403", rec.Code)
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Run to verify these fail to compile (expected)**
|
||
|
||
Run: `cd backend && go vet ./internal/api/...`
|
||
Expected: FAIL — `s.resolveUser`/`userFromContext`/`requireProvisionedUser` undefined.
|
||
|
||
- [ ] **Step 3: Write `backend/internal/api/usercontext.go`**
|
||
|
||
```go
|
||
package api
|
||
|
||
import (
|
||
"context"
|
||
"net/http"
|
||
|
||
"geniusrun/backend/internal/auth"
|
||
)
|
||
|
||
type userContextKey int
|
||
|
||
const resolvedUserContextKey userContextKey = iota
|
||
|
||
// resolvedUser is the geniusrun account (if any) bound to the current
|
||
// session's OIDC subject.
|
||
type resolvedUser struct {
|
||
ID int64
|
||
DisplayName string
|
||
}
|
||
|
||
// resolveUser runs after auth.RequireSession on every request and looks up
|
||
// whether the session's OIDC subject has a provisioned geniusrun user. It
|
||
// never blocks the request itself -- it only attaches the result (found or
|
||
// not) to context -- since a couple of routes (session/me, setup) must stay
|
||
// reachable for an authorized-but-not-yet-provisioned session. Routes that
|
||
// require a provisioned user are wrapped in requireProvisionedUser as well.
|
||
func (s *Server) resolveUser(next http.Handler) http.Handler {
|
||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
claims, ok := auth.ClaimsFromContext(r.Context())
|
||
if !ok {
|
||
// Unreachable in practice -- RequireSession already 401s before
|
||
// this middleware runs -- but fail closed rather than panic.
|
||
next.ServeHTTP(w, r)
|
||
return
|
||
}
|
||
u, found, err := s.DB.GetUserBySub(r.Context(), claims.Sub)
|
||
if err != nil {
|
||
writeError(w, http.StatusInternalServerError, err.Error())
|
||
return
|
||
}
|
||
ctx := r.Context()
|
||
if found {
|
||
ctx = context.WithValue(ctx, resolvedUserContextKey, resolvedUser{ID: u.ID, DisplayName: u.DisplayName})
|
||
}
|
||
next.ServeHTTP(w, r.WithContext(ctx))
|
||
})
|
||
}
|
||
|
||
// requireProvisionedUser wraps routes that operate on a user's data: it
|
||
// 403s if the session's OIDC identity has no provisioned geniusrun user yet
|
||
// (resolveUser must run earlier in the chain). This -- not any
|
||
// client-supplied id -- is the only source of truth for "which user's data"
|
||
// a request may touch.
|
||
func requireProvisionedUser(next http.Handler) http.Handler {
|
||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
if _, ok := userFromContext(r.Context()); !ok {
|
||
writeError(w, http.StatusForbidden, "no profile provisioned for this account yet")
|
||
return
|
||
}
|
||
next.ServeHTTP(w, r)
|
||
})
|
||
}
|
||
|
||
// userFromContext returns the resolved user for the current session, as
|
||
// populated by resolveUser.
|
||
func userFromContext(ctx context.Context) (resolvedUser, bool) {
|
||
u, ok := ctx.Value(resolvedUserContextKey).(resolvedUser)
|
||
return u, ok
|
||
}
|
||
|
||
// userIDFromContext is a convenience for the overwhelming majority of
|
||
// handlers, which only need the id. Panics if called somewhere
|
||
// requireProvisionedUser didn't already guarantee a resolved user -- that
|
||
// would be a routing bug, not a runtime condition to handle gracefully.
|
||
func userIDFromContext(ctx context.Context) int64 {
|
||
u, ok := userFromContext(ctx)
|
||
if !ok {
|
||
panic("api: userIDFromContext called without requireProvisionedUser in the middleware chain")
|
||
}
|
||
return u.ID
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Run the usercontext tests**
|
||
|
||
Run: `cd backend && go test ./internal/api/... -run 'TestResolveUser|TestRequireProvisionedUser' -v`
|
||
Expected: PASS.
|
||
|
||
- [ ] **Step 5: Extend `sessionMeResponse`/`handleSessionMe` in `backend/internal/api/session.go`**
|
||
|
||
```go
|
||
type sessionMeResponse struct {
|
||
Name string `json:"name"`
|
||
Email string `json:"email"`
|
||
HasProfile bool `json:"has_profile"`
|
||
DisplayName string `json:"display_name,omitempty"`
|
||
}
|
||
```
|
||
|
||
```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
|
||
}
|
||
writeJSON(w, http.StatusOK, resp)
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 6: Write the failing test for setup, then `backend/internal/api/setup.go`**
|
||
|
||
`backend/internal/api/setup_test.go`:
|
||
|
||
```go
|
||
package api
|
||
|
||
import (
|
||
"net/http"
|
||
"testing"
|
||
)
|
||
|
||
func TestSetup_ProvisionsNewUserWithDisplayName(t *testing.T) {
|
||
s, db := newTestServer(t)
|
||
router := s.Router()
|
||
|
||
rec := doJSON(t, router, http.MethodGet, "/api/session/me", nil)
|
||
var me sessionMeResponse
|
||
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 {
|
||
t.Fatalf("setup status = %d, body = %s", rec.Code, rec.Body.String())
|
||
}
|
||
|
||
rec = doJSON(t, router, http.MethodGet, "/api/session/me", nil)
|
||
unmarshalBody(t, rec, &me)
|
||
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) {
|
||
s, _ := newTestServer(t)
|
||
rec := doJSON(t, s.Router(), http.MethodPost, "/api/setup", map[string]any{"display_name": ""})
|
||
if rec.Code != http.StatusBadRequest {
|
||
t.Fatalf("status = %d, want 400", rec.Code)
|
||
}
|
||
}
|
||
|
||
func TestSetup_RejectsWhenAlreadyProvisioned(t *testing.T) {
|
||
s, db := newTestServer(t)
|
||
if _, err := db.ProvisionUser(newCtx(), "test-user", "Already Here"); err != nil {
|
||
t.Fatalf("ProvisionUser: %v", err)
|
||
}
|
||
rec := doJSON(t, s.Router(), http.MethodPost, "/api/setup", 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())
|
||
}
|
||
}
|
||
|
||
// unmarshalBody is a small shared helper -- add it once here; later handler
|
||
// test tasks may reuse it instead of repeating json.Unmarshal(rec.Body.Bytes(), ...) inline.
|
||
func unmarshalBody(t *testing.T, rec interface{ Result() *http.Response }, v any) {
|
||
t.Helper()
|
||
_ = rec // placeholder signature note removed below; see actual implementation
|
||
}
|
||
```
|
||
|
||
Replace that last placeholder helper with a real, correctly-typed one (it takes `*httptest.ResponseRecorder`, not an interface — the interface above was illustrative only, not real Go):
|
||
|
||
```go
|
||
func unmarshalBody(t *testing.T, rec *httptest.ResponseRecorder, v any) {
|
||
t.Helper()
|
||
if err := json.Unmarshal(rec.Body.Bytes(), v); err != nil {
|
||
t.Fatalf("unmarshal response body %q: %v", rec.Body.String(), err)
|
||
}
|
||
}
|
||
```
|
||
|
||
(Add `"encoding/json"` and `"net/http/httptest"` to this file's imports.)
|
||
|
||
Now write `backend/internal/api/setup.go`:
|
||
|
||
```go
|
||
package api
|
||
|
||
import (
|
||
"encoding/json"
|
||
"net/http"
|
||
|
||
"geniusrun/backend/internal/auth"
|
||
)
|
||
|
||
func (s *Server) handleSetup(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 {
|
||
DisplayName string `json:"display_name"`
|
||
}
|
||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||
writeError(w, http.StatusBadRequest, "invalid request body")
|
||
return
|
||
}
|
||
if body.DisplayName == "" {
|
||
writeError(w, http.StatusBadRequest, "display_name is required")
|
||
return
|
||
}
|
||
|
||
userID, err := s.DB.ProvisionUser(r.Context(), claims.Sub, body.DisplayName)
|
||
if err != nil {
|
||
writeError(w, http.StatusInternalServerError, err.Error())
|
||
return
|
||
}
|
||
writeJSON(w, http.StatusOK, map[string]any{"user_id": userID, "display_name": body.DisplayName})
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 7: Wire `/api/setup` into the router (temporary, minimal — Task 13 does the full router rewrite)**
|
||
|
||
In `backend/internal/api/server.go`'s `Router()`, inside the existing `r.Group` that has `auth.RequireSession`, add right after the `r.Get("/session/me", ...)`/`r.Post("/session/logout", ...)` lines:
|
||
|
||
```go
|
||
r.Use(s.resolveUser)
|
||
r.Post("/setup", s.handleSetup)
|
||
```
|
||
|
||
(`r.Use(s.resolveUser)` must come before any route registration in that group to apply to all of them, including the pre-existing profile/auth/sync/etc. routes below it — this is intentionally a minimal, functional wiring; Task 13 restructures this same group into the final two-tier shape with `requireProvisionedUser` around the data routes specifically.)
|
||
|
||
- [ ] **Step 8: Run the full `internal/api` test suite**
|
||
|
||
Run: `cd backend && go build ./internal/api/... && go test ./internal/api/... -v`
|
||
Expected: PASS for the new tests; other tests may now fail if they hit `userIDFromContext` panics somewhere — that's expected and addressed by Task 13's full router restructure plus Tasks 14/15's handler updates. If `go build` fails elsewhere (e.g. `cmd/geniusrund`), that's expected too (Task 13).
|
||
|
||
- [ ] **Step 9: `gofmt` and commit**
|
||
|
||
Run: `cd backend && gofmt -l internal/api/`
|
||
Expected: no output.
|
||
|
||
```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 commit -m "$(cat <<'EOF'
|
||
api: add user-resolution middleware and POST /api/setup
|
||
|
||
resolveUser attaches the session's provisioned geniusrun user (if any) to
|
||
request context without blocking; requireProvisionedUser (wired fully in
|
||
Task 13) 403s routes that need one. GET /api/session/me now reports
|
||
has_profile/display_name so the frontend can show the setup screen.
|
||
EOF
|
||
)"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 13: Per-user `garmin.Client`/`sync.Service` wiring in `api.Server`
|
||
|
||
**Files:**
|
||
- Modify: `backend/internal/api/server.go`
|
||
- Modify: `backend/cmd/geniusrund/main.go`
|
||
- Modify: `backend/internal/api/api_test.go` (`newTestServer` helper)
|
||
|
||
**Interfaces:**
|
||
- Consumes: `sync.NewService(g, db, userID, cfg, now)` (Task 10), `garmin.NewClient(cfg)` (unchanged), `requireProvisionedUser`/`userIDFromContext` (Task 12).
|
||
- Produces: `func NewServer(db *store.DB, garminFactory func(garmin.Config) garmin.Client, garminBase garmin.Config, syncConfig appsync.Config, authVerifier auth.Verifier, session SessionConfig) *Server`; `func (s *Server) garminFor(ctx context.Context, userID int64) (garmin.Client, error)`; `func (s *Server) syncFor(ctx context.Context, userID int64) (*appsync.Service, error)`; `func (s *Server) backgroundSync(userID int64, fn func(ctx context.Context) error) bool` (replaces the old unscoped version); `func (s *Server) RunIncrementalSyncForAllUsers(ctx context.Context)`. Tasks 14/15 call `s.garminFor`/`s.syncFor`/`s.backgroundSync(userID, ...)` from handlers instead of touching fixed `s.Garmin`/`s.Sync` fields (which no longer exist).
|
||
|
||
- [ ] **Step 1: Rewrite the `Server` struct, `NewServer`, and add the per-user accessors in `backend/internal/api/server.go`**
|
||
|
||
```go
|
||
// Package api is geniusrun's HTTP layer: REST handlers over internal/store,
|
||
// internal/garmin, and internal/sync.
|
||
package api
|
||
|
||
import (
|
||
"context"
|
||
"encoding/json"
|
||
"fmt"
|
||
"log"
|
||
"net/http"
|
||
"path/filepath"
|
||
"strconv"
|
||
"sync"
|
||
|
||
"github.com/go-chi/chi/v5"
|
||
|
||
"geniusrun/backend/internal/auth"
|
||
"geniusrun/backend/internal/garmin"
|
||
"geniusrun/backend/internal/store"
|
||
appsync "geniusrun/backend/internal/sync"
|
||
)
|
||
|
||
// Server wires the HTTP handlers to the app's dependencies. garmin.Client
|
||
// and sync.Service are per-user (each user might have their own Garmin
|
||
// account), built lazily on first use via GarminFactory and cached.
|
||
type Server struct {
|
||
DB *store.DB
|
||
Auth auth.Verifier
|
||
Session SessionConfig
|
||
|
||
// GarminFactory builds a real (or fake, in tests) garmin.Client from a
|
||
// fully-resolved per-user Config. Production wiring passes
|
||
// garmin.NewClient; tests inject a factory returning a shared
|
||
// *mock.Client (see newTestServer in api_test.go).
|
||
GarminFactory func(garmin.Config) garmin.Client
|
||
// GarminBase holds the plumbing shared by every user's garmin.Config
|
||
// (subprocess paths + the token-store root directory); only
|
||
// GarminEmail/GarminPassword/TokenStorePath vary per user, filled in by
|
||
// garminFor.
|
||
GarminBase garmin.Config
|
||
SyncConfig appsync.Config
|
||
|
||
mu sync.Mutex
|
||
userGarmin map[int64]garmin.Client
|
||
userSync map[int64]*appsync.Service
|
||
userAuthStatus map[int64]garmin.AuthStatus
|
||
userAuthMessage map[int64]string
|
||
userSyncRunning map[int64]bool
|
||
}
|
||
|
||
// 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 {
|
||
return &Server{
|
||
DB: db, GarminFactory: garminFactory, GarminBase: garminBase, SyncConfig: syncConfig,
|
||
Auth: authVerifier, Session: session,
|
||
userGarmin: map[int64]garmin.Client{},
|
||
userSync: map[int64]*appsync.Service{},
|
||
userAuthStatus: map[int64]garmin.AuthStatus{},
|
||
userAuthMessage: map[int64]string{},
|
||
userSyncRunning: map[int64]bool{},
|
||
}
|
||
}
|
||
|
||
// 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
|
||
}
|
||
|
||
// 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.
|
||
func (s *Server) Router() http.Handler {
|
||
r := chi.NewRouter()
|
||
r.Use(corsMiddleware)
|
||
r.Route("/api", func(r chi.Router) {
|
||
r.Get("/health", s.handleHealth)
|
||
|
||
// Unprotected: these two ARE the login flow, so they can't require
|
||
// a session yet.
|
||
r.Get("/session/login", s.handleSessionLogin)
|
||
r.Get("/session/callback", s.handleSessionCallback)
|
||
|
||
r.Group(func(r chi.Router) {
|
||
r.Use(auth.RequireSession(s.Session.Secret))
|
||
r.Use(s.resolveUser)
|
||
|
||
r.Get("/session/me", s.handleSessionMe)
|
||
r.Post("/session/logout", s.handleSessionLogout)
|
||
r.Post("/setup", s.handleSetup)
|
||
|
||
r.Group(func(r chi.Router) {
|
||
r.Use(requireProvisionedUser)
|
||
|
||
r.Route("/profile", func(r chi.Router) {
|
||
r.Get("/", s.handleGetProfile)
|
||
r.Put("/", s.handleUpdateProfile)
|
||
})
|
||
|
||
r.Route("/auth", func(r chi.Router) {
|
||
r.Post("/login", s.handleAuthLogin)
|
||
r.Post("/mfa", s.handleAuthMFA)
|
||
r.Get("/status", s.handleAuthStatus)
|
||
})
|
||
|
||
r.Route("/sync", func(r chi.Router) {
|
||
r.Post("/run", s.handleSyncRun)
|
||
r.Post("/reset", s.handleSyncReset)
|
||
r.Get("/runs", s.handleSyncRuns)
|
||
r.Get("/status", s.handleSyncStatus)
|
||
})
|
||
|
||
r.Route("/activities", func(r chi.Router) {
|
||
r.Get("/", s.handleListActivities)
|
||
r.Get("/{id}", s.handleGetActivity)
|
||
})
|
||
|
||
r.Route("/workout-kinds", func(r chi.Router) {
|
||
r.Get("/", s.handleListWorkoutKinds)
|
||
r.Get("/{id}", s.handleGetWorkoutKind)
|
||
r.Put("/{id}", s.handleUpdateWorkoutKind)
|
||
})
|
||
|
||
r.Post("/reclassify", s.handleReclassifyAll)
|
||
|
||
r.Route("/review-queue", func(r chi.Router) {
|
||
r.Get("/", s.handleReviewQueue)
|
||
r.Post("/{activityID}/resolve", s.handleResolveReview)
|
||
r.Post("/{activityID}/unlock", s.handleUnlockReview)
|
||
r.Post("/{activityID}/unassign", s.handleUnassignReview)
|
||
})
|
||
|
||
r.Get("/progression/{kindID}", s.handleProgression)
|
||
})
|
||
})
|
||
})
|
||
return r
|
||
}
|
||
|
||
// corsMiddleware allows the frontend dev server (a different port) to call
|
||
// this API. Reflecting any origin back is safe even with credentials
|
||
// enabled: this remains a single-operator app whose real access control is
|
||
// the OIDC login gate (internal/auth), not origin-based CSRF defense.
|
||
func corsMiddleware(next http.Handler) http.Handler {
|
||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
if origin := r.Header.Get("Origin"); origin != "" {
|
||
w.Header().Set("Access-Control-Allow-Origin", origin)
|
||
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
|
||
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
|
||
w.Header().Set("Access-Control-Allow-Credentials", "true")
|
||
}
|
||
if r.Method == http.MethodOptions {
|
||
w.WriteHeader(http.StatusNoContent)
|
||
return
|
||
}
|
||
next.ServeHTTP(w, r)
|
||
})
|
||
}
|
||
|
||
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
|
||
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
||
}
|
||
|
||
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 {
|
||
log.Printf("api: encode response: %v", err)
|
||
}
|
||
}
|
||
|
||
func writeError(w http.ResponseWriter, status int, msg string) {
|
||
writeJSON(w, status, map[string]string{"error": msg})
|
||
}
|
||
|
||
// backgroundSync runs fn in a goroutine with a fresh context, guarded so
|
||
// only one sync operation per userID runs at a time. Returns false if one
|
||
// is already in progress for that user.
|
||
func (s *Server) backgroundSync(userID int64, fn func(ctx context.Context) error) bool {
|
||
s.mu.Lock()
|
||
if s.userSyncRunning[userID] {
|
||
s.mu.Unlock()
|
||
return false
|
||
}
|
||
s.userSyncRunning[userID] = true
|
||
s.mu.Unlock()
|
||
|
||
go func() {
|
||
defer func() {
|
||
s.mu.Lock()
|
||
s.userSyncRunning[userID] = false
|
||
s.mu.Unlock()
|
||
}()
|
||
if err := fn(context.Background()); err != nil {
|
||
log.Printf("api: background sync error (user %d): %v", userID, err)
|
||
}
|
||
}()
|
||
return true
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Update `backend/cmd/geniusrund/main.go`**
|
||
|
||
```go
|
||
// Command geniusrund is geniusrun's backend server: syncs runs from Garmin,
|
||
// classifies them into workout kinds, and serves the REST API the frontend
|
||
// talks to.
|
||
package main
|
||
|
||
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"
|
||
)
|
||
|
||
func main() {
|
||
cfg, err := config.Load()
|
||
if err != nil {
|
||
log.Fatalf("config: %v", err)
|
||
}
|
||
|
||
db, err := store.Open(cfg.DBPath)
|
||
if err != nil {
|
||
log.Fatalf("open database: %v", err)
|
||
}
|
||
defer db.Close()
|
||
|
||
if cfg.LegacyOwnerOIDCSub != "" {
|
||
if err := db.ClaimLegacyOwner(context.Background(), cfg.LegacyOwnerOIDCSub); err != nil {
|
||
log.Fatalf("claim legacy owner: %v", err)
|
||
}
|
||
}
|
||
|
||
authVerifier, err := auth.NewOIDCVerifier(context.Background(), auth.OIDCConfig{
|
||
IssuerURL: cfg.OIDCIssuerURL,
|
||
ClientID: cfg.OIDCClientID,
|
||
ClientSecret: cfg.OIDCClientSecret,
|
||
RedirectURL: cfg.OIDCRedirectURL,
|
||
RequiredRole: cfg.OIDCRequiredRole,
|
||
})
|
||
if err != nil {
|
||
log.Fatalf("oidc: %v", err)
|
||
}
|
||
|
||
server := api.NewServer(db, garmin.NewClient, garmin.Config{
|
||
PythonPath: cfg.GarminPythonPath,
|
||
ServerPath: cfg.GarminServerPath,
|
||
TokenStorePath: cfg.GarminTokenStoreRoot,
|
||
}, appsync.Config{
|
||
MinConfidence: cfg.MinConfidence,
|
||
}, authVerifier, api.SessionConfig{
|
||
Secret: cfg.SessionSecret,
|
||
Duration: cfg.SessionDuration,
|
||
Secure: cfg.SessionSecure,
|
||
PublicBaseURL: cfg.PublicBaseURL,
|
||
})
|
||
|
||
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
||
defer stop()
|
||
|
||
go runIncrementalSyncLoop(ctx, server, cfg.IncrementalSyncEvery)
|
||
|
||
httpServer := &http.Server{Addr: cfg.Addr, Handler: server.Router()}
|
||
go func() {
|
||
log.Printf("geniusrund listening on %s", cfg.Addr)
|
||
if err := httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||
log.Fatalf("http server: %v", err)
|
||
}
|
||
}()
|
||
|
||
<-ctx.Done()
|
||
log.Println("shutting down...")
|
||
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||
defer cancel()
|
||
if err := httpServer.Shutdown(shutdownCtx); err != nil {
|
||
log.Printf("http server shutdown: %v", 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)
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3: Update `newTestServer` in `backend/internal/api/api_test.go`**
|
||
|
||
```go
|
||
func newTestServer(t *testing.T) (*Server, *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() })
|
||
|
||
m := &mock.Client{}
|
||
garminFactory := func(garmin.Config) garmin.Client { return m }
|
||
s := NewServer(db, garminFactory, garmin.Config{}, appsync.Config{}, &authmock.Verifier{}, testSessionConfig)
|
||
return s, db
|
||
}
|
||
```
|
||
|
||
(Add `"geniusrun/backend/internal/garmin"` to this file's imports if not already present — it likely isn't, since the old test only imported `garmin/mock`.)
|
||
|
||
Every existing test in `api_test.go` that constructs a server via `newTestServer` and then does its own OIDC-authenticated request now needs a provisioned user, since Task 13's router puts every data route behind `requireProvisionedUser`. Since `doJSON`'s test cookie always carries `Sub: "test-user"`, add one line near the top of `newTestServer` (after opening `db`, before returning) that provisions that exact user, so every existing test keeps working with zero further changes to individual test bodies:
|
||
|
||
```go
|
||
func newTestServer(t *testing.T) (*Server, *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() })
|
||
|
||
if _, err := db.ProvisionUser(context.Background(), "test-user", "Test User"); err != nil {
|
||
t.Fatalf("ProvisionUser: %v", err)
|
||
}
|
||
|
||
m := &mock.Client{}
|
||
garminFactory := func(garmin.Config) garmin.Client { return m }
|
||
s := NewServer(db, garminFactory, garmin.Config{}, appsync.Config{}, &authmock.Verifier{}, testSessionConfig)
|
||
return s, db
|
||
}
|
||
```
|
||
|
||
This makes `newTestServer`'s pre-provisioned "test-user" the norm going forward — Task 12's `TestSetup_*` tests, which specifically need an *unprovisioned* session, already call `db.ProvisionUser`/expect no profile using their own `newTestServer(t)` call before setup runs, so double check those still make sense: `TestSetup_ProvisionsNewUserWithDisplayName` and `TestSetup_RejectsEmptyDisplayName` assumed a fresh, unprovisioned session — since `newTestServer` now *always* provisions `"test-user"`, those two tests need updating. Fix them now:
|
||
|
||
```go
|
||
func TestSetup_ProvisionsNewUserWithDisplayName(t *testing.T) {
|
||
// This test specifically needs an *unprovisioned* session, unlike every
|
||
// other test in this package -- build the server without the
|
||
// newTestServer helper's automatic ProvisionUser call.
|
||
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 := &mock.Client{}
|
||
s := NewServer(db, func(garmin.Config) garmin.Client { return m }, garmin.Config{}, appsync.Config{}, &authmock.Verifier{}, testSessionConfig)
|
||
router := s.Router()
|
||
|
||
rec := doJSON(t, router, http.MethodGet, "/api/session/me", nil)
|
||
var me sessionMeResponse
|
||
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 {
|
||
t.Fatalf("setup status = %d, body = %s", rec.Code, rec.Body.String())
|
||
}
|
||
|
||
rec = doJSON(t, router, http.MethodGet, "/api/session/me", nil)
|
||
unmarshalBody(t, rec, &me)
|
||
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")
|
||
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) {
|
||
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 := &mock.Client{}
|
||
s := NewServer(db, func(garmin.Config) garmin.Client { return m }, garmin.Config{}, appsync.Config{}, &authmock.Verifier{}, testSessionConfig)
|
||
|
||
rec := doJSON(t, s.Router(), http.MethodPost, "/api/setup", map[string]any{"display_name": ""})
|
||
if rec.Code != http.StatusBadRequest {
|
||
t.Fatalf("status = %d, want 400", rec.Code)
|
||
}
|
||
}
|
||
```
|
||
|
||
`TestSetup_RejectsWhenAlreadyProvisioned` (Task 12) already calls `db.ProvisionUser(newCtx(), "test-user", "Already Here")` explicitly on top of a `newTestServer(t)` call — since `newTestServer` now provisions `"test-user"` itself, that explicit call becomes redundant and will fail with a UNIQUE constraint error (`users.oidc_sub`). Remove that now-redundant `db.ProvisionUser` call from that test — `newTestServer` alone already provisions it.
|
||
|
||
- [ ] **Step 4: Build everything and fix remaining call sites**
|
||
|
||
Run: `cd backend && go build ./... 2>&1 | head -50`
|
||
Expected: remaining errors are all inside `internal/api/*.go` handler files (`profile.go`, `activities.go`, `sync.go`, `kinds.go`, `review.go`, `reclassify.go`, `progression.go`, `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.
|
||
|
||
- [ ] **Step 5: `gofmt` and commit**
|
||
|
||
Run: `cd backend && gofmt -l internal/api/ cmd/geniusrund/`
|
||
Expected: no output.
|
||
|
||
```bash
|
||
git add backend/internal/api/server.go backend/cmd/geniusrund/main.go backend/internal/api/api_test.go backend/internal/api/setup_test.go
|
||
git commit -m "$(cat <<'EOF'
|
||
api: build per-user garmin.Client/sync.Service via lazy caching
|
||
|
||
Server no longer holds one fixed Garmin/Sync pair -- garminFor/syncFor
|
||
build and cache one instance per user, keyed off their own profile's
|
||
Garmin credentials and a per-user token-store subdirectory. The background
|
||
incremental sync loop now iterates every provisioned user each tick
|
||
instead of syncing one global account.
|
||
EOF
|
||
)"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 14: Thread `userID` into `profile.go`, `kinds.go`, `auth.go`, `progression.go`
|
||
|
||
**Files:**
|
||
- Modify: `backend/internal/api/profile.go`
|
||
- Modify: `backend/internal/api/kinds.go`
|
||
- Modify: `backend/internal/api/auth.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)
|
||
|
||
**Interfaces:**
|
||
- Consumes: `userIDFromContext(ctx)` (Task 12), `s.garminFor(ctx, userID)` (Task 13), every scoped store method (Tasks 4-8).
|
||
|
||
- [ ] **Step 1: Update `backend/internal/api/profile.go`**
|
||
|
||
Keep `validateProfile` unchanged; replace the two handlers:
|
||
|
||
```go
|
||
func (s *Server) handleGetProfile(w http.ResponseWriter, r *http.Request) {
|
||
userID := userIDFromContext(r.Context())
|
||
p, err := s.DB.GetProfile(r.Context(), userID)
|
||
if err != nil {
|
||
writeError(w, http.StatusInternalServerError, err.Error())
|
||
return
|
||
}
|
||
writeJSON(w, http.StatusOK, p)
|
||
}
|
||
|
||
func (s *Server) handleUpdateProfile(w http.ResponseWriter, r *http.Request) {
|
||
userID := userIDFromContext(r.Context())
|
||
var p store.Profile
|
||
if err := json.NewDecoder(r.Body).Decode(&p); err != nil {
|
||
writeError(w, http.StatusBadRequest, "invalid request body")
|
||
return
|
||
}
|
||
if err := validateProfile(p); err != nil {
|
||
writeError(w, http.StatusBadRequest, err.Error())
|
||
return
|
||
}
|
||
|
||
if err := s.DB.UpdateProfile(r.Context(), userID, p); err != nil {
|
||
writeError(w, http.StatusInternalServerError, err.Error())
|
||
return
|
||
}
|
||
client, err := s.garminFor(r.Context(), userID)
|
||
if err != nil {
|
||
writeError(w, http.StatusInternalServerError, err.Error())
|
||
return
|
||
}
|
||
client.UpdateCredentials(p.GarminEmail, p.GarminPassword)
|
||
|
||
updated, err := s.DB.GetProfile(r.Context(), userID)
|
||
if err != nil {
|
||
writeError(w, http.StatusInternalServerError, err.Error())
|
||
return
|
||
}
|
||
writeJSON(w, http.StatusOK, updated)
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Update `backend/internal/api/kinds.go`**
|
||
|
||
Keep `workoutKindResponse`, `workoutKindRequest`, and `(req workoutKindRequest) validate()` unchanged; replace `toWorkoutKindResponse` and the three handlers:
|
||
|
||
```go
|
||
func (s *Server) toWorkoutKindResponse(r *http.Request, k store.WorkoutKind) (workoutKindResponse, error) {
|
||
userID := userIDFromContext(r.Context())
|
||
pace, err := s.DB.GetWorkoutTypePace(r.Context(), userID, k.ID)
|
||
if err != nil {
|
||
return workoutKindResponse{}, err
|
||
}
|
||
return workoutKindResponse{
|
||
WorkoutKind: k,
|
||
PaceMinSecPerKm: pace.PaceMinSecPerKm,
|
||
PaceMaxSecPerKm: pace.PaceMaxSecPerKm,
|
||
HRMinPctHRR: pace.HRMinPctHRR,
|
||
HRMaxPctHRR: pace.HRMaxPctHRR,
|
||
}, nil
|
||
}
|
||
|
||
func (s *Server) handleListWorkoutKinds(w http.ResponseWriter, r *http.Request) {
|
||
userID := userIDFromContext(r.Context())
|
||
activeOnly := r.URL.Query().Get("include_inactive") != "true"
|
||
kinds, err := s.DB.ListWorkoutKinds(r.Context(), userID, activeOnly)
|
||
if err != nil {
|
||
writeError(w, http.StatusInternalServerError, err.Error())
|
||
return
|
||
}
|
||
resp := make([]workoutKindResponse, 0, len(kinds))
|
||
for _, k := range kinds {
|
||
wr, err := s.toWorkoutKindResponse(r, k)
|
||
if err != nil {
|
||
writeError(w, http.StatusInternalServerError, err.Error())
|
||
return
|
||
}
|
||
resp = append(resp, wr)
|
||
}
|
||
writeJSON(w, http.StatusOK, resp)
|
||
}
|
||
|
||
func (s *Server) handleGetWorkoutKind(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 workout kind id")
|
||
return
|
||
}
|
||
kind, ok, err := s.DB.GetWorkoutKind(r.Context(), userID, id)
|
||
if err != nil {
|
||
writeError(w, http.StatusInternalServerError, err.Error())
|
||
return
|
||
}
|
||
if !ok {
|
||
writeError(w, http.StatusNotFound, "workout kind not found")
|
||
return
|
||
}
|
||
resp, err := s.toWorkoutKindResponse(r, kind)
|
||
if err != nil {
|
||
writeError(w, http.StatusInternalServerError, err.Error())
|
||
return
|
||
}
|
||
writeJSON(w, http.StatusOK, resp)
|
||
}
|
||
|
||
func (s *Server) handleUpdateWorkoutKind(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 workout kind id")
|
||
return
|
||
}
|
||
existing, ok, err := s.DB.GetWorkoutKind(r.Context(), userID, id)
|
||
if err != nil {
|
||
writeError(w, http.StatusInternalServerError, err.Error())
|
||
return
|
||
}
|
||
if !ok {
|
||
writeError(w, http.StatusNotFound, "workout kind not found")
|
||
return
|
||
}
|
||
|
||
var req workoutKindRequest
|
||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||
writeError(w, http.StatusBadRequest, "invalid request body")
|
||
return
|
||
}
|
||
if _, err := req.validate(); err != nil {
|
||
writeError(w, http.StatusBadRequest, err.Error())
|
||
return
|
||
}
|
||
|
||
isActive := existing.IsActive
|
||
if req.IsActive != nil {
|
||
isActive = *req.IsActive
|
||
}
|
||
|
||
if err := s.DB.UpdateWorkoutKind(r.Context(), userID, store.WorkoutKind{
|
||
ID: id, Name: req.Name, Description: req.Description, Color: req.Color,
|
||
RuleJSON: string(req.Rule), Priority: req.Priority, IsActive: isActive,
|
||
}); err != nil {
|
||
writeError(w, http.StatusInternalServerError, err.Error())
|
||
return
|
||
}
|
||
if err := s.DB.UpdateWorkoutTypePace(r.Context(), userID, store.WorkoutTypePace{
|
||
WorkoutKindID: id,
|
||
PaceMinSecPerKm: req.PaceMinSecPerKm,
|
||
PaceMaxSecPerKm: req.PaceMaxSecPerKm,
|
||
HRMinPctHRR: req.HRMinPctHRR,
|
||
HRMaxPctHRR: req.HRMaxPctHRR,
|
||
}); err != nil {
|
||
writeError(w, http.StatusInternalServerError, err.Error())
|
||
return
|
||
}
|
||
|
||
kind, _, _ := s.DB.GetWorkoutKind(r.Context(), userID, id)
|
||
resp, err := s.toWorkoutKindResponse(r, kind)
|
||
if err != nil {
|
||
writeError(w, http.StatusInternalServerError, err.Error())
|
||
return
|
||
}
|
||
writeJSON(w, http.StatusOK, resp)
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3: Update `backend/internal/api/auth.go`**
|
||
|
||
Keep `authResponse`, `authStatusString` unchanged; replace `recordAuthResult` and the three handlers:
|
||
|
||
```go
|
||
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})
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Update `backend/internal/api/progression.go`**
|
||
|
||
Keep `progressionPoint` and `metricValue` unchanged; replace `handleProgression`:
|
||
|
||
```go
|
||
func (s *Server) handleProgression(w http.ResponseWriter, r *http.Request) {
|
||
userID := userIDFromContext(r.Context())
|
||
kindID, err := strconv.ParseInt(chi.URLParam(r, "kindID"), 10, 64)
|
||
if err != nil {
|
||
writeError(w, http.StatusBadRequest, "invalid workout kind id")
|
||
return
|
||
}
|
||
metric := r.URL.Query().Get("metric")
|
||
if metric == "" {
|
||
metric = "pace"
|
||
}
|
||
from, to := r.URL.Query().Get("from"), r.URL.Query().Get("to")
|
||
|
||
assignments, err := s.DB.AssignmentsForKind(r.Context(), userID, kindID)
|
||
if err != nil {
|
||
writeError(w, http.StatusInternalServerError, err.Error())
|
||
return
|
||
}
|
||
|
||
points := []progressionPoint{}
|
||
for _, a := range assignments {
|
||
activity, ok, err := s.DB.GetActivity(r.Context(), userID, a.ActivityID)
|
||
if err != nil {
|
||
writeError(w, http.StatusInternalServerError, err.Error())
|
||
return
|
||
}
|
||
if !ok {
|
||
continue
|
||
}
|
||
if from != "" && activity.StartTimeUTC < from {
|
||
continue
|
||
}
|
||
if to != "" && activity.StartTimeUTC > to+" 23:59:59" {
|
||
continue
|
||
}
|
||
value, ok := metricValue(metric, activity)
|
||
if !ok {
|
||
continue
|
||
}
|
||
points = append(points, progressionPoint{Date: activity.StartTimeUTC, ActivityID: activity.ID, Value: value})
|
||
}
|
||
|
||
sort.Slice(points, func(i, j int) bool { return points[i].Date < points[j].Date })
|
||
writeJSON(w, http.StatusOK, points)
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 5: Build and run the whole `internal/api` suite**
|
||
|
||
Run: `cd backend && go build ./internal/api/... && go test ./internal/api/... -v 2>&1 | tail -80`
|
||
Expected: tests touching profile/workout-kinds/auth/progression endpoints (`TestWorkoutKindList_*`, `TestWorkoutKindUpdate_*`, `TestProfile_*`, `TestProgression_*`, `TestMetricValue_*`) PASS. Tests touching activities/sync/review/reclassify endpoints still FAIL (Task 15) — confirm the failures are only in those, not in anything this task touched.
|
||
|
||
- [ ] **Step 6: `gofmt` and commit**
|
||
|
||
Run: `cd backend && gofmt -l internal/api/`
|
||
Expected: no output.
|
||
|
||
```bash
|
||
git add backend/internal/api/profile.go backend/internal/api/kinds.go backend/internal/api/auth.go backend/internal/api/progression.go
|
||
git commit -m "$(cat <<'EOF'
|
||
api: scope profile, workout-kind, Garmin auth, and progression handlers to userID
|
||
|
||
Pulled from userIDFromContext (never a URL/body parameter) and threaded
|
||
into every store call plus the per-user garmin.Client accessor.
|
||
EOF
|
||
)"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 15: Thread `userID` into `activities.go`, `sync.go`, `review.go`, `reclassify.go`
|
||
|
||
**Files:**
|
||
- Modify: `backend/internal/api/activities.go`
|
||
- Modify: `backend/internal/api/sync.go`
|
||
- Modify: `backend/internal/api/review.go`
|
||
- Modify: `backend/internal/api/reclassify.go`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `userIDFromContext(ctx)` (Task 12), `s.syncFor(ctx, userID)`/`s.backgroundSync(userID, fn)` (Task 13), every scoped store method (Tasks 4-8).
|
||
|
||
- [ ] **Step 1: Update `backend/internal/api/activities.go`**
|
||
|
||
Keep `activityListItem` unchanged; replace the two handlers:
|
||
|
||
```go
|
||
func (s *Server) handleListActivities(w http.ResponseWriter, r *http.Request) {
|
||
userID := userIDFromContext(r.Context())
|
||
q := r.URL.Query()
|
||
filter := store.ActivityFilter{
|
||
FromDate: q.Get("from"),
|
||
ToDate: q.Get("to"),
|
||
}
|
||
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
|
||
}
|
||
|
||
activities, err := s.DB.ListActivities(r.Context(), userID, filter)
|
||
if err != nil {
|
||
writeError(w, http.StatusInternalServerError, err.Error())
|
||
return
|
||
}
|
||
|
||
kinds, err := s.DB.ListWorkoutKinds(r.Context(), userID, false)
|
||
if err != nil {
|
||
writeError(w, http.StatusInternalServerError, err.Error())
|
||
return
|
||
}
|
||
kindNames := make(map[int64]string, len(kinds))
|
||
for _, k := range kinds {
|
||
kindNames[k.ID] = k.Name
|
||
}
|
||
|
||
resp := make([]activityListItem, 0, len(activities))
|
||
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 {
|
||
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 {
|
||
writeError(w, http.StatusInternalServerError, err.Error())
|
||
return
|
||
}
|
||
if !ok {
|
||
writeError(w, http.StatusNotFound, "activity not found")
|
||
return
|
||
}
|
||
|
||
laps, err := s.DB.LapsForActivity(r.Context(), userID, id)
|
||
if err != nil {
|
||
writeError(w, http.StatusInternalServerError, err.Error())
|
||
return
|
||
}
|
||
|
||
assignment, hasAssignment, err := s.DB.CurrentAssignment(r.Context(), userID, id)
|
||
if err != nil {
|
||
writeError(w, http.StatusInternalServerError, err.Error())
|
||
return
|
||
}
|
||
|
||
resp := map[string]any{"activity": toActivityResponse(activity), "laps": toLapResponses(laps)}
|
||
if hasAssignment {
|
||
resp["assignment"] = assignment
|
||
}
|
||
writeJSON(w, http.StatusOK, resp)
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Update `backend/internal/api/sync.go`**
|
||
|
||
Keep `detailFillBatchSize` unchanged; replace all four handlers:
|
||
|
||
```go
|
||
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"})
|
||
}
|
||
|
||
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)
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3: Update `backend/internal/api/review.go`**
|
||
|
||
Keep `defaultReviewQueuePageSize` and `reviewQueueItem` unchanged; replace all four handlers:
|
||
|
||
```go
|
||
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")
|
||
|
||
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})
|
||
}
|
||
|
||
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
|
||
}
|
||
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"})
|
||
}
|
||
|
||
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"})
|
||
}
|
||
|
||
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"})
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Update `backend/internal/api/reclassify.go`**
|
||
|
||
```go
|
||
func (s *Server) handleReclassifyAll(w http.ResponseWriter, r *http.Request) {
|
||
userID := userIDFromContext(r.Context())
|
||
raceKind, hasRaceKind, err := s.DB.GetWorkoutKindByName(r.Context(), userID, "Race")
|
||
if err != nil {
|
||
writeError(w, http.StatusInternalServerError, err.Error())
|
||
return
|
||
}
|
||
|
||
assignments, err := s.DB.AllCurrentAssignments(r.Context(), userID)
|
||
if err != nil {
|
||
writeError(w, http.StatusInternalServerError, err.Error())
|
||
return
|
||
}
|
||
|
||
var activityIDs []int64
|
||
for _, a := range assignments {
|
||
if a.AssignmentSource == store.AssignmentSourceManual {
|
||
continue
|
||
}
|
||
if hasRaceKind && a.WorkoutKindID != nil && *a.WorkoutKindID == raceKind.ID {
|
||
continue
|
||
}
|
||
activityIDs = append(activityIDs, a.ActivityID)
|
||
}
|
||
|
||
svc, err := s.syncFor(r.Context(), userID)
|
||
if err != nil {
|
||
writeError(w, http.StatusInternalServerError, err.Error())
|
||
return
|
||
}
|
||
for _, activityID := range activityIDs {
|
||
if err := svc.ClassifyActivity(r.Context(), activityID); err != nil {
|
||
writeError(w, http.StatusInternalServerError, err.Error())
|
||
return
|
||
}
|
||
}
|
||
writeJSON(w, http.StatusOK, map[string]int{"reclassified": len(activityIDs)})
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 5: Build and run the full `internal/api` suite**
|
||
|
||
Run: `cd backend && go build ./... 2>&1 | head -50`
|
||
Expected: `internal/api` now compiles cleanly. Only `cmd/seedsample/main.go` should still fail (Task 17).
|
||
|
||
Run: `cd backend && go test ./internal/api/... -v`
|
||
Expected: every existing test PASSes.
|
||
|
||
- [ ] **Step 6: `gofmt` and commit**
|
||
|
||
Run: `cd backend && gofmt -l internal/api/`
|
||
Expected: no output.
|
||
|
||
```bash
|
||
git add backend/internal/api/activities.go backend/internal/api/sync.go backend/internal/api/review.go backend/internal/api/reclassify.go
|
||
git commit -m "$(cat <<'EOF'
|
||
api: scope activities, sync, review-queue, and reclassify handlers to userID
|
||
|
||
Completes the internal/api scoping pass -- the whole package now compiles
|
||
against the per-user store/sync/garmin signatures from Tasks 4-13.
|
||
EOF
|
||
)"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 16: API-level adversarial cross-user isolation tests
|
||
|
||
**Files:**
|
||
- Create: `backend/internal/api/isolation_test.go`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `newTestServer` (Task 13), `doJSON` (existing), `auth.MintSessionCookie` (existing). This task adds no new production code — it's the HTTP-level counterpart to Task 9's store-level isolation tests, proving the whole request path (middleware + handlers) enforces the boundary end-to-end, not just the store queries in isolation.
|
||
|
||
- [ ] **Step 1: Write `backend/internal/api/isolation_test.go`**
|
||
|
||
```go
|
||
package api
|
||
|
||
import (
|
||
"bytes"
|
||
"encoding/json"
|
||
"net/http"
|
||
"net/http/httptest"
|
||
"testing"
|
||
|
||
"geniusrun/backend/internal/auth"
|
||
authmock "geniusrun/backend/internal/auth/mock"
|
||
"geniusrun/backend/internal/garmin"
|
||
"geniusrun/backend/internal/garmin/mock"
|
||
"geniusrun/backend/internal/store"
|
||
appsync "geniusrun/backend/internal/sync"
|
||
)
|
||
|
||
// doJSONAs is doJSON but for an explicit session Sub, for tests that need
|
||
// two distinct logged-in users against the same server (doJSON itself
|
||
// always mints a cookie for Sub: "test-user", the identity newTestServer
|
||
// pre-provisions).
|
||
func doJSONAs(t *testing.T, handler http.Handler, sub, method, path string, body any) *httptest.ResponseRecorder {
|
||
t.Helper()
|
||
var reader *bytes.Reader
|
||
if body != nil {
|
||
b, err := json.Marshal(body)
|
||
if err != nil {
|
||
t.Fatalf("marshal request body: %v", err)
|
||
}
|
||
reader = bytes.NewReader(b)
|
||
} else {
|
||
reader = bytes.NewReader(nil)
|
||
}
|
||
req := httptest.NewRequest(method, path, reader)
|
||
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)
|
||
if err != nil {
|
||
t.Fatalf("mint test session cookie: %v", err)
|
||
}
|
||
req.AddCookie(cookie)
|
||
rec := httptest.NewRecorder()
|
||
handler.ServeHTTP(rec, req)
|
||
return rec
|
||
}
|
||
|
||
func TestIsolation_ActivityDetailNotVisibleToOtherUser(t *testing.T) {
|
||
s, db := newTestServer(t) // provisions "test-user" (userA)
|
||
userB, err := db.ProvisionUser(newCtx(), "user-b", "B")
|
||
if err != nil {
|
||
t.Fatalf("ProvisionUser(b): %v", err)
|
||
}
|
||
|
||
userA, found, err := db.GetUserBySub(newCtx(), "test-user")
|
||
if err != nil || !found {
|
||
t.Fatalf("GetUserBySub(test-user): found=%v err=%v", found, err)
|
||
}
|
||
activityID, err := db.UpsertActivity(newCtx(), userA.ID, store.Activity{GarminActivityID: 1, StartTimeUTC: "2026-07-11 06:00:00", RawJSON: "{}"})
|
||
if err != nil {
|
||
t.Fatalf("UpsertActivity: %v", err)
|
||
}
|
||
_ = userB
|
||
|
||
router := s.Router()
|
||
|
||
// userA (the default doJSON identity) can see it.
|
||
rec := doJSON(t, router, http.MethodGet, "/api/activities/"+itoa(activityID), nil)
|
||
if rec.Code != http.StatusOK {
|
||
t.Fatalf("userA GetActivity status = %d, body = %s", rec.Code, rec.Body.String())
|
||
}
|
||
|
||
// userB, given the exact same activity id, gets 404 -- not another
|
||
// user's data, and not a 500 that would leak existence either way.
|
||
rec = doJSONAs(t, router, "user-b", http.MethodGet, "/api/activities/"+itoa(activityID), nil)
|
||
if rec.Code != http.StatusNotFound {
|
||
t.Fatalf("userB GetActivity(userA's id) status = %d, want 404, body = %s", rec.Code, rec.Body.String())
|
||
}
|
||
}
|
||
|
||
func TestIsolation_WorkoutKindUpdateCannotTargetOtherUsersKind(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)
|
||
}
|
||
userA, _, _ := db.GetUserBySub(newCtx(), "test-user")
|
||
kindsA, err := db.ListWorkoutKinds(newCtx(), userA.ID, false)
|
||
if err != nil || len(kindsA) == 0 {
|
||
t.Fatalf("ListWorkoutKinds(a): len=%d err=%v", len(kindsA), err)
|
||
}
|
||
targetID := kindsA[0].ID
|
||
originalName := kindsA[0].Name
|
||
|
||
router := s.Router()
|
||
rec := doJSONAs(t, router, "user-b", http.MethodPut, "/api/workout-kinds/"+itoa(targetID), map[string]any{
|
||
"name": "Hijacked",
|
||
"rule": json.RawMessage(`{"match":"all","conditions":[]}`),
|
||
})
|
||
if rec.Code != http.StatusNotFound {
|
||
t.Fatalf("userB updating userA's kind status = %d, want 404, body = %s", rec.Code, rec.Body.String())
|
||
}
|
||
|
||
rec = doJSON(t, router, http.MethodGet, "/api/workout-kinds/"+itoa(targetID), nil)
|
||
var got workoutKindResponse
|
||
json.Unmarshal(rec.Body.Bytes(), &got)
|
||
if got.Name != originalName {
|
||
t.Fatalf("userA's kind name changed to %q despite userB's update being rejected", got.Name)
|
||
}
|
||
}
|
||
|
||
func TestIsolation_ReviewQueueOnlyShowsOwnActivities(t *testing.T) {
|
||
s, db := newTestServer(t) // provisions "test-user" (userA)
|
||
userB, err := db.ProvisionUser(newCtx(), "user-b", "B")
|
||
if err != nil {
|
||
t.Fatalf("ProvisionUser(b): %v", err)
|
||
}
|
||
if _, err := db.UpsertActivity(newCtx(), userB, store.Activity{GarminActivityID: 42, StartTimeUTC: "2026-07-11 06:00:00", RawJSON: "{}"}); err != nil {
|
||
t.Fatalf("UpsertActivity(b): %v", err)
|
||
}
|
||
if _, err := db.InsertKindAssignment(newCtx(), userB, store.KindAssignment{
|
||
ActivityID: func() int64 {
|
||
acts, _ := db.ListActivities(newCtx(), userB, store.ActivityFilter{})
|
||
return acts[0].ID
|
||
}(),
|
||
AssignmentSource: store.AssignmentSourceRuleEngine, Status: store.AssignmentStatusNeedsReview, CandidateKindsJSON: "[]",
|
||
}); err != nil {
|
||
t.Fatalf("InsertKindAssignment(b): %v", err)
|
||
}
|
||
|
||
rec := doJSON(t, s.Router(), http.MethodGet, "/api/review-queue/", nil) // as userA
|
||
if rec.Code != http.StatusOK {
|
||
t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String())
|
||
}
|
||
var page struct {
|
||
Items []map[string]any `json:"items"`
|
||
Total int `json:"total"`
|
||
}
|
||
json.Unmarshal(rec.Body.Bytes(), &page)
|
||
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))
|
||
}
|
||
}
|
||
|
||
func TestIsolation_RequireProvisionedUser_BlocksDataRoutesUntilSetup(t *testing.T) {
|
||
db, err := store.Open(t.TempDir() + "/isolation_test.db")
|
||
if err != nil {
|
||
t.Fatalf("store.Open: %v", err)
|
||
}
|
||
defer db.Close()
|
||
m := &mock.Client{}
|
||
s := NewServer(db, func(garmin.Config) garmin.Client { return m }, garmin.Config{}, appsync.Config{}, &authmock.Verifier{}, testSessionConfig)
|
||
|
||
rec := doJSON(t, s.Router(), http.MethodGet, "/api/activities/", nil) // "test-user" sub, never provisioned
|
||
if rec.Code != http.StatusForbidden {
|
||
t.Fatalf("status = %d, want 403 (no profile provisioned yet), body = %s", rec.Code, rec.Body.String())
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Run the isolation tests**
|
||
|
||
Run: `cd backend && go test ./internal/api/... -run TestIsolation -v`
|
||
Expected: PASS. If any test fails, it points at a real cross-user leak in a Task 14/15 handler (missing `userIDFromContext` scoping, or a route not wrapped in `requireProvisionedUser`) — fix the handler/router, not the test.
|
||
|
||
- [ ] **Step 3: Run the full `internal/api` suite one more time**
|
||
|
||
Run: `cd backend && gofmt -l internal/api/ && go vet ./internal/api/... && go test ./internal/api/... -v`
|
||
Expected: `gofmt` prints nothing; all PASS.
|
||
|
||
- [ ] **Step 4: Commit**
|
||
|
||
```bash
|
||
git add backend/internal/api/isolation_test.go
|
||
git commit -m "$(cat <<'EOF'
|
||
api: add end-to-end cross-user isolation tests
|
||
|
||
HTTP-level counterpart to the store-layer isolation tests: proves the full
|
||
middleware+handler chain rejects/hides another user's activities, workout
|
||
kinds, and review queue even when given that user's real row ids, and that
|
||
an unprovisioned session is blocked from every data route.
|
||
EOF
|
||
)"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 17: Update `cmd/seedsample/main.go` for per-user provisioning
|
||
|
||
**Files:**
|
||
- Modify: `backend/cmd/seedsample/main.go`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `db.ProvisionUser` (Task 2), every scoped store method (Tasks 4-8), `appsync.NewService(m, db, userID, cfg, nil)` (Task 10).
|
||
|
||
- [ ] **Step 1: Provision a user at the top of `main()` and thread `userID` through every call**
|
||
|
||
Replace the profile-loading block near the top:
|
||
|
||
```go
|
||
ctx := context.Background()
|
||
db, err := store.Open(*dbPath)
|
||
if err != nil {
|
||
log.Fatalf("open db: %v", err)
|
||
}
|
||
defer db.Close()
|
||
|
||
userID, err := db.ProvisionUser(ctx, "seedsample-user", "Sample")
|
||
must(err)
|
||
|
||
// Set a max heart rate on the profile so avg_hr_pct_max is computed
|
||
// during classification below (it's nil/unset by default).
|
||
profile, err := db.GetProfile(ctx, userID)
|
||
must(err)
|
||
maxHR := 190.0
|
||
profile.MaxHeartRate = &maxHR
|
||
must(db.UpdateProfile(ctx, userID, profile))
|
||
```
|
||
|
||
Then update every remaining call in `main()` and its helper functions to pass `userID`:
|
||
|
||
- `mustFindKindID(ctx, db, "Easy")` → `mustFindKindID(ctx, db, userID, "Easy")` (and its two other calls, `"Tempo"`/`"Intervals"`)
|
||
- Each `db.UpdateWorkoutKind(ctx, store.WorkoutKind{...})` → `db.UpdateWorkoutKind(ctx, userID, store.WorkoutKind{...})`
|
||
- `appsync.NewService(m, db, appsync.Config{MinConfidence: 0.6}, nil)` → `appsync.NewService(m, db, userID, appsync.Config{MinConfidence: 0.6}, nil)`
|
||
- Every `seedActivity(ctx, db, seedParams{...})` call needs `userID` threaded through — update `seedActivity`'s signature and body:
|
||
|
||
```go
|
||
func seedActivity(ctx context.Context, db *store.DB, userID int64, p seedParams) int64 {
|
||
speed := p.speedMps
|
||
hr := p.avgHR
|
||
aerobic := p.aerobicTE
|
||
anaerobic := p.anaerobicTE
|
||
id, err := db.UpsertActivity(ctx, userID, store.Activity{
|
||
GarminActivityID: p.garminID,
|
||
StartTimeUTC: p.start.Format("2006-01-02 15:04:05"),
|
||
DurationSeconds: p.duration,
|
||
DistanceMeters: p.distance,
|
||
AvgSpeedMps: &speed,
|
||
AvgHR: &hr,
|
||
AerobicTrainingEffect: &aerobic,
|
||
AnaerobicTrainingEffect: &anaerobic,
|
||
RawJSON: fmt.Sprintf(`{"activityName":%q,"activityType":{"typeKey":"running"}}`, p.name),
|
||
})
|
||
must(err)
|
||
return id
|
||
}
|
||
```
|
||
|
||
and every call site `seedActivity(ctx, db, seedParams{...})` → `seedActivity(ctx, db, userID, seedParams{...})`.
|
||
- `seedIntervalLapsAndSamples(ctx, db, id)` → update its signature to take `userID` and pass it through to its own `db.ReplaceActivitySamples`/`db.ReplaceLaps` calls:
|
||
|
||
```go
|
||
func seedIntervalLapsAndSamples(ctx context.Context, db *store.DB, userID, activityID int64) {
|
||
// ... body unchanged except the final two calls:
|
||
must(db.ReplaceActivitySamples(ctx, userID, activityID, samples))
|
||
must(db.ReplaceLaps(ctx, userID, activityID, laps))
|
||
}
|
||
```
|
||
|
||
and its one call site: `seedIntervalLapsAndSamples(ctx, db, id)` → `seedIntervalLapsAndSamples(ctx, db, userID, id)`.
|
||
- `svc.ClassifyActivity(ctx, id)` in the final loop is unchanged (`Service.ClassifyActivity` still just takes `activityID` — `userID` is baked into `svc` per Task 10).
|
||
- `mustFindKindID`'s own body: update its signature and internal call:
|
||
|
||
```go
|
||
func mustFindKindID(ctx context.Context, db *store.DB, userID int64, name string) int64 {
|
||
kinds, err := db.ListWorkoutKinds(ctx, userID, false)
|
||
must(err)
|
||
for _, k := range kinds {
|
||
if k.Name == name {
|
||
return k.ID
|
||
}
|
||
}
|
||
log.Fatalf("seedsample: no workout kind named %q found for the seeded user (did ProvisionUser seed the taxonomy?)", name)
|
||
return 0
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Build**
|
||
|
||
Run: `cd backend && go build ./cmd/seedsample/...`
|
||
Expected: compiles clean. If any call site was missed, the compiler error names the exact line — fix it the same way as above.
|
||
|
||
- [ ] **Step 3: Smoke-test it actually runs and produces the expected output**
|
||
|
||
Run: `cd backend && go run ./cmd/seedsample -db /tmp/geniusrun_seedsample_test.db && rm /tmp/geniusrun_seedsample_test.db`
|
||
Expected: prints `Seeded 10 activities (kinds: Easy=<id>, Tempo=<id>) into /tmp/geniusrun_seedsample_test.db` (or similar, matching the existing `fmt.Printf` in `main()`) with no errors.
|
||
|
||
- [ ] **Step 4: `gofmt` and commit**
|
||
|
||
Run: `cd backend && gofmt -l cmd/seedsample/`
|
||
Expected: no output.
|
||
|
||
```bash
|
||
git add backend/cmd/seedsample/main.go
|
||
git commit -m "$(cat <<'EOF'
|
||
cmd/seedsample: provision a sample user before seeding
|
||
|
||
Every store call now needs a userID -- seedsample provisions one fixed
|
||
"seedsample-user" account up front and threads it through the rest of the
|
||
seeding logic, unchanged in what it actually seeds.
|
||
EOF
|
||
)"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 18: Frontend — Create-profile setup screen + API client wiring
|
||
|
||
**Files:**
|
||
- Modify: `frontend/src/types/api.ts` (extend `SessionInfo`)
|
||
- Modify: `frontend/src/api/client.ts` (add `setup()`)
|
||
- Create: `frontend/src/CreateProfile.tsx`
|
||
- Create: `frontend/src/CreateProfile.css`
|
||
- Modify: `frontend/src/LoginGate.tsx`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `GET /api/session/me` (now returns `has_profile`/`display_name`, Task 12), `POST /api/setup` (Task 12).
|
||
- Produces: `api.setup(displayName: string): Promise<{user_id: number; display_name: string}>`; `<CreateProfile onCreated={(displayName: string) => void} />` component, rendered by `LoginGate` in place of `<App>` when the session is authenticated but `has_profile` is false.
|
||
|
||
- [ ] **Step 1: Extend `SessionInfo` in `frontend/src/types/api.ts`**
|
||
|
||
```typescript
|
||
export interface SessionInfo {
|
||
name: string;
|
||
email: string;
|
||
has_profile: boolean;
|
||
display_name?: string;
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Add `api.setup` in `frontend/src/api/client.ts`**
|
||
|
||
Add near the `getSessionInfo` entry (same "Session" comment block):
|
||
|
||
```typescript
|
||
setup: (displayName: string) =>
|
||
request<{ user_id: number; display_name: string }>("/api/setup", {
|
||
method: "POST",
|
||
body: JSON.stringify({ display_name: displayName }),
|
||
}),
|
||
```
|
||
|
||
- [ ] **Step 3: Write `frontend/src/CreateProfile.css`**
|
||
|
||
```css
|
||
.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;
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Write `frontend/src/CreateProfile.tsx`**
|
||
|
||
```tsx
|
||
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>
|
||
);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 5: Wire it into `frontend/src/LoginGate.tsx`**
|
||
|
||
Replace the whole file:
|
||
|
||
```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";
|
||
|
||
type Status = "loading" | "authenticated" | "unauthenticated";
|
||
|
||
const AUTH_ERROR_MESSAGES: Record<string, string> = {
|
||
forbidden: "Your account isn't authorized for geniusrun.",
|
||
failed: "Login failed, please try again.",
|
||
};
|
||
|
||
// 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.
|
||
export function LoginGate() {
|
||
const [status, setStatus] = useState<Status>("loading");
|
||
const [session, setSession] = useState<SessionInfo | null>(null);
|
||
|
||
useEffect(() => {
|
||
api
|
||
.getSessionInfo()
|
||
.then((s) => {
|
||
setSession(s);
|
||
setStatus("authenticated");
|
||
})
|
||
.catch(() => setStatus("unauthenticated"));
|
||
}, []);
|
||
|
||
if (status === "loading") {
|
||
return <div className="login-gate-loading">Loading…</div>;
|
||
}
|
||
|
||
if (status === "unauthenticated") {
|
||
const authError = new URLSearchParams(window.location.search).get("auth_error");
|
||
return (
|
||
<div className="login-gate">
|
||
<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`}>
|
||
Log in
|
||
</a>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
if (!session!.has_profile) {
|
||
return (
|
||
<CreateProfile
|
||
onCreated={(displayName) => setSession((s) => (s ? { ...s, has_profile: true, display_name: displayName } : s))}
|
||
/>
|
||
);
|
||
}
|
||
|
||
return <App session={session!} />;
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 6: Build the frontend and confirm no type errors**
|
||
|
||
Run: `cd frontend && npm run build`
|
||
Expected: `tsc -b && vite build` completes with no type errors.
|
||
|
||
Run: `cd frontend && npm run lint`
|
||
Expected: no new lint errors from the changed/added files.
|
||
|
||
- [ ] **Step 7: Manual smoke test**
|
||
|
||
Run the backend against a fresh (or freshly-reset) DB and the frontend dev server (`./start.sh` in each of `backend/`/`frontend/`, per this repo's existing dev workflow), then in a browser:
|
||
1. Log in as a user with no prior `users` row (a fresh DB has none) — confirm the "Welcome to geniusrun" screen appears instead of the normal app.
|
||
2. Submit a display name — confirm it lands on the normal app (Activities tab) afterward, and the profile-name button in the header shows that display name.
|
||
3. Reload the page — confirm it goes straight to the normal app now (not back to the create-profile screen), since `has_profile` is now `true` for that session.
|
||
|
||
Since this can't be automated in this plan (it needs a live browser + OIDC-authenticated session), explicitly note the result of this manual check when reporting the task done — don't claim success without having actually clicked through it.
|
||
|
||
- [ ] **Step 8: Commit**
|
||
|
||
```bash
|
||
git add frontend/src/types/api.ts frontend/src/api/client.ts frontend/src/CreateProfile.tsx frontend/src/CreateProfile.css frontend/src/LoginGate.tsx
|
||
git commit -m "$(cat <<'EOF'
|
||
frontend: add Create-profile setup screen for brand-new accounts
|
||
|
||
LoginGate now shows CreateProfile (display name only) instead of App when
|
||
an authenticated session has no provisioned geniusrun profile yet, backed
|
||
by the new POST /api/setup endpoint and session/me's has_profile flag.
|
||
EOF
|
||
)"
|
||
```
|
||
|
||
---
|
||
|
||
## Final verification
|
||
|
||
After all 18 tasks are complete, run the full test suite and a clean build one more time before considering this feature done:
|
||
|
||
```bash
|
||
cd backend && gofmt -l . && go build ./... && go vet ./... && go test ./...
|
||
cd ../frontend && npm run build && npm run lint
|
||
```
|
||
|
||
Expected: `gofmt -l .` prints nothing; every Go package builds and vets clean; every Go test passes; the frontend builds and lints clean. Then walk through the manual smoke test in Task 18's Step 7 one more time end-to-end (fresh login → create profile → normal app → reload persists), plus a second browser session (or incognito window) logged in as a *different* OIDC identity to confirm it gets its own empty create-profile flow and never sees the first account's data anywhere in the UI.
|
||
|