Compare commits

..

29 Commits

Author SHA1 Message Date
1329387528 Merge branch 'worktree-per-user-profile' 2026-07-25 19:00:04 +02:00
00605f950b config: default GarminTokenStoreRoot instead of leaving it empty
Per-user profile isolation namespaces each user's mcp-garmin session
cache under {GarminTokenStoreRoot}/{userID} (api.Server.garminFor), but
Load() left GarminTokenStoreRoot ("" when GARMIN_TOKENSTORE is unset)
with no required-var check and no safe default. In that state
garminFor's `if cfg.TokenStorePath != ""` guard skips the per-user
join entirely, so every user's subprocess would fall back to the same
default token cache -- a cross-user Garmin-session collision risk in
any deployment that forgets to set GARMIN_TOKENSTORE.

Default it to a "garmin-tokenstores" directory next to DBPath when
unset, so every deployment gets per-user isolation automatically,
while GARMIN_TOKENSTORE can still override it explicitly. Log the
derived default. Update the field's doc comment (no longer "if set")
and add config_test.go cases covering the default derivation and the
explicit-override precedence.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-25 18:55:53 +02:00
7f5d2da6a2 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.
2026-07-25 18:42:23 +02:00
d62544f1cc 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.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-25 18:38:09 +02:00
ad906a10f7 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.
2026-07-25 18:33:44 +02:00
e37173ea08 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. Also
fixes the test helpers (newTestServer now returns the provisioned userID)
and a latent bug in TestResolveUser_LeavesContextEmptyWhenNotProvisioned,
which relied on doJSON's hardcoded "test-user" session sub being
unprovisioned -- never caught before since internal/api couldn't compile
since Task 12.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-25 18:28:02 +02:00
eaca8e602b 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.
2026-07-25 18:16:06 +02:00
5930cc4ef5 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.
2026-07-25 18:10:09 +02:00
2cda0adbf8 api: fix chi middleware-ordering panic in resolveUser registration
r.Use(s.resolveUser) was registered after /session/me and /session/logout
had already been added to the same chi inline-mux group. Chi requires all
r.Use() calls on a mux to precede any route registration on it, or it
panics with "chi: all middlewares must be defined before routes on a mux"
(reproduced against the real chi v5.3.1 dependency). This meant
server.Router() would crash at startup, taking down every internal/api
test that builds a Router along with it.

Separately, since chi captures each route's middleware chain at
registration time, /session/me and /session/logout would never have run
resolveUser even without the panic -- so handleSessionMe's
has_profile/display_name logic could never see a resolved user on that
route.

Fix: move r.Use(s.resolveUser) immediately after
r.Use(auth.RequireSession(...)), before any route in the group is
registered, so the ordering is legal and resolveUser applies to
/session/me, /session/logout, and /setup alike.
2026-07-25 18:02:49 +02:00
4dceb03fc0 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.
2026-07-25 17:55:13 +02:00
9140a00da7 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.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-25 17:49:51 +02:00
69103b7a4b 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.
2026-07-25 17:45:41 +02:00
3a1333017d 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.
2026-07-25 17:37:40 +02:00
99984e319e 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.
2026-07-25 17:33:43 +02:00
47dbf4e446 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.
2026-07-25 17:27:51 +02:00
f82549524b docs: update Task 3 design for removed dead-code scenario
Update the per-user-profile plan document to reflect the corrected Task 3
design: remove the now-unreachable TestClaimLegacyOwner_NoOpOnGenuinelyFreshInstall
test from the code example, update the ClaimLegacyOwner implementation example to
remove the dead-code branch and false doc comment about fresh installs, and fix
the commit message to match the corrected design.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-25 17:05:37 +02:00
c2bdfe3798 store: remove dead-code branch from ClaimLegacyOwner
Task 3 correction: migration 0003 unconditionally seeds a profile row on every
fresh install, and Task 1's migration 0023 preserves this seeded row with
user_id = NULL. Therefore, a fresh install always has at least one profile row
with user_id IS NULL when users table is empty -- the "no-op on genuinely fresh
install" scenario was unreachable dead code. Confirmed with the codebase owner
that no scenario requires defending against a missing profile row.

Collapse the two-branch error handling into a single `if err != nil` check
(matching the pattern used elsewhere in the function), remove the now-unused
database/sql import, update the function's doc comment to remove the false claim
about fresh installs, and delete the now-unreachable
TestClaimLegacyOwner_NoOpOnGenuinelyFreshInstall test.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-25 17:02:52 +02:00
b08f0152ef store: fix migration 0026's silent-cascade-delete FK toggle bug
Migration 0026 (activities table rebuild for the per-user unique
constraint) issued its own PRAGMA foreign_keys = OFF/ON inside the
migration's SQL content, but the whole migration file runs inside one
db.go tx.Begin()/tx.Exec()/tx.Commit() transaction, and SQLite documents
PRAGMA foreign_keys as a no-op once a transaction is open. As a result FK
enforcement never actually got disabled, so DROP TABLE activities
triggered SQLite's implicit DELETE FROM semantics, firing ON DELETE
CASCADE on every row in laps, activity_samples, and kind_assignments for
every activity -- silently, with no error. On a real upgrade with synced
data this would have permanently destroyed all lap/sample/classification
history. It was masked because every existing migration test runs
migrations back-to-back on an empty temp database with no pre-existing
child rows.

Fix: add "0026_activities_unique_constraint.sql" to db.go's
tableRebuildMigrations map so it gets the same autocommit-mode FK
disable/enable toggle (before/after the transaction) already used for
migrations 0023/0024/0025, and remove the now-redundant/misleading
mid-transaction PRAGMA lines from the migration file itself, matching
the established pattern.

Also restore the DEFAULT '' on event_type_key in migration 0026's
rebuilt activities table -- it was dropped from migration 0007's
original column definition during the rebuild, which broke every insert
that omits event_type_key and relies on that default
(TestClaimLegacyOwner_BindsExistingSingletonRowsToOneNewUser and others).

Extend TestRebuildMigrationsPreserveForeignKeyReferences to cover 0026:
seed a laps row and an activity_samples row (in addition to the existing
kind_assignments row) against a pre-existing activities row before the
rebuild migrations run, then verify after 0023-0026 complete that all
three child rows still exist and still reference the same activity, and
that FK enforcement rejects bogus activity_id/workout_kind_id afterward.
This is the regression guard that would have caught the original bug.

Note: TestClaimLegacyOwner_NoOpOnGenuinelyFreshInstall still fails on
this branch; verified it fails identically at the prior commit
(d8b7228), so it's a pre-existing, unrelated bug in ClaimLegacyOwner
(not migration 0026 or this FK-toggle bug) and out of scope for this fix.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-25 14:28:16 +02:00
d8b722820d store: activities UNIQUE constraint rebuild for per-user design
Migration 0026 rebuilds the activities table to enforce UNIQUE(user_id,
garmin_activity_id) as the composite primary constraint, removing the old
global UNIQUE(garmin_activity_id) constraint from migration 0001. This
allows the same Garmin activity ID to appear for different users without
conflicts, which is essential for the per-user profile design and enables
the TestUpsertActivity_SameGarminActivityIDAllowedAcrossDifferentUsers
test to pass.
2026-07-25 14:12:54 +02:00
62753e4604 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.
2026-07-25 14:11:51 +02:00
e8bde134ae 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.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-25 13:44:06 +02:00
ef410863c6 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. This also
requires scoping ListActivities, ListWorkoutKinds, UpsertActivity,
CreateWorkoutKind, GetSyncState, UpdateSyncState, and ResetAllSyncedData
to userID, plus updating all related tests in the store package.
2026-07-25 13:17:17 +02:00
b0a7462ebe 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.
2026-07-25 13:06:20 +02:00
36bc651d66 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.
2026-07-25 12:59:56 +02:00
46ad8a7e08 store: test that pre-existing FK-referencing rows survive the 0023-0025 rebuild
Every existing migration test opens a brand-new DB via store.Open, which
runs migrations 0001-0025 in one uninterrupted pass over an empty
database -- so migrations 0023/0024/0025's table-rebuild (create-new/copy/
drop-old/rename-into-place) never had any real pre-existing rows to carry
across, and no test proved that a genuinely in-use single-tenant install
(real workout_kinds, real synced activities, kind_assignments referencing
workout_kinds by FK) upgrades safely.

TestRebuildMigrationsPreserveForeignKeyReferences manually applies
migrations up to (but not including) the three rebuilds, inserts rows
simulating that pre-existing install, applies the rebuilds, then verifies
the kind_assignments row still resolves to the correct workout_kinds row
by name, and that FK enforcement is genuinely back on afterward (a bogus
workout_kind_id is rejected).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-25 12:25:01 +02:00
f57b314be4 store: fix PRAGMA foreign_keys scoping for table-rebuild migrations
The previous implementation issued PRAGMA foreign_keys toggles inside
transactions (after tx.Begin()), but SQLite ignores pragmas once a
transaction is open with modernc.org/sqlite, making those calls no-ops.

This fix relocates the toggle to autocommit mode (db.Exec, not tx.Exec)
and limits it to only the three migrations that need it (0023/0024/0025),
which rebuild tables with incoming foreign keys (kind_assignments/
workout_type_paces reference workout_kinds; activities/sync_runs
reference sync_state).

The connection string now correctly restores ?_pragma=foreign_keys(1),
ensuring FK enforcement is ON by default for normal runtime operation
and during migrations that don't need the special handling.

Each affected migration now:
1. Disables FK in autocommit mode before starting its transaction
2. Runs the migration's CREATE/INSERT/DROP/RENAME sequence
3. Re-enables FK in autocommit mode after the transaction commits

All other migrations run normally with FK enforcement active throughout,
protecting against cascading deletions silently failing if a future
connection change causes FK enforcement to inadvertently remain off.

Added TestForeignKeyEnforcementPostMigration to verify FK is correctly
enforced after all migrations complete: valid FK references are accepted,
and invalid ones are rejected.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-25 10:33:35 +02:00
245d4bf821 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.
2026-07-25 10:20:59 +02:00
033e412cb7 docs: add per-user profile implementation plan
Copied into this worktree so subagent-driven-development can operate on
it; the plan was written and reviewed on main before this branch existed.
2026-07-25 10:13:12 +02:00
d29f69903d docs: design per-user profile and data isolation
Reverses the "single active profile, OIDC is access-only" decision from
2026-07-24 now that login needs to map each user to their own dataset
instead of a shared singleton.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-25 09:20:37 +02:00
57 changed files with 7469 additions and 532 deletions

View File

@@ -31,24 +31,12 @@ func main() {
}
defer db.Close()
profile, err := db.GetProfile(context.Background())
if err != nil {
log.Fatalf("load profile: %v", err)
if cfg.LegacyOwnerOIDCSub != "" {
if err := db.ClaimLegacyOwner(context.Background(), cfg.LegacyOwnerOIDCSub); err != nil {
log.Fatalf("claim legacy owner: %v", err)
}
}
garminClient := garmin.NewClient(garmin.Config{
PythonPath: cfg.GarminPythonPath,
ServerPath: cfg.GarminServerPath,
GarminEmail: profile.GarminEmail,
GarminPassword: profile.GarminPassword,
TokenStorePath: cfg.GarminTokenStore,
})
defer garminClient.Close()
syncSvc := appsync.NewService(garminClient, db, appsync.Config{
MinConfidence: cfg.MinConfidence,
}, nil)
authVerifier, err := auth.NewOIDCVerifier(context.Background(), auth.OIDCConfig{
IssuerURL: cfg.OIDCIssuerURL,
ClientID: cfg.OIDCClientID,
@@ -60,7 +48,13 @@ func main() {
log.Fatalf("oidc: %v", err)
}
server := api.NewServer(db, garminClient, syncSvc, authVerifier, api.SessionConfig{
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,
@@ -70,7 +64,7 @@ func main() {
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
go runIncrementalSyncLoop(ctx, syncSvc, cfg.IncrementalSyncEvery)
go runIncrementalSyncLoop(ctx, server, cfg.IncrementalSyncEvery)
httpServer := &http.Server{Addr: cfg.Addr, Handler: server.Router()}
go func() {
@@ -89,9 +83,10 @@ func main() {
}
}
// runIncrementalSyncLoop periodically syncs new activities in the
// background so the frontend doesn't need to trigger every sync manually.
func runIncrementalSyncLoop(ctx context.Context, svc *appsync.Service, every time.Duration) {
// 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 {
@@ -99,13 +94,7 @@ func runIncrementalSyncLoop(ctx context.Context, svc *appsync.Service, every tim
case <-ctx.Done():
return
case <-ticker.C:
if err := svc.IncrementalSync(ctx); err != nil {
log.Printf("incremental sync: %v", err)
continue
}
if err := svc.FillPendingDetails(ctx, 50); err != nil {
log.Printf("fill pending details: %v", err)
}
server.RunIncrementalSyncForAllUsers(ctx)
}
}
}

View File

@@ -29,13 +29,16 @@ func main() {
}
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)
profile, err := db.GetProfile(ctx, userID)
must(err)
maxHR := 190.0
profile.MaxHeartRate = &maxHR
must(db.UpdateProfile(ctx, profile))
must(db.UpdateProfile(ctx, userID, profile))
// Easy Run and Tempo's pace/HR ranges deliberately overlap a little
// (330-340 sec/km, 0.70-0.85 HR%max) so a run that lands in that overlap
@@ -43,8 +46,8 @@ func main() {
// gap between disjoint ranges. The taxonomy migration already seeded
// these rows (by fixed name) -- update their rules in place rather than
// creating new ones, since names are unique.
easyID := mustFindKindID(ctx, db, "Easy")
must(db.UpdateWorkoutKind(ctx, store.WorkoutKind{
easyID := mustFindKindID(ctx, db, userID, "Easy")
must(db.UpdateWorkoutKind(ctx, userID, store.WorkoutKind{
ID: easyID, Name: "Easy", Description: "Easy conversational runs", Color: "#22c55e",
RuleJSON: `{"match":"all","conditions":[
{"metric":"avg_pace_sec_per_km","op":"between","value":[330,420]},
@@ -53,8 +56,8 @@ func main() {
IsActive: true,
}))
tempoID := mustFindKindID(ctx, db, "Tempo")
must(db.UpdateWorkoutKind(ctx, store.WorkoutKind{
tempoID := mustFindKindID(ctx, db, userID, "Tempo")
must(db.UpdateWorkoutKind(ctx, userID, store.WorkoutKind{
ID: tempoID, Name: "Tempo", Description: "Comfortably hard sustained effort", Color: "#f59e0b",
RuleJSON: `{"match":"all","conditions":[
{"metric":"avg_pace_sec_per_km","op":"between","value":[300,340]},
@@ -63,15 +66,15 @@ func main() {
IsActive: true,
}))
intervalID := mustFindKindID(ctx, db, "Intervals")
must(db.UpdateWorkoutKind(ctx, store.WorkoutKind{
intervalID := mustFindKindID(ctx, db, userID, "Intervals")
must(db.UpdateWorkoutKind(ctx, userID, store.WorkoutKind{
ID: intervalID, Name: "Intervals", Description: "Structured work/rest intervals", Color: "#ef4444",
RuleJSON: `{"match":"all","conditions":[{"metric":"lap_interval_pattern","op":"==","value":true}]}`,
IsActive: true,
}))
m := &mock.Client{}
svc := appsync.NewService(m, db, appsync.Config{MinConfidence: 0.6}, nil)
svc := appsync.NewService(m, db, userID, appsync.Config{MinConfidence: 0.6}, nil)
today := time.Now()
activityIDs := []int64{}
@@ -81,7 +84,7 @@ func main() {
start := today.AddDate(0, 0, -60+i*10)
speed := 1000.0 / (390 - float64(i)*5) // pace improving from 390 -> 370 sec/km
hr := 125.0 + float64(i) // pct-of-max stays comfortably under the 0.75 ceiling
id := seedActivity(ctx, db, seedParams{
id := seedActivity(ctx, db, userID, seedParams{
garminID: 1000 + int64(i), name: "Easy morning run", start: start,
distance: 8000, duration: 8000 / (speed) * 1, speedMps: speed, avgHR: hr,
aerobicTE: 2.5, anaerobicTE: 0.3,
@@ -93,7 +96,7 @@ func main() {
for i := 0; i < 3; i++ {
start := today.AddDate(0, 0, -45+i*15)
speed := 1000.0 / 320.0 // centered in Tempo's [300,340] range
id := seedActivity(ctx, db, seedParams{
id := seedActivity(ctx, db, userID, seedParams{
garminID: 2000 + int64(i), name: "Tempo run", start: start,
distance: 6000, duration: 1800, speedMps: speed, avgHR: 168,
aerobicTE: 3.8, anaerobicTE: 1.2,
@@ -104,12 +107,12 @@ func main() {
// 1 Interval workout, with alternating ACTIVE/REST laps + HR samples
// showing drift on the work intervals and recovery on the rest ones.
{
id := seedActivity(ctx, db, seedParams{
id := seedActivity(ctx, db, userID, seedParams{
garminID: 3000, name: "Track intervals", start: today.AddDate(0, 0, -5),
distance: 8000, duration: 2400, speedMps: 1000.0 / 240.0, avgHR: 165,
aerobicTE: 3.0, anaerobicTE: 3.5,
})
seedIntervalLapsAndSamples(ctx, db, id)
seedIntervalLapsAndSamples(ctx, db, userID, id)
activityIDs = append(activityIDs, id)
}
@@ -118,7 +121,7 @@ func main() {
// and Tempo's >=0.70 -- so it should land in the review queue with two
// candidates, not a clean single match.
{
id := seedActivity(ctx, db, seedParams{
id := seedActivity(ctx, db, userID, seedParams{
garminID: 4000, name: "Ambiguous run", start: today.AddDate(0, 0, -2),
distance: 7000, duration: 2200, speedMps: 1000.0 / 335.0, avgHR: 148,
aerobicTE: 3.0, anaerobicTE: 0.8,
@@ -145,12 +148,12 @@ type seedParams struct {
anaerobicTE float64
}
func seedActivity(ctx context.Context, db *store.DB, p seedParams) int64 {
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, store.Activity{
id, err := db.UpsertActivity(ctx, userID, store.Activity{
GarminActivityID: p.garminID,
StartTimeUTC: p.start.Format("2006-01-02 15:04:05"),
DurationSeconds: p.duration,
@@ -171,7 +174,7 @@ func seedActivity(ctx context.Context, db *store.DB, p seedParams) int64 {
// seedIntervalLapsAndSamples gives one activity 6 alternating ACTIVE/REST
// laps plus per-second HR samples: rising HR within each ACTIVE lap (drift)
// and falling HR within each REST lap (recovery).
func seedIntervalLapsAndSamples(ctx context.Context, db *store.DB, activityID int64) {
func seedIntervalLapsAndSamples(ctx context.Context, db *store.DB, userID, activityID int64) {
var laps []store.Lap
var samples []store.Sample
elapsed := 0.0
@@ -225,8 +228,8 @@ func seedIntervalLapsAndSamples(ctx context.Context, db *store.DB, activityID in
elapsed += lapDuration
}
must(db.ReplaceActivitySamples(ctx, activityID, samples))
must(db.ReplaceLaps(ctx, activityID, laps))
must(db.ReplaceActivitySamples(ctx, userID, activityID, samples))
must(db.ReplaceLaps(ctx, userID, activityID, laps))
}
func must(err error) {
@@ -235,14 +238,14 @@ func must(err error) {
}
}
func mustFindKindID(ctx context.Context, db *store.DB, name string) int64 {
kinds, err := db.ListWorkoutKinds(ctx, false)
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 (did migration 0004 run?)", name)
log.Fatalf("seedsample: no workout kind named %q found for the seeded user (did ProvisionUser seed the taxonomy?)", name)
return 0
}

View File

@@ -25,6 +25,7 @@ type activityListItem struct {
}
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"),
@@ -37,13 +38,13 @@ func (s *Server) handleListActivities(w http.ResponseWriter, r *http.Request) {
filter.Offset = offset
}
activities, err := s.DB.ListActivities(r.Context(), filter)
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(), false)
kinds, err := s.DB.ListWorkoutKinds(r.Context(), userID, false)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
@@ -56,7 +57,7 @@ func (s *Server) handleListActivities(w http.ResponseWriter, r *http.Request) {
resp := make([]activityListItem, 0, len(activities))
for _, a := range activities {
item := activityListItem{activityResponse: toActivityResponse(a)}
assignment, ok, err := s.DB.CurrentAssignment(r.Context(), a.ID)
assignment, ok, err := s.DB.CurrentAssignment(r.Context(), userID, a.ID)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
@@ -79,13 +80,14 @@ func (s *Server) handleListActivities(w http.ResponseWriter, r *http.Request) {
}
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(), id)
activity, ok, err := s.DB.GetActivity(r.Context(), userID, id)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
@@ -95,13 +97,13 @@ func (s *Server) handleGetActivity(w http.ResponseWriter, r *http.Request) {
return
}
laps, err := s.DB.LapsForActivity(r.Context(), id)
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(), id)
assignment, hasAssignment, err := s.DB.CurrentAssignment(r.Context(), userID, id)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return

View File

@@ -15,6 +15,7 @@ import (
"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"
@@ -29,7 +30,7 @@ var testSessionConfig = SessionConfig{
PublicBaseURL: "https://geniusrun.example.com",
}
func newTestServer(t *testing.T) (*Server, *store.DB) {
func newTestServer(t *testing.T) (*Server, *store.DB, int64) {
t.Helper()
db, err := store.Open(filepath.Join(t.TempDir(), "geniusrun_test.db"))
if err != nil {
@@ -37,9 +38,15 @@ func newTestServer(t *testing.T) (*Server, *store.DB) {
}
t.Cleanup(func() { db.Close() })
userID, err := db.ProvisionUser(context.Background(), "test-user", "Test User")
if err != nil {
t.Fatalf("ProvisionUser: %v", err)
}
m := &mock.Client{}
svc := appsync.NewService(m, db, appsync.Config{}, func() time.Time { return time.Date(2026, 7, 11, 0, 0, 0, 0, time.UTC) })
return NewServer(db, m, svc, &authmock.Verifier{}, testSessionConfig), db
garminFactory := func(garmin.Config) garmin.Client { return m }
s := NewServer(db, garminFactory, garmin.Config{}, appsync.Config{}, &authmock.Verifier{}, testSessionConfig)
return s, db, userID
}
func doJSON(t *testing.T, handler http.Handler, method, path string, body any) *httptest.ResponseRecorder {
@@ -67,7 +74,7 @@ func doJSON(t *testing.T, handler http.Handler, method, path string, body any) *
}
func TestHealth(t *testing.T) {
s, _ := newTestServer(t)
s, _, _ := newTestServer(t)
rec := doJSON(t, s.Router(), http.MethodGet, "/api/health", nil)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rec.Code)
@@ -75,7 +82,7 @@ func TestHealth(t *testing.T) {
}
func TestWorkoutKindList_ReturnsEightSeededTypesWithPaceFields(t *testing.T) {
s, _ := newTestServer(t)
s, _, _ := newTestServer(t)
rec := doJSON(t, s.Router(), http.MethodGet, "/api/workout-kinds/", nil)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d", rec.Code)
@@ -95,7 +102,7 @@ func TestWorkoutKindList_ReturnsEightSeededTypesWithPaceFields(t *testing.T) {
}
func TestWorkoutKindUpdate_SetsRuleAndPace(t *testing.T) {
s, _ := newTestServer(t)
s, _, _ := newTestServer(t)
router := s.Router()
rec := doJSON(t, router, http.MethodGet, "/api/workout-kinds/", nil)
@@ -132,7 +139,7 @@ func TestWorkoutKindUpdate_SetsRuleAndPace(t *testing.T) {
}
func TestWorkoutKindUpdate_RejectsInvalidRule(t *testing.T) {
s, _ := newTestServer(t)
s, _, _ := newTestServer(t)
router := s.Router()
rec := doJSON(t, router, http.MethodGet, "/api/workout-kinds/", nil)
@@ -149,7 +156,7 @@ func TestWorkoutKindUpdate_RejectsInvalidRule(t *testing.T) {
}
func TestWorkoutKindUpdate_RejectsInvertedHRRange(t *testing.T) {
s, _ := newTestServer(t)
s, _, _ := newTestServer(t)
router := s.Router()
rec := doJSON(t, router, http.MethodGet, "/api/workout-kinds/", nil)
@@ -168,7 +175,7 @@ func TestWorkoutKindUpdate_RejectsInvertedHRRange(t *testing.T) {
}
func TestWorkoutKindUpdate_RejectsInvertedPaceRange(t *testing.T) {
s, _ := newTestServer(t)
s, _, _ := newTestServer(t)
router := s.Router()
rec := doJSON(t, router, http.MethodGet, "/api/workout-kinds/", nil)
@@ -187,31 +194,31 @@ func TestWorkoutKindUpdate_RejectsInvertedPaceRange(t *testing.T) {
}
func TestReviewQueueResolve(t *testing.T) {
s, db := newTestServer(t)
s, db, userID := newTestServer(t)
ctx := newCtx()
activityID, err := db.UpsertActivity(ctx, store.Activity{GarminActivityID: 1, StartTimeUTC: "2026-07-11 06:00:00", RawJSON: "{}"})
activityID, err := db.UpsertActivity(ctx, userID, store.Activity{GarminActivityID: 1, StartTimeUTC: "2026-07-11 06:00:00", RawJSON: "{}"})
if err != nil {
t.Fatalf("UpsertActivity: %v", err)
}
kindID, err := db.CreateWorkoutKind(ctx, store.WorkoutKind{Name: "Test Kind", RuleJSON: `{"match":"all","conditions":[]}`, IsActive: true})
kindID, err := db.CreateWorkoutKind(ctx, userID, store.WorkoutKind{Name: "Test Kind", RuleJSON: `{"match":"all","conditions":[]}`, IsActive: true})
if err != nil {
t.Fatalf("CreateWorkoutKind: %v", err)
}
if _, err := db.InsertKindAssignment(ctx, store.KindAssignment{
if _, err := db.InsertKindAssignment(ctx, userID, store.KindAssignment{
ActivityID: activityID, AssignmentSource: store.AssignmentSourceRuleEngine, Status: store.AssignmentStatusNeedsReview,
CandidateKindsJSON: "[]",
}); err != nil {
t.Fatalf("InsertKindAssignment: %v", err)
}
targetLow, targetHigh := 3.0, 3.5
if err := db.ReplaceLaps(ctx, activityID, []store.Lap{
if err := db.ReplaceLaps(ctx, userID, activityID, []store.Lap{
{LapIndex: 1, TargetPaceLowMps: &targetLow, TargetPaceHighMps: &targetHigh},
}); err != nil {
t.Fatalf("ReplaceLaps: %v", err)
}
hr := 150.0
if err := db.ReplaceActivitySamples(ctx, activityID, []store.Sample{
if err := db.ReplaceActivitySamples(ctx, userID, activityID, []store.Sample{
{ElapsedSeconds: 0, HeartRate: &hr},
}); err != nil {
t.Fatalf("ReplaceActivitySamples: %v", err)
@@ -316,19 +323,19 @@ func TestReviewQueueResolve(t *testing.T) {
}
func TestReviewQueue_PaginatesByCursor(t *testing.T) {
s, db := newTestServer(t)
s, db, userID := newTestServer(t)
ctx := newCtx()
// 5 activities, newest first once sorted: 2026-07-05 .. 2026-07-01.
for i := 1; i <= 5; i++ {
activityID, err := db.UpsertActivity(ctx, store.Activity{
activityID, err := db.UpsertActivity(ctx, userID, store.Activity{
GarminActivityID: int64(i),
StartTimeUTC: fmt.Sprintf("2026-07-0%d 06:00:00", i), RawJSON: "{}",
StartTimeUTC: fmt.Sprintf("2026-07-0%d 06:00:00", i), RawJSON: "{}",
})
if err != nil {
t.Fatalf("UpsertActivity: %v", err)
}
if _, err := db.InsertKindAssignment(ctx, store.KindAssignment{
if _, err := db.InsertKindAssignment(ctx, userID, store.KindAssignment{
ActivityID: activityID, AssignmentSource: store.AssignmentSourceRuleEngine, Status: store.AssignmentStatusNeedsReview,
CandidateKindsJSON: "[]",
}); err != nil {
@@ -394,10 +401,10 @@ func TestReviewQueue_PaginatesByCursor(t *testing.T) {
}
func TestReviewQueue_FiltersByKindAndUnclassifiedStayPaginated(t *testing.T) {
s, db := newTestServer(t)
s, db, userID := newTestServer(t)
ctx := newCtx()
kinds, err := db.ListWorkoutKinds(ctx, true)
kinds, err := db.ListWorkoutKinds(ctx, userID, true)
if err != nil || len(kinds) < 2 {
t.Fatalf("ListWorkoutKinds: %v (len=%d)", err, len(kinds))
}
@@ -405,9 +412,9 @@ func TestReviewQueue_FiltersByKindAndUnclassifiedStayPaginated(t *testing.T) {
// 2 activities assigned to kindA, 1 to kindB, 1 unclassified.
makeActivity := func(n int64, kindID *int64) {
activityID, err := db.UpsertActivity(ctx, store.Activity{
activityID, err := db.UpsertActivity(ctx, userID, store.Activity{
GarminActivityID: n,
StartTimeUTC: fmt.Sprintf("2026-07-0%d 06:00:00", n), RawJSON: "{}",
StartTimeUTC: fmt.Sprintf("2026-07-0%d 06:00:00", n), RawJSON: "{}",
})
if err != nil {
t.Fatalf("UpsertActivity: %v", err)
@@ -416,7 +423,7 @@ func TestReviewQueue_FiltersByKindAndUnclassifiedStayPaginated(t *testing.T) {
if kindID == nil {
status = store.AssignmentStatusNeedsReview
}
if _, err := db.InsertKindAssignment(ctx, store.KindAssignment{
if _, err := db.InsertKindAssignment(ctx, userID, store.KindAssignment{
ActivityID: activityID, WorkoutKindID: kindID, AssignmentSource: store.AssignmentSourceRuleEngine,
Status: status, CandidateKindsJSON: "[]",
}); err != nil {
@@ -481,14 +488,14 @@ func TestReviewQueue_FiltersByKindAndUnclassifiedStayPaginated(t *testing.T) {
}
func TestResolveReview_RejectsRaceKind(t *testing.T) {
s, db := newTestServer(t)
s, db, userID := newTestServer(t)
ctx := newCtx()
activityID, err := db.UpsertActivity(ctx, store.Activity{GarminActivityID: 1, StartTimeUTC: "2026-07-11 06:00:00", RawJSON: "{}"})
activityID, err := db.UpsertActivity(ctx, userID, store.Activity{GarminActivityID: 1, StartTimeUTC: "2026-07-11 06:00:00", RawJSON: "{}"})
if err != nil {
t.Fatalf("UpsertActivity: %v", err)
}
raceKind, ok, err := db.GetWorkoutKindByName(ctx, "Race")
raceKind, ok, err := db.GetWorkoutKindByName(ctx, userID, "Race")
if err != nil || !ok {
t.Fatalf("GetWorkoutKindByName(Race): ok=%v err=%v", ok, err)
}
@@ -500,35 +507,35 @@ func TestResolveReview_RejectsRaceKind(t *testing.T) {
}
func TestReclassifyAll_SkipsManualAndRaceAssignments(t *testing.T) {
s, db := newTestServer(t)
s, db, userID := newTestServer(t)
ctx := newCtx()
easyID, err := db.CreateWorkoutKind(ctx, store.WorkoutKind{Name: "Test Easy", RuleJSON: `{"match":"all","conditions":[{"metric":"distance_meters","op":">=","value":0}]}`, IsActive: true})
easyID, err := db.CreateWorkoutKind(ctx, userID, store.WorkoutKind{Name: "Test Easy", RuleJSON: `{"match":"all","conditions":[{"metric":"distance_meters","op":">=","value":0}]}`, IsActive: true})
if err != nil {
t.Fatalf("CreateWorkoutKind: %v", err)
}
raceKind, ok, err := db.GetWorkoutKindByName(ctx, "Race")
raceKind, ok, err := db.GetWorkoutKindByName(ctx, userID, "Race")
if err != nil || !ok {
t.Fatalf("GetWorkoutKindByName(Race): ok=%v err=%v", ok, err)
}
ruleEngineActivity, _ := db.UpsertActivity(ctx, store.Activity{GarminActivityID: 1, StartTimeUTC: "2026-07-01 06:00:00", RawJSON: "{}"})
manualActivity, _ := db.UpsertActivity(ctx, store.Activity{GarminActivityID: 2, StartTimeUTC: "2026-07-02 06:00:00", RawJSON: "{}"})
raceActivity, _ := db.UpsertActivity(ctx, store.Activity{GarminActivityID: 3, EventTypeKey: "race", StartTimeUTC: "2026-07-03 06:00:00", RawJSON: "{}"})
ruleEngineActivity, _ := db.UpsertActivity(ctx, userID, store.Activity{GarminActivityID: 1, StartTimeUTC: "2026-07-01 06:00:00", RawJSON: "{}"})
manualActivity, _ := db.UpsertActivity(ctx, userID, store.Activity{GarminActivityID: 2, StartTimeUTC: "2026-07-02 06:00:00", RawJSON: "{}"})
raceActivity, _ := db.UpsertActivity(ctx, userID, store.Activity{GarminActivityID: 3, EventTypeKey: "race", StartTimeUTC: "2026-07-03 06:00:00", RawJSON: "{}"})
if _, err := db.InsertKindAssignment(ctx, store.KindAssignment{
if _, err := db.InsertKindAssignment(ctx, userID, store.KindAssignment{
ActivityID: ruleEngineActivity, WorkoutKindID: &easyID, AssignmentSource: store.AssignmentSourceRuleEngine,
Status: store.AssignmentStatusAssigned, CandidateKindsJSON: "[]",
}); err != nil {
t.Fatalf("InsertKindAssignment (rule engine): %v", err)
}
if _, err := db.InsertKindAssignment(ctx, store.KindAssignment{
if _, err := db.InsertKindAssignment(ctx, userID, store.KindAssignment{
ActivityID: manualActivity, WorkoutKindID: &easyID, AssignmentSource: store.AssignmentSourceManual,
Status: store.AssignmentStatusAssigned, CandidateKindsJSON: "[]",
}); err != nil {
t.Fatalf("InsertKindAssignment (manual): %v", err)
}
if _, err := db.InsertKindAssignment(ctx, store.KindAssignment{
if _, err := db.InsertKindAssignment(ctx, userID, store.KindAssignment{
ActivityID: raceActivity, WorkoutKindID: &raceKind.ID, AssignmentSource: store.AssignmentSourceRuleEngine,
Status: store.AssignmentStatusAssigned, CandidateKindsJSON: "[]",
}); err != nil {
@@ -549,7 +556,7 @@ func TestReclassifyAll_SkipsManualAndRaceAssignments(t *testing.T) {
t.Fatalf("reclassified = %d, want 1 (only the non-locked rule-engine activity)", body.Reclassified)
}
manualAssignment, ok, err := db.CurrentAssignment(ctx, manualActivity)
manualAssignment, ok, err := db.CurrentAssignment(ctx, userID, manualActivity)
if err != nil || !ok {
t.Fatalf("CurrentAssignment(manual): ok=%v err=%v", ok, err)
}
@@ -557,7 +564,7 @@ func TestReclassifyAll_SkipsManualAndRaceAssignments(t *testing.T) {
t.Errorf("manual assignment was overwritten: %+v", manualAssignment)
}
raceAssignment, ok, err := db.CurrentAssignment(ctx, raceActivity)
raceAssignment, ok, err := db.CurrentAssignment(ctx, userID, raceActivity)
if err != nil || !ok {
t.Fatalf("CurrentAssignment(race): ok=%v err=%v", ok, err)
}
@@ -567,25 +574,25 @@ func TestReclassifyAll_SkipsManualAndRaceAssignments(t *testing.T) {
}
func TestListActivities_ReportsLockedForManualAndRaceAssignments(t *testing.T) {
s, db := newTestServer(t)
s, db, userID := newTestServer(t)
ctx := newCtx()
easyID, err := db.CreateWorkoutKind(ctx, store.WorkoutKind{Name: "Test Easy Locked", RuleJSON: `{"match":"all","conditions":[]}`, IsActive: true})
easyID, err := db.CreateWorkoutKind(ctx, userID, store.WorkoutKind{Name: "Test Easy Locked", RuleJSON: `{"match":"all","conditions":[]}`, IsActive: true})
if err != nil {
t.Fatalf("CreateWorkoutKind: %v", err)
}
raceKind, ok, err := db.GetWorkoutKindByName(ctx, "Race")
raceKind, ok, err := db.GetWorkoutKindByName(ctx, userID, "Race")
if err != nil || !ok {
t.Fatalf("GetWorkoutKindByName(Race): ok=%v err=%v", ok, err)
}
ruleEngineActivity, _ := db.UpsertActivity(ctx, store.Activity{GarminActivityID: 1, StartTimeUTC: "2026-07-01 06:00:00", RawJSON: "{}"})
manualActivity, _ := db.UpsertActivity(ctx, store.Activity{GarminActivityID: 2, StartTimeUTC: "2026-07-02 06:00:00", RawJSON: "{}"})
raceActivity, _ := db.UpsertActivity(ctx, store.Activity{GarminActivityID: 3, EventTypeKey: "race", StartTimeUTC: "2026-07-03 06:00:00", RawJSON: "{}"})
ruleEngineActivity, _ := db.UpsertActivity(ctx, userID, store.Activity{GarminActivityID: 1, StartTimeUTC: "2026-07-01 06:00:00", RawJSON: "{}"})
manualActivity, _ := db.UpsertActivity(ctx, userID, store.Activity{GarminActivityID: 2, StartTimeUTC: "2026-07-02 06:00:00", RawJSON: "{}"})
raceActivity, _ := db.UpsertActivity(ctx, userID, store.Activity{GarminActivityID: 3, EventTypeKey: "race", StartTimeUTC: "2026-07-03 06:00:00", RawJSON: "{}"})
db.InsertKindAssignment(ctx, store.KindAssignment{ActivityID: ruleEngineActivity, WorkoutKindID: &easyID, AssignmentSource: store.AssignmentSourceRuleEngine, Status: store.AssignmentStatusAssigned, CandidateKindsJSON: "[]"})
db.InsertKindAssignment(ctx, store.KindAssignment{ActivityID: manualActivity, WorkoutKindID: &easyID, AssignmentSource: store.AssignmentSourceManual, Status: store.AssignmentStatusAssigned, CandidateKindsJSON: "[]"})
db.InsertKindAssignment(ctx, store.KindAssignment{ActivityID: raceActivity, WorkoutKindID: &raceKind.ID, AssignmentSource: store.AssignmentSourceRuleEngine, Status: store.AssignmentStatusAssigned, CandidateKindsJSON: "[]"})
db.InsertKindAssignment(ctx, userID, store.KindAssignment{ActivityID: ruleEngineActivity, WorkoutKindID: &easyID, AssignmentSource: store.AssignmentSourceRuleEngine, Status: store.AssignmentStatusAssigned, CandidateKindsJSON: "[]"})
db.InsertKindAssignment(ctx, userID, store.KindAssignment{ActivityID: manualActivity, WorkoutKindID: &easyID, AssignmentSource: store.AssignmentSourceManual, Status: store.AssignmentStatusAssigned, CandidateKindsJSON: "[]"})
db.InsertKindAssignment(ctx, userID, store.KindAssignment{ActivityID: raceActivity, WorkoutKindID: &raceKind.ID, AssignmentSource: store.AssignmentSourceRuleEngine, Status: store.AssignmentStatusAssigned, CandidateKindsJSON: "[]"})
rec := doJSON(t, s.Router(), http.MethodGet, "/api/activities/", nil)
if rec.Code != http.StatusOK {
@@ -618,10 +625,10 @@ func TestListActivities_ReportsLockedForManualAndRaceAssignments(t *testing.T) {
}
func TestSyncReset_DeletesActivitiesAndBackfillEndpointIsGone(t *testing.T) {
s, db := newTestServer(t)
s, db, userID := newTestServer(t)
ctx := newCtx()
if _, err := db.UpsertActivity(ctx, store.Activity{GarminActivityID: 1, StartTimeUTC: "2026-07-11 06:00:00", RawJSON: "{}"}); err != nil {
if _, err := db.UpsertActivity(ctx, userID, store.Activity{GarminActivityID: 1, StartTimeUTC: "2026-07-11 06:00:00", RawJSON: "{}"}); err != nil {
t.Fatalf("UpsertActivity: %v", err)
}
@@ -633,7 +640,7 @@ func TestSyncReset_DeletesActivitiesAndBackfillEndpointIsGone(t *testing.T) {
deadline := time.Now().Add(2 * time.Second)
for {
activities, err := db.ListActivities(ctx, store.ActivityFilter{})
activities, err := db.ListActivities(ctx, userID, store.ActivityFilter{})
if err != nil {
t.Fatalf("ListActivities: %v", err)
}
@@ -654,17 +661,17 @@ func TestSyncReset_DeletesActivitiesAndBackfillEndpointIsGone(t *testing.T) {
}
func TestProgression_ReturnsSortedTimeSeries(t *testing.T) {
s, db := newTestServer(t)
s, db, userID := newTestServer(t)
ctx := newCtx()
kindID, _ := db.CreateWorkoutKind(ctx, store.WorkoutKind{Name: "Test Kind", RuleJSON: `{}`, IsActive: true})
kindID, _ := db.CreateWorkoutKind(ctx, userID, store.WorkoutKind{Name: "Test Kind", RuleJSON: `{}`, IsActive: true})
speed := 3.0
a1, _ := db.UpsertActivity(ctx, store.Activity{GarminActivityID: 1, StartTimeUTC: "2026-07-01 06:00:00", AvgSpeedMps: &speed, RawJSON: "{}"})
a2, _ := db.UpsertActivity(ctx, store.Activity{GarminActivityID: 2, StartTimeUTC: "2026-07-05 06:00:00", AvgSpeedMps: &speed, RawJSON: "{}"})
a1, _ := db.UpsertActivity(ctx, userID, store.Activity{GarminActivityID: 1, StartTimeUTC: "2026-07-01 06:00:00", AvgSpeedMps: &speed, RawJSON: "{}"})
a2, _ := db.UpsertActivity(ctx, userID, store.Activity{GarminActivityID: 2, StartTimeUTC: "2026-07-05 06:00:00", AvgSpeedMps: &speed, RawJSON: "{}"})
for _, id := range []int64{a2, a1} { // insert out of order on purpose
db.InsertKindAssignment(ctx, store.KindAssignment{ActivityID: id, WorkoutKindID: &kindID, AssignmentSource: store.AssignmentSourceManual, Status: store.AssignmentStatusAssigned, CandidateKindsJSON: "[]"})
db.InsertKindAssignment(ctx, userID, store.KindAssignment{ActivityID: id, WorkoutKindID: &kindID, AssignmentSource: store.AssignmentSourceManual, Status: store.AssignmentStatusAssigned, CandidateKindsJSON: "[]"})
}
rec := doJSON(t, s.Router(), http.MethodGet, "/api/progression/"+itoa(kindID)+"?metric=pace", nil)
@@ -707,7 +714,7 @@ func itoa(v int64) string {
}
func TestProfile_GetDefaultsThenUpdate(t *testing.T) {
s, _ := newTestServer(t)
s, _, _ := newTestServer(t)
router := s.Router()
rec := doJSON(t, router, http.MethodGet, "/api/profile", nil)
@@ -739,7 +746,7 @@ func TestProfile_GetDefaultsThenUpdate(t *testing.T) {
}
func TestProfile_RejectsInvalidHRZones(t *testing.T) {
s, _ := newTestServer(t)
s, _, _ := newTestServer(t)
router := s.Router()
rec := doJSON(t, router, http.MethodGet, "/api/profile", nil)
@@ -758,13 +765,13 @@ func TestProfile_RejectsInvalidHRZones(t *testing.T) {
func newTestServerWithAuth(t *testing.T, verifier auth.Verifier) (*Server, *store.DB) {
t.Helper()
s, db := newTestServer(t)
s, db, _ := newTestServer(t)
s.Auth = verifier
return s, db
}
func TestHealth_NoSessionRequired(t *testing.T) {
s, _ := newTestServer(t)
s, _, _ := newTestServer(t)
req := httptest.NewRequest(http.MethodGet, "/api/health", nil)
rec := httptest.NewRecorder()
s.Router().ServeHTTP(rec, req)
@@ -774,7 +781,7 @@ func TestHealth_NoSessionRequired(t *testing.T) {
}
func TestProtectedRoute_RejectsMissingSession(t *testing.T) {
s, _ := newTestServer(t)
s, _, _ := newTestServer(t)
req := httptest.NewRequest(http.MethodGet, "/api/profile/", nil)
rec := httptest.NewRecorder()
s.Router().ServeHTTP(rec, req)
@@ -893,7 +900,7 @@ func TestSessionMe_ReturnsAuthenticatedUser(t *testing.T) {
func mustServerRouter(t *testing.T) http.Handler {
t.Helper()
s, _ := newTestServer(t)
s, _, _ := newTestServer(t)
return s.Router()
}

View File

@@ -25,24 +25,31 @@ func authStatusString(s garmin.AuthStatus) string {
}
}
func (s *Server) recordAuthResult(res garmin.AuthResult) {
func (s *Server) recordAuthResult(userID int64, res garmin.AuthResult) {
s.mu.Lock()
s.authStatus = res.Status
s.authMessage = res.Message
s.userAuthStatus[userID] = res.Status
s.userAuthMessage[userID] = res.Message
s.mu.Unlock()
}
func (s *Server) handleAuthLogin(w http.ResponseWriter, r *http.Request) {
res, err := s.Garmin.Authenticate(r.Context())
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(res)
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"`
}
@@ -55,18 +62,24 @@ func (s *Server) handleAuthMFA(w http.ResponseWriter, r *http.Request) {
return
}
res, err := s.Garmin.CompleteMFA(r.Context(), body.Code)
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(res)
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.authStatus, s.authMessage
status, msg := s.userAuthStatus[userID], s.userAuthMessage[userID]
s.mu.Unlock()
writeJSON(w, http.StatusOK, authResponse{Status: authStatusString(status), Message: msg})
}

View File

@@ -0,0 +1,155 @@
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())
}
}

View File

@@ -24,7 +24,8 @@ type workoutKindResponse struct {
}
func (s *Server) toWorkoutKindResponse(r *http.Request, k store.WorkoutKind) (workoutKindResponse, error) {
pace, err := s.DB.GetWorkoutTypePace(r.Context(), k.ID)
userID := userIDFromContext(r.Context())
pace, err := s.DB.GetWorkoutTypePace(r.Context(), userID, k.ID)
if err != nil {
return workoutKindResponse{}, err
}
@@ -71,8 +72,9 @@ func (req workoutKindRequest) validate() (classify.Node, error) {
}
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(), activeOnly)
kinds, err := s.DB.ListWorkoutKinds(r.Context(), userID, activeOnly)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
@@ -90,12 +92,13 @@ func (s *Server) handleListWorkoutKinds(w http.ResponseWriter, r *http.Request)
}
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(), id)
kind, ok, err := s.DB.GetWorkoutKind(r.Context(), userID, id)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
@@ -113,12 +116,13 @@ func (s *Server) handleGetWorkoutKind(w http.ResponseWriter, r *http.Request) {
}
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(), id)
existing, ok, err := s.DB.GetWorkoutKind(r.Context(), userID, id)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
@@ -143,14 +147,14 @@ func (s *Server) handleUpdateWorkoutKind(w http.ResponseWriter, r *http.Request)
isActive = *req.IsActive
}
if err := s.DB.UpdateWorkoutKind(r.Context(), store.WorkoutKind{
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(), store.WorkoutTypePace{
if err := s.DB.UpdateWorkoutTypePace(r.Context(), userID, store.WorkoutTypePace{
WorkoutKindID: id,
PaceMinSecPerKm: req.PaceMinSecPerKm,
PaceMaxSecPerKm: req.PaceMaxSecPerKm,
@@ -161,7 +165,7 @@ func (s *Server) handleUpdateWorkoutKind(w http.ResponseWriter, r *http.Request)
return
}
kind, _, _ := s.DB.GetWorkoutKind(r.Context(), id)
kind, _, _ := s.DB.GetWorkoutKind(r.Context(), userID, id)
resp, err := s.toWorkoutKindResponse(r, kind)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())

View File

@@ -36,7 +36,8 @@ func validateProfile(p store.Profile) error {
}
func (s *Server) handleGetProfile(w http.ResponseWriter, r *http.Request) {
p, err := s.DB.GetProfile(r.Context())
userID := userIDFromContext(r.Context())
p, err := s.DB.GetProfile(r.Context(), userID)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
@@ -45,6 +46,7 @@ func (s *Server) handleGetProfile(w http.ResponseWriter, r *http.Request) {
}
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")
@@ -55,13 +57,18 @@ func (s *Server) handleUpdateProfile(w http.ResponseWriter, r *http.Request) {
return
}
if err := s.DB.UpdateProfile(r.Context(), p); err != nil {
if err := s.DB.UpdateProfile(r.Context(), userID, p); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
s.Garmin.UpdateCredentials(p.GarminEmail, p.GarminPassword)
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())
updated, err := s.DB.GetProfile(r.Context(), userID)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return

View File

@@ -61,6 +61,7 @@ func metricValue(metric string, a store.Activity) (float64, bool) {
// handleProgression returns a time series of the requested metric for every
// activity currently assigned to a workout kind, for progression charts.
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")
@@ -72,7 +73,7 @@ func (s *Server) handleProgression(w http.ResponseWriter, r *http.Request) {
}
from, to := r.URL.Query().Get("from"), r.URL.Query().Get("to")
assignments, err := s.DB.AssignmentsForKind(r.Context(), kindID)
assignments, err := s.DB.AssignmentsForKind(r.Context(), userID, kindID)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
@@ -80,7 +81,7 @@ func (s *Server) handleProgression(w http.ResponseWriter, r *http.Request) {
points := []progressionPoint{}
for _, a := range assignments {
activity, ok, err := s.DB.GetActivity(r.Context(), a.ActivityID)
activity, ok, err := s.DB.GetActivity(r.Context(), userID, a.ActivityID)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return

View File

@@ -18,13 +18,14 @@ import (
// It's a synchronous, bounded operation (unlike sync), so it runs inline
// rather than in the background.
func (s *Server) handleReclassifyAll(w http.ResponseWriter, r *http.Request) {
raceKind, hasRaceKind, err := s.DB.GetWorkoutKindByName(r.Context(), "Race")
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())
assignments, err := s.DB.AllCurrentAssignments(r.Context(), userID)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
@@ -41,8 +42,13 @@ func (s *Server) handleReclassifyAll(w http.ResponseWriter, r *http.Request) {
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 := s.Sync.ClassifyActivity(r.Context(), activityID); err != nil {
if err := svc.ClassifyActivity(r.Context(), activityID); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}

View File

@@ -35,6 +35,7 @@ type reviewQueueItem struct {
// that cursor slicing too, so a filtered view still only loads (and
// chart-renders) one page at a time instead of the whole matching backlog.
func (s *Server) handleReviewQueue(w http.ResponseWriter, r *http.Request) {
userID := userIDFromContext(r.Context())
limit := defaultReviewQueuePageSize
if v := r.URL.Query().Get("limit"); v != "" {
if n, err := strconv.Atoi(v); err == nil && n > 0 {
@@ -51,7 +52,7 @@ func (s *Server) handleReviewQueue(w http.ResponseWriter, r *http.Request) {
}
unclassifiedOnly := r.URL.Query().Get("unclassified") == "true"
queue, err := s.DB.AllCurrentAssignments(r.Context())
queue, err := s.DB.AllCurrentAssignments(r.Context(), userID)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
@@ -69,7 +70,7 @@ func (s *Server) handleReviewQueue(w http.ResponseWriter, r *http.Request) {
if unclassifiedOnly && a.WorkoutKindID != nil {
continue
}
activity, ok, err := s.DB.GetActivity(r.Context(), a.ActivityID)
activity, ok, err := s.DB.GetActivity(r.Context(), userID, a.ActivityID)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
@@ -102,7 +103,7 @@ func (s *Server) handleReviewQueue(w http.ResponseWriter, r *http.Request) {
items := make([]reviewQueueItem, 0, len(all))
for _, wa := range all {
laps, err := s.DB.LapsForActivity(r.Context(), wa.assignment.ActivityID)
laps, err := s.DB.LapsForActivity(r.Context(), userID, wa.assignment.ActivityID)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
@@ -110,7 +111,7 @@ func (s *Server) handleReviewQueue(w http.ResponseWriter, r *http.Request) {
// Per-second telemetry, not just per-lap averages, so the chart can
// show real within-lap variation instead of one flat segment per lap
// (most activities only have a handful of laps).
samples, err := s.DB.SamplesForActivity(r.Context(), wa.assignment.ActivityID)
samples, err := s.DB.SamplesForActivity(r.Context(), userID, wa.assignment.ActivityID)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
@@ -137,6 +138,7 @@ func (s *Server) handleReviewQueue(w http.ResponseWriter, r *http.Request) {
}
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")
@@ -155,7 +157,7 @@ func (s *Server) handleResolveReview(w http.ResponseWriter, r *http.Request) {
return
}
kind, ok, err := s.DB.GetWorkoutKind(r.Context(), body.WorkoutKindID)
kind, ok, err := s.DB.GetWorkoutKind(r.Context(), userID, body.WorkoutKindID)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
@@ -169,7 +171,7 @@ func (s *Server) handleResolveReview(w http.ResponseWriter, r *http.Request) {
}
kindID := body.WorkoutKindID
if _, err := s.DB.InsertKindAssignment(r.Context(), store.KindAssignment{
if _, err := s.DB.InsertKindAssignment(r.Context(), userID, store.KindAssignment{
ActivityID: activityID,
WorkoutKindID: &kindID,
AssignmentSource: store.AssignmentSourceManual,
@@ -191,13 +193,14 @@ func (s *Server) handleResolveReview(w http.ResponseWriter, r *http.Request) {
// different kind), but the backend doesn't re-enforce that here, matching
// handleResolveReview's own lack of a lock precondition check.
func (s *Server) handleUnassignReview(w http.ResponseWriter, r *http.Request) {
userID := userIDFromContext(r.Context())
activityID, err := strconv.ParseInt(chi.URLParam(r, "activityID"), 10, 64)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid activity id")
return
}
if _, err := s.DB.InsertKindAssignment(r.Context(), store.KindAssignment{
if _, err := s.DB.InsertKindAssignment(r.Context(), userID, store.KindAssignment{
ActivityID: activityID,
WorkoutKindID: nil,
AssignmentSource: store.AssignmentSourceManual,
@@ -216,13 +219,14 @@ func (s *Server) handleUnassignReview(w http.ResponseWriter, r *http.Request) {
// handleResolveReview, not a delete: the kind stays visible as-is until
// something actually reclassifies it.
func (s *Server) handleUnlockReview(w http.ResponseWriter, r *http.Request) {
userID := userIDFromContext(r.Context())
activityID, err := strconv.ParseInt(chi.URLParam(r, "activityID"), 10, 64)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid activity id")
return
}
current, ok, err := s.DB.CurrentAssignment(r.Context(), activityID)
current, ok, err := s.DB.CurrentAssignment(r.Context(), userID, activityID)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
@@ -240,7 +244,7 @@ func (s *Server) handleUnlockReview(w http.ResponseWriter, r *http.Request) {
if current.WorkoutKindID == nil {
status = store.AssignmentStatusNeedsReview
}
if _, err := s.DB.InsertKindAssignment(r.Context(), store.KindAssignment{
if _, err := s.DB.InsertKindAssignment(r.Context(), userID, store.KindAssignment{
ActivityID: activityID,
WorkoutKindID: current.WorkoutKindID,
AssignmentSource: store.AssignmentSourceRuleEngine,

View File

@@ -5,8 +5,11 @@ package api
import (
"context"
"encoding/json"
"fmt"
"log"
"net/http"
"path/filepath"
"strconv"
"sync"
"github.com/go-chi/chi/v5"
@@ -17,23 +20,125 @@ import (
appsync "geniusrun/backend/internal/sync"
)
// Server wires the HTTP handlers to the app's dependencies.
// 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
Garmin garmin.Client
Sync *appsync.Service
Auth auth.Verifier
Session SessionConfig
mu sync.Mutex
authStatus garmin.AuthStatus
authMessage string
syncRunning bool
// 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, g garmin.Client, s *appsync.Service, authVerifier auth.Verifier, session SessionConfig) *Server {
return &Server{DB: db, Garmin: g, Sync: s, Auth: authVerifier, Session: session}
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.
@@ -50,49 +155,55 @@ func (s *Server) Router() http.Handler {
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.Route("/profile", func(r chi.Router) {
r.Get("/", s.handleGetProfile)
r.Put("/", s.handleUpdateProfile)
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)
})
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
@@ -135,25 +246,25 @@ func writeError(w http.ResponseWriter, status int, msg string) {
}
// backgroundSync runs fn in a goroutine with a fresh context, guarded so
// only one sync operation runs at a time. Returns false if one is already
// in progress.
func (s *Server) backgroundSync(fn func(ctx context.Context) error) bool {
// 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.syncRunning {
if s.userSyncRunning[userID] {
s.mu.Unlock()
return false
}
s.syncRunning = true
s.userSyncRunning[userID] = true
s.mu.Unlock()
go func() {
defer func() {
s.mu.Lock()
s.syncRunning = false
s.userSyncRunning[userID] = false
s.mu.Unlock()
}()
if err := fn(context.Background()); err != nil {
log.Printf("api: background sync error: %v", err)
log.Printf("api: background sync error (user %d): %v", userID, err)
}
}()
return true

View File

@@ -24,8 +24,10 @@ type SessionConfig struct {
}
type sessionMeResponse struct {
Name string `json:"name"`
Email string `json:"email"`
Name string `json:"name"`
Email string `json:"email"`
HasProfile bool `json:"has_profile"`
DisplayName string `json:"display_name,omitempty"`
}
func (s *Server) handleSessionLogin(w http.ResponseWriter, r *http.Request) {
@@ -92,5 +94,10 @@ func (s *Server) handleSessionMe(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusUnauthorized, "not authenticated")
return
}
writeJSON(w, http.StatusOK, sessionMeResponse{Name: claims.Name, Email: claims.Email})
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)
}

View File

@@ -0,0 +1,39 @@
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})
}

View File

@@ -0,0 +1,85 @@
package api
import (
"encoding/json"
"net/http"
"net/http/httptest"
"path/filepath"
"testing"
authmock "geniusrun/backend/internal/auth/mock"
"geniusrun/backend/internal/garmin"
"geniusrun/backend/internal/garmin/mock"
"geniusrun/backend/internal/store"
appsync "geniusrun/backend/internal/sync"
)
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") // 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) {
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)
}
}
func TestSetup_RejectsWhenAlreadyProvisioned(t *testing.T) {
s, _, _ := newTestServer(t)
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())
}
}
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)
}
}

View File

@@ -19,8 +19,14 @@ const detailFillBatchSize = 50
// FullSync run so "last sync" reports the combined activity count, not just
// whichever of Backfill/IncrementalSync happened to finish last.
func (s *Server) handleSyncRun(w http.ResponseWriter, r *http.Request) {
ok := s.backgroundSync(func(ctx context.Context) error {
return s.Sync.FullSync(ctx, detailFillBatchSize)
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")
@@ -34,8 +40,14 @@ func (s *Server) handleSyncRun(w http.ResponseWriter, r *http.Request) {
// performs a genuinely fresh pull from Garmin. Destructive -- the frontend
// gates this behind a confirmation.
func (s *Server) handleSyncReset(w http.ResponseWriter, r *http.Request) {
ok := s.backgroundSync(func(ctx context.Context) error {
return s.Sync.ResetAll(ctx)
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")
@@ -45,7 +57,8 @@ func (s *Server) handleSyncReset(w http.ResponseWriter, r *http.Request) {
}
func (s *Server) handleSyncRuns(w http.ResponseWriter, r *http.Request) {
runs, err := s.DB.ListSyncRuns(r.Context(), 20)
userID := userIDFromContext(r.Context())
runs, err := s.DB.ListSyncRuns(r.Context(), userID, 20)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
@@ -54,22 +67,28 @@ func (s *Server) handleSyncRuns(w http.ResponseWriter, r *http.Request) {
}
func (s *Server) handleSyncStatus(w http.ResponseWriter, r *http.Request) {
run, ok, err := s.DB.LatestSyncRun(r.Context())
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())
remaining, err := s.DB.CountActivitiesMissingDetails(r.Context(), userID)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
s.mu.Lock()
inProgress := s.syncRunning
inProgress := s.userSyncRunning[userID]
s.mu.Unlock()
progress := s.Sync.Progress()
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,

View File

@@ -0,0 +1,81 @@
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
}

View File

@@ -0,0 +1,68 @@
package api
import (
"net/http"
"net/http/httptest"
"path/filepath"
"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"
)
func TestResolveUser_AttachesResolvedUserWhenProvisioned(t *testing.T) {
s, _, userID := newTestServer(t)
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) {
// Deliberately not newTestServer(t): that helper auto-provisions the
// "test-user" sub that doJSON's cookie always carries, which would
// defeat the point of this test. Build a server against a bare DB
// instead, same pattern as setup_test.go's unprovisioned-session tests.
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)
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)
}
}

View File

@@ -7,7 +7,9 @@ package config
import (
"fmt"
"log"
"os"
"path/filepath"
"strconv"
"strings"
"time"
@@ -24,9 +26,14 @@ type Config struct {
GarminPythonPath string
// GarminServerPath is mcp-garmin's server.py.
GarminServerPath string
// GarminTokenStore, if set, overrides mcp-garmin's default ~/.garth
// session cache location.
GarminTokenStore string
// GarminTokenStoreRoot is the root directory under which each user's
// mcp-garmin session cache lives (one subdirectory per user id, e.g.
// "<root>/3"). Read from the GARMIN_TOKENSTORE env var; if unset, it
// defaults to a "garmin-tokenstores" directory next to DBPath so every
// deployment gets per-user isolation automatically -- multi-tenant
// operation always relies on this being a real, distinct-per-user path
// (see api.Server.garminFor), so it can never be silently left empty.
GarminTokenStoreRoot string
MinConfidence float64
IncrementalSyncEvery time.Duration
@@ -45,6 +52,13 @@ type Config struct {
SessionSecret []byte
SessionDuration time.Duration
SessionSecure bool
// 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
}
// Load reads configuration from environment variables, applying defaults
@@ -55,7 +69,7 @@ func Load() (Config, error) {
DBPath: getEnvDefault("GENIUSRUN_DB_PATH", "geniusrun.db"),
GarminPythonPath: os.Getenv("MCP_GARMIN_PYTHON"),
GarminServerPath: os.Getenv("MCP_GARMIN_SERVER"),
GarminTokenStore: os.Getenv("GARMIN_TOKENSTORE"),
GarminTokenStoreRoot: os.Getenv("GARMIN_TOKENSTORE"),
MinConfidence: getEnvFloat("GENIUSRUN_MIN_CONFIDENCE", 0.6),
IncrementalSyncEvery: getEnvDuration("GENIUSRUN_INCREMENTAL_SYNC_EVERY", 6*time.Hour),
PublicBaseURL: strings.TrimRight(os.Getenv("GENIUSRUN_PUBLIC_BASE_URL"), "/"),
@@ -64,6 +78,12 @@ func Load() (Config, error) {
OIDCClientSecret: os.Getenv("GENIUSRUN_OIDC_CLIENT_SECRET"),
OIDCRequiredRole: getEnvDefault("GENIUSRUN_OIDC_REQUIRED_ROLE", "geniusrun-user"),
SessionDuration: getEnvDuration("GENIUSRUN_SESSION_DURATION", 720*time.Hour),
LegacyOwnerOIDCSub: os.Getenv("GENIUSRUN_LEGACY_OWNER_OIDC_SUB"),
}
if cfg.GarminTokenStoreRoot == "" {
cfg.GarminTokenStoreRoot = filepath.Join(filepath.Dir(cfg.DBPath), "garmin-tokenstores")
log.Printf("GARMIN_TOKENSTORE not set, defaulting to %q", cfg.GarminTokenStoreRoot)
}
if cfg.GarminPythonPath == "" {

View File

@@ -1,6 +1,7 @@
package config
import (
"path/filepath"
"testing"
"time"
)
@@ -80,6 +81,35 @@ func TestLoad_SessionSecretTooShort(t *testing.T) {
}
}
func TestLoad_GarminTokenStoreRootDefaultsNextToDBPath(t *testing.T) {
setRequiredEnv(t)
t.Setenv("GARMIN_TOKENSTORE", "")
t.Setenv("GENIUSRUN_DB_PATH", "/var/lib/geniusrun/geniusrun.db")
cfg, err := Load()
if err != nil {
t.Fatalf("Load: %v", err)
}
want := filepath.Join("/var/lib/geniusrun", "garmin-tokenstores")
if cfg.GarminTokenStoreRoot != want {
t.Errorf("GarminTokenStoreRoot = %q, want %q", cfg.GarminTokenStoreRoot, want)
}
}
func TestLoad_GarminTokenStoreRootExplicitOverridesDefault(t *testing.T) {
setRequiredEnv(t)
t.Setenv("GARMIN_TOKENSTORE", "/custom/tokenstores")
t.Setenv("GENIUSRUN_DB_PATH", "/var/lib/geniusrun/geniusrun.db")
cfg, err := Load()
if err != nil {
t.Fatalf("Load: %v", err)
}
if cfg.GarminTokenStoreRoot != "/custom/tokenstores" {
t.Errorf("GarminTokenStoreRoot = %q, want explicit override to take precedence", cfg.GarminTokenStoreRoot)
}
}
func TestLoad_CustomRoleAndDuration(t *testing.T) {
setRequiredEnv(t)
t.Setenv("GENIUSRUN_OIDC_REQUIRED_ROLE", "admin")

View File

@@ -49,19 +49,19 @@ type Activity struct {
UpdatedAt string
}
// UpsertActivity inserts a new activity or updates the existing row for the
// same garmin_activity_id (idempotent, safe to call on every sync pass), and
// returns its internal id.
func (db *DB) UpsertActivity(ctx context.Context, a Activity) (int64, error) {
// 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 (
garmin_activity_id, event_type_key, workout_id, start_time_utc,
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(garmin_activity_id) DO UPDATE SET
) 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,
@@ -77,19 +77,19 @@ func (db *DB) UpsertActivity(ctx context.Context, a Activity) (int64, error) {
raw_json=excluded.raw_json,
updated_at=datetime('now')
`,
a.GarminActivityID, a.EventTypeKey, a.WorkoutID, a.StartTimeUTC,
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: %w", a.GarminActivityID, err)
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 garmin_activity_id = ?`, a.GarminActivityID).Scan(&id); err != nil {
return 0, fmt.Errorf("fetch id for activity %d: %w", a.GarminActivityID, err)
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
}
@@ -116,15 +116,15 @@ const activityColumns = `
created_at, updated_at
`
// GetActivity fetches one activity by its internal id.
func (db *DB) GetActivity(ctx context.Context, id int64) (Activity, bool, error) {
row := db.QueryRowContext(ctx, `SELECT `+activityColumns+` FROM activities WHERE id = ?`, id)
// 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: %w", id, err)
return Activity{}, false, fmt.Errorf("get activity %d for user %d: %w", id, userID, err)
}
return a, true, nil
}
@@ -137,10 +137,11 @@ type ActivityFilter struct {
Offset int
}
// ListActivities returns activities newest-first, optionally filtered by date range.
func (db *DB) ListActivities(ctx context.Context, f ActivityFilter) ([]Activity, error) {
query := `SELECT ` + activityColumns + ` FROM activities WHERE 1=1`
var args []any
// 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)
@@ -157,7 +158,7 @@ func (db *DB) ListActivities(ctx context.Context, f ActivityFilter) ([]Activity,
rows, err := db.QueryContext(ctx, query, args...)
if err != nil {
return nil, fmt.Errorf("list activities: %w", err)
return nil, fmt.Errorf("list activities for user %d: %w", userID, err)
}
defer rows.Close()
@@ -172,83 +173,79 @@ func (db *DB) ListActivities(ctx context.Context, f ActivityFilter) ([]Activity,
return activities, rows.Err()
}
// ActivityExists reports whether an activity with this garmin_activity_id is
// already stored, checked before each upsert during a sync pass so the
// reported "activities fetched" count reflects genuinely new activities, not
// every activity Garmin's API happens to return for the queried date range
// (which, thanks to the incremental overlap window and backfill's
// already-covered history, is almost always a re-listing of known ones).
func (db *DB) ActivityExists(ctx context.Context, garminActivityID int64) (bool, error) {
// 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 garmin_activity_id = ?`, garminActivityID).Scan(&id)
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: %w", garminActivityID, err)
return false, fmt.Errorf("check activity %d exists for user %d: %w", garminActivityID, userID, err)
}
return true, nil
}
// LatestActivityStartTime returns the start_time_utc of the most recently
// started activity we have, used to compute the incremental sync window.
func (db *DB) LatestActivityStartTime(ctx context.Context) (string, bool, error) {
// 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 ORDER BY start_time_utc DESC LIMIT 1`).Scan(&t)
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: %w", err)
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, activityID int64, rawJSON string) error {
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 = ?`, rawJSON, activityID)
WHERE id = ? AND user_id = ?`, rawJSON, activityID, userID)
if err != nil {
return fmt.Errorf("set activity %d details: %w", activityID, err)
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, activityID int64, rawJSON string) error {
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 = ?`, rawJSON, activityID)
WHERE id = ? AND user_id = ?`, rawJSON, activityID, userID)
if err != nil {
return fmt.Errorf("set activity %d workout: %w", activityID, err)
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, activityID int64) error {
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 = ?`, activityID)
WHERE id = ? AND user_id = ?`, activityID, userID)
if err != nil {
return fmt.Errorf("set activity %d splits fetched: %w", activityID, err)
return fmt.Errorf("set activity %d splits fetched for user %d: %w", activityID, userID, err)
}
return nil
}
// ActivitiesMissingDetails returns activities that haven't had
// 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, limit int) ([]Activity, error) {
func (db *DB) ActivitiesMissingDetails(ctx context.Context, userID int64, limit int) ([]Activity, error) {
rows, err := db.QueryContext(ctx, `SELECT `+activityColumns+` FROM activities
WHERE details_fetched_at IS NULL OR splits_fetched_at IS NULL
ORDER BY start_time_utc DESC LIMIT ?`, limit)
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: %w", err)
return nil, fmt.Errorf("list activities missing details for user %d: %w", userID, err)
}
defer rows.Close()
@@ -263,15 +260,15 @@ func (db *DB) ActivitiesMissingDetails(ctx context.Context, limit int) ([]Activi
return activities, rows.Err()
}
// CountActivitiesMissingDetails returns how many 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) (int, error) {
// 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 details_fetched_at IS NULL OR splits_fetched_at IS NULL`).Scan(&n)
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: %w", err)
return 0, fmt.Errorf("count activities missing details for user %d: %w", userID, err)
}
return n, nil
}

View File

@@ -28,8 +28,18 @@ type KindAssignment struct {
CreatedAt string
}
// InsertKindAssignment appends a new assignment row for an activity.
func (db *DB) InsertKindAssignment(ctx context.Context, a KindAssignment) (int64, error) {
// 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 (?,?,?,?,?,?)`,
@@ -46,29 +56,37 @@ func scanKindAssignment(row interface{ Scan(...any) error }) (KindAssignment, er
return a, err
}
const kindAssignmentColumns = `id, activity_id, workout_kind_id, assignment_source, status, confidence, candidate_kinds_json, created_at`
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, if any.
func (db *DB) CurrentAssignment(ctx context.Context, activityID int64) (KindAssignment, bool, error) {
row := db.QueryRowContext(ctx, `SELECT `+kindAssignmentColumns+` FROM current_kind_assignment WHERE activity_id = ?`, activityID)
// 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: %w", activityID, err)
return KindAssignment{}, false, fmt.Errorf("current assignment for activity %d (user %d): %w", activityID, userID, err)
}
return a, true, nil
}
// ReviewQueue returns activities whose current assignment status is
// needs_review, newest first.
func (db *DB) ReviewQueue(ctx context.Context) ([]KindAssignment, error) {
// 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
WHERE status = ? ORDER BY created_at DESC`, AssignmentStatusNeedsReview)
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: %w", err)
return nil, fmt.Errorf("review queue for user %d: %w", userID, err)
}
defer rows.Close()
@@ -83,37 +101,44 @@ func (db *DB) ReviewQueue(ctx context.Context) ([]KindAssignment, error) {
return assignments, rows.Err()
}
// AllCurrentAssignments returns the latest assignment for every activity
// that has one, regardless of status or source -- the basis for deciding
// which activities a global reclassify pass is allowed to touch.
func (db *DB) AllCurrentAssignments(ctx context.Context) ([]KindAssignment, error) {
rows, err := db.QueryContext(ctx, `SELECT `+kindAssignmentColumns+` FROM current_kind_assignment`)
if err != nil {
return nil, fmt.Errorf("all current assignments: %w", 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 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, workoutKindID int64) ([]KindAssignment, error) {
// 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
WHERE workout_kind_id = ? AND status = ? ORDER BY created_at ASC`,
workoutKindID, AssignmentStatusAssigned)
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("assignments for kind %d: %w", workoutKindID, err)
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()

View File

@@ -78,21 +78,65 @@ func (db *DB) migrate() error {
if err != nil {
return fmt.Errorf("read migration %s: %w", name, err)
}
// Migrations 0023, 0024, 0025, and 0026 rebuild tables that have
// incoming foreign keys (kind_assignments/workout_type_paces
// reference workout_kinds; sync_state is referenced by
// activities/sync_runs; laps/activity_samples/kind_assignments
// reference activities). These require temporary FK disable in
// autocommit mode (before the transaction begins) so the DROP
// TABLE succeeds; a mid-transaction PRAGMA is a no-op with
// modernc.org/sqlite.
tableRebuildMigrations := map[string]bool{
"0023_profile_user_scoped.sql": true,
"0024_workout_kinds_user_scoped.sql": true,
"0025_sync_state_user_scoped.sql": true,
"0026_activities_unique_constraint.sql": true,
}
needsFKToggle := tableRebuildMigrations[name]
if needsFKToggle {
if _, err := db.Exec(`PRAGMA foreign_keys = OFF`); err != nil {
return fmt.Errorf("disable foreign keys before migration %s: %w", name, err)
}
}
tx, err := db.Begin()
if err != nil {
if needsFKToggle {
db.Exec(`PRAGMA foreign_keys = ON`)
}
return fmt.Errorf("begin migration tx for %s: %w", name, err)
}
if _, err := tx.Exec(string(content)); err != nil {
tx.Rollback()
if needsFKToggle {
db.Exec(`PRAGMA foreign_keys = ON`)
}
return fmt.Errorf("apply migration %s: %w", name, err)
}
if _, err := tx.Exec(`INSERT INTO schema_migrations (filename) VALUES (?)`, name); err != nil {
tx.Rollback()
if needsFKToggle {
db.Exec(`PRAGMA foreign_keys = ON`)
}
return fmt.Errorf("record migration %s: %w", name, err)
}
if err := tx.Commit(); err != nil {
if needsFKToggle {
db.Exec(`PRAGMA foreign_keys = ON`)
}
return fmt.Errorf("commit migration %s: %w", name, err)
}
if needsFKToggle {
if _, err := db.Exec(`PRAGMA foreign_keys = ON`); err != nil {
return fmt.Errorf("enable foreign keys after migration %s: %w", name, err)
}
}
}
return nil
}

View File

@@ -0,0 +1,163 @@
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")
}
}

View File

@@ -2,6 +2,7 @@ package store
import (
"context"
"database/sql"
"fmt"
)
@@ -35,9 +36,19 @@ type Lap struct {
RawJSON string
}
// ReplaceLaps deletes any existing laps for activityID and inserts the given
// set, so re-syncing an activity's splits is idempotent.
func (db *DB) ReplaceLaps(ctx context.Context, activityID int64, laps []Lap) error {
// 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)
@@ -66,15 +77,19 @@ func (db *DB) ReplaceLaps(ctx context.Context, activityID int64, laps []Lap) err
return tx.Commit()
}
// LapsForActivity returns all laps for an activity, ordered by lap_index.
func (db *DB) LapsForActivity(ctx context.Context, activityID int64) ([]Lap, error) {
// 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 id, 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
FROM laps WHERE activity_id = ? ORDER BY lap_index`, activityID)
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: %w", activityID, err)
return nil, fmt.Errorf("laps for activity %d (user %d): %w", activityID, userID, err)
}
defer rows.Close()

View File

@@ -0,0 +1,54 @@
package store
import (
"context"
"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.
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 != 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()
}

View File

@@ -0,0 +1,97 @@
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)
}
}

View File

@@ -0,0 +1,6 @@
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'))
);

View File

@@ -0,0 +1,15 @@
-- 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);

View File

@@ -0,0 +1,76 @@
-- 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.
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;

View File

@@ -0,0 +1,31 @@
-- 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.
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;

View File

@@ -0,0 +1,16 @@
-- Same CHECK(id = 1) rebuild reasoning as migration 0023's profile rebuild.
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;

View File

@@ -0,0 +1,55 @@
-- activities.garmin_activity_id's UNIQUE constraint (migration 0001) must become
-- per-user (UNIQUE(user_id, garmin_activity_id)) now that the per-user-profile
-- design allows multiple users to share the same Garmin activity ID. The old
-- constraint alone was intentionally left in place by migration 0022 as a
-- belts-and-suspenders safeguard (Garmin IDs are globally unique in practice),
-- but it must be removed now to allow Task 6's cross-user test to pass.
--
-- The FK enforcement toggle needed for this rebuild (DROP TABLE on a table
-- with real inbound foreign keys) happens in Go code around this migration's
-- execution (db.go's tableRebuildMigrations map), in autocommit mode before
-- the transaction begins -- a mid-transaction PRAGMA foreign_keys statement
-- is a documented no-op with modernc.org/sqlite, so it must not appear here.
--
-- event_type_key keeps the DEFAULT '' that migration 0007 originally gave
-- it (ALTER TABLE ... ADD COLUMN event_type_key TEXT NOT NULL DEFAULT '');
-- dropping the default here (as an earlier draft of this migration did)
-- broke every INSERT that omits event_type_key and relies on that default,
-- which several existing tests (e.g. TestClaimLegacyOwner_*) do.
CREATE TABLE activities_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER REFERENCES users(id),
garmin_activity_id INTEGER NOT NULL,
event_type_key TEXT NOT NULL DEFAULT '',
workout_id INTEGER,
start_time_utc TEXT NOT NULL,
duration_seconds REAL NOT NULL,
distance_meters REAL NOT NULL,
avg_hr REAL,
max_hr REAL,
avg_speed_mps REAL,
elevation_gain_m REAL,
aerobic_training_effect REAL,
anaerobic_training_effect REAL,
vo2max_value REAL,
raw_json TEXT NOT NULL,
details_fetched_at TEXT,
details_raw_json TEXT,
splits_fetched_at TEXT,
workout_raw_json TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE(user_id, garmin_activity_id)
);
INSERT INTO activities_new (id, 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, details_fetched_at, details_raw_json, splits_fetched_at, workout_raw_json, created_at, updated_at)
SELECT id, 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, details_fetched_at, details_raw_json, splits_fetched_at, workout_raw_json, created_at, updated_at
FROM activities;
DROP TABLE activities;
ALTER TABLE activities_new RENAME TO activities;
CREATE INDEX idx_activities_start_time ON activities(start_time_utc);
CREATE INDEX idx_activities_user_garmin_id ON activities(user_id, garmin_activity_id);

View File

@@ -81,10 +81,10 @@ const profileColumns = `
created_at, updated_at
`
// GetProfile returns the single profile row.
func (db *DB) GetProfile(ctx context.Context) (Profile, error) {
// 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 id = 1`).Scan(
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,
@@ -96,15 +96,15 @@ func (db *DB) GetProfile(ctx context.Context) (Profile, error) {
&p.CreatedAt, &p.UpdatedAt,
)
if err != nil {
return Profile{}, fmt.Errorf("get profile: %w", err)
return Profile{}, fmt.Errorf("get profile for user %d: %w", userID, err)
}
return p, nil
}
// UpdateProfile overwrites the single profile row. Callers should read via
// 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, p Profile) error {
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=?,
@@ -116,7 +116,7 @@ func (db *DB) UpdateProfile(ctx context.Context, p Profile) error {
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 id = 1`,
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,
@@ -125,9 +125,10 @@ func (db *DB) UpdateProfile(ctx context.Context, p Profile) error {
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: %w", err)
return fmt.Errorf("update profile for user %d: %w", userID, err)
}
return nil
}

View File

@@ -8,8 +8,12 @@ import (
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)
p, err := db.GetProfile(ctx, userID)
if err != nil {
t.Fatalf("GetProfile: %v", err)
}
@@ -64,11 +68,11 @@ func TestProfile_DefaultsThenUpdate(t *testing.T) {
p.MinRepresentativePaceSecPerKm = 600
p.MinRepresentativeTimeSeconds = 5
if err := db.UpdateProfile(ctx, p); err != nil {
if err := db.UpdateProfile(ctx, userID, p); err != nil {
t.Fatalf("UpdateProfile: %v", err)
}
got, err := db.GetProfile(ctx)
got, err := db.GetProfile(ctx, userID)
if err != nil {
t.Fatalf("GetProfile after update: %v", err)
}

View File

@@ -5,23 +5,23 @@ import (
"fmt"
)
// ResetAllSyncedData deletes every synced activity (cascading to its laps,
// ResetAllSyncedData deletes every synced activity for userID (cascading to its laps,
// activity_samples, and kind_assignments via ON DELETE CASCADE) and rewinds
// the backfill watermark to its initial state, so a subsequent Backfill
// starts a genuinely fresh pull instead of thinking history is already
// covered. Workout kinds (the user's taxonomy) are left untouched.
func (db *DB) ResetAllSyncedData(ctx context.Context) error {
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`); err != nil {
return fmt.Errorf("delete activities: %w", err)
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 id = 1`); err != nil {
return fmt.Errorf("reset sync state: %w", 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()
}

View File

@@ -8,51 +8,55 @@ import (
func TestResetAllSyncedData_DeletesActivitiesCascadesAndRewindsWatermark(t *testing.T) {
db := openTestDB(t)
ctx := context.Background()
userID, err := db.ProvisionUser(ctx, "test-sub", "Test User")
if err != nil {
t.Fatalf("ProvisionUser: %v", err)
}
activityID, err := db.UpsertActivity(ctx, Activity{GarminActivityID: 1, StartTimeUTC: "2026-07-11 06:00:00", RawJSON: "{}"})
activityID, err := db.UpsertActivity(ctx, userID, Activity{GarminActivityID: 1, StartTimeUTC: "2026-07-11 06:00:00", RawJSON: "{}"})
if err != nil {
t.Fatalf("UpsertActivity: %v", err)
}
kindID, err := db.CreateWorkoutKind(ctx, WorkoutKind{Name: "Test Reset Kind", RuleJSON: `{"match":"all","conditions":[]}`, IsActive: true})
kindID, err := db.CreateWorkoutKind(ctx, userID, WorkoutKind{Name: "Test Reset Kind", RuleJSON: `{"match":"all","conditions":[]}`, IsActive: true})
if err != nil {
t.Fatalf("CreateWorkoutKind: %v", err)
}
if err := db.ReplaceLaps(ctx, activityID, []Lap{{LapIndex: 1}}); err != nil {
if err := db.ReplaceLaps(ctx, userID, activityID, []Lap{{LapIndex: 1}}); err != nil {
t.Fatalf("ReplaceLaps: %v", err)
}
if _, err := db.InsertKindAssignment(ctx, KindAssignment{
if _, err := db.InsertKindAssignment(ctx, userID, KindAssignment{
ActivityID: activityID, WorkoutKindID: &kindID, AssignmentSource: AssignmentSourceRuleEngine,
Status: AssignmentStatusAssigned, CandidateKindsJSON: "[]",
}); err != nil {
t.Fatalf("InsertKindAssignment: %v", err)
}
if err := db.UpdateSyncState(ctx, "2020-01-01", true); err != nil {
if err := db.UpdateSyncState(ctx, userID, "2020-01-01", true); err != nil {
t.Fatalf("UpdateSyncState: %v", err)
}
if err := db.ResetAllSyncedData(ctx); err != nil {
if err := db.ResetAllSyncedData(ctx, userID); err != nil {
t.Fatalf("ResetAllSyncedData: %v", err)
}
activities, err := db.ListActivities(ctx, ActivityFilter{})
activities, err := db.ListActivities(ctx, userID, ActivityFilter{})
if err != nil {
t.Fatalf("ListActivities: %v", err)
}
if len(activities) != 0 {
t.Errorf("expected 0 activities after reset, got %d", len(activities))
}
laps, err := db.LapsForActivity(ctx, activityID)
laps, err := db.LapsForActivity(ctx, userID, activityID)
if err != nil {
t.Fatalf("LapsForActivity: %v", err)
}
if len(laps) != 0 {
t.Errorf("expected laps to cascade-delete, got %d", len(laps))
}
if _, ok, err := db.CurrentAssignment(ctx, activityID); err != nil || ok {
if _, ok, err := db.CurrentAssignment(ctx, userID, activityID); err != nil || ok {
t.Errorf("expected kind assignment to cascade-delete, ok=%v err=%v", ok, err)
}
state, err := db.GetSyncState(ctx)
state, err := db.GetSyncState(ctx, userID)
if err != nil {
t.Fatalf("GetSyncState: %v", err)
}
@@ -61,7 +65,7 @@ func TestResetAllSyncedData_DeletesActivitiesCascadesAndRewindsWatermark(t *test
}
// Workout kinds (the user's taxonomy) must survive a reset.
kind, ok, err := db.GetWorkoutKind(ctx, kindID)
kind, ok, err := db.GetWorkoutKind(ctx, userID, kindID)
if err != nil || !ok {
t.Fatalf("GetWorkoutKind after reset: ok=%v err=%v", ok, err)
}

View File

@@ -2,6 +2,7 @@ package store
import (
"context"
"database/sql"
"fmt"
)
@@ -15,9 +16,19 @@ type Sample struct {
ElevationM *float64
}
// ReplaceActivitySamples deletes any existing samples for activityID and
// bulk-inserts the given set, so re-syncing an activity's details is idempotent.
func (db *DB) ReplaceActivitySamples(ctx context.Context, activityID int64, samples []Sample) error {
// 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)
@@ -44,13 +55,18 @@ func (db *DB) ReplaceActivitySamples(ctx context.Context, activityID int64, samp
return tx.Commit()
}
// SamplesForActivity returns all samples for an activity, ordered by elapsed_seconds.
func (db *DB) SamplesForActivity(ctx context.Context, activityID int64) ([]Sample, error) {
// 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 elapsed_seconds, timestamp_ms, heart_rate, speed_mps, distance_m, elevation_m
FROM activity_samples WHERE activity_id = ? ORDER BY elapsed_seconds`, activityID)
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: %w", activityID, err)
return nil, fmt.Errorf("samples for activity %d (user %d): %w", activityID, userID, err)
}
defer rows.Close()

View File

@@ -2,7 +2,11 @@ package store
import (
"context"
"database/sql"
"io/fs"
"path/filepath"
"sort"
"strings"
"testing"
)
@@ -37,6 +41,10 @@ func TestMigrateIsIdempotent(t *testing.T) {
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,
@@ -47,13 +55,13 @@ func TestUpsertActivity_InsertThenUpdateIsIdempotent(t *testing.T) {
RawJSON: `{"activityId":23554066504,"activityName":"Auriol - W2-5-Base Endurance","activityType":{"typeKey":"running"}}`,
}
id, err := db.UpsertActivity(ctx, a)
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, a)
id2, err := db.UpsertActivity(ctx, userID, a)
if err != nil {
t.Fatalf("UpsertActivity (update): %v", err)
}
@@ -61,7 +69,7 @@ func TestUpsertActivity_InsertThenUpdateIsIdempotent(t *testing.T) {
t.Fatalf("expected same internal id across upserts, got %d then %d", id, id2)
}
got, ok, err := db.GetActivity(ctx, id)
got, ok, err := db.GetActivity(ctx, userID, id)
if err != nil || !ok {
t.Fatalf("GetActivity: ok=%v err=%v", ok, err)
}
@@ -69,7 +77,7 @@ func TestUpsertActivity_InsertThenUpdateIsIdempotent(t *testing.T) {
t.Errorf("AvgHR = %v, want 150 (update should have applied)", *got.AvgHR)
}
all, err := db.ListActivities(ctx, ActivityFilter{})
all, err := db.ListActivities(ctx, userID, ActivityFilter{})
if err != nil {
t.Fatalf("ListActivities: %v", err)
}
@@ -78,11 +86,45 @@ func TestUpsertActivity_InsertThenUpdateIsIdempotent(t *testing.T) {
}
}
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)
}
}
func TestKindAssignment_AppendOnlyHistoryAndCurrentView(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)
}
activityID, err := db.UpsertActivity(ctx, Activity{
activityID, err := db.UpsertActivity(ctx, userID, Activity{
GarminActivityID: 1,
StartTimeUTC: "2026-07-11 05:00:00",
RawJSON: "{}",
@@ -91,7 +133,7 @@ func TestKindAssignment_AppendOnlyHistoryAndCurrentView(t *testing.T) {
t.Fatalf("UpsertActivity: %v", err)
}
kindID, err := db.CreateWorkoutKind(ctx, WorkoutKind{
kindID, err := db.CreateWorkoutKind(ctx, userID, WorkoutKind{
Name: "Test Assignment Custom",
RuleJSON: `{"match":"all","conditions":[]}`,
IsActive: true,
@@ -101,7 +143,7 @@ func TestKindAssignment_AppendOnlyHistoryAndCurrentView(t *testing.T) {
}
// First: rule engine says needs_review (ambiguous).
if _, err := db.InsertKindAssignment(ctx, KindAssignment{
if _, err := db.InsertKindAssignment(ctx, userID, KindAssignment{
ActivityID: activityID,
AssignmentSource: AssignmentSourceRuleEngine,
Status: AssignmentStatusNeedsReview,
@@ -110,7 +152,7 @@ func TestKindAssignment_AppendOnlyHistoryAndCurrentView(t *testing.T) {
t.Fatalf("InsertKindAssignment (rule engine): %v", err)
}
queue, err := db.ReviewQueue(ctx)
queue, err := db.ReviewQueue(ctx, userID)
if err != nil {
t.Fatalf("ReviewQueue: %v", err)
}
@@ -119,7 +161,7 @@ func TestKindAssignment_AppendOnlyHistoryAndCurrentView(t *testing.T) {
}
// Then: user manually resolves it.
if _, err := db.InsertKindAssignment(ctx, KindAssignment{
if _, err := db.InsertKindAssignment(ctx, userID, KindAssignment{
ActivityID: activityID,
WorkoutKindID: &kindID,
AssignmentSource: AssignmentSourceManual,
@@ -128,7 +170,7 @@ func TestKindAssignment_AppendOnlyHistoryAndCurrentView(t *testing.T) {
t.Fatalf("InsertKindAssignment (manual): %v", err)
}
queue, err = db.ReviewQueue(ctx)
queue, err = db.ReviewQueue(ctx, userID)
if err != nil {
t.Fatalf("ReviewQueue after resolve: %v", err)
}
@@ -136,7 +178,7 @@ func TestKindAssignment_AppendOnlyHistoryAndCurrentView(t *testing.T) {
t.Fatalf("expected 0 items in review queue after manual resolve, got %d", len(queue))
}
current, ok, err := db.CurrentAssignment(ctx, activityID)
current, ok, err := db.CurrentAssignment(ctx, userID, activityID)
if err != nil || !ok {
t.Fatalf("CurrentAssignment: ok=%v err=%v", ok, err)
}
@@ -153,7 +195,7 @@ func TestKindAssignment_AppendOnlyHistoryAndCurrentView(t *testing.T) {
t.Errorf("expected 2 historical assignment rows (rule engine + manual), got %d", historyCount)
}
forKind, err := db.AssignmentsForKind(ctx, kindID)
forKind, err := db.AssignmentsForKind(ctx, userID, kindID)
if err != nil {
t.Fatalf("AssignmentsForKind: %v", err)
}
@@ -165,8 +207,12 @@ func TestKindAssignment_AppendOnlyHistoryAndCurrentView(t *testing.T) {
func TestReplaceLapsIsIdempotent(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)
}
activityID, err := db.UpsertActivity(ctx, Activity{
activityID, err := db.UpsertActivity(ctx, userID, Activity{
GarminActivityID: 2,
StartTimeUTC: "2026-07-11 05:00:00",
RawJSON: "{}",
@@ -179,15 +225,15 @@ func TestReplaceLapsIsIdempotent(t *testing.T) {
{LapIndex: 1, RawJSON: "{}"},
{LapIndex: 2, RawJSON: "{}"},
}
if err := db.ReplaceLaps(ctx, activityID, laps); err != nil {
if err := db.ReplaceLaps(ctx, userID, activityID, laps); err != nil {
t.Fatalf("ReplaceLaps (first): %v", err)
}
// Re-sync with a different set (e.g. corrected data) should fully replace, not append.
if err := db.ReplaceLaps(ctx, activityID, laps[:1]); err != nil {
if err := db.ReplaceLaps(ctx, userID, activityID, laps[:1]); err != nil {
t.Fatalf("ReplaceLaps (second): %v", err)
}
got, err := db.LapsForActivity(ctx, activityID)
got, err := db.LapsForActivity(ctx, userID, activityID)
if err != nil {
t.Fatalf("LapsForActivity: %v", err)
}
@@ -195,3 +241,371 @@ func TestReplaceLapsIsIdempotent(t *testing.T) {
t.Fatalf("expected 1 lap after replace, got %d", len(got))
}
}
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)")
}
}
func TestForeignKeyEnforcementPostMigration(t *testing.T) {
db := openTestDB(t)
ctx := context.Background()
// Verify that FK enforcement is correctly active after migrations complete.
// This tests that the PRAGMA foreign_keys toggle in db.migrate() (for
// migrations 0023/0024/0025) is properly scoped and re-enabled after each
// table rebuild.
userID, err := db.ProvisionUser(ctx, "test-sub", "Test")
if err != nil {
t.Fatalf("ProvisionUser: %v", err)
}
// Insert an activity that we can reference.
activityID, err := db.UpsertActivity(ctx, userID, Activity{
GarminActivityID: 1001,
StartTimeUTC: "2026-07-11 05:00:00",
RawJSON: "{}",
})
if err != nil {
t.Fatalf("UpsertActivity: %v", err)
}
// Get the ID of one of the 8 migration-seeded workout_kinds.
var seedKindID int64
if err := db.QueryRowContext(ctx, `SELECT id FROM workout_kinds LIMIT 1`).Scan(&seedKindID); err != nil {
t.Fatalf("query seeded workout_kind: %v", err)
}
// Inserting a kind_assignment with a valid FK reference should succeed.
assignmentID, err := db.InsertKindAssignment(ctx, userID, KindAssignment{
ActivityID: activityID,
WorkoutKindID: &seedKindID,
AssignmentSource: AssignmentSourceManual,
Status: AssignmentStatusAssigned,
})
if err != nil {
t.Fatalf("InsertKindAssignment with valid FK: %v", err)
}
if assignmentID == 0 {
t.Fatal("expected non-zero assignment ID")
}
// Inserting a kind_assignment with a nonexistent workout_kind_id should
// be rejected by the FK constraint, proving FK enforcement is ON.
nonexistentKindID := int64(999999)
_, err = db.InsertKindAssignment(ctx, userID, KindAssignment{
ActivityID: activityID,
WorkoutKindID: &nonexistentKindID,
AssignmentSource: AssignmentSourceManual,
Status: AssignmentStatusAssigned,
})
if err == nil {
t.Fatal("expected FK constraint violation for nonexistent workout_kind_id, but insert succeeded")
}
// The error message should mention FOREIGN KEY or similar constraint issue.
if !contains(err.Error(), "FOREIGN KEY", "constraint", "UNIQUE") {
t.Logf("FK error message: %v", err)
// Still pass, but log it; the exact error text varies by driver/context.
}
}
// TestRebuildMigrationsPreserveForeignKeyReferences proves the specific
// property none of the other migration tests cover: every other test opens
// a fresh DB via store.Open, which runs migrations 0001-0026 in one
// uninterrupted pass over an empty database, so migrations 0023/0024/0025/0026's
// table-rebuild (create-new/copy/drop-old/rename-into-place) never has any
// real pre-existing rows to carry across. A real single-tenant deployment
// upgrading to this schema version has months of synced activities, laps,
// activity_samples, and kind_assignments rows referencing real activities/
// workout_kinds rows by foreign key (per this repo's CLAUDE.md: one profile,
// real Garmin history, a review queue with manual overrides already
// recorded). This test manually applies migrations up through 0022, inserts
// rows simulating that pre-existing install, then applies 0023/0024/0025/0026
// and verifies every FK-referencing row still resolves correctly -- and that
// FK enforcement is genuinely back on afterward -- rather than just checking
// that migrations apply to an empty DB without erroring. This is the
// regression guard for a real bug: migration 0026 (activities table rebuild)
// originally issued its own mid-transaction `PRAGMA foreign_keys = OFF/ON`,
// which SQLite documents as a no-op once a transaction is open, so
// `DROP TABLE activities` silently cascade-deleted every laps/
// activity_samples/kind_assignments row for every activity via
// `ON DELETE CASCADE` -- with no error at all. It was masked because every
// other test runs migrations back-to-back on an empty database with no
// pre-existing child rows.
func TestRebuildMigrationsPreserveForeignKeyReferences(t *testing.T) {
ctx := context.Background()
path := filepath.Join(t.TempDir(), "geniusrun_rebuild_test.db")
// Deliberately not store.Open: that always applies every migration in
// one uninterrupted pass with no way to stop partway through. The sqlite
// driver itself is already registered via db.go's blank import.
sqlDB, err := sql.Open("sqlite", path+"?_pragma=foreign_keys(1)")
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
defer sqlDB.Close()
if _, err := sqlDB.ExecContext(ctx, `CREATE TABLE IF NOT EXISTS schema_migrations (
filename TEXT PRIMARY KEY,
applied_at TEXT NOT NULL DEFAULT (datetime('now'))
)`); err != nil {
t.Fatalf("create schema_migrations table: %v", err)
}
rebuildMigrations := map[string]bool{
"0023_profile_user_scoped.sql": true,
"0024_workout_kinds_user_scoped.sql": true,
"0025_sync_state_user_scoped.sql": true,
"0026_activities_unique_constraint.sql": true,
}
// Mirrors db.go's migrate() method: FK toggle in autocommit mode around
// the transaction, only for the four table-rebuild migrations.
applyMigration := func(name string) {
t.Helper()
content, err := migrationsFS.ReadFile("migrations/" + name)
if err != nil {
t.Fatalf("read migration %s: %v", name, err)
}
needsFKToggle := rebuildMigrations[name]
if needsFKToggle {
if _, err := sqlDB.ExecContext(ctx, `PRAGMA foreign_keys = OFF`); err != nil {
t.Fatalf("disable foreign keys before migration %s: %v", name, err)
}
}
tx, err := sqlDB.BeginTx(ctx, nil)
if err != nil {
t.Fatalf("begin migration tx for %s: %v", name, err)
}
if _, err := tx.ExecContext(ctx, string(content)); err != nil {
tx.Rollback()
t.Fatalf("apply migration %s: %v", name, err)
}
if _, err := tx.ExecContext(ctx, `INSERT INTO schema_migrations (filename) VALUES (?)`, name); err != nil {
tx.Rollback()
t.Fatalf("record migration %s: %v", name, err)
}
if err := tx.Commit(); err != nil {
t.Fatalf("commit migration %s: %v", name, err)
}
if needsFKToggle {
if _, err := sqlDB.ExecContext(ctx, `PRAGMA foreign_keys = ON`); err != nil {
t.Fatalf("enable foreign keys after migration %s: %v", name, err)
}
}
}
entries, err := fs.Glob(migrationsFS, "migrations/*.sql")
if err != nil {
t.Fatalf("glob migrations: %v", err)
}
sort.Strings(entries)
// Apply every migration up to (but not including) the three rebuilds.
for _, entry := range entries {
name := entry[len("migrations/"):]
if rebuildMigrations[name] {
continue
}
applyMigration(name)
}
// Simulate a real pre-existing single-tenant install at this point in
// schema history: a workout kind, a synced activity, and a
// kind_assignment referencing both by foreign key.
res, err := sqlDB.ExecContext(ctx, `INSERT INTO workout_kinds (name, rule_json) VALUES ('Pre-Existing Custom Kind', '{}')`)
if err != nil {
t.Fatalf("insert pre-existing workout_kinds row: %v", err)
}
kindID, err := res.LastInsertId()
if err != nil {
t.Fatalf("workout_kinds LastInsertId: %v", err)
}
res, err = sqlDB.ExecContext(ctx, `
INSERT INTO activities (garmin_activity_id, start_time_utc, duration_seconds, distance_meters, raw_json)
VALUES (123456789, '2026-01-01 06:00:00', 1800, 5000, '{}')`)
if err != nil {
t.Fatalf("insert pre-existing activities row: %v", err)
}
activityID, err := res.LastInsertId()
if err != nil {
t.Fatalf("activities LastInsertId: %v", err)
}
res, err = sqlDB.ExecContext(ctx, `
INSERT INTO kind_assignments (activity_id, workout_kind_id, assignment_source, status)
VALUES (?, ?, 'rule_engine', 'assigned')`, activityID, kindID)
if err != nil {
t.Fatalf("insert pre-existing kind_assignments row: %v", err)
}
assignmentID, err := res.LastInsertId()
if err != nil {
t.Fatalf("kind_assignments LastInsertId: %v", err)
}
res, err = sqlDB.ExecContext(ctx, `
INSERT INTO laps (activity_id, lap_index, raw_json)
VALUES (?, 0, '{}')`, activityID)
if err != nil {
t.Fatalf("insert pre-existing laps row: %v", err)
}
lapID, err := res.LastInsertId()
if err != nil {
t.Fatalf("laps LastInsertId: %v", err)
}
res, err = sqlDB.ExecContext(ctx, `
INSERT INTO activity_samples (activity_id, elapsed_seconds, timestamp_ms, heart_rate)
VALUES (?, 60, 1735711260000, 150)`, activityID)
if err != nil {
t.Fatalf("insert pre-existing activity_samples row: %v", err)
}
sampleID, err := res.LastInsertId()
if err != nil {
t.Fatalf("activity_samples LastInsertId: %v", err)
}
// Now apply the four rebuild migrations that drop/recreate profile,
// workout_kinds, sync_state, and (0026) activities itself.
for _, name := range []string{
"0023_profile_user_scoped.sql",
"0024_workout_kinds_user_scoped.sql",
"0025_sync_state_user_scoped.sql",
"0026_activities_unique_constraint.sql",
} {
applyMigration(name)
}
// The pre-existing kind_assignment row must still resolve to the same
// workout_kind, by the same name, across the drop/recreate/rename.
var resolvedName string
if err := sqlDB.QueryRowContext(ctx, `
SELECT wk.name FROM kind_assignments ka
JOIN workout_kinds wk ON wk.id = ka.workout_kind_id
WHERE ka.id = ?`, assignmentID).Scan(&resolvedName); err != nil {
t.Fatalf("join kind_assignments -> workout_kinds after rebuild: %v", err)
}
if resolvedName != "Pre-Existing Custom Kind" {
t.Fatalf("expected pre-existing kind_assignment to still resolve to 'Pre-Existing Custom Kind' after rebuild, got %q", resolvedName)
}
// The pre-existing laps row must still exist and still reference the
// same activity -- this is the exact regression guard for migration
// 0026's DROP TABLE activities silently cascade-deleting laps via
// ON DELETE CASCADE when FK enforcement wasn't actually disabled.
var lapActivityID int64
if err := sqlDB.QueryRowContext(ctx, `
SELECT activity_id FROM laps WHERE id = ?`, lapID).Scan(&lapActivityID); err != nil {
t.Fatalf("query pre-existing laps row after rebuild: %v", err)
}
if lapActivityID != activityID {
t.Fatalf("expected pre-existing laps row to still reference activity %d after rebuild, got %d", activityID, lapActivityID)
}
// Same guard for activity_samples.
var sampleActivityID int64
if err := sqlDB.QueryRowContext(ctx, `
SELECT activity_id FROM activity_samples WHERE id = ?`, sampleID).Scan(&sampleActivityID); err != nil {
t.Fatalf("query pre-existing activity_samples row after rebuild: %v", err)
}
if sampleActivityID != activityID {
t.Fatalf("expected pre-existing activity_samples row to still reference activity %d after rebuild, got %d", activityID, sampleActivityID)
}
// FK enforcement must be genuinely active again post-migration: a bogus
// workout_kind_id, activity_id (laps), and activity_id (activity_samples)
// must all be rejected, not silently accepted.
if _, err := sqlDB.ExecContext(ctx, `
INSERT INTO kind_assignments (activity_id, workout_kind_id, assignment_source, status)
VALUES (?, 999999, 'rule_engine', 'assigned')`, activityID); err == nil {
t.Fatal("expected FK constraint violation for nonexistent workout_kind_id after rebuild, but insert succeeded")
}
if _, err := sqlDB.ExecContext(ctx, `
INSERT INTO laps (activity_id, lap_index, raw_json)
VALUES (999999, 0, '{}')`); err == nil {
t.Fatal("expected FK constraint violation for nonexistent activity_id in laps after rebuild, but insert succeeded")
}
if _, err := sqlDB.ExecContext(ctx, `
INSERT INTO activity_samples (activity_id, elapsed_seconds, timestamp_ms, heart_rate)
VALUES (999999, 60, 1735711260000, 150)`); err == nil {
t.Fatal("expected FK constraint violation for nonexistent activity_id in activity_samples after rebuild, but insert succeeded")
}
}
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")
}
}
func contains(s string, substrs ...string) bool {
lower := strings.ToLower(s)
for _, substr := range substrs {
if strings.Contains(lower, strings.ToLower(substr)) {
return true
}
}
return false
}

View File

@@ -31,56 +31,56 @@ type SyncRun struct {
ErrorMessage *string
}
// StartSyncRun records a new in-progress sync run and returns its id.
func (db *DB) StartSyncRun(ctx context.Context, kind string) (int64, error) {
// 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 (kind, started_at, status) VALUES (?, datetime('now'), ?)`,
kind, SyncStatusRunning)
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: %w", err)
return 0, fmt.Errorf("start sync run for user %d: %w", userID, err)
}
return res.LastInsertId()
}
// FinishSyncRun marks a sync run as finished, recording how many activities
// were fetched and whether it succeeded.
func (db *DB) FinishSyncRun(ctx context.Context, id int64, activitiesFetched int, errMsg *string) error {
// 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 = ?`, activitiesFetched, status, errMsg, id)
WHERE id = ? AND user_id = ?`, activitiesFetched, status, errMsg, id, userID)
if err != nil {
return fmt.Errorf("finish sync run %d: %w", id, err)
return fmt.Errorf("finish sync run %d for user %d: %w", id, userID, err)
}
return nil
}
// LatestSyncRun returns the most recent sync run, if any.
func (db *DB) LatestSyncRun(ctx context.Context) (SyncRun, bool, error) {
// 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 ORDER BY id DESC LIMIT 1`)
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: %w", err)
return SyncRun{}, false, fmt.Errorf("latest sync run for user %d: %w", userID, err)
}
return r, true, nil
}
// ListSyncRuns returns recent sync runs, newest first.
func (db *DB) ListSyncRuns(ctx context.Context, limit int) ([]SyncRun, error) {
// 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 ORDER BY id DESC LIMIT ?`, limit)
FROM sync_runs WHERE user_id = ? ORDER BY id DESC LIMIT ?`, userID, limit)
if err != nil {
return nil, fmt.Errorf("list sync runs: %w", err)
return nil, fmt.Errorf("list sync runs for user %d: %w", userID, err)
}
defer rows.Close()

View File

@@ -13,25 +13,24 @@ type SyncState struct {
BackfillComplete bool // true once backfill has reached the configured horizon (or Garmin's own history start)
}
// GetSyncState returns the current backfill watermark (the singleton row,
// created by migration 0002).
func (db *DB) GetSyncState(ctx context.Context) (SyncState, error) {
// GetSyncState returns the current backfill watermark for userID.
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 id = 1`).
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: %w", err)
return SyncState{}, fmt.Errorf("get sync state for user %d: %w", userID, err)
}
return s, nil
}
// UpdateSyncState records progress of a backfill run.
func (db *DB) UpdateSyncState(ctx context.Context, earliestSyncedDate string, complete bool) error {
// 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 id = 1`,
earliestSyncedDate, complete)
UPDATE sync_state SET earliest_synced_date = ?, backfill_complete = ? WHERE user_id = ?`,
earliestSyncedDate, complete, userID)
if err != nil {
return fmt.Errorf("update sync state: %w", err)
return fmt.Errorf("update sync state for user %d: %w", userID, err)
}
return nil
}

View File

@@ -8,8 +8,12 @@ import (
func TestSyncState_DefaultsAndUpdate(t *testing.T) {
db := openTestDB(t)
ctx := context.Background()
userID, err := db.ProvisionUser(ctx, "test-sub", "Test User")
if err != nil {
t.Fatalf("ProvisionUser: %v", err)
}
state, err := db.GetSyncState(ctx)
state, err := db.GetSyncState(ctx, userID)
if err != nil {
t.Fatalf("GetSyncState: %v", err)
}
@@ -17,11 +21,11 @@ func TestSyncState_DefaultsAndUpdate(t *testing.T) {
t.Fatalf("expected fresh DB to have no watermark, got %+v", state)
}
if err := db.UpdateSyncState(ctx, "2023-01-01", true); err != nil {
if err := db.UpdateSyncState(ctx, userID, "2023-01-01", true); err != nil {
t.Fatalf("UpdateSyncState: %v", err)
}
state, err = db.GetSyncState(ctx)
state, err = db.GetSyncState(ctx, userID)
if err != nil {
t.Fatalf("GetSyncState after update: %v", err)
}

View File

@@ -0,0 +1,133 @@
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()
}

View File

@@ -0,0 +1,91 @@
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")
}
}

View File

@@ -29,67 +29,69 @@ func scanWorkoutKind(row interface{ Scan(...any) error }) (WorkoutKind, error) {
const workoutKindColumns = `id, name, description, color, rule_json, priority, is_active, created_at, updated_at`
// CreateWorkoutKind inserts a new workout kind and returns its id.
func (db *DB) CreateWorkoutKind(ctx context.Context, k WorkoutKind) (int64, error) {
func (db *DB) CreateWorkoutKind(ctx context.Context, userID int64, k WorkoutKind) (int64, error) {
res, err := db.ExecContext(ctx, `
INSERT INTO workout_kinds (name, description, color, rule_json, priority, is_active)
VALUES (?,?,?,?,?,?)`,
k.Name, k.Description, k.Color, k.RuleJSON, k.Priority, k.IsActive)
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: %w", k.Name, err)
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.
func (db *DB) UpdateWorkoutKind(ctx context.Context, k WorkoutKind) error {
// 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=?`,
k.Name, k.Description, k.Color, k.RuleJSON, k.Priority, k.IsActive, k.ID)
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: %w", k.ID, err)
return fmt.Errorf("update workout kind %d for user %d: %w", k.ID, userID, err)
}
return nil
}
// GetWorkoutKind fetches one workout kind by id.
func (db *DB) GetWorkoutKind(ctx context.Context, id int64) (WorkoutKind, bool, error) {
row := db.QueryRowContext(ctx, `SELECT `+workoutKindColumns+` FROM workout_kinds WHERE id = ?`, id)
// 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: %w", id, err)
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 its unique name.
func (db *DB) GetWorkoutKindByName(ctx context.Context, name string) (WorkoutKind, bool, error) {
row := db.QueryRowContext(ctx, `SELECT `+workoutKindColumns+` FROM workout_kinds WHERE name = ?`, name)
// 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: %w", name, err)
return WorkoutKind{}, false, fmt.Errorf("get workout kind %q for user %d: %w", name, userID, err)
}
return k, true, nil
}
// ListWorkoutKinds returns workout kinds. If activeOnly, soft-deleted
// ListWorkoutKinds returns workout kinds for userID. If activeOnly, soft-deleted
// (is_active=0) kinds are excluded.
func (db *DB) ListWorkoutKinds(ctx context.Context, activeOnly bool) ([]WorkoutKind, error) {
query := `SELECT ` + workoutKindColumns + ` FROM workout_kinds`
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 += ` WHERE is_active = 1`
query += ` AND is_active = 1`
}
query += ` ORDER BY priority DESC, name`
rows, err := db.QueryContext(ctx, query)
rows, err := db.QueryContext(ctx, query, userID)
if err != nil {
return nil, fmt.Errorf("list workout kinds: %w", err)
return nil, fmt.Errorf("list workout kinds for user %d: %w", userID, err)
}
defer rows.Close()
@@ -105,11 +107,11 @@ func (db *DB) ListWorkoutKinds(ctx context.Context, activeOnly bool) ([]WorkoutK
}
// SoftDeleteWorkoutKind sets is_active=0, keeping history (kind_assignments
// referencing it) intact.
func (db *DB) SoftDeleteWorkoutKind(ctx context.Context, id int64) error {
_, err := db.ExecContext(ctx, `UPDATE workout_kinds SET is_active=0, updated_at=datetime('now') WHERE id=?`, id)
// 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: %w", id, err)
return fmt.Errorf("soft delete workout kind %d for user %d: %w", id, userID, err)
}
return nil
}

View File

@@ -8,8 +8,12 @@ import (
func TestWorkoutTaxonomy_SeededWithEightFixedTypes(t *testing.T) {
db := openTestDB(t)
ctx := context.Background()
userID, err := db.ProvisionUser(ctx, "test-sub", "Test User")
if err != nil {
t.Fatalf("ProvisionUser: %v", err)
}
kinds, err := db.ListWorkoutKinds(ctx, true)
kinds, err := db.ListWorkoutKinds(ctx, userID, true)
if err != nil {
t.Fatalf("ListWorkoutKinds: %v", err)
}
@@ -42,8 +46,12 @@ func TestWorkoutTaxonomy_SeededWithEightFixedTypes(t *testing.T) {
func TestWorkoutTaxonomy_ListedInFixedDisplayOrder(t *testing.T) {
db := openTestDB(t)
ctx := context.Background()
userID, err := db.ProvisionUser(ctx, "test-sub", "Test User")
if err != nil {
t.Fatalf("ProvisionUser: %v", err)
}
kinds, err := db.ListWorkoutKinds(ctx, true)
kinds, err := db.ListWorkoutKinds(ctx, userID, true)
if err != nil {
t.Fatalf("ListWorkoutKinds: %v", err)
}

View File

@@ -9,7 +9,9 @@ import (
// 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.
// 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
@@ -24,38 +26,49 @@ func scanWorkoutTypePace(row interface{ Scan(...any) error }) (WorkoutTypePace,
return p, err
}
const workoutTypePaceColumns = `workout_kind_id, pace_min_sec_per_km, pace_max_sec_per_km, hr_min_pct_hrr, hr_max_pct_hrr`
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.
func (db *DB) GetWorkoutTypePace(ctx context.Context, workoutKindID int64) (WorkoutTypePace, error) {
row := db.QueryRowContext(ctx, `SELECT `+workoutTypePaceColumns+` FROM workout_type_paces WHERE workout_kind_id = ?`, workoutKindID)
// 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: %w", workoutKindID, err)
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.
func (db *DB) UpdateWorkoutTypePace(ctx context.Context, p WorkoutTypePace) error {
// 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=?`,
p.PaceMinSecPerKm, p.PaceMaxSecPerKm, p.HRMinPctHRR, p.HRMaxPctHRR, p.WorkoutKindID)
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: %w", p.WorkoutKindID, err)
return fmt.Errorf("update workout type pace for kind %d (user %d): %w", p.WorkoutKindID, userID, err)
}
return nil
}
// ListWorkoutTypePaces returns every workout kind's pace/zone row.
func (db *DB) ListWorkoutTypePaces(ctx context.Context) ([]WorkoutTypePace, error) {
rows, err := db.QueryContext(ctx, `SELECT `+workoutTypePaceColumns+` FROM workout_type_paces ORDER BY workout_kind_id`)
// 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: %w", err)
return nil, fmt.Errorf("list workout type paces for user %d: %w", userID, err)
}
defer rows.Close()

View File

@@ -9,7 +9,12 @@ func TestWorkoutTypePaces_SeededOnePerKindThenUpdate(t *testing.T) {
db := openTestDB(t)
ctx := context.Background()
all, err := db.ListWorkoutTypePaces(ctx)
userID, err := db.ProvisionUser(ctx, "test-sub", "Test")
if err != nil {
t.Fatalf("ProvisionUser: %v", err)
}
all, err := db.ListWorkoutTypePaces(ctx, userID)
if err != nil {
t.Fatalf("ListWorkoutTypePaces: %v", err)
}
@@ -29,11 +34,11 @@ func TestWorkoutTypePaces_SeededOnePerKindThenUpdate(t *testing.T) {
target.HRMinPctHRR = &hrMin
target.HRMaxPctHRR = &hrMax
if err := db.UpdateWorkoutTypePace(ctx, target); err != nil {
if err := db.UpdateWorkoutTypePace(ctx, userID, target); err != nil {
t.Fatalf("UpdateWorkoutTypePace: %v", err)
}
got, err := db.GetWorkoutTypePace(ctx, target.WorkoutKindID)
got, err := db.GetWorkoutTypePace(ctx, userID, target.WorkoutKindID)
if err != nil {
t.Fatalf("GetWorkoutTypePace: %v", err)
}

View File

@@ -61,10 +61,12 @@ type Progress struct {
Total int
}
// Service is the sync orchestrator.
// 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
@@ -72,13 +74,13 @@ type Service struct {
progress Progress
}
// NewService builds a Service. now defaults to time.Now if nil (tests can
// override it for deterministic date windows).
func NewService(g garmin.Client, db *store.DB, cfg Config, now func() time.Time) *Service {
// 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, cfg: cfg.withDefaults(), now: now}
return &Service{garmin: g, db: db, userID: userID, cfg: cfg.withDefaults(), now: now}
}
// Progress returns the current detail-fill progress (0/0 when idle).
@@ -106,7 +108,7 @@ func (s *Service) setProgress(done, total int) {
// history. Widening the horizon between calls resumes further back instead
// of re-fetching everything.
func (s *Service) Backfill(ctx context.Context) error {
runID, err := s.db.StartSyncRun(ctx, store.SyncKindBackfill)
runID, err := s.db.StartSyncRun(ctx, s.userID, store.SyncKindBackfill)
if err != nil {
return err
}
@@ -114,10 +116,10 @@ func (s *Service) Backfill(ctx context.Context) error {
total, err := s.backfillCore(ctx)
if err != nil {
msg := err.Error()
s.db.FinishSyncRun(ctx, runID, total, &msg)
s.db.FinishSyncRun(ctx, s.userID, runID, total, &msg)
return err
}
return s.db.FinishSyncRun(ctx, runID, total, nil)
return s.db.FinishSyncRun(ctx, s.userID, runID, total, nil)
}
// backfillCore holds Backfill's actual fetch logic, without the SyncRun
@@ -126,13 +128,13 @@ func (s *Service) Backfill(ctx context.Context) error {
// whatever was fetched even when an error is also returned, matching
// Backfill's own partial-progress-on-error behavior.
func (s *Service) backfillCore(ctx context.Context) (int, error) {
profile, err := s.db.GetProfile(ctx)
profile, err := s.db.GetProfile(ctx, s.userID)
if err != nil {
return 0, fmt.Errorf("load profile: %w", err)
}
horizon := s.now().AddDate(0, 0, -profile.BackfillHorizonDays)
state, err := s.db.GetSyncState(ctx)
state, err := s.db.GetSyncState(ctx, s.userID)
if err != nil {
return 0, err
}
@@ -167,12 +169,12 @@ func (s *Service) backfillCore(ctx context.Context) (int, error) {
// Empty page: reached the start of this account's history,
// regardless of the configured horizon.
reachedStartOfHistory = true
if err := s.db.UpdateSyncState(ctx, dateStr(start), true); err != nil {
if err := s.db.UpdateSyncState(ctx, s.userID, dateStr(start), true); err != nil {
return total, err
}
break
}
if err := s.db.UpdateSyncState(ctx, dateStr(start), false); err != nil {
if err := s.db.UpdateSyncState(ctx, s.userID, dateStr(start), false); err != nil {
return total, err
}
end = start.AddDate(0, 0, -1)
@@ -181,7 +183,7 @@ func (s *Service) backfillCore(ctx context.Context) (int, error) {
if !reachedStartOfHistory {
// Reached the configured horizon (not Garmin's actual history
// start) -- mark complete relative to that horizon.
if err := s.db.UpdateSyncState(ctx, dateStr(horizon), true); err != nil {
if err := s.db.UpdateSyncState(ctx, s.userID, dateStr(horizon), true); err != nil {
return total, err
}
}
@@ -192,7 +194,7 @@ func (s *Service) backfillCore(ctx context.Context) (int, error) {
// IncrementalSync fetches activities from just before the latest known
// activity (or a short recent window if none exist yet) through today.
func (s *Service) IncrementalSync(ctx context.Context) error {
runID, err := s.db.StartSyncRun(ctx, store.SyncKindIncremental)
runID, err := s.db.StartSyncRun(ctx, s.userID, store.SyncKindIncremental)
if err != nil {
return err
}
@@ -200,17 +202,17 @@ func (s *Service) IncrementalSync(ctx context.Context) error {
n, err := s.incrementalSyncCore(ctx)
if err != nil {
msg := err.Error()
s.db.FinishSyncRun(ctx, runID, n, &msg)
s.db.FinishSyncRun(ctx, s.userID, runID, n, &msg)
return err
}
return s.db.FinishSyncRun(ctx, runID, n, nil)
return s.db.FinishSyncRun(ctx, s.userID, runID, n, nil)
}
// incrementalSyncCore holds IncrementalSync's actual fetch logic, without
// the SyncRun bookkeeping -- see backfillCore.
func (s *Service) incrementalSyncCore(ctx context.Context) (int, error) {
start := s.now().AddDate(0, 0, -s.cfg.IncrementalOverlapDays)
if latest, ok, err := s.db.LatestActivityStartTime(ctx); err == nil && ok {
if latest, ok, err := s.db.LatestActivityStartTime(ctx, s.userID); err == nil && ok {
if t, err := time.Parse("2006-01-02 15:04:05", latest); err == nil {
start = t.AddDate(0, 0, -s.cfg.IncrementalOverlapDays)
}
@@ -229,7 +231,7 @@ func (s *Service) incrementalSyncCore(ctx context.Context) (int, error) {
// would silently hide however many activities Backfill fetched. Recording
// one combined run makes the reported count match the whole action.
func (s *Service) FullSync(ctx context.Context, detailFillLimit int) error {
runID, err := s.db.StartSyncRun(ctx, store.SyncKindFull)
runID, err := s.db.StartSyncRun(ctx, s.userID, store.SyncKindFull)
if err != nil {
return err
}
@@ -237,7 +239,7 @@ func (s *Service) FullSync(ctx context.Context, detailFillLimit int) error {
backfillCount, err := s.backfillCore(ctx)
if err != nil {
msg := err.Error()
s.db.FinishSyncRun(ctx, runID, backfillCount, &msg)
s.db.FinishSyncRun(ctx, s.userID, runID, backfillCount, &msg)
return err
}
@@ -245,17 +247,17 @@ func (s *Service) FullSync(ctx context.Context, detailFillLimit int) error {
total := backfillCount + incrementalCount
if err != nil {
msg := err.Error()
s.db.FinishSyncRun(ctx, runID, total, &msg)
s.db.FinishSyncRun(ctx, s.userID, runID, total, &msg)
return err
}
if err := s.FillPendingDetails(ctx, detailFillLimit); err != nil {
msg := err.Error()
s.db.FinishSyncRun(ctx, runID, total, &msg)
s.db.FinishSyncRun(ctx, s.userID, runID, total, &msg)
return err
}
return s.db.FinishSyncRun(ctx, runID, total, nil)
return s.db.FinishSyncRun(ctx, s.userID, runID, total, nil)
}
// ResetAll deletes every synced activity (and its laps/samples/kind
@@ -263,7 +265,7 @@ func (s *Service) FullSync(ctx context.Context, detailFillLimit int) error {
// call performs a genuinely fresh pull from Garmin instead of resuming from
// wherever the previous one left off. Workout kinds are left untouched.
func (s *Service) ResetAll(ctx context.Context) error {
return s.db.ResetAllSyncedData(ctx)
return s.db.ResetAllSyncedData(ctx, s.userID)
}
// fetchAndStoreWindow returns two counts: rawCount is every activity Garmin's
@@ -290,11 +292,11 @@ func (s *Service) fetchAndStoreWindow(ctx context.Context, startDate, endDate st
if !isRunningActivityType(a.ActivityType.TypeKey) {
continue
}
exists, err := s.db.ActivityExists(ctx, a.ActivityID)
exists, err := s.db.ActivityExists(ctx, s.userID, a.ActivityID)
if err != nil {
return 0, 0, err
}
if _, err := s.db.UpsertActivity(ctx, toActivityRow(a)); err != nil {
if _, err := s.db.UpsertActivity(ctx, s.userID, toActivityRow(a)); err != nil {
return 0, 0, fmt.Errorf("store activity %d: %w", a.ActivityID, err)
}
if !exists {
@@ -309,11 +311,11 @@ func (s *Service) fetchAndStoreWindow(ctx context.Context, startDate, endDate st
// Calls are made sequentially with Config.InterCallDelay between them to
// avoid Garmin/Cloudflare rate limiting.
func (s *Service) FillPendingDetails(ctx context.Context, limit int) error {
pending, err := s.db.ActivitiesMissingDetails(ctx, limit)
pending, err := s.db.ActivitiesMissingDetails(ctx, s.userID, limit)
if err != nil {
return err
}
profile, err := s.db.GetProfile(ctx)
profile, err := s.db.GetProfile(ctx, s.userID)
if err != nil {
return fmt.Errorf("load profile: %w", err)
}
@@ -357,41 +359,41 @@ func (s *Service) fillActivityDetails(ctx context.Context, a store.Activity, pro
log.Printf("sync: get_workout_by_id(%d) for activity %d failed, continuing without target zones: %v", *a.WorkoutID, a.GarminActivityID, err)
} else {
targets = alignWorkoutTargets(splits.Laps, workout)
if err := s.db.SetActivityWorkout(ctx, a.ID, string(workout.Raw)); err != nil {
if err := s.db.SetActivityWorkout(ctx, s.userID, a.ID, string(workout.Raw)); err != nil {
return err
}
}
}
samples := garmin.ExtractSamples(details)
if err := s.db.ReplaceActivitySamples(ctx, a.ID, toSampleRows(samples)); err != nil {
if err := s.db.ReplaceActivitySamples(ctx, s.userID, a.ID, toSampleRows(samples)); err != nil {
return err
}
if err := s.db.ReplaceLaps(ctx, a.ID, toLapRows(splits.Laps, samples, targets, profile)); err != nil {
if err := s.db.ReplaceLaps(ctx, s.userID, a.ID, toLapRows(splits.Laps, samples, targets, profile)); err != nil {
return err
}
if err := s.db.SetActivityDetails(ctx, a.ID, string(details.Raw)); err != nil {
if err := s.db.SetActivityDetails(ctx, s.userID, a.ID, string(details.Raw)); err != nil {
return err
}
return s.db.SetActivitySplitsFetched(ctx, a.ID)
return s.db.SetActivitySplitsFetched(ctx, s.userID, a.ID)
}
// ClassifyActivity (re)runs the rule engine for one activity against the
// currently active workout kinds and appends a new kind_assignments row.
// Safe to call repeatedly (e.g. after editing a workout kind's rule).
func (s *Service) ClassifyActivity(ctx context.Context, activityID int64) error {
activity, ok, err := s.db.GetActivity(ctx, activityID)
activity, ok, err := s.db.GetActivity(ctx, s.userID, activityID)
if err != nil {
return err
}
if !ok {
return fmt.Errorf("activity %d not found", activityID)
}
laps, err := s.db.LapsForActivity(ctx, activityID)
laps, err := s.db.LapsForActivity(ctx, s.userID, activityID)
if err != nil {
return err
}
kindRows, err := s.db.ListWorkoutKinds(ctx, true)
kindRows, err := s.db.ListWorkoutKinds(ctx, s.userID, true)
if err != nil {
return err
}
@@ -399,7 +401,7 @@ func (s *Service) ClassifyActivity(ctx context.Context, activityID int64) error
if err != nil {
return fmt.Errorf("parse workout kind rules: %w", err)
}
profile, err := s.db.GetProfile(ctx)
profile, err := s.db.GetProfile(ctx, s.userID)
if err != nil {
return fmt.Errorf("load profile: %w", err)
}
@@ -416,7 +418,7 @@ func (s *Service) ClassifyActivity(ctx context.Context, activityID int64) error
return err
}
_, err = s.db.InsertKindAssignment(ctx, store.KindAssignment{
_, err = s.db.InsertKindAssignment(ctx, s.userID, store.KindAssignment{
ActivityID: activityID,
WorkoutKindID: result.WorkoutKindID,
AssignmentSource: store.AssignmentSourceRuleEngine,

View File

@@ -28,17 +28,26 @@ func fixedNow(t time.Time) func() time.Time {
return func() time.Time { return t }
}
func provisionTestUser(t *testing.T, db *store.DB) int64 {
t.Helper()
userID, err := db.ProvisionUser(context.Background(), "test-sub", "Test")
if err != nil {
t.Fatalf("ProvisionUser: %v", err)
}
return userID
}
// setBackfillHorizon sets Profile.BackfillHorizonDays, which Backfill reads
// fresh on every call (it's no longer part of Config).
func setBackfillHorizon(t *testing.T, db *store.DB, days int) {
func setBackfillHorizon(t *testing.T, db *store.DB, userID int64, days int) {
t.Helper()
ctx := context.Background()
profile, err := db.GetProfile(ctx)
profile, err := db.GetProfile(ctx, userID)
if err != nil {
t.Fatalf("GetProfile: %v", err)
}
profile.BackfillHorizonDays = days
if err := db.UpdateProfile(ctx, profile); err != nil {
if err := db.UpdateProfile(ctx, userID, profile); err != nil {
t.Fatalf("UpdateProfile: %v", err)
}
}
@@ -46,18 +55,19 @@ func setBackfillHorizon(t *testing.T, db *store.DB, days int) {
func TestBackfill_StoresActivitiesAndRecordsSyncRun(t *testing.T) {
db := openTestDB(t)
ctx := context.Background()
userID := provisionTestUser(t, db)
m := &mock.Client{Activities: []garmin.Activity{
{ActivityID: 1, ActivityName: "Morning Run", ActivityType: garmin.ActivityType{TypeKey: "running"},
StartTimeGMT: "2026-07-01 06:00:00", Distance: 5000, Duration: 1500, AverageSpeed: 3.33, AverageHR: 145},
}}
svc := NewService(m, db, Config{}, fixedNow(time.Date(2026, 7, 11, 0, 0, 0, 0, time.UTC)))
svc := NewService(m, db, userID, Config{}, fixedNow(time.Date(2026, 7, 11, 0, 0, 0, 0, time.UTC)))
if err := svc.Backfill(ctx); err != nil {
t.Fatalf("Backfill: %v", err)
}
activities, err := db.ListActivities(ctx, store.ActivityFilter{})
activities, err := db.ListActivities(ctx, userID, store.ActivityFilter{})
if err != nil {
t.Fatalf("ListActivities: %v", err)
}
@@ -68,7 +78,7 @@ func TestBackfill_StoresActivitiesAndRecordsSyncRun(t *testing.T) {
t.Errorf("GarminActivityID = %d, want 1", activities[0].GarminActivityID)
}
runs, err := db.ListSyncRuns(ctx, 10)
runs, err := db.ListSyncRuns(ctx, userID, 10)
if err != nil {
t.Fatalf("ListSyncRuns: %v", err)
}
@@ -188,6 +198,7 @@ func TestTargetHRRange_CustomRangeAndZoneNumberViaKarvonen(t *testing.T) {
func TestBackfill_SkipsNonRunningActivities(t *testing.T) {
db := openTestDB(t)
ctx := context.Background()
userID := provisionTestUser(t, db)
m := &mock.Client{Activities: []garmin.Activity{
{ActivityID: 1, ActivityType: garmin.ActivityType{TypeKey: "running"},
@@ -199,13 +210,13 @@ func TestBackfill_SkipsNonRunningActivities(t *testing.T) {
{ActivityID: 4, ActivityType: garmin.ActivityType{TypeKey: "indoor_cycling"},
StartTimeGMT: "2026-07-04 06:00:00", Distance: 0, Duration: 1800},
}}
svc := NewService(m, db, Config{}, fixedNow(time.Date(2026, 7, 11, 0, 0, 0, 0, time.UTC)))
svc := NewService(m, db, userID, Config{}, fixedNow(time.Date(2026, 7, 11, 0, 0, 0, 0, time.UTC)))
if err := svc.Backfill(ctx); err != nil {
t.Fatalf("Backfill: %v", err)
}
activities, err := db.ListActivities(ctx, store.ActivityFilter{})
activities, err := db.ListActivities(ctx, userID, store.ActivityFilter{})
if err != nil {
t.Fatalf("ListActivities: %v", err)
}
@@ -222,6 +233,7 @@ func TestBackfill_SkipsNonRunningActivities(t *testing.T) {
func TestFillPendingDetailsAndClassify_EndToEnd(t *testing.T) {
db := openTestDB(t)
ctx := context.Background()
userID := provisionTestUser(t, db)
const garminActivityID = 42
m := &mock.Client{
@@ -244,7 +256,7 @@ func TestFillPendingDetailsAndClassify_EndToEnd(t *testing.T) {
},
},
}
svc := NewService(m, db, Config{MinConfidence: 0.5}, fixedNow(time.Date(2026, 7, 11, 0, 0, 0, 0, time.UTC)))
svc := NewService(m, db, userID, Config{MinConfidence: 0.5}, fixedNow(time.Date(2026, 7, 11, 0, 0, 0, 0, time.UTC)))
if err := svc.Backfill(ctx); err != nil {
t.Fatalf("Backfill: %v", err)
@@ -252,7 +264,7 @@ func TestFillPendingDetailsAndClassify_EndToEnd(t *testing.T) {
// A workout kind that should cleanly match the seeded activity's pace.
ruleJSON := `{"match":"all","conditions":[{"metric":"avg_pace_sec_per_km","op":"between","value":[280,320]}]}`
if _, err := db.CreateWorkoutKind(ctx, store.WorkoutKind{Name: "Test Classification Tempo", RuleJSON: ruleJSON, IsActive: true}); err != nil {
if _, err := db.CreateWorkoutKind(ctx, userID, store.WorkoutKind{Name: "Test Classification Tempo", RuleJSON: ruleJSON, IsActive: true}); err != nil {
t.Fatalf("CreateWorkoutKind: %v", err)
}
@@ -260,7 +272,7 @@ func TestFillPendingDetailsAndClassify_EndToEnd(t *testing.T) {
t.Fatalf("FillPendingDetails: %v", err)
}
activities, err := db.ListActivities(ctx, store.ActivityFilter{})
activities, err := db.ListActivities(ctx, userID, store.ActivityFilter{})
if err != nil || len(activities) != 1 {
t.Fatalf("ListActivities: %v, %+v", err, activities)
}
@@ -273,12 +285,12 @@ func TestFillPendingDetailsAndClassify_EndToEnd(t *testing.T) {
t.Error("expected SplitsFetchedAt to be set after FillPendingDetails")
}
laps, err := db.LapsForActivity(ctx, activityID)
laps, err := db.LapsForActivity(ctx, userID, activityID)
if err != nil || len(laps) != 1 {
t.Fatalf("LapsForActivity: %v, %+v", err, laps)
}
assignment, ok, err := db.CurrentAssignment(ctx, activityID)
assignment, ok, err := db.CurrentAssignment(ctx, userID, activityID)
if err != nil || !ok {
t.Fatalf("CurrentAssignment: ok=%v err=%v", ok, err)
}
@@ -290,6 +302,7 @@ func TestFillPendingDetailsAndClassify_EndToEnd(t *testing.T) {
func TestFillPendingDetails_ResolvesWorkoutTargetsOntoLaps(t *testing.T) {
db := openTestDB(t)
ctx := context.Background()
userID := provisionTestUser(t, db)
const garminActivityID = 55
const workoutID = 999
@@ -313,7 +326,7 @@ func TestFillPendingDetails_ResolvesWorkoutTargetsOntoLaps(t *testing.T) {
}},
},
}
svc := NewService(m, db, Config{}, fixedNow(time.Date(2026, 7, 11, 0, 0, 0, 0, time.UTC)))
svc := NewService(m, db, userID, Config{}, fixedNow(time.Date(2026, 7, 11, 0, 0, 0, 0, time.UTC)))
if err := svc.Backfill(ctx); err != nil {
t.Fatalf("Backfill: %v", err)
@@ -322,11 +335,11 @@ func TestFillPendingDetails_ResolvesWorkoutTargetsOntoLaps(t *testing.T) {
t.Fatalf("FillPendingDetails: %v", err)
}
activities, err := db.ListActivities(ctx, store.ActivityFilter{})
activities, err := db.ListActivities(ctx, userID, store.ActivityFilter{})
if err != nil || len(activities) != 1 {
t.Fatalf("ListActivities: %v, %+v", err, activities)
}
laps, err := db.LapsForActivity(ctx, activities[0].ID)
laps, err := db.LapsForActivity(ctx, userID, activities[0].ID)
if err != nil || len(laps) != 1 {
t.Fatalf("LapsForActivity: %v, %+v", err, laps)
}
@@ -341,6 +354,7 @@ func TestFillPendingDetails_ResolvesWorkoutTargetsOntoLaps(t *testing.T) {
func TestFillPendingDetails_OneExtraTrailingLapKeepsOtherLapsTargets(t *testing.T) {
db := openTestDB(t)
ctx := context.Background()
userID := provisionTestUser(t, db)
const garminActivityID = 56
const workoutID = 1000
@@ -369,7 +383,7 @@ func TestFillPendingDetails_OneExtraTrailingLapKeepsOtherLapsTargets(t *testing.
}},
},
}
svc := NewService(m, db, Config{}, fixedNow(time.Date(2026, 7, 11, 0, 0, 0, 0, time.UTC)))
svc := NewService(m, db, userID, Config{}, fixedNow(time.Date(2026, 7, 11, 0, 0, 0, 0, time.UTC)))
if err := svc.Backfill(ctx); err != nil {
t.Fatalf("Backfill: %v", err)
@@ -378,8 +392,8 @@ func TestFillPendingDetails_OneExtraTrailingLapKeepsOtherLapsTargets(t *testing.
t.Fatalf("FillPendingDetails: %v", err)
}
activities, _ := db.ListActivities(ctx, store.ActivityFilter{})
laps, err := db.LapsForActivity(ctx, activities[0].ID)
activities, _ := db.ListActivities(ctx, userID, store.ActivityFilter{})
laps, err := db.LapsForActivity(ctx, userID, activities[0].ID)
if err != nil || len(laps) != 2 {
t.Fatalf("LapsForActivity: %v, %+v", err, laps)
}
@@ -443,13 +457,14 @@ func TestBuildMetricContext_DerivesIsRace(t *testing.T) {
func TestBackfill_SecondRunIsANoOpOnceHorizonFullyCovered(t *testing.T) {
db := openTestDB(t)
ctx := context.Background()
userID := provisionTestUser(t, db)
m := &mock.Client{Activities: []garmin.Activity{
{ActivityID: 1, ActivityType: garmin.ActivityType{TypeKey: "running"}, StartTimeGMT: "2026-07-05 06:00:00", Distance: 5000, Duration: 1500},
}}
svc := NewService(m, db, Config{BackfillWindowDays: 10},
svc := NewService(m, db, userID, Config{BackfillWindowDays: 10},
fixedNow(time.Date(2026, 7, 11, 0, 0, 0, 0, time.UTC)))
setBackfillHorizon(t, db, 10)
setBackfillHorizon(t, db, userID, 10)
if err := svc.Backfill(ctx); err != nil {
t.Fatalf("first Backfill: %v", err)
@@ -459,7 +474,7 @@ func TestBackfill_SecondRunIsANoOpOnceHorizonFullyCovered(t *testing.T) {
t.Fatal("expected first backfill to call GetActivities at least once")
}
state, err := db.GetSyncState(ctx)
state, err := db.GetSyncState(ctx, userID)
if err != nil {
t.Fatalf("GetSyncState: %v", err)
}
@@ -479,13 +494,14 @@ func TestBackfill_SecondRunIsANoOpOnceHorizonFullyCovered(t *testing.T) {
func TestResetAll_AllowsFreshBackfillAfterwards(t *testing.T) {
db := openTestDB(t)
ctx := context.Background()
userID := provisionTestUser(t, db)
m := &mock.Client{Activities: []garmin.Activity{
{ActivityID: 1, ActivityType: garmin.ActivityType{TypeKey: "running"}, StartTimeGMT: "2026-07-05 06:00:00", Distance: 5000, Duration: 1500},
}}
svc := NewService(m, db, Config{BackfillWindowDays: 10},
svc := NewService(m, db, userID, Config{BackfillWindowDays: 10},
fixedNow(time.Date(2026, 7, 11, 0, 0, 0, 0, time.UTC)))
setBackfillHorizon(t, db, 10)
setBackfillHorizon(t, db, userID, 10)
if err := svc.Backfill(ctx); err != nil {
t.Fatalf("first Backfill: %v", err)
@@ -495,7 +511,7 @@ func TestResetAll_AllowsFreshBackfillAfterwards(t *testing.T) {
if err := svc.ResetAll(ctx); err != nil {
t.Fatalf("ResetAll: %v", err)
}
activities, err := db.ListActivities(ctx, store.ActivityFilter{})
activities, err := db.ListActivities(ctx, userID, store.ActivityFilter{})
if err != nil {
t.Fatalf("ListActivities: %v", err)
}
@@ -509,7 +525,7 @@ func TestResetAll_AllowsFreshBackfillAfterwards(t *testing.T) {
if m.GetActivitiesCalls <= firstCallCount {
t.Errorf("expected Backfill after ResetAll to call GetActivities again (fresh pull), call count stayed at %d", m.GetActivitiesCalls)
}
activities, err = db.ListActivities(ctx, store.ActivityFilter{})
activities, err = db.ListActivities(ctx, userID, store.ActivityFilter{})
if err != nil {
t.Fatalf("ListActivities after re-backfill: %v", err)
}
@@ -521,14 +537,15 @@ func TestResetAll_AllowsFreshBackfillAfterwards(t *testing.T) {
func TestBackfill_ResumesFromWatermarkWhenHorizonGrows(t *testing.T) {
db := openTestDB(t)
ctx := context.Background()
userID := provisionTestUser(t, db)
m := &mock.Client{Activities: []garmin.Activity{
{ActivityID: 1, ActivityType: garmin.ActivityType{TypeKey: "running"}, StartTimeGMT: "2026-07-05 06:00:00", Distance: 5000, Duration: 1500},
}}
now := fixedNow(time.Date(2026, 7, 11, 0, 0, 0, 0, time.UTC))
svc := NewService(m, db, Config{BackfillWindowDays: 10}, now)
setBackfillHorizon(t, db, 10)
svc := NewService(m, db, userID, Config{BackfillWindowDays: 10}, now)
setBackfillHorizon(t, db, userID, 10)
if err := svc.Backfill(ctx); err != nil {
t.Fatalf("first Backfill: %v", err)
}
@@ -537,8 +554,8 @@ func TestBackfill_ResumesFromWatermarkWhenHorizonGrows(t *testing.T) {
// Simulate the user widening the horizon later -- should resume from the
// watermark (not re-fetch the already-covered recent window) but still
// make progress toward the new, deeper horizon.
setBackfillHorizon(t, db, 30)
svc2 := NewService(m, db, Config{BackfillWindowDays: 10}, now)
setBackfillHorizon(t, db, userID, 30)
svc2 := NewService(m, db, userID, Config{BackfillWindowDays: 10}, now)
if err := svc2.Backfill(ctx); err != nil {
t.Fatalf("second Backfill: %v", err)
}
@@ -546,7 +563,7 @@ func TestBackfill_ResumesFromWatermarkWhenHorizonGrows(t *testing.T) {
t.Errorf("expected additional GetActivities calls when horizon grows, got %d total (was %d)", m.GetActivitiesCalls, firstCallCount)
}
state, err := db.GetSyncState(ctx)
state, err := db.GetSyncState(ctx, userID)
if err != nil {
t.Fatalf("GetSyncState: %v", err)
}
@@ -558,6 +575,7 @@ func TestBackfill_ResumesFromWatermarkWhenHorizonGrows(t *testing.T) {
func TestFullSync_RecordsOneCombinedSyncRun(t *testing.T) {
db := openTestDB(t)
ctx := context.Background()
userID := provisionTestUser(t, db)
m := &mock.Client{
Activities: []garmin.Activity{
@@ -571,15 +589,15 @@ func TestFullSync_RecordsOneCombinedSyncRun(t *testing.T) {
1: {ActivityID: 1}, 2: {ActivityID: 2},
},
}
svc := NewService(m, db, Config{BackfillWindowDays: 10},
svc := NewService(m, db, userID, Config{BackfillWindowDays: 10},
fixedNow(time.Date(2026, 7, 11, 0, 0, 0, 0, time.UTC)))
setBackfillHorizon(t, db, 10)
setBackfillHorizon(t, db, userID, 10)
if err := svc.FullSync(ctx, 10); err != nil {
t.Fatalf("FullSync: %v", err)
}
runs, err := db.ListSyncRuns(ctx, 10)
runs, err := db.ListSyncRuns(ctx, userID, 10)
if err != nil {
t.Fatalf("ListSyncRuns: %v", err)
}
@@ -606,7 +624,7 @@ func TestFullSync_RecordsOneCombinedSyncRun(t *testing.T) {
t.Errorf("ActivitiesFetched = %d, want 2 (genuinely new activities, deduped across stages)", run.ActivitiesFetched)
}
activities, err := db.ListActivities(ctx, store.ActivityFilter{})
activities, err := db.ListActivities(ctx, userID, store.ActivityFilter{})
if err != nil {
t.Fatalf("ListActivities: %v", err)
}
@@ -618,6 +636,7 @@ func TestFullSync_RecordsOneCombinedSyncRun(t *testing.T) {
func TestFillPendingDetails_ReportsLiveProgress(t *testing.T) {
db := openTestDB(t)
ctx := context.Background()
userID := provisionTestUser(t, db)
m := &mock.Client{
Activities: []garmin.Activity{},
@@ -634,7 +653,7 @@ func TestFillPendingDetails_ReportsLiveProgress(t *testing.T) {
m.Details[i] = garmin.ActivityDetails{ActivityID: i}
}
svc := NewService(m, db, Config{InterCallDelay: 150 * time.Millisecond},
svc := NewService(m, db, userID, Config{InterCallDelay: 150 * time.Millisecond},
fixedNow(time.Date(2026, 7, 11, 0, 0, 0, 0, time.UTC)))
if err := svc.Backfill(ctx); err != nil {
t.Fatalf("Backfill: %v", err)
@@ -663,3 +682,47 @@ func TestFillPendingDetails_ReportsLiveProgress(t *testing.T) {
t.Errorf("Progress after completion = %+v, want zero value (idle)", final)
}
}
func TestService_TwoUsersSyncIndependently(t *testing.T) {
db := openTestDB(t)
ctx := context.Background()
userA, err := db.ProvisionUser(ctx, "sub-a", "A")
if err != nil {
t.Fatalf("ProvisionUser(a): %v", err)
}
userB, err := db.ProvisionUser(ctx, "sub-b", "B")
if err != nil {
t.Fatalf("ProvisionUser(b): %v", err)
}
mA := &mock.Client{Activities: []garmin.Activity{
{ActivityID: 1, ActivityType: garmin.ActivityType{TypeKey: "running"}, StartTimeGMT: "2026-07-01 06:00:00", Distance: 5000, Duration: 1500},
}}
mB := &mock.Client{Activities: []garmin.Activity{
{ActivityID: 2, ActivityType: garmin.ActivityType{TypeKey: "running"}, StartTimeGMT: "2026-07-01 06:00:00", Distance: 8000, Duration: 2400},
}}
svcA := NewService(mA, db, userA, Config{}, fixedNow(time.Date(2026, 7, 11, 0, 0, 0, 0, time.UTC)))
svcB := NewService(mB, db, userB, Config{}, fixedNow(time.Date(2026, 7, 11, 0, 0, 0, 0, time.UTC)))
if err := svcA.Backfill(ctx); err != nil {
t.Fatalf("Backfill(a): %v", err)
}
if err := svcB.Backfill(ctx); err != nil {
t.Fatalf("Backfill(b): %v", err)
}
activitiesA, err := db.ListActivities(ctx, userA, store.ActivityFilter{})
if err != nil {
t.Fatalf("ListActivities(a): %v", err)
}
activitiesB, err := db.ListActivities(ctx, userB, store.ActivityFilter{})
if err != nil {
t.Fatalf("ListActivities(b): %v", err)
}
if len(activitiesA) != 1 || activitiesA[0].GarminActivityID != 1 {
t.Fatalf("userA's activities = %+v, want exactly garmin id 1", activitiesA)
}
if len(activitiesB) != 1 || activitiesB[0].GarminActivityID != 2 {
t.Fatalf("userB's activities = %+v, want exactly garmin id 2", activitiesB)
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,129 @@
# Per-user profile & data isolation — design
Date: 2026-07-25
## Overview
`docs/superpowers/specs/2026-07-24-oidc-authentication-design.md` deliberately scoped OIDC login as an access gate only: every authenticated+authorized user reaches the same singleton `profile` row and the same dataset. That doc explicitly called multi-tenancy "a separate, much larger project" and listed it as a non-goal.
This is that project. Each OIDC-authenticated user now gets their own profile, their own Garmin credentials, and their own fully independent synced/classified dataset. There is no cross-user visibility and no admin/impersonation path — switching to a different user's data is only possible by logging out and back in as that user.
This reverses the "single active profile" decision recorded in `docs/superpowers/specs/2026-07-17-profile-taxonomy-analysis-engine-design.md`.
## Goals
- Every table that currently holds a singleton or globally-shared row (`profile`, `workout_kinds`, `sync_state`) or shared rows (`activities`, `sync_runs`) becomes scoped to the logged-in user.
- A user's `user_id` is derived exclusively from their signed session cookie (the OIDC `sub`, resolved server-side) — never from any client-supplied path/query/body parameter. This is the entire isolation boundary and must hold in every handler without exception.
- New users get an explicit one-screen setup step (display name only) before they can use the rest of the app; nothing is auto-provisioned silently.
- The existing single-user dataset (today's "Kriss" profile and everything synced under it) becomes one specific user's data via a one-time migration step, with zero data loss and zero re-sync required.
- Each user brings their own Garmin account; Garmin sync, MFA, and classification all keep working exactly as they do today, just scoped per user.
## Non-goals
- Any admin UI, user list, or "view as" capability. There is deliberately no way to see or touch another user's data short of holding their session cookie.
- Self-service account deletion/merging. Not needed yet; can be a later, separate project.
- Changing the OIDC login flow itself (`/api/session/*`), the required-role gate, or the Garmin credential/MFA flow (`/api/auth/*`) — all of that is reused as-is, just now resolved per-user instead of globally.
- Any change to the classification rule engine (`internal/classify`) itself — it's already pure (`MetricContext` + `RuleKind`s in, verdict out) and has no notion of "whose" data it's operating on.
## Data model
### New table
```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'))
);
```
### Scoped tables
| Table | Change |
|---|---|
| `profile` | add `user_id INTEGER NOT NULL REFERENCES users(id)`, `UNIQUE(user_id)`; drop `CHECK(id = 1)`. Queries move from `WHERE id = 1` to `WHERE user_id = ?`. |
| `workout_kinds` | add `user_id INTEGER NOT NULL REFERENCES users(id)`; unique constraint becomes `UNIQUE(user_id, name)` instead of `UNIQUE(name)`. |
| `activities` | add `user_id INTEGER NOT NULL REFERENCES users(id)`; unique constraint becomes `UNIQUE(user_id, garmin_activity_id)` instead of `UNIQUE(garmin_activity_id)`. |
| `sync_state` | add `user_id INTEGER NOT NULL REFERENCES users(id)`, `UNIQUE(user_id)`; drop `CHECK(id = 1)`. |
| `sync_runs` | add `user_id INTEGER NOT NULL REFERENCES users(id)`. |
### Tables that stay unchanged
`laps`, `activity_samples`, `kind_assignments`, `workout_type_paces` are never queried except joined off `activity_id` or `workout_kind_id`, whose owning row already carries `user_id`. Adding a redundant `user_id` there would just create a second source of truth to keep in sync — ownership is checked transitively (e.g. `SELECT ... FROM laps JOIN activities ON activities.id = laps.activity_id WHERE activities.user_id = ? AND laps.id = ?`).
### Migration mechanics
SQLite's `ALTER TABLE ADD COLUMN` can't add a `NOT NULL` column with no default to a table that already has rows without a full table rebuild. Since every environment this runs in (today, just this one deployment) is about to have its rows backfilled in the same upgrade anyway, the pragmatic sequence across two new numbered migration files is:
1. `00XX_users_table.sql` — creates `users`.
2. `00XX_scope_tables_to_user.sql` — adds each `user_id` column as nullable (no `NOT NULL` yet, since SQLite requires a default for that on a non-empty table and there's no sensible default at schema-definition time), adds the new/changed unique constraints. The `NOT NULL` behavior is then enforced at the application layer from here on (every insert path is being rewritten anyway as part of this change), matching how this codebase already treats some invariants as store/API-layer contracts rather than DB-level constraints (e.g. `kind_assignments.status` values).
### Legacy data binding
Rather than a placeholder-user-then-manual-claim dance, a Go-level bootstrap step (not raw SQL, since migrations can't take parameters) runs once at `geniusrund` startup:
- New required-at-this-one-time env var: `GENIUSRUN_LEGACY_OWNER_OIDC_SUB` — your Keycloak `sub` claim.
- On startup, if the `users` table is empty and any pre-existing `profile`/`activities`/`workout_kinds`/`sync_state`/`sync_runs` rows have `user_id IS NULL`: insert one `users` row (`oidc_sub = GENIUSRUN_LEGACY_OWNER_OIDC_SUB`, `display_name` = the existing `profile.name` column value), then `UPDATE` every such row to that new `user_id`, in one transaction.
- Once this has run, the env var is no longer read (the `users` table is no longer empty, so the bootstrap step is a no-op on every subsequent startup) and can be removed from the environment.
- This requires knowing your own `sub` ahead of time — it's already visible today via `GET /api/session/me` (`Claims.Sub`) after logging in once under the current single-tenant code, before this migration ships.
## Auth & per-user runtime wiring
### Session -> user resolution
A new middleware step runs immediately after the existing `RequireSession` (role-checked) middleware:
- Looks up `SELECT id, display_name FROM users WHERE oidc_sub = ?` using the already-verified session claims' `Sub`.
- Found: injects the resolved `user_id` (and `display_name`) into the request context via a new `UserFromContext(ctx)` helper, alongside the existing `ClaimsFromContext`.
- Not found: every route except `POST /api/setup` and the existing `/api/session/logout` returns `403` (or the frontend equivalent — see below) instead of reaching its handler.
**Security invariant** (the entire isolation boundary): every handler that touches `profile`, `activities`, `workout_kinds`, `sync_state`, `sync_runs`, or anything joined off them must obtain `user_id` from `UserFromContext(ctx)` only — never from a URL param, query string, or request body field. This is a specific thing to check for in code review for every handler touched by this change.
### `garmin.Client` / `sync.Service` become per-user
`internal/garmin.Client` and `internal/sync.Service` already take their config/dependencies as constructor params with no hidden global state, so per-user instantiation is mechanical:
- `api.Server`'s current single fixed `Garmin`/`Sync` fields and flat Garmin-auth-status fields (today: plain fields on `Server`, single-user assumption) become two mutex-guarded maps: `map[int64]*garmin.Client` and `map[int64]*sync.Service`, keyed by `user_id`, lazily constructed on first access for a given user.
- A per-user `garmin.Client` is built from that user's own `profile` row (`GarminEmail`/`GarminPassword`) and a per-user token store path: `filepath.Join(cfg.GarminTokenStoreRoot, strconv.FormatInt(userID, 10))`. `GarminTokenStore` in `internal/config` is renamed/repurposed to `GarminTokenStoreRoot` (a directory, not a single tokenstore path) to reflect this.
- `sync.Service.Progress()` becomes naturally per-user once each user has their own `Service` instance — no separate change needed there.
- `store.DB` stays a single shared connection against one SQLite file — this design keeps the single-shared-DB-with-`user_id`-columns approach, so no per-user DB file/connection is needed.
## New-user setup flow
On first login, the session->user middleware finds no `users` row for that `sub`. The frontend shows a "Create your profile" screen (structurally similar to how `LoginGate` already blocks unauthenticated users) asking only for **Display Name**.
On submit, `POST /api/setup {display_name}` runs one transaction via a new `store.ProvisionUser(ctx, oidcSub, displayName string) (userID int64, err error)`:
1. Insert the `users` row.
2. Insert a default `profile` row for that `user_id` (same defaults as today's fresh-install `profile` seed row).
3. Seed the same 8 default `workout_kinds` rows (same placeholder rules as migrations `0004`/`0007`) for that `user_id`.
4. Insert a default `sync_state` row (`earliest_synced_date = NULL`, `backfill_complete = 0`).
This seeding logic must exist as reusable Go code rather than living only in migration SQL, since it now runs continuously (every new signup) rather than once at deploy time. Garmin credentials are filled in afterward via the existing Profile screen, unchanged from today's flow.
## Frontend changes
- New "Create your profile" screen/component, shown when `GET /api/session/me` (or a follow-up call) indicates an authorized session with no provisioned profile yet. Only field: Display Name.
- Existing Profile screen: the `profile.name` column (added in migration `0011` for exactly this eventual purpose) becomes the user-editable "Display Name" field — no schema change needed there, it already exists.
- No profile switcher, no user list, anywhere in the UI.
## API changes summary
Every existing handler in `internal/api` that reads/writes `profile`, `activities`, `workout_kinds`, `sync_state`, or `sync_runs` (directly or via `internal/sync`/`internal/store` calls) gets a `userID` parameter threaded from `UserFromContext(ctx)` down through the corresponding `store`/`sync` method signatures. This is a mechanical but wide-reaching signature change — most of `internal/store`'s non-classification methods, `internal/sync.Service`'s methods, and their `internal/api` callers are touched.
## Testing plan
- `internal/store`: a shared test helper provisions a `users` row (and typically a full `ProvisionUser` call) before exercising any scoped table, replacing the current "just open a temp DB" setup. New test cases:
- Two users' `profile`/`workout_kinds`/`activities`/`sync_state` rows never leak into each other's `Get*` calls.
- The new unique constraints (`UNIQUE(user_id, name)`, `UNIQUE(user_id, garmin_activity_id)`) allow the same name/Garmin ID across different users but reject duplicates within one user.
- `internal/api`: `httptest` setup logs in as a specific test user (sets a session cookie carrying that user's `sub`, matching a seeded `users` row) for existing tests; new tests assert that user A's session can never read or mutate user B's `profile`/`activities`/`workout_kinds` even when handed user B's internal IDs directly (the security invariant, tested adversarially).
- `internal/sync`/`internal/garmin`: verify the keyed-map lazy construction — two users produce two independent `Client`/`Service` instances, and one user's `Progress()` counters never reflect another user's in-flight sync.
- New-user flow: `POST /api/setup` with no prior `users` row succeeds and seeds all four tables in one transaction; a second call for the same `sub` is rejected (already provisioned).
- Legacy migration bootstrap: a test DB with pre-migration singleton rows (`user_id IS NULL`) plus `GENIUSRUN_LEGACY_OWNER_OIDC_SUB` set gets every row correctly attributed to one new user; running the bootstrap a second time (now that `users` is non-empty) is a no-op.
## Rollout notes
- Before deploying: log into the current single-tenant build once, note your `sub` from `GET /api/session/me`, and set `GENIUSRUN_LEGACY_OWNER_OIDC_SUB` to it for the first startup after upgrading.
- `GARMIN_TOKENSTORE` env var / `GarminTokenStore` config field is repurposed as a root directory (`GarminTokenStoreRoot`) rather than a single tokenstore path — existing deployments need to point it at a directory rather than mcp-garmin's old single-file/dir location, and any already-cached Garmin session under the old path is effectively invalidated (that one user will need to log into Garmin again post-migration, once, under their new per-user subdirectory).
- No frontend routing changes beyond the new setup screen — everything else (Dashboard, Review Queue, Plan stub, Profile) is unchanged in shape, just now reflecting whichever user is logged in.

View File

@@ -0,0 +1,43 @@
.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;
}

View File

@@ -0,0 +1,52 @@
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>
);
}

View File

@@ -2,6 +2,7 @@ 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";
@@ -13,8 +14,10 @@ const AUTH_ERROR_MESSAGES: Record<string, string> = {
// 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 only fork in the whole frontend between "show the login
// screen" and "show the app."
// 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);
@@ -46,5 +49,13 @@ export function LoginGate() {
);
}
if (!session!.has_profile) {
return (
<CreateProfile
onCreated={(displayName) => setSession((s) => (s ? { ...s, has_profile: true, display_name: displayName } : s))}
/>
);
}
return <App session={session!} />;
}

View File

@@ -46,6 +46,11 @@ export const api = {
// browser navigation. Logout must POST (see server.go's route), which an
// <a href> can't do, hence the form.
getSessionInfo: () => request<SessionInfo>("/api/session/me"),
setup: (displayName: string) =>
request<{ user_id: number; display_name: string }>("/api/setup", {
method: "POST",
body: JSON.stringify({ display_name: displayName }),
}),
// Auth
login: () => request<AuthResponse>("/api/auth/login", { method: "POST" }),

View File

@@ -189,6 +189,8 @@ export interface AuthResponse {
export interface SessionInfo {
name: string;
email: string;
has_profile: boolean;
display_name?: string;
}
export interface DetailFillProgress {