Compare commits
29 Commits
502e61e7b4
...
1329387528
| Author | SHA1 | Date | |
|---|---|---|---|
| 1329387528 | |||
| 00605f950b | |||
| 7f5d2da6a2 | |||
| d62544f1cc | |||
| ad906a10f7 | |||
| e37173ea08 | |||
| eaca8e602b | |||
| 5930cc4ef5 | |||
| 2cda0adbf8 | |||
| 4dceb03fc0 | |||
| 9140a00da7 | |||
| 69103b7a4b | |||
| 3a1333017d | |||
| 99984e319e | |||
| 47dbf4e446 | |||
| f82549524b | |||
| c2bdfe3798 | |||
| b08f0152ef | |||
| d8b722820d | |||
| 62753e4604 | |||
| e8bde134ae | |||
| ef410863c6 | |||
| b0a7462ebe | |||
| 36bc651d66 | |||
| 46ad8a7e08 | |||
| f57b314be4 | |||
| 245d4bf821 | |||
| 033e412cb7 | |||
| d29f69903d |
@@ -31,24 +31,12 @@ func main() {
|
|||||||
}
|
}
|
||||||
defer db.Close()
|
defer db.Close()
|
||||||
|
|
||||||
profile, err := db.GetProfile(context.Background())
|
if cfg.LegacyOwnerOIDCSub != "" {
|
||||||
if err != nil {
|
if err := db.ClaimLegacyOwner(context.Background(), cfg.LegacyOwnerOIDCSub); err != nil {
|
||||||
log.Fatalf("load profile: %v", err)
|
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{
|
authVerifier, err := auth.NewOIDCVerifier(context.Background(), auth.OIDCConfig{
|
||||||
IssuerURL: cfg.OIDCIssuerURL,
|
IssuerURL: cfg.OIDCIssuerURL,
|
||||||
ClientID: cfg.OIDCClientID,
|
ClientID: cfg.OIDCClientID,
|
||||||
@@ -60,7 +48,13 @@ func main() {
|
|||||||
log.Fatalf("oidc: %v", err)
|
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,
|
Secret: cfg.SessionSecret,
|
||||||
Duration: cfg.SessionDuration,
|
Duration: cfg.SessionDuration,
|
||||||
Secure: cfg.SessionSecure,
|
Secure: cfg.SessionSecure,
|
||||||
@@ -70,7 +64,7 @@ func main() {
|
|||||||
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
||||||
defer stop()
|
defer stop()
|
||||||
|
|
||||||
go runIncrementalSyncLoop(ctx, syncSvc, cfg.IncrementalSyncEvery)
|
go runIncrementalSyncLoop(ctx, server, cfg.IncrementalSyncEvery)
|
||||||
|
|
||||||
httpServer := &http.Server{Addr: cfg.Addr, Handler: server.Router()}
|
httpServer := &http.Server{Addr: cfg.Addr, Handler: server.Router()}
|
||||||
go func() {
|
go func() {
|
||||||
@@ -89,9 +83,10 @@ func main() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// runIncrementalSyncLoop periodically syncs new activities in the
|
// runIncrementalSyncLoop periodically syncs new activities for every
|
||||||
// background so the frontend doesn't need to trigger every sync manually.
|
// provisioned user in the background so the frontend doesn't need to
|
||||||
func runIncrementalSyncLoop(ctx context.Context, svc *appsync.Service, every time.Duration) {
|
// trigger every sync manually.
|
||||||
|
func runIncrementalSyncLoop(ctx context.Context, server *api.Server, every time.Duration) {
|
||||||
ticker := time.NewTicker(every)
|
ticker := time.NewTicker(every)
|
||||||
defer ticker.Stop()
|
defer ticker.Stop()
|
||||||
for {
|
for {
|
||||||
@@ -99,13 +94,7 @@ func runIncrementalSyncLoop(ctx context.Context, svc *appsync.Service, every tim
|
|||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
return
|
return
|
||||||
case <-ticker.C:
|
case <-ticker.C:
|
||||||
if err := svc.IncrementalSync(ctx); err != nil {
|
server.RunIncrementalSyncForAllUsers(ctx)
|
||||||
log.Printf("incremental sync: %v", err)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if err := svc.FillPendingDetails(ctx, 50); err != nil {
|
|
||||||
log.Printf("fill pending details: %v", err)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,13 +29,16 @@ func main() {
|
|||||||
}
|
}
|
||||||
defer db.Close()
|
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
|
// Set a max heart rate on the profile so avg_hr_pct_max is computed
|
||||||
// during classification below (it's nil/unset by default).
|
// during classification below (it's nil/unset by default).
|
||||||
profile, err := db.GetProfile(ctx)
|
profile, err := db.GetProfile(ctx, userID)
|
||||||
must(err)
|
must(err)
|
||||||
maxHR := 190.0
|
maxHR := 190.0
|
||||||
profile.MaxHeartRate = &maxHR
|
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
|
// 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
|
// (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
|
// gap between disjoint ranges. The taxonomy migration already seeded
|
||||||
// these rows (by fixed name) -- update their rules in place rather than
|
// these rows (by fixed name) -- update their rules in place rather than
|
||||||
// creating new ones, since names are unique.
|
// creating new ones, since names are unique.
|
||||||
easyID := mustFindKindID(ctx, db, "Easy")
|
easyID := mustFindKindID(ctx, db, userID, "Easy")
|
||||||
must(db.UpdateWorkoutKind(ctx, store.WorkoutKind{
|
must(db.UpdateWorkoutKind(ctx, userID, store.WorkoutKind{
|
||||||
ID: easyID, Name: "Easy", Description: "Easy conversational runs", Color: "#22c55e",
|
ID: easyID, Name: "Easy", Description: "Easy conversational runs", Color: "#22c55e",
|
||||||
RuleJSON: `{"match":"all","conditions":[
|
RuleJSON: `{"match":"all","conditions":[
|
||||||
{"metric":"avg_pace_sec_per_km","op":"between","value":[330,420]},
|
{"metric":"avg_pace_sec_per_km","op":"between","value":[330,420]},
|
||||||
@@ -53,8 +56,8 @@ func main() {
|
|||||||
IsActive: true,
|
IsActive: true,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
tempoID := mustFindKindID(ctx, db, "Tempo")
|
tempoID := mustFindKindID(ctx, db, userID, "Tempo")
|
||||||
must(db.UpdateWorkoutKind(ctx, store.WorkoutKind{
|
must(db.UpdateWorkoutKind(ctx, userID, store.WorkoutKind{
|
||||||
ID: tempoID, Name: "Tempo", Description: "Comfortably hard sustained effort", Color: "#f59e0b",
|
ID: tempoID, Name: "Tempo", Description: "Comfortably hard sustained effort", Color: "#f59e0b",
|
||||||
RuleJSON: `{"match":"all","conditions":[
|
RuleJSON: `{"match":"all","conditions":[
|
||||||
{"metric":"avg_pace_sec_per_km","op":"between","value":[300,340]},
|
{"metric":"avg_pace_sec_per_km","op":"between","value":[300,340]},
|
||||||
@@ -63,15 +66,15 @@ func main() {
|
|||||||
IsActive: true,
|
IsActive: true,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
intervalID := mustFindKindID(ctx, db, "Intervals")
|
intervalID := mustFindKindID(ctx, db, userID, "Intervals")
|
||||||
must(db.UpdateWorkoutKind(ctx, store.WorkoutKind{
|
must(db.UpdateWorkoutKind(ctx, userID, store.WorkoutKind{
|
||||||
ID: intervalID, Name: "Intervals", Description: "Structured work/rest intervals", Color: "#ef4444",
|
ID: intervalID, Name: "Intervals", Description: "Structured work/rest intervals", Color: "#ef4444",
|
||||||
RuleJSON: `{"match":"all","conditions":[{"metric":"lap_interval_pattern","op":"==","value":true}]}`,
|
RuleJSON: `{"match":"all","conditions":[{"metric":"lap_interval_pattern","op":"==","value":true}]}`,
|
||||||
IsActive: true,
|
IsActive: true,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
m := &mock.Client{}
|
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()
|
today := time.Now()
|
||||||
activityIDs := []int64{}
|
activityIDs := []int64{}
|
||||||
@@ -81,7 +84,7 @@ func main() {
|
|||||||
start := today.AddDate(0, 0, -60+i*10)
|
start := today.AddDate(0, 0, -60+i*10)
|
||||||
speed := 1000.0 / (390 - float64(i)*5) // pace improving from 390 -> 370 sec/km
|
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
|
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,
|
garminID: 1000 + int64(i), name: "Easy morning run", start: start,
|
||||||
distance: 8000, duration: 8000 / (speed) * 1, speedMps: speed, avgHR: hr,
|
distance: 8000, duration: 8000 / (speed) * 1, speedMps: speed, avgHR: hr,
|
||||||
aerobicTE: 2.5, anaerobicTE: 0.3,
|
aerobicTE: 2.5, anaerobicTE: 0.3,
|
||||||
@@ -93,7 +96,7 @@ func main() {
|
|||||||
for i := 0; i < 3; i++ {
|
for i := 0; i < 3; i++ {
|
||||||
start := today.AddDate(0, 0, -45+i*15)
|
start := today.AddDate(0, 0, -45+i*15)
|
||||||
speed := 1000.0 / 320.0 // centered in Tempo's [300,340] range
|
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,
|
garminID: 2000 + int64(i), name: "Tempo run", start: start,
|
||||||
distance: 6000, duration: 1800, speedMps: speed, avgHR: 168,
|
distance: 6000, duration: 1800, speedMps: speed, avgHR: 168,
|
||||||
aerobicTE: 3.8, anaerobicTE: 1.2,
|
aerobicTE: 3.8, anaerobicTE: 1.2,
|
||||||
@@ -104,12 +107,12 @@ func main() {
|
|||||||
// 1 Interval workout, with alternating ACTIVE/REST laps + HR samples
|
// 1 Interval workout, with alternating ACTIVE/REST laps + HR samples
|
||||||
// showing drift on the work intervals and recovery on the rest ones.
|
// 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),
|
garminID: 3000, name: "Track intervals", start: today.AddDate(0, 0, -5),
|
||||||
distance: 8000, duration: 2400, speedMps: 1000.0 / 240.0, avgHR: 165,
|
distance: 8000, duration: 2400, speedMps: 1000.0 / 240.0, avgHR: 165,
|
||||||
aerobicTE: 3.0, anaerobicTE: 3.5,
|
aerobicTE: 3.0, anaerobicTE: 3.5,
|
||||||
})
|
})
|
||||||
seedIntervalLapsAndSamples(ctx, db, id)
|
seedIntervalLapsAndSamples(ctx, db, userID, id)
|
||||||
activityIDs = append(activityIDs, 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
|
// and Tempo's >=0.70 -- so it should land in the review queue with two
|
||||||
// candidates, not a clean single match.
|
// 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),
|
garminID: 4000, name: "Ambiguous run", start: today.AddDate(0, 0, -2),
|
||||||
distance: 7000, duration: 2200, speedMps: 1000.0 / 335.0, avgHR: 148,
|
distance: 7000, duration: 2200, speedMps: 1000.0 / 335.0, avgHR: 148,
|
||||||
aerobicTE: 3.0, anaerobicTE: 0.8,
|
aerobicTE: 3.0, anaerobicTE: 0.8,
|
||||||
@@ -145,12 +148,12 @@ type seedParams struct {
|
|||||||
anaerobicTE float64
|
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
|
speed := p.speedMps
|
||||||
hr := p.avgHR
|
hr := p.avgHR
|
||||||
aerobic := p.aerobicTE
|
aerobic := p.aerobicTE
|
||||||
anaerobic := p.anaerobicTE
|
anaerobic := p.anaerobicTE
|
||||||
id, err := db.UpsertActivity(ctx, store.Activity{
|
id, err := db.UpsertActivity(ctx, userID, store.Activity{
|
||||||
GarminActivityID: p.garminID,
|
GarminActivityID: p.garminID,
|
||||||
StartTimeUTC: p.start.Format("2006-01-02 15:04:05"),
|
StartTimeUTC: p.start.Format("2006-01-02 15:04:05"),
|
||||||
DurationSeconds: p.duration,
|
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
|
// seedIntervalLapsAndSamples gives one activity 6 alternating ACTIVE/REST
|
||||||
// laps plus per-second HR samples: rising HR within each ACTIVE lap (drift)
|
// laps plus per-second HR samples: rising HR within each ACTIVE lap (drift)
|
||||||
// and falling HR within each REST lap (recovery).
|
// 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 laps []store.Lap
|
||||||
var samples []store.Sample
|
var samples []store.Sample
|
||||||
elapsed := 0.0
|
elapsed := 0.0
|
||||||
@@ -225,8 +228,8 @@ func seedIntervalLapsAndSamples(ctx context.Context, db *store.DB, activityID in
|
|||||||
elapsed += lapDuration
|
elapsed += lapDuration
|
||||||
}
|
}
|
||||||
|
|
||||||
must(db.ReplaceActivitySamples(ctx, activityID, samples))
|
must(db.ReplaceActivitySamples(ctx, userID, activityID, samples))
|
||||||
must(db.ReplaceLaps(ctx, activityID, laps))
|
must(db.ReplaceLaps(ctx, userID, activityID, laps))
|
||||||
}
|
}
|
||||||
|
|
||||||
func must(err error) {
|
func must(err error) {
|
||||||
@@ -235,14 +238,14 @@ func must(err error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func mustFindKindID(ctx context.Context, db *store.DB, name string) int64 {
|
func mustFindKindID(ctx context.Context, db *store.DB, userID int64, name string) int64 {
|
||||||
kinds, err := db.ListWorkoutKinds(ctx, false)
|
kinds, err := db.ListWorkoutKinds(ctx, userID, false)
|
||||||
must(err)
|
must(err)
|
||||||
for _, k := range kinds {
|
for _, k := range kinds {
|
||||||
if k.Name == name {
|
if k.Name == name {
|
||||||
return k.ID
|
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
|
return 0
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ type activityListItem struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) handleListActivities(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleListActivities(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userID := userIDFromContext(r.Context())
|
||||||
q := r.URL.Query()
|
q := r.URL.Query()
|
||||||
filter := store.ActivityFilter{
|
filter := store.ActivityFilter{
|
||||||
FromDate: q.Get("from"),
|
FromDate: q.Get("from"),
|
||||||
@@ -37,13 +38,13 @@ func (s *Server) handleListActivities(w http.ResponseWriter, r *http.Request) {
|
|||||||
filter.Offset = offset
|
filter.Offset = offset
|
||||||
}
|
}
|
||||||
|
|
||||||
activities, err := s.DB.ListActivities(r.Context(), filter)
|
activities, err := s.DB.ListActivities(r.Context(), userID, filter)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, http.StatusInternalServerError, err.Error())
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
kinds, err := s.DB.ListWorkoutKinds(r.Context(), false)
|
kinds, err := s.DB.ListWorkoutKinds(r.Context(), userID, false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, http.StatusInternalServerError, err.Error())
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
return
|
return
|
||||||
@@ -56,7 +57,7 @@ func (s *Server) handleListActivities(w http.ResponseWriter, r *http.Request) {
|
|||||||
resp := make([]activityListItem, 0, len(activities))
|
resp := make([]activityListItem, 0, len(activities))
|
||||||
for _, a := range activities {
|
for _, a := range activities {
|
||||||
item := activityListItem{activityResponse: toActivityResponse(a)}
|
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 {
|
if err != nil {
|
||||||
writeError(w, http.StatusInternalServerError, err.Error())
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
return
|
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) {
|
func (s *Server) handleGetActivity(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userID := userIDFromContext(r.Context())
|
||||||
id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
|
id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, http.StatusBadRequest, "invalid activity id")
|
writeError(w, http.StatusBadRequest, "invalid activity id")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
activity, ok, err := s.DB.GetActivity(r.Context(), id)
|
activity, ok, err := s.DB.GetActivity(r.Context(), userID, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, http.StatusInternalServerError, err.Error())
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
return
|
return
|
||||||
@@ -95,13 +97,13 @@ func (s *Server) handleGetActivity(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
laps, err := s.DB.LapsForActivity(r.Context(), id)
|
laps, err := s.DB.LapsForActivity(r.Context(), userID, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, http.StatusInternalServerError, err.Error())
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
assignment, hasAssignment, err := s.DB.CurrentAssignment(r.Context(), id)
|
assignment, hasAssignment, err := s.DB.CurrentAssignment(r.Context(), userID, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, http.StatusInternalServerError, err.Error())
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import (
|
|||||||
|
|
||||||
"geniusrun/backend/internal/auth"
|
"geniusrun/backend/internal/auth"
|
||||||
authmock "geniusrun/backend/internal/auth/mock"
|
authmock "geniusrun/backend/internal/auth/mock"
|
||||||
|
"geniusrun/backend/internal/garmin"
|
||||||
"geniusrun/backend/internal/garmin/mock"
|
"geniusrun/backend/internal/garmin/mock"
|
||||||
"geniusrun/backend/internal/store"
|
"geniusrun/backend/internal/store"
|
||||||
appsync "geniusrun/backend/internal/sync"
|
appsync "geniusrun/backend/internal/sync"
|
||||||
@@ -29,7 +30,7 @@ var testSessionConfig = SessionConfig{
|
|||||||
PublicBaseURL: "https://geniusrun.example.com",
|
PublicBaseURL: "https://geniusrun.example.com",
|
||||||
}
|
}
|
||||||
|
|
||||||
func newTestServer(t *testing.T) (*Server, *store.DB) {
|
func newTestServer(t *testing.T) (*Server, *store.DB, int64) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
db, err := store.Open(filepath.Join(t.TempDir(), "geniusrun_test.db"))
|
db, err := store.Open(filepath.Join(t.TempDir(), "geniusrun_test.db"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -37,9 +38,15 @@ func newTestServer(t *testing.T) (*Server, *store.DB) {
|
|||||||
}
|
}
|
||||||
t.Cleanup(func() { db.Close() })
|
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{}
|
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) })
|
garminFactory := func(garmin.Config) garmin.Client { return m }
|
||||||
return NewServer(db, m, svc, &authmock.Verifier{}, testSessionConfig), db
|
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 {
|
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) {
|
func TestHealth(t *testing.T) {
|
||||||
s, _ := newTestServer(t)
|
s, _, _ := newTestServer(t)
|
||||||
rec := doJSON(t, s.Router(), http.MethodGet, "/api/health", nil)
|
rec := doJSON(t, s.Router(), http.MethodGet, "/api/health", nil)
|
||||||
if rec.Code != http.StatusOK {
|
if rec.Code != http.StatusOK {
|
||||||
t.Fatalf("status = %d, want 200", rec.Code)
|
t.Fatalf("status = %d, want 200", rec.Code)
|
||||||
@@ -75,7 +82,7 @@ func TestHealth(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestWorkoutKindList_ReturnsEightSeededTypesWithPaceFields(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)
|
rec := doJSON(t, s.Router(), http.MethodGet, "/api/workout-kinds/", nil)
|
||||||
if rec.Code != http.StatusOK {
|
if rec.Code != http.StatusOK {
|
||||||
t.Fatalf("status = %d", rec.Code)
|
t.Fatalf("status = %d", rec.Code)
|
||||||
@@ -95,7 +102,7 @@ func TestWorkoutKindList_ReturnsEightSeededTypesWithPaceFields(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestWorkoutKindUpdate_SetsRuleAndPace(t *testing.T) {
|
func TestWorkoutKindUpdate_SetsRuleAndPace(t *testing.T) {
|
||||||
s, _ := newTestServer(t)
|
s, _, _ := newTestServer(t)
|
||||||
router := s.Router()
|
router := s.Router()
|
||||||
|
|
||||||
rec := doJSON(t, router, http.MethodGet, "/api/workout-kinds/", nil)
|
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) {
|
func TestWorkoutKindUpdate_RejectsInvalidRule(t *testing.T) {
|
||||||
s, _ := newTestServer(t)
|
s, _, _ := newTestServer(t)
|
||||||
router := s.Router()
|
router := s.Router()
|
||||||
|
|
||||||
rec := doJSON(t, router, http.MethodGet, "/api/workout-kinds/", nil)
|
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) {
|
func TestWorkoutKindUpdate_RejectsInvertedHRRange(t *testing.T) {
|
||||||
s, _ := newTestServer(t)
|
s, _, _ := newTestServer(t)
|
||||||
router := s.Router()
|
router := s.Router()
|
||||||
|
|
||||||
rec := doJSON(t, router, http.MethodGet, "/api/workout-kinds/", nil)
|
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) {
|
func TestWorkoutKindUpdate_RejectsInvertedPaceRange(t *testing.T) {
|
||||||
s, _ := newTestServer(t)
|
s, _, _ := newTestServer(t)
|
||||||
router := s.Router()
|
router := s.Router()
|
||||||
|
|
||||||
rec := doJSON(t, router, http.MethodGet, "/api/workout-kinds/", nil)
|
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) {
|
func TestReviewQueueResolve(t *testing.T) {
|
||||||
s, db := newTestServer(t)
|
s, db, userID := newTestServer(t)
|
||||||
ctx := newCtx()
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("UpsertActivity: %v", err)
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("CreateWorkoutKind: %v", err)
|
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,
|
ActivityID: activityID, AssignmentSource: store.AssignmentSourceRuleEngine, Status: store.AssignmentStatusNeedsReview,
|
||||||
CandidateKindsJSON: "[]",
|
CandidateKindsJSON: "[]",
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
t.Fatalf("InsertKindAssignment: %v", err)
|
t.Fatalf("InsertKindAssignment: %v", err)
|
||||||
}
|
}
|
||||||
targetLow, targetHigh := 3.0, 3.5
|
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},
|
{LapIndex: 1, TargetPaceLowMps: &targetLow, TargetPaceHighMps: &targetHigh},
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
t.Fatalf("ReplaceLaps: %v", err)
|
t.Fatalf("ReplaceLaps: %v", err)
|
||||||
}
|
}
|
||||||
hr := 150.0
|
hr := 150.0
|
||||||
if err := db.ReplaceActivitySamples(ctx, activityID, []store.Sample{
|
if err := db.ReplaceActivitySamples(ctx, userID, activityID, []store.Sample{
|
||||||
{ElapsedSeconds: 0, HeartRate: &hr},
|
{ElapsedSeconds: 0, HeartRate: &hr},
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
t.Fatalf("ReplaceActivitySamples: %v", err)
|
t.Fatalf("ReplaceActivitySamples: %v", err)
|
||||||
@@ -316,19 +323,19 @@ func TestReviewQueueResolve(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestReviewQueue_PaginatesByCursor(t *testing.T) {
|
func TestReviewQueue_PaginatesByCursor(t *testing.T) {
|
||||||
s, db := newTestServer(t)
|
s, db, userID := newTestServer(t)
|
||||||
ctx := newCtx()
|
ctx := newCtx()
|
||||||
|
|
||||||
// 5 activities, newest first once sorted: 2026-07-05 .. 2026-07-01.
|
// 5 activities, newest first once sorted: 2026-07-05 .. 2026-07-01.
|
||||||
for i := 1; i <= 5; i++ {
|
for i := 1; i <= 5; i++ {
|
||||||
activityID, err := db.UpsertActivity(ctx, store.Activity{
|
activityID, err := db.UpsertActivity(ctx, userID, store.Activity{
|
||||||
GarminActivityID: int64(i),
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("UpsertActivity: %v", err)
|
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,
|
ActivityID: activityID, AssignmentSource: store.AssignmentSourceRuleEngine, Status: store.AssignmentStatusNeedsReview,
|
||||||
CandidateKindsJSON: "[]",
|
CandidateKindsJSON: "[]",
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
@@ -394,10 +401,10 @@ func TestReviewQueue_PaginatesByCursor(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestReviewQueue_FiltersByKindAndUnclassifiedStayPaginated(t *testing.T) {
|
func TestReviewQueue_FiltersByKindAndUnclassifiedStayPaginated(t *testing.T) {
|
||||||
s, db := newTestServer(t)
|
s, db, userID := newTestServer(t)
|
||||||
ctx := newCtx()
|
ctx := newCtx()
|
||||||
|
|
||||||
kinds, err := db.ListWorkoutKinds(ctx, true)
|
kinds, err := db.ListWorkoutKinds(ctx, userID, true)
|
||||||
if err != nil || len(kinds) < 2 {
|
if err != nil || len(kinds) < 2 {
|
||||||
t.Fatalf("ListWorkoutKinds: %v (len=%d)", err, len(kinds))
|
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.
|
// 2 activities assigned to kindA, 1 to kindB, 1 unclassified.
|
||||||
makeActivity := func(n int64, kindID *int64) {
|
makeActivity := func(n int64, kindID *int64) {
|
||||||
activityID, err := db.UpsertActivity(ctx, store.Activity{
|
activityID, err := db.UpsertActivity(ctx, userID, store.Activity{
|
||||||
GarminActivityID: n,
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("UpsertActivity: %v", err)
|
t.Fatalf("UpsertActivity: %v", err)
|
||||||
@@ -416,7 +423,7 @@ func TestReviewQueue_FiltersByKindAndUnclassifiedStayPaginated(t *testing.T) {
|
|||||||
if kindID == nil {
|
if kindID == nil {
|
||||||
status = store.AssignmentStatusNeedsReview
|
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,
|
ActivityID: activityID, WorkoutKindID: kindID, AssignmentSource: store.AssignmentSourceRuleEngine,
|
||||||
Status: status, CandidateKindsJSON: "[]",
|
Status: status, CandidateKindsJSON: "[]",
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
@@ -481,14 +488,14 @@ func TestReviewQueue_FiltersByKindAndUnclassifiedStayPaginated(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestResolveReview_RejectsRaceKind(t *testing.T) {
|
func TestResolveReview_RejectsRaceKind(t *testing.T) {
|
||||||
s, db := newTestServer(t)
|
s, db, userID := newTestServer(t)
|
||||||
ctx := newCtx()
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("UpsertActivity: %v", err)
|
t.Fatalf("UpsertActivity: %v", err)
|
||||||
}
|
}
|
||||||
raceKind, ok, err := db.GetWorkoutKindByName(ctx, "Race")
|
raceKind, ok, err := db.GetWorkoutKindByName(ctx, userID, "Race")
|
||||||
if err != nil || !ok {
|
if err != nil || !ok {
|
||||||
t.Fatalf("GetWorkoutKindByName(Race): ok=%v err=%v", ok, err)
|
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) {
|
func TestReclassifyAll_SkipsManualAndRaceAssignments(t *testing.T) {
|
||||||
s, db := newTestServer(t)
|
s, db, userID := newTestServer(t)
|
||||||
ctx := newCtx()
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("CreateWorkoutKind: %v", err)
|
t.Fatalf("CreateWorkoutKind: %v", err)
|
||||||
}
|
}
|
||||||
raceKind, ok, err := db.GetWorkoutKindByName(ctx, "Race")
|
raceKind, ok, err := db.GetWorkoutKindByName(ctx, userID, "Race")
|
||||||
if err != nil || !ok {
|
if err != nil || !ok {
|
||||||
t.Fatalf("GetWorkoutKindByName(Race): ok=%v err=%v", ok, err)
|
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: "{}"})
|
ruleEngineActivity, _ := db.UpsertActivity(ctx, userID, 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: "{}"})
|
manualActivity, _ := db.UpsertActivity(ctx, userID, 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: "{}"})
|
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,
|
ActivityID: ruleEngineActivity, WorkoutKindID: &easyID, AssignmentSource: store.AssignmentSourceRuleEngine,
|
||||||
Status: store.AssignmentStatusAssigned, CandidateKindsJSON: "[]",
|
Status: store.AssignmentStatusAssigned, CandidateKindsJSON: "[]",
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
t.Fatalf("InsertKindAssignment (rule engine): %v", err)
|
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,
|
ActivityID: manualActivity, WorkoutKindID: &easyID, AssignmentSource: store.AssignmentSourceManual,
|
||||||
Status: store.AssignmentStatusAssigned, CandidateKindsJSON: "[]",
|
Status: store.AssignmentStatusAssigned, CandidateKindsJSON: "[]",
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
t.Fatalf("InsertKindAssignment (manual): %v", err)
|
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,
|
ActivityID: raceActivity, WorkoutKindID: &raceKind.ID, AssignmentSource: store.AssignmentSourceRuleEngine,
|
||||||
Status: store.AssignmentStatusAssigned, CandidateKindsJSON: "[]",
|
Status: store.AssignmentStatusAssigned, CandidateKindsJSON: "[]",
|
||||||
}); err != nil {
|
}); 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)
|
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 {
|
if err != nil || !ok {
|
||||||
t.Fatalf("CurrentAssignment(manual): ok=%v err=%v", ok, err)
|
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)
|
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 {
|
if err != nil || !ok {
|
||||||
t.Fatalf("CurrentAssignment(race): ok=%v err=%v", ok, err)
|
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) {
|
func TestListActivities_ReportsLockedForManualAndRaceAssignments(t *testing.T) {
|
||||||
s, db := newTestServer(t)
|
s, db, userID := newTestServer(t)
|
||||||
ctx := newCtx()
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("CreateWorkoutKind: %v", err)
|
t.Fatalf("CreateWorkoutKind: %v", err)
|
||||||
}
|
}
|
||||||
raceKind, ok, err := db.GetWorkoutKindByName(ctx, "Race")
|
raceKind, ok, err := db.GetWorkoutKindByName(ctx, userID, "Race")
|
||||||
if err != nil || !ok {
|
if err != nil || !ok {
|
||||||
t.Fatalf("GetWorkoutKindByName(Race): ok=%v err=%v", ok, err)
|
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: "{}"})
|
ruleEngineActivity, _ := db.UpsertActivity(ctx, userID, 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: "{}"})
|
manualActivity, _ := db.UpsertActivity(ctx, userID, 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: "{}"})
|
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, userID, 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, userID, 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: raceActivity, WorkoutKindID: &raceKind.ID, AssignmentSource: store.AssignmentSourceRuleEngine, Status: store.AssignmentStatusAssigned, CandidateKindsJSON: "[]"})
|
||||||
|
|
||||||
rec := doJSON(t, s.Router(), http.MethodGet, "/api/activities/", nil)
|
rec := doJSON(t, s.Router(), http.MethodGet, "/api/activities/", nil)
|
||||||
if rec.Code != http.StatusOK {
|
if rec.Code != http.StatusOK {
|
||||||
@@ -618,10 +625,10 @@ func TestListActivities_ReportsLockedForManualAndRaceAssignments(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestSyncReset_DeletesActivitiesAndBackfillEndpointIsGone(t *testing.T) {
|
func TestSyncReset_DeletesActivitiesAndBackfillEndpointIsGone(t *testing.T) {
|
||||||
s, db := newTestServer(t)
|
s, db, userID := newTestServer(t)
|
||||||
ctx := newCtx()
|
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)
|
t.Fatalf("UpsertActivity: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -633,7 +640,7 @@ func TestSyncReset_DeletesActivitiesAndBackfillEndpointIsGone(t *testing.T) {
|
|||||||
|
|
||||||
deadline := time.Now().Add(2 * time.Second)
|
deadline := time.Now().Add(2 * time.Second)
|
||||||
for {
|
for {
|
||||||
activities, err := db.ListActivities(ctx, store.ActivityFilter{})
|
activities, err := db.ListActivities(ctx, userID, store.ActivityFilter{})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("ListActivities: %v", err)
|
t.Fatalf("ListActivities: %v", err)
|
||||||
}
|
}
|
||||||
@@ -654,17 +661,17 @@ func TestSyncReset_DeletesActivitiesAndBackfillEndpointIsGone(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestProgression_ReturnsSortedTimeSeries(t *testing.T) {
|
func TestProgression_ReturnsSortedTimeSeries(t *testing.T) {
|
||||||
s, db := newTestServer(t)
|
s, db, userID := newTestServer(t)
|
||||||
ctx := newCtx()
|
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
|
speed := 3.0
|
||||||
a1, _ := db.UpsertActivity(ctx, store.Activity{GarminActivityID: 1, StartTimeUTC: "2026-07-01 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, store.Activity{GarminActivityID: 2, StartTimeUTC: "2026-07-05 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
|
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)
|
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) {
|
func TestProfile_GetDefaultsThenUpdate(t *testing.T) {
|
||||||
s, _ := newTestServer(t)
|
s, _, _ := newTestServer(t)
|
||||||
router := s.Router()
|
router := s.Router()
|
||||||
|
|
||||||
rec := doJSON(t, router, http.MethodGet, "/api/profile", nil)
|
rec := doJSON(t, router, http.MethodGet, "/api/profile", nil)
|
||||||
@@ -739,7 +746,7 @@ func TestProfile_GetDefaultsThenUpdate(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestProfile_RejectsInvalidHRZones(t *testing.T) {
|
func TestProfile_RejectsInvalidHRZones(t *testing.T) {
|
||||||
s, _ := newTestServer(t)
|
s, _, _ := newTestServer(t)
|
||||||
router := s.Router()
|
router := s.Router()
|
||||||
|
|
||||||
rec := doJSON(t, router, http.MethodGet, "/api/profile", nil)
|
rec := doJSON(t, router, http.MethodGet, "/api/profile", nil)
|
||||||
@@ -758,13 +765,13 @@ func TestProfile_RejectsInvalidHRZones(t *testing.T) {
|
|||||||
|
|
||||||
func newTestServerWithAuth(t *testing.T, verifier auth.Verifier) (*Server, *store.DB) {
|
func newTestServerWithAuth(t *testing.T, verifier auth.Verifier) (*Server, *store.DB) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
s, db := newTestServer(t)
|
s, db, _ := newTestServer(t)
|
||||||
s.Auth = verifier
|
s.Auth = verifier
|
||||||
return s, db
|
return s, db
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestHealth_NoSessionRequired(t *testing.T) {
|
func TestHealth_NoSessionRequired(t *testing.T) {
|
||||||
s, _ := newTestServer(t)
|
s, _, _ := newTestServer(t)
|
||||||
req := httptest.NewRequest(http.MethodGet, "/api/health", nil)
|
req := httptest.NewRequest(http.MethodGet, "/api/health", nil)
|
||||||
rec := httptest.NewRecorder()
|
rec := httptest.NewRecorder()
|
||||||
s.Router().ServeHTTP(rec, req)
|
s.Router().ServeHTTP(rec, req)
|
||||||
@@ -774,7 +781,7 @@ func TestHealth_NoSessionRequired(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestProtectedRoute_RejectsMissingSession(t *testing.T) {
|
func TestProtectedRoute_RejectsMissingSession(t *testing.T) {
|
||||||
s, _ := newTestServer(t)
|
s, _, _ := newTestServer(t)
|
||||||
req := httptest.NewRequest(http.MethodGet, "/api/profile/", nil)
|
req := httptest.NewRequest(http.MethodGet, "/api/profile/", nil)
|
||||||
rec := httptest.NewRecorder()
|
rec := httptest.NewRecorder()
|
||||||
s.Router().ServeHTTP(rec, req)
|
s.Router().ServeHTTP(rec, req)
|
||||||
@@ -893,7 +900,7 @@ func TestSessionMe_ReturnsAuthenticatedUser(t *testing.T) {
|
|||||||
|
|
||||||
func mustServerRouter(t *testing.T) http.Handler {
|
func mustServerRouter(t *testing.T) http.Handler {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
s, _ := newTestServer(t)
|
s, _, _ := newTestServer(t)
|
||||||
return s.Router()
|
return s.Router()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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.mu.Lock()
|
||||||
s.authStatus = res.Status
|
s.userAuthStatus[userID] = res.Status
|
||||||
s.authMessage = res.Message
|
s.userAuthMessage[userID] = res.Message
|
||||||
s.mu.Unlock()
|
s.mu.Unlock()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) handleAuthLogin(w http.ResponseWriter, r *http.Request) {
|
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 {
|
if err != nil {
|
||||||
writeError(w, http.StatusBadGateway, err.Error())
|
writeError(w, http.StatusBadGateway, err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
s.recordAuthResult(res)
|
s.recordAuthResult(userID, res)
|
||||||
writeJSON(w, http.StatusOK, authResponse{Status: authStatusString(res.Status), Message: res.Message})
|
writeJSON(w, http.StatusOK, authResponse{Status: authStatusString(res.Status), Message: res.Message})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) handleAuthMFA(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleAuthMFA(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userID := userIDFromContext(r.Context())
|
||||||
var body struct {
|
var body struct {
|
||||||
Code string `json:"code"`
|
Code string `json:"code"`
|
||||||
}
|
}
|
||||||
@@ -55,18 +62,24 @@ func (s *Server) handleAuthMFA(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
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 {
|
if err != nil {
|
||||||
writeError(w, http.StatusBadGateway, err.Error())
|
writeError(w, http.StatusBadGateway, err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
s.recordAuthResult(res)
|
s.recordAuthResult(userID, res)
|
||||||
writeJSON(w, http.StatusOK, authResponse{Status: authStatusString(res.Status), Message: res.Message})
|
writeJSON(w, http.StatusOK, authResponse{Status: authStatusString(res.Status), Message: res.Message})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) handleAuthStatus(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleAuthStatus(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userID := userIDFromContext(r.Context())
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
status, msg := s.authStatus, s.authMessage
|
status, msg := s.userAuthStatus[userID], s.userAuthMessage[userID]
|
||||||
s.mu.Unlock()
|
s.mu.Unlock()
|
||||||
writeJSON(w, http.StatusOK, authResponse{Status: authStatusString(status), Message: msg})
|
writeJSON(w, http.StatusOK, authResponse{Status: authStatusString(status), Message: msg})
|
||||||
}
|
}
|
||||||
|
|||||||
155
backend/internal/api/isolation_test.go
Normal file
155
backend/internal/api/isolation_test.go
Normal 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())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -24,7 +24,8 @@ type workoutKindResponse struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) toWorkoutKindResponse(r *http.Request, k store.WorkoutKind) (workoutKindResponse, error) {
|
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 {
|
if err != nil {
|
||||||
return workoutKindResponse{}, err
|
return workoutKindResponse{}, err
|
||||||
}
|
}
|
||||||
@@ -71,8 +72,9 @@ func (req workoutKindRequest) validate() (classify.Node, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) handleListWorkoutKinds(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleListWorkoutKinds(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userID := userIDFromContext(r.Context())
|
||||||
activeOnly := r.URL.Query().Get("include_inactive") != "true"
|
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 {
|
if err != nil {
|
||||||
writeError(w, http.StatusInternalServerError, err.Error())
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
return
|
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) {
|
func (s *Server) handleGetWorkoutKind(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userID := userIDFromContext(r.Context())
|
||||||
id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
|
id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, http.StatusBadRequest, "invalid workout kind id")
|
writeError(w, http.StatusBadRequest, "invalid workout kind id")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
kind, ok, err := s.DB.GetWorkoutKind(r.Context(), id)
|
kind, ok, err := s.DB.GetWorkoutKind(r.Context(), userID, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, http.StatusInternalServerError, err.Error())
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
return
|
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) {
|
func (s *Server) handleUpdateWorkoutKind(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userID := userIDFromContext(r.Context())
|
||||||
id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
|
id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, http.StatusBadRequest, "invalid workout kind id")
|
writeError(w, http.StatusBadRequest, "invalid workout kind id")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
existing, ok, err := s.DB.GetWorkoutKind(r.Context(), id)
|
existing, ok, err := s.DB.GetWorkoutKind(r.Context(), userID, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, http.StatusInternalServerError, err.Error())
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
return
|
return
|
||||||
@@ -143,14 +147,14 @@ func (s *Server) handleUpdateWorkoutKind(w http.ResponseWriter, r *http.Request)
|
|||||||
isActive = *req.IsActive
|
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,
|
ID: id, Name: req.Name, Description: req.Description, Color: req.Color,
|
||||||
RuleJSON: string(req.Rule), Priority: req.Priority, IsActive: isActive,
|
RuleJSON: string(req.Rule), Priority: req.Priority, IsActive: isActive,
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
writeError(w, http.StatusInternalServerError, err.Error())
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := s.DB.UpdateWorkoutTypePace(r.Context(), store.WorkoutTypePace{
|
if err := s.DB.UpdateWorkoutTypePace(r.Context(), userID, store.WorkoutTypePace{
|
||||||
WorkoutKindID: id,
|
WorkoutKindID: id,
|
||||||
PaceMinSecPerKm: req.PaceMinSecPerKm,
|
PaceMinSecPerKm: req.PaceMinSecPerKm,
|
||||||
PaceMaxSecPerKm: req.PaceMaxSecPerKm,
|
PaceMaxSecPerKm: req.PaceMaxSecPerKm,
|
||||||
@@ -161,7 +165,7 @@ func (s *Server) handleUpdateWorkoutKind(w http.ResponseWriter, r *http.Request)
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
kind, _, _ := s.DB.GetWorkoutKind(r.Context(), id)
|
kind, _, _ := s.DB.GetWorkoutKind(r.Context(), userID, id)
|
||||||
resp, err := s.toWorkoutKindResponse(r, kind)
|
resp, err := s.toWorkoutKindResponse(r, kind)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, http.StatusInternalServerError, err.Error())
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
|||||||
@@ -36,7 +36,8 @@ func validateProfile(p store.Profile) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) handleGetProfile(w http.ResponseWriter, r *http.Request) {
|
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 {
|
if err != nil {
|
||||||
writeError(w, http.StatusInternalServerError, err.Error())
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
return
|
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) {
|
func (s *Server) handleUpdateProfile(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userID := userIDFromContext(r.Context())
|
||||||
var p store.Profile
|
var p store.Profile
|
||||||
if err := json.NewDecoder(r.Body).Decode(&p); err != nil {
|
if err := json.NewDecoder(r.Body).Decode(&p); err != nil {
|
||||||
writeError(w, http.StatusBadRequest, "invalid request body")
|
writeError(w, http.StatusBadRequest, "invalid request body")
|
||||||
@@ -55,13 +57,18 @@ func (s *Server) handleUpdateProfile(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
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())
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
return
|
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 {
|
if err != nil {
|
||||||
writeError(w, http.StatusInternalServerError, err.Error())
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -61,6 +61,7 @@ func metricValue(metric string, a store.Activity) (float64, bool) {
|
|||||||
// handleProgression returns a time series of the requested metric for every
|
// handleProgression returns a time series of the requested metric for every
|
||||||
// activity currently assigned to a workout kind, for progression charts.
|
// activity currently assigned to a workout kind, for progression charts.
|
||||||
func (s *Server) handleProgression(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleProgression(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userID := userIDFromContext(r.Context())
|
||||||
kindID, err := strconv.ParseInt(chi.URLParam(r, "kindID"), 10, 64)
|
kindID, err := strconv.ParseInt(chi.URLParam(r, "kindID"), 10, 64)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, http.StatusBadRequest, "invalid workout kind id")
|
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")
|
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 {
|
if err != nil {
|
||||||
writeError(w, http.StatusInternalServerError, err.Error())
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
return
|
return
|
||||||
@@ -80,7 +81,7 @@ func (s *Server) handleProgression(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
points := []progressionPoint{}
|
points := []progressionPoint{}
|
||||||
for _, a := range assignments {
|
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 {
|
if err != nil {
|
||||||
writeError(w, http.StatusInternalServerError, err.Error())
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -18,13 +18,14 @@ import (
|
|||||||
// It's a synchronous, bounded operation (unlike sync), so it runs inline
|
// It's a synchronous, bounded operation (unlike sync), so it runs inline
|
||||||
// rather than in the background.
|
// rather than in the background.
|
||||||
func (s *Server) handleReclassifyAll(w http.ResponseWriter, r *http.Request) {
|
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 {
|
if err != nil {
|
||||||
writeError(w, http.StatusInternalServerError, err.Error())
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
assignments, err := s.DB.AllCurrentAssignments(r.Context())
|
assignments, err := s.DB.AllCurrentAssignments(r.Context(), userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, http.StatusInternalServerError, err.Error())
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
return
|
return
|
||||||
@@ -41,8 +42,13 @@ func (s *Server) handleReclassifyAll(w http.ResponseWriter, r *http.Request) {
|
|||||||
activityIDs = append(activityIDs, a.ActivityID)
|
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 {
|
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())
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ type reviewQueueItem struct {
|
|||||||
// that cursor slicing too, so a filtered view still only loads (and
|
// 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.
|
// chart-renders) one page at a time instead of the whole matching backlog.
|
||||||
func (s *Server) handleReviewQueue(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleReviewQueue(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userID := userIDFromContext(r.Context())
|
||||||
limit := defaultReviewQueuePageSize
|
limit := defaultReviewQueuePageSize
|
||||||
if v := r.URL.Query().Get("limit"); v != "" {
|
if v := r.URL.Query().Get("limit"); v != "" {
|
||||||
if n, err := strconv.Atoi(v); err == nil && n > 0 {
|
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"
|
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 {
|
if err != nil {
|
||||||
writeError(w, http.StatusInternalServerError, err.Error())
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
return
|
return
|
||||||
@@ -69,7 +70,7 @@ func (s *Server) handleReviewQueue(w http.ResponseWriter, r *http.Request) {
|
|||||||
if unclassifiedOnly && a.WorkoutKindID != nil {
|
if unclassifiedOnly && a.WorkoutKindID != nil {
|
||||||
continue
|
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 {
|
if err != nil {
|
||||||
writeError(w, http.StatusInternalServerError, err.Error())
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
return
|
return
|
||||||
@@ -102,7 +103,7 @@ func (s *Server) handleReviewQueue(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
items := make([]reviewQueueItem, 0, len(all))
|
items := make([]reviewQueueItem, 0, len(all))
|
||||||
for _, wa := range 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 {
|
if err != nil {
|
||||||
writeError(w, http.StatusInternalServerError, err.Error())
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
return
|
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
|
// Per-second telemetry, not just per-lap averages, so the chart can
|
||||||
// show real within-lap variation instead of one flat segment per lap
|
// show real within-lap variation instead of one flat segment per lap
|
||||||
// (most activities only have a handful of laps).
|
// (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 {
|
if err != nil {
|
||||||
writeError(w, http.StatusInternalServerError, err.Error())
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
return
|
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) {
|
func (s *Server) handleResolveReview(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userID := userIDFromContext(r.Context())
|
||||||
activityID, err := strconv.ParseInt(chi.URLParam(r, "activityID"), 10, 64)
|
activityID, err := strconv.ParseInt(chi.URLParam(r, "activityID"), 10, 64)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, http.StatusBadRequest, "invalid activity id")
|
writeError(w, http.StatusBadRequest, "invalid activity id")
|
||||||
@@ -155,7 +157,7 @@ func (s *Server) handleResolveReview(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
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 {
|
if err != nil {
|
||||||
writeError(w, http.StatusInternalServerError, err.Error())
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
return
|
return
|
||||||
@@ -169,7 +171,7 @@ func (s *Server) handleResolveReview(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
kindID := body.WorkoutKindID
|
kindID := body.WorkoutKindID
|
||||||
if _, err := s.DB.InsertKindAssignment(r.Context(), store.KindAssignment{
|
if _, err := s.DB.InsertKindAssignment(r.Context(), userID, store.KindAssignment{
|
||||||
ActivityID: activityID,
|
ActivityID: activityID,
|
||||||
WorkoutKindID: &kindID,
|
WorkoutKindID: &kindID,
|
||||||
AssignmentSource: store.AssignmentSourceManual,
|
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
|
// different kind), but the backend doesn't re-enforce that here, matching
|
||||||
// handleResolveReview's own lack of a lock precondition check.
|
// handleResolveReview's own lack of a lock precondition check.
|
||||||
func (s *Server) handleUnassignReview(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleUnassignReview(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userID := userIDFromContext(r.Context())
|
||||||
activityID, err := strconv.ParseInt(chi.URLParam(r, "activityID"), 10, 64)
|
activityID, err := strconv.ParseInt(chi.URLParam(r, "activityID"), 10, 64)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, http.StatusBadRequest, "invalid activity id")
|
writeError(w, http.StatusBadRequest, "invalid activity id")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if _, err := s.DB.InsertKindAssignment(r.Context(), store.KindAssignment{
|
if _, err := s.DB.InsertKindAssignment(r.Context(), userID, store.KindAssignment{
|
||||||
ActivityID: activityID,
|
ActivityID: activityID,
|
||||||
WorkoutKindID: nil,
|
WorkoutKindID: nil,
|
||||||
AssignmentSource: store.AssignmentSourceManual,
|
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
|
// handleResolveReview, not a delete: the kind stays visible as-is until
|
||||||
// something actually reclassifies it.
|
// something actually reclassifies it.
|
||||||
func (s *Server) handleUnlockReview(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleUnlockReview(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userID := userIDFromContext(r.Context())
|
||||||
activityID, err := strconv.ParseInt(chi.URLParam(r, "activityID"), 10, 64)
|
activityID, err := strconv.ParseInt(chi.URLParam(r, "activityID"), 10, 64)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, http.StatusBadRequest, "invalid activity id")
|
writeError(w, http.StatusBadRequest, "invalid activity id")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
current, ok, err := s.DB.CurrentAssignment(r.Context(), activityID)
|
current, ok, err := s.DB.CurrentAssignment(r.Context(), userID, activityID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, http.StatusInternalServerError, err.Error())
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
return
|
return
|
||||||
@@ -240,7 +244,7 @@ func (s *Server) handleUnlockReview(w http.ResponseWriter, r *http.Request) {
|
|||||||
if current.WorkoutKindID == nil {
|
if current.WorkoutKindID == nil {
|
||||||
status = store.AssignmentStatusNeedsReview
|
status = store.AssignmentStatusNeedsReview
|
||||||
}
|
}
|
||||||
if _, err := s.DB.InsertKindAssignment(r.Context(), store.KindAssignment{
|
if _, err := s.DB.InsertKindAssignment(r.Context(), userID, store.KindAssignment{
|
||||||
ActivityID: activityID,
|
ActivityID: activityID,
|
||||||
WorkoutKindID: current.WorkoutKindID,
|
WorkoutKindID: current.WorkoutKindID,
|
||||||
AssignmentSource: store.AssignmentSourceRuleEngine,
|
AssignmentSource: store.AssignmentSourceRuleEngine,
|
||||||
|
|||||||
@@ -5,8 +5,11 @@ package api
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
@@ -17,23 +20,125 @@ import (
|
|||||||
appsync "geniusrun/backend/internal/sync"
|
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 {
|
type Server struct {
|
||||||
DB *store.DB
|
DB *store.DB
|
||||||
Garmin garmin.Client
|
|
||||||
Sync *appsync.Service
|
|
||||||
Auth auth.Verifier
|
Auth auth.Verifier
|
||||||
Session SessionConfig
|
Session SessionConfig
|
||||||
|
|
||||||
mu sync.Mutex
|
// GarminFactory builds a real (or fake, in tests) garmin.Client from a
|
||||||
authStatus garmin.AuthStatus
|
// fully-resolved per-user Config. Production wiring passes
|
||||||
authMessage string
|
// garmin.NewClient; tests inject a factory returning a shared
|
||||||
syncRunning bool
|
// *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.
|
// NewServer builds a Server.
|
||||||
func NewServer(db *store.DB, g garmin.Client, s *appsync.Service, authVerifier auth.Verifier, session SessionConfig) *Server {
|
func NewServer(db *store.DB, garminFactory func(garmin.Config) garmin.Client, garminBase garmin.Config, syncConfig appsync.Config, authVerifier auth.Verifier, session SessionConfig) *Server {
|
||||||
return &Server{DB: db, Garmin: g, Sync: s, Auth: authVerifier, Session: session}
|
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.
|
// Router builds the HTTP routes.
|
||||||
@@ -50,49 +155,55 @@ func (s *Server) Router() http.Handler {
|
|||||||
|
|
||||||
r.Group(func(r chi.Router) {
|
r.Group(func(r chi.Router) {
|
||||||
r.Use(auth.RequireSession(s.Session.Secret))
|
r.Use(auth.RequireSession(s.Session.Secret))
|
||||||
|
r.Use(s.resolveUser)
|
||||||
|
|
||||||
r.Get("/session/me", s.handleSessionMe)
|
r.Get("/session/me", s.handleSessionMe)
|
||||||
r.Post("/session/logout", s.handleSessionLogout)
|
r.Post("/session/logout", s.handleSessionLogout)
|
||||||
|
r.Post("/setup", s.handleSetup)
|
||||||
|
|
||||||
r.Route("/profile", func(r chi.Router) {
|
r.Group(func(r chi.Router) {
|
||||||
r.Get("/", s.handleGetProfile)
|
r.Use(requireProvisionedUser)
|
||||||
r.Put("/", s.handleUpdateProfile)
|
|
||||||
|
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
|
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
|
// 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
|
// only one sync operation per userID runs at a time. Returns false if one
|
||||||
// in progress.
|
// is already in progress for that user.
|
||||||
func (s *Server) backgroundSync(fn func(ctx context.Context) error) bool {
|
func (s *Server) backgroundSync(userID int64, fn func(ctx context.Context) error) bool {
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
if s.syncRunning {
|
if s.userSyncRunning[userID] {
|
||||||
s.mu.Unlock()
|
s.mu.Unlock()
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
s.syncRunning = true
|
s.userSyncRunning[userID] = true
|
||||||
s.mu.Unlock()
|
s.mu.Unlock()
|
||||||
|
|
||||||
go func() {
|
go func() {
|
||||||
defer func() {
|
defer func() {
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
s.syncRunning = false
|
s.userSyncRunning[userID] = false
|
||||||
s.mu.Unlock()
|
s.mu.Unlock()
|
||||||
}()
|
}()
|
||||||
if err := fn(context.Background()); err != nil {
|
if err := fn(context.Background()); err != nil {
|
||||||
log.Printf("api: background sync error: %v", err)
|
log.Printf("api: background sync error (user %d): %v", userID, err)
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
return true
|
return true
|
||||||
|
|||||||
@@ -24,8 +24,10 @@ type SessionConfig struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type sessionMeResponse struct {
|
type sessionMeResponse struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Email string `json:"email"`
|
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) {
|
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")
|
writeError(w, http.StatusUnauthorized, "not authenticated")
|
||||||
return
|
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)
|
||||||
}
|
}
|
||||||
|
|||||||
39
backend/internal/api/setup.go
Normal file
39
backend/internal/api/setup.go
Normal 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})
|
||||||
|
}
|
||||||
85
backend/internal/api/setup_test.go
Normal file
85
backend/internal/api/setup_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -19,8 +19,14 @@ const detailFillBatchSize = 50
|
|||||||
// FullSync run so "last sync" reports the combined activity count, not just
|
// FullSync run so "last sync" reports the combined activity count, not just
|
||||||
// whichever of Backfill/IncrementalSync happened to finish last.
|
// whichever of Backfill/IncrementalSync happened to finish last.
|
||||||
func (s *Server) handleSyncRun(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleSyncRun(w http.ResponseWriter, r *http.Request) {
|
||||||
ok := s.backgroundSync(func(ctx context.Context) error {
|
userID := userIDFromContext(r.Context())
|
||||||
return s.Sync.FullSync(ctx, detailFillBatchSize)
|
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 {
|
if !ok {
|
||||||
writeError(w, http.StatusConflict, "a sync is already in progress")
|
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
|
// performs a genuinely fresh pull from Garmin. Destructive -- the frontend
|
||||||
// gates this behind a confirmation.
|
// gates this behind a confirmation.
|
||||||
func (s *Server) handleSyncReset(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleSyncReset(w http.ResponseWriter, r *http.Request) {
|
||||||
ok := s.backgroundSync(func(ctx context.Context) error {
|
userID := userIDFromContext(r.Context())
|
||||||
return s.Sync.ResetAll(ctx)
|
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 {
|
if !ok {
|
||||||
writeError(w, http.StatusConflict, "a sync is already in progress")
|
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) {
|
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 {
|
if err != nil {
|
||||||
writeError(w, http.StatusInternalServerError, err.Error())
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
return
|
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) {
|
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 {
|
if err != nil {
|
||||||
writeError(w, http.StatusInternalServerError, err.Error())
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
remaining, err := s.DB.CountActivitiesMissingDetails(r.Context())
|
remaining, err := s.DB.CountActivitiesMissingDetails(r.Context(), userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, http.StatusInternalServerError, err.Error())
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
inProgress := s.syncRunning
|
inProgress := s.userSyncRunning[userID]
|
||||||
s.mu.Unlock()
|
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{
|
resp := map[string]any{
|
||||||
"in_progress": inProgress,
|
"in_progress": inProgress,
|
||||||
|
|||||||
81
backend/internal/api/usercontext.go
Normal file
81
backend/internal/api/usercontext.go
Normal 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
|
||||||
|
}
|
||||||
68
backend/internal/api/usercontext_test.go
Normal file
68
backend/internal/api/usercontext_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,7 +7,9 @@ package config
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"log"
|
||||||
"os"
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@@ -24,9 +26,14 @@ type Config struct {
|
|||||||
GarminPythonPath string
|
GarminPythonPath string
|
||||||
// GarminServerPath is mcp-garmin's server.py.
|
// GarminServerPath is mcp-garmin's server.py.
|
||||||
GarminServerPath string
|
GarminServerPath string
|
||||||
// GarminTokenStore, if set, overrides mcp-garmin's default ~/.garth
|
// GarminTokenStoreRoot is the root directory under which each user's
|
||||||
// session cache location.
|
// mcp-garmin session cache lives (one subdirectory per user id, e.g.
|
||||||
GarminTokenStore string
|
// "<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
|
MinConfidence float64
|
||||||
IncrementalSyncEvery time.Duration
|
IncrementalSyncEvery time.Duration
|
||||||
@@ -45,6 +52,13 @@ type Config struct {
|
|||||||
SessionSecret []byte
|
SessionSecret []byte
|
||||||
SessionDuration time.Duration
|
SessionDuration time.Duration
|
||||||
SessionSecure bool
|
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
|
// Load reads configuration from environment variables, applying defaults
|
||||||
@@ -55,7 +69,7 @@ func Load() (Config, error) {
|
|||||||
DBPath: getEnvDefault("GENIUSRUN_DB_PATH", "geniusrun.db"),
|
DBPath: getEnvDefault("GENIUSRUN_DB_PATH", "geniusrun.db"),
|
||||||
GarminPythonPath: os.Getenv("MCP_GARMIN_PYTHON"),
|
GarminPythonPath: os.Getenv("MCP_GARMIN_PYTHON"),
|
||||||
GarminServerPath: os.Getenv("MCP_GARMIN_SERVER"),
|
GarminServerPath: os.Getenv("MCP_GARMIN_SERVER"),
|
||||||
GarminTokenStore: os.Getenv("GARMIN_TOKENSTORE"),
|
GarminTokenStoreRoot: os.Getenv("GARMIN_TOKENSTORE"),
|
||||||
MinConfidence: getEnvFloat("GENIUSRUN_MIN_CONFIDENCE", 0.6),
|
MinConfidence: getEnvFloat("GENIUSRUN_MIN_CONFIDENCE", 0.6),
|
||||||
IncrementalSyncEvery: getEnvDuration("GENIUSRUN_INCREMENTAL_SYNC_EVERY", 6*time.Hour),
|
IncrementalSyncEvery: getEnvDuration("GENIUSRUN_INCREMENTAL_SYNC_EVERY", 6*time.Hour),
|
||||||
PublicBaseURL: strings.TrimRight(os.Getenv("GENIUSRUN_PUBLIC_BASE_URL"), "/"),
|
PublicBaseURL: strings.TrimRight(os.Getenv("GENIUSRUN_PUBLIC_BASE_URL"), "/"),
|
||||||
@@ -64,6 +78,12 @@ func Load() (Config, error) {
|
|||||||
OIDCClientSecret: os.Getenv("GENIUSRUN_OIDC_CLIENT_SECRET"),
|
OIDCClientSecret: os.Getenv("GENIUSRUN_OIDC_CLIENT_SECRET"),
|
||||||
OIDCRequiredRole: getEnvDefault("GENIUSRUN_OIDC_REQUIRED_ROLE", "geniusrun-user"),
|
OIDCRequiredRole: getEnvDefault("GENIUSRUN_OIDC_REQUIRED_ROLE", "geniusrun-user"),
|
||||||
SessionDuration: getEnvDuration("GENIUSRUN_SESSION_DURATION", 720*time.Hour),
|
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 == "" {
|
if cfg.GarminPythonPath == "" {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package config
|
package config
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"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) {
|
func TestLoad_CustomRoleAndDuration(t *testing.T) {
|
||||||
setRequiredEnv(t)
|
setRequiredEnv(t)
|
||||||
t.Setenv("GENIUSRUN_OIDC_REQUIRED_ROLE", "admin")
|
t.Setenv("GENIUSRUN_OIDC_REQUIRED_ROLE", "admin")
|
||||||
|
|||||||
@@ -49,19 +49,19 @@ type Activity struct {
|
|||||||
UpdatedAt string
|
UpdatedAt string
|
||||||
}
|
}
|
||||||
|
|
||||||
// UpsertActivity inserts a new activity or updates the existing row for the
|
// UpsertActivity inserts a new activity for userID or updates the existing
|
||||||
// same garmin_activity_id (idempotent, safe to call on every sync pass), and
|
// row for the same (userID, garmin_activity_id) pair (idempotent, safe to
|
||||||
// returns its internal id.
|
// call on every sync pass), and returns its internal id.
|
||||||
func (db *DB) UpsertActivity(ctx context.Context, a Activity) (int64, error) {
|
func (db *DB) UpsertActivity(ctx context.Context, userID int64, a Activity) (int64, error) {
|
||||||
_, err := db.ExecContext(ctx, `
|
_, err := db.ExecContext(ctx, `
|
||||||
INSERT INTO activities (
|
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,
|
duration_seconds, distance_meters, avg_hr, max_hr,
|
||||||
avg_speed_mps, elevation_gain_m,
|
avg_speed_mps, elevation_gain_m,
|
||||||
aerobic_training_effect, anaerobic_training_effect, vo2max_value,
|
aerobic_training_effect, anaerobic_training_effect, vo2max_value,
|
||||||
raw_json, updated_at
|
raw_json, updated_at
|
||||||
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?, datetime('now'))
|
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?, datetime('now'))
|
||||||
ON CONFLICT(garmin_activity_id) DO UPDATE SET
|
ON CONFLICT(user_id, garmin_activity_id) DO UPDATE SET
|
||||||
event_type_key=excluded.event_type_key,
|
event_type_key=excluded.event_type_key,
|
||||||
workout_id=excluded.workout_id,
|
workout_id=excluded.workout_id,
|
||||||
start_time_utc=excluded.start_time_utc,
|
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,
|
raw_json=excluded.raw_json,
|
||||||
updated_at=datetime('now')
|
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.DurationSeconds, a.DistanceMeters, a.AvgHR, a.MaxHR,
|
||||||
a.AvgSpeedMps, a.ElevationGainM,
|
a.AvgSpeedMps, a.ElevationGainM,
|
||||||
a.AerobicTrainingEffect, a.AnaerobicTrainingEffect, a.VO2MaxValue,
|
a.AerobicTrainingEffect, a.AnaerobicTrainingEffect, a.VO2MaxValue,
|
||||||
a.RawJSON,
|
a.RawJSON,
|
||||||
)
|
)
|
||||||
if err != nil {
|
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
|
var id int64
|
||||||
if err := db.QueryRowContext(ctx, `SELECT id FROM activities WHERE garmin_activity_id = ?`, a.GarminActivityID).Scan(&id); err != nil {
|
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: %w", a.GarminActivityID, err)
|
return 0, fmt.Errorf("fetch id for activity %d (user %d): %w", a.GarminActivityID, userID, err)
|
||||||
}
|
}
|
||||||
return id, nil
|
return id, nil
|
||||||
}
|
}
|
||||||
@@ -116,15 +116,15 @@ const activityColumns = `
|
|||||||
created_at, updated_at
|
created_at, updated_at
|
||||||
`
|
`
|
||||||
|
|
||||||
// GetActivity fetches one activity by its internal id.
|
// GetActivity fetches one activity by its internal id, scoped to userID.
|
||||||
func (db *DB) GetActivity(ctx context.Context, id int64) (Activity, bool, error) {
|
func (db *DB) GetActivity(ctx context.Context, userID, id int64) (Activity, bool, error) {
|
||||||
row := db.QueryRowContext(ctx, `SELECT `+activityColumns+` FROM activities WHERE id = ?`, id)
|
row := db.QueryRowContext(ctx, `SELECT `+activityColumns+` FROM activities WHERE id = ? AND user_id = ?`, id, userID)
|
||||||
a, err := scanActivity(row)
|
a, err := scanActivity(row)
|
||||||
if err == sql.ErrNoRows {
|
if err == sql.ErrNoRows {
|
||||||
return Activity{}, false, nil
|
return Activity{}, false, nil
|
||||||
}
|
}
|
||||||
if err != 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
|
return a, true, nil
|
||||||
}
|
}
|
||||||
@@ -137,10 +137,11 @@ type ActivityFilter struct {
|
|||||||
Offset int
|
Offset int
|
||||||
}
|
}
|
||||||
|
|
||||||
// ListActivities returns activities newest-first, optionally filtered by date range.
|
// ListActivities returns userID's activities newest-first, optionally
|
||||||
func (db *DB) ListActivities(ctx context.Context, f ActivityFilter) ([]Activity, error) {
|
// filtered by date range.
|
||||||
query := `SELECT ` + activityColumns + ` FROM activities WHERE 1=1`
|
func (db *DB) ListActivities(ctx context.Context, userID int64, f ActivityFilter) ([]Activity, error) {
|
||||||
var args []any
|
query := `SELECT ` + activityColumns + ` FROM activities WHERE user_id = ?`
|
||||||
|
args := []any{userID}
|
||||||
if f.FromDate != "" {
|
if f.FromDate != "" {
|
||||||
query += ` AND start_time_utc >= ?`
|
query += ` AND start_time_utc >= ?`
|
||||||
args = append(args, f.FromDate)
|
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...)
|
rows, err := db.QueryContext(ctx, query, args...)
|
||||||
if err != nil {
|
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()
|
defer rows.Close()
|
||||||
|
|
||||||
@@ -172,83 +173,79 @@ func (db *DB) ListActivities(ctx context.Context, f ActivityFilter) ([]Activity,
|
|||||||
return activities, rows.Err()
|
return activities, rows.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
// ActivityExists reports whether an activity with this garmin_activity_id is
|
// ActivityExists reports whether userID already has an activity with this
|
||||||
// already stored, checked before each upsert during a sync pass so the
|
// garmin_activity_id stored.
|
||||||
// reported "activities fetched" count reflects genuinely new activities, not
|
func (db *DB) ActivityExists(ctx context.Context, userID, garminActivityID int64) (bool, error) {
|
||||||
// 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) {
|
|
||||||
var id int64
|
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 {
|
if err == sql.ErrNoRows {
|
||||||
return false, nil
|
return false, nil
|
||||||
}
|
}
|
||||||
if err != 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
|
return true, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// LatestActivityStartTime returns the start_time_utc of the most recently
|
// LatestActivityStartTime returns userID's most recently started activity's
|
||||||
// started activity we have, used to compute the incremental sync window.
|
// start_time_utc, used to compute the incremental sync window.
|
||||||
func (db *DB) LatestActivityStartTime(ctx context.Context) (string, bool, error) {
|
func (db *DB) LatestActivityStartTime(ctx context.Context, userID int64) (string, bool, error) {
|
||||||
var t string
|
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 {
|
if err == sql.ErrNoRows {
|
||||||
return "", false, nil
|
return "", false, nil
|
||||||
}
|
}
|
||||||
if err != 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
|
return t, true, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetActivityDetails records that get_activity_details has been fetched for
|
// SetActivityDetails records that get_activity_details has been fetched for
|
||||||
// this activity, storing the raw response for future reprocessing.
|
// 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, `
|
_, err := db.ExecContext(ctx, `
|
||||||
UPDATE activities SET details_fetched_at = datetime('now'), details_raw_json = ?, updated_at = datetime('now')
|
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 {
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetActivityWorkout stores the raw get_workout_by_id() response used to
|
// SetActivityWorkout stores the raw get_workout_by_id() response used to
|
||||||
// compute this activity's laps' target pace/HR bands.
|
// 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, `
|
_, err := db.ExecContext(ctx, `
|
||||||
UPDATE activities SET workout_raw_json = ?, updated_at = datetime('now')
|
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 {
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetActivitySplitsFetched records that get_activity_splits has been fetched
|
// SetActivitySplitsFetched records that get_activity_splits has been fetched
|
||||||
// for this activity.
|
// for this activity.
|
||||||
func (db *DB) SetActivitySplitsFetched(ctx context.Context, activityID int64) error {
|
func (db *DB) SetActivitySplitsFetched(ctx context.Context, userID, activityID int64) error {
|
||||||
_, err := db.ExecContext(ctx, `
|
_, err := db.ExecContext(ctx, `
|
||||||
UPDATE activities SET splits_fetched_at = datetime('now'), updated_at = datetime('now')
|
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 {
|
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
|
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
|
// get_activity_details/get_activity_splits fetched yet, for the lazy
|
||||||
// background detail-fill pass.
|
// 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
|
rows, err := db.QueryContext(ctx, `SELECT `+activityColumns+` FROM activities
|
||||||
WHERE details_fetched_at IS NULL OR splits_fetched_at IS NULL
|
WHERE user_id = ? AND (details_fetched_at IS NULL OR splits_fetched_at IS NULL)
|
||||||
ORDER BY start_time_utc DESC LIMIT ?`, limit)
|
ORDER BY start_time_utc DESC LIMIT ?`, userID, limit)
|
||||||
if err != nil {
|
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()
|
defer rows.Close()
|
||||||
|
|
||||||
@@ -263,15 +260,15 @@ func (db *DB) ActivitiesMissingDetails(ctx context.Context, limit int) ([]Activi
|
|||||||
return activities, rows.Err()
|
return activities, rows.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
// CountActivitiesMissingDetails returns how many activities still need
|
// CountActivitiesMissingDetails returns how many of userID's activities
|
||||||
// get_activity_details/get_activity_splits fetched, regardless of any
|
// still need get_activity_details/get_activity_splits fetched, regardless
|
||||||
// per-call batch limit -- used to report overall remaining work.
|
// of any per-call batch limit -- used to report overall remaining work.
|
||||||
func (db *DB) CountActivitiesMissingDetails(ctx context.Context) (int, error) {
|
func (db *DB) CountActivitiesMissingDetails(ctx context.Context, userID int64) (int, error) {
|
||||||
var n int
|
var n int
|
||||||
err := db.QueryRowContext(ctx, `SELECT COUNT(*) FROM activities
|
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 {
|
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
|
return n, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,8 +28,18 @@ type KindAssignment struct {
|
|||||||
CreatedAt string
|
CreatedAt string
|
||||||
}
|
}
|
||||||
|
|
||||||
// InsertKindAssignment appends a new assignment row for an activity.
|
// InsertKindAssignment appends a new assignment row for an activity owned
|
||||||
func (db *DB) InsertKindAssignment(ctx context.Context, a KindAssignment) (int64, error) {
|
// 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, `
|
res, err := db.ExecContext(ctx, `
|
||||||
INSERT INTO kind_assignments (activity_id, workout_kind_id, assignment_source, status, confidence, candidate_kinds_json)
|
INSERT INTO kind_assignments (activity_id, workout_kind_id, assignment_source, status, confidence, candidate_kinds_json)
|
||||||
VALUES (?,?,?,?,?,?)`,
|
VALUES (?,?,?,?,?,?)`,
|
||||||
@@ -46,29 +56,37 @@ func scanKindAssignment(row interface{ Scan(...any) error }) (KindAssignment, er
|
|||||||
return a, err
|
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.
|
// CurrentAssignment returns the latest assignment for an activity owned by
|
||||||
func (db *DB) CurrentAssignment(ctx context.Context, activityID int64) (KindAssignment, bool, error) {
|
// userID, if any.
|
||||||
row := db.QueryRowContext(ctx, `SELECT `+kindAssignmentColumns+` FROM current_kind_assignment WHERE activity_id = ?`, activityID)
|
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)
|
a, err := scanKindAssignment(row)
|
||||||
if err == sql.ErrNoRows {
|
if err == sql.ErrNoRows {
|
||||||
return KindAssignment{}, false, nil
|
return KindAssignment{}, false, nil
|
||||||
}
|
}
|
||||||
if err != 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
|
return a, true, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ReviewQueue returns activities whose current assignment status is
|
// ReviewQueue returns userID's activities whose current assignment status
|
||||||
// needs_review, newest first.
|
// is needs_review, newest first.
|
||||||
func (db *DB) ReviewQueue(ctx context.Context) ([]KindAssignment, error) {
|
func (db *DB) ReviewQueue(ctx context.Context, userID int64) ([]KindAssignment, error) {
|
||||||
rows, err := db.QueryContext(ctx, `
|
rows, err := db.QueryContext(ctx, `
|
||||||
SELECT `+kindAssignmentColumns+` FROM current_kind_assignment
|
SELECT `+kindAssignmentColumns+`
|
||||||
WHERE status = ? ORDER BY created_at DESC`, AssignmentStatusNeedsReview)
|
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 {
|
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()
|
defer rows.Close()
|
||||||
|
|
||||||
@@ -83,37 +101,44 @@ func (db *DB) ReviewQueue(ctx context.Context) ([]KindAssignment, error) {
|
|||||||
return assignments, rows.Err()
|
return assignments, rows.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
// AllCurrentAssignments returns the latest assignment for every activity
|
// AllCurrentAssignments returns the latest assignment for every one of
|
||||||
// that has one, regardless of status or source -- the basis for deciding
|
// userID's activities that has one, regardless of status or source -- the
|
||||||
// which activities a global reclassify pass is allowed to touch.
|
// basis for deciding which activities a global reclassify pass may touch.
|
||||||
func (db *DB) AllCurrentAssignments(ctx context.Context) ([]KindAssignment, error) {
|
func (db *DB) AllCurrentAssignments(ctx context.Context, userID int64) ([]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) {
|
|
||||||
rows, err := db.QueryContext(ctx, `
|
rows, err := db.QueryContext(ctx, `
|
||||||
SELECT `+kindAssignmentColumns+` FROM current_kind_assignment
|
SELECT `+kindAssignmentColumns+`
|
||||||
WHERE workout_kind_id = ? AND status = ? ORDER BY created_at ASC`,
|
FROM current_kind_assignment a
|
||||||
workoutKindID, AssignmentStatusAssigned)
|
JOIN activities ON activities.id = a.activity_id
|
||||||
|
WHERE activities.user_id = ?`, userID)
|
||||||
if err != nil {
|
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()
|
defer rows.Close()
|
||||||
|
|
||||||
|
|||||||
@@ -78,21 +78,65 @@ func (db *DB) migrate() error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("read migration %s: %w", name, err)
|
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()
|
tx, err := db.Begin()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
if needsFKToggle {
|
||||||
|
db.Exec(`PRAGMA foreign_keys = ON`)
|
||||||
|
}
|
||||||
return fmt.Errorf("begin migration tx for %s: %w", name, err)
|
return fmt.Errorf("begin migration tx for %s: %w", name, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if _, err := tx.Exec(string(content)); err != nil {
|
if _, err := tx.Exec(string(content)); err != nil {
|
||||||
tx.Rollback()
|
tx.Rollback()
|
||||||
|
if needsFKToggle {
|
||||||
|
db.Exec(`PRAGMA foreign_keys = ON`)
|
||||||
|
}
|
||||||
return fmt.Errorf("apply migration %s: %w", name, err)
|
return fmt.Errorf("apply migration %s: %w", name, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if _, err := tx.Exec(`INSERT INTO schema_migrations (filename) VALUES (?)`, name); err != nil {
|
if _, err := tx.Exec(`INSERT INTO schema_migrations (filename) VALUES (?)`, name); err != nil {
|
||||||
tx.Rollback()
|
tx.Rollback()
|
||||||
|
if needsFKToggle {
|
||||||
|
db.Exec(`PRAGMA foreign_keys = ON`)
|
||||||
|
}
|
||||||
return fmt.Errorf("record migration %s: %w", name, err)
|
return fmt.Errorf("record migration %s: %w", name, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := tx.Commit(); err != nil {
|
if err := tx.Commit(); err != nil {
|
||||||
|
if needsFKToggle {
|
||||||
|
db.Exec(`PRAGMA foreign_keys = ON`)
|
||||||
|
}
|
||||||
return fmt.Errorf("commit migration %s: %w", name, err)
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
163
backend/internal/store/isolation_test.go
Normal file
163
backend/internal/store/isolation_test.go
Normal 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ package store
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"database/sql"
|
||||||
"fmt"
|
"fmt"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -35,9 +36,19 @@ type Lap struct {
|
|||||||
RawJSON string
|
RawJSON string
|
||||||
}
|
}
|
||||||
|
|
||||||
// ReplaceLaps deletes any existing laps for activityID and inserts the given
|
// ReplaceLaps deletes any existing laps for activityID (owned by userID)
|
||||||
// set, so re-syncing an activity's splits is idempotent.
|
// and inserts the given set, so re-syncing an activity's splits is
|
||||||
func (db *DB) ReplaceLaps(ctx context.Context, activityID int64, laps []Lap) error {
|
// 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)
|
tx, err := db.BeginTx(ctx, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("begin replace laps tx: %w", err)
|
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()
|
return tx.Commit()
|
||||||
}
|
}
|
||||||
|
|
||||||
// LapsForActivity returns all laps for an activity, ordered by lap_index.
|
// LapsForActivity returns all laps for an activity owned by userID,
|
||||||
func (db *DB) LapsForActivity(ctx context.Context, activityID int64) ([]Lap, error) {
|
// ordered by lap_index.
|
||||||
|
func (db *DB) LapsForActivity(ctx context.Context, userID, activityID int64) ([]Lap, error) {
|
||||||
rows, err := db.QueryContext(ctx, `
|
rows, err := db.QueryContext(ctx, `
|
||||||
SELECT id, activity_id, lap_index, avg_speed_mps,
|
SELECT laps.id, laps.activity_id, laps.lap_index, laps.avg_speed_mps,
|
||||||
intensity_type, hr_drift_bpm_per_min, hr_recovery_bpm_per_min,
|
laps.intensity_type, laps.hr_drift_bpm_per_min, laps.hr_recovery_bpm_per_min,
|
||||||
target_pace_low_mps, target_pace_high_mps, target_hr_low_bpm, target_hr_high_bpm, raw_json
|
laps.target_pace_low_mps, laps.target_pace_high_mps, laps.target_hr_low_bpm, laps.target_hr_high_bpm, laps.raw_json
|
||||||
FROM laps WHERE activity_id = ? ORDER BY lap_index`, activityID)
|
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 {
|
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()
|
defer rows.Close()
|
||||||
|
|
||||||
|
|||||||
54
backend/internal/store/legacy_claim.go
Normal file
54
backend/internal/store/legacy_claim.go
Normal 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()
|
||||||
|
}
|
||||||
97
backend/internal/store/legacy_claim_test.go
Normal file
97
backend/internal/store/legacy_claim_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
6
backend/internal/store/migrations/0021_users_table.sql
Normal file
6
backend/internal/store/migrations/0021_users_table.sql
Normal 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'))
|
||||||
|
);
|
||||||
@@ -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);
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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);
|
||||||
@@ -81,10 +81,10 @@ const profileColumns = `
|
|||||||
created_at, updated_at
|
created_at, updated_at
|
||||||
`
|
`
|
||||||
|
|
||||||
// GetProfile returns the single profile row.
|
// GetProfile returns the profile row for userID.
|
||||||
func (db *DB) GetProfile(ctx context.Context) (Profile, error) {
|
func (db *DB) GetProfile(ctx context.Context, userID int64) (Profile, error) {
|
||||||
var p Profile
|
var p Profile
|
||||||
err := db.QueryRowContext(ctx, `SELECT `+profileColumns+` FROM profile WHERE 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.Name, &p.GarminEmail, &p.GarminPassword, &p.RollingWindowDays, &p.BackfillHorizonDays, &p.MaxHeartRate, &p.RestingHeartRate,
|
||||||
&p.HRZone1MinPct, &p.HRZone1MaxPct, &p.HRZone2MinPct, &p.HRZone2MaxPct,
|
&p.HRZone1MinPct, &p.HRZone1MaxPct, &p.HRZone2MinPct, &p.HRZone2MaxPct,
|
||||||
&p.HRZone3MinPct, &p.HRZone3MaxPct, &p.HRZone4MinPct, &p.HRZone4MaxPct,
|
&p.HRZone3MinPct, &p.HRZone3MaxPct, &p.HRZone4MinPct, &p.HRZone4MaxPct,
|
||||||
@@ -96,15 +96,15 @@ func (db *DB) GetProfile(ctx context.Context) (Profile, error) {
|
|||||||
&p.CreatedAt, &p.UpdatedAt,
|
&p.CreatedAt, &p.UpdatedAt,
|
||||||
)
|
)
|
||||||
if err != nil {
|
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
|
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
|
// GetProfile first and modify the fields they intend to change, since this
|
||||||
// replaces every column.
|
// 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, `
|
_, err := db.ExecContext(ctx, `
|
||||||
UPDATE profile SET
|
UPDATE profile SET
|
||||||
name=?, garmin_email=?, garmin_password=?, rolling_window_days=?, backfill_horizon_days=?, max_heart_rate=?, resting_heart_rate=?,
|
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=?,
|
pace_color=?, heart_rate_color=?, warmup_color=?, effort_color=?, recovery_color=?, cooldown_color=?,
|
||||||
main_line_tint_pct=?, background_darken_pct=?, target_brighten_pct=?,
|
main_line_tint_pct=?, background_darken_pct=?, target_brighten_pct=?,
|
||||||
updated_at=datetime('now')
|
updated_at=datetime('now')
|
||||||
WHERE id = 1`,
|
WHERE user_id = ?`,
|
||||||
p.Name, p.GarminEmail, p.GarminPassword, p.RollingWindowDays, p.BackfillHorizonDays, p.MaxHeartRate, p.RestingHeartRate,
|
p.Name, p.GarminEmail, p.GarminPassword, p.RollingWindowDays, p.BackfillHorizonDays, p.MaxHeartRate, p.RestingHeartRate,
|
||||||
p.HRZone1MinPct, p.HRZone1MaxPct, p.HRZone2MinPct, p.HRZone2MaxPct,
|
p.HRZone1MinPct, p.HRZone1MaxPct, p.HRZone2MinPct, p.HRZone2MaxPct,
|
||||||
p.HRZone3MinPct, p.HRZone3MaxPct, p.HRZone4MinPct, p.HRZone4MaxPct,
|
p.HRZone3MinPct, p.HRZone3MaxPct, p.HRZone4MinPct, p.HRZone4MaxPct,
|
||||||
@@ -125,9 +125,10 @@ func (db *DB) UpdateProfile(ctx context.Context, p Profile) error {
|
|||||||
p.MinRepresentativePaceSecPerKm, p.MinRepresentativeTimeSeconds,
|
p.MinRepresentativePaceSecPerKm, p.MinRepresentativeTimeSeconds,
|
||||||
p.PaceColor, p.HeartRateColor, p.WarmupColor, p.EffortColor, p.RecoveryColor, p.CooldownColor,
|
p.PaceColor, p.HeartRateColor, p.WarmupColor, p.EffortColor, p.RecoveryColor, p.CooldownColor,
|
||||||
p.MainLineTintPct, p.BackgroundDarkenPct, p.TargetBrightenPct,
|
p.MainLineTintPct, p.BackgroundDarkenPct, p.TargetBrightenPct,
|
||||||
|
userID,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("update profile: %w", err)
|
return fmt.Errorf("update profile for user %d: %w", userID, err)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,8 +8,12 @@ import (
|
|||||||
func TestProfile_DefaultsThenUpdate(t *testing.T) {
|
func TestProfile_DefaultsThenUpdate(t *testing.T) {
|
||||||
db := openTestDB(t)
|
db := openTestDB(t)
|
||||||
ctx := context.Background()
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("GetProfile: %v", err)
|
t.Fatalf("GetProfile: %v", err)
|
||||||
}
|
}
|
||||||
@@ -64,11 +68,11 @@ func TestProfile_DefaultsThenUpdate(t *testing.T) {
|
|||||||
p.MinRepresentativePaceSecPerKm = 600
|
p.MinRepresentativePaceSecPerKm = 600
|
||||||
p.MinRepresentativeTimeSeconds = 5
|
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)
|
t.Fatalf("UpdateProfile: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
got, err := db.GetProfile(ctx)
|
got, err := db.GetProfile(ctx, userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("GetProfile after update: %v", err)
|
t.Fatalf("GetProfile after update: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,23 +5,23 @@ import (
|
|||||||
"fmt"
|
"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
|
// activity_samples, and kind_assignments via ON DELETE CASCADE) and rewinds
|
||||||
// the backfill watermark to its initial state, so a subsequent Backfill
|
// the backfill watermark to its initial state, so a subsequent Backfill
|
||||||
// starts a genuinely fresh pull instead of thinking history is already
|
// starts a genuinely fresh pull instead of thinking history is already
|
||||||
// covered. Workout kinds (the user's taxonomy) are left untouched.
|
// 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)
|
tx, err := db.BeginTx(ctx, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("begin reset tx: %w", err)
|
return fmt.Errorf("begin reset tx: %w", err)
|
||||||
}
|
}
|
||||||
defer tx.Rollback()
|
defer tx.Rollback()
|
||||||
|
|
||||||
if _, err := tx.ExecContext(ctx, `DELETE FROM activities`); err != nil {
|
if _, err := tx.ExecContext(ctx, `DELETE FROM activities WHERE user_id = ?`, userID); err != nil {
|
||||||
return fmt.Errorf("delete activities: %w", err)
|
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 {
|
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: %w", err)
|
return fmt.Errorf("reset sync state for user %d: %w", userID, err)
|
||||||
}
|
}
|
||||||
return tx.Commit()
|
return tx.Commit()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,51 +8,55 @@ import (
|
|||||||
func TestResetAllSyncedData_DeletesActivitiesCascadesAndRewindsWatermark(t *testing.T) {
|
func TestResetAllSyncedData_DeletesActivitiesCascadesAndRewindsWatermark(t *testing.T) {
|
||||||
db := openTestDB(t)
|
db := openTestDB(t)
|
||||||
ctx := context.Background()
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("UpsertActivity: %v", err)
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("CreateWorkoutKind: %v", err)
|
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)
|
t.Fatalf("ReplaceLaps: %v", err)
|
||||||
}
|
}
|
||||||
if _, err := db.InsertKindAssignment(ctx, KindAssignment{
|
if _, err := db.InsertKindAssignment(ctx, userID, KindAssignment{
|
||||||
ActivityID: activityID, WorkoutKindID: &kindID, AssignmentSource: AssignmentSourceRuleEngine,
|
ActivityID: activityID, WorkoutKindID: &kindID, AssignmentSource: AssignmentSourceRuleEngine,
|
||||||
Status: AssignmentStatusAssigned, CandidateKindsJSON: "[]",
|
Status: AssignmentStatusAssigned, CandidateKindsJSON: "[]",
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
t.Fatalf("InsertKindAssignment: %v", err)
|
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)
|
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)
|
t.Fatalf("ResetAllSyncedData: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
activities, err := db.ListActivities(ctx, ActivityFilter{})
|
activities, err := db.ListActivities(ctx, userID, ActivityFilter{})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("ListActivities: %v", err)
|
t.Fatalf("ListActivities: %v", err)
|
||||||
}
|
}
|
||||||
if len(activities) != 0 {
|
if len(activities) != 0 {
|
||||||
t.Errorf("expected 0 activities after reset, got %d", len(activities))
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("LapsForActivity: %v", err)
|
t.Fatalf("LapsForActivity: %v", err)
|
||||||
}
|
}
|
||||||
if len(laps) != 0 {
|
if len(laps) != 0 {
|
||||||
t.Errorf("expected laps to cascade-delete, got %d", len(laps))
|
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)
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("GetSyncState: %v", err)
|
t.Fatalf("GetSyncState: %v", err)
|
||||||
}
|
}
|
||||||
@@ -61,7 +65,7 @@ func TestResetAllSyncedData_DeletesActivitiesCascadesAndRewindsWatermark(t *test
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Workout kinds (the user's taxonomy) must survive a reset.
|
// 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 {
|
if err != nil || !ok {
|
||||||
t.Fatalf("GetWorkoutKind after reset: ok=%v err=%v", ok, err)
|
t.Fatalf("GetWorkoutKind after reset: ok=%v err=%v", ok, err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package store
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"database/sql"
|
||||||
"fmt"
|
"fmt"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -15,9 +16,19 @@ type Sample struct {
|
|||||||
ElevationM *float64
|
ElevationM *float64
|
||||||
}
|
}
|
||||||
|
|
||||||
// ReplaceActivitySamples deletes any existing samples for activityID and
|
// ReplaceActivitySamples deletes any existing samples for activityID (owned
|
||||||
// bulk-inserts the given set, so re-syncing an activity's details is idempotent.
|
// by userID) and bulk-inserts the given set, so re-syncing an activity's
|
||||||
func (db *DB) ReplaceActivitySamples(ctx context.Context, activityID int64, samples []Sample) error {
|
// 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)
|
tx, err := db.BeginTx(ctx, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("begin replace samples tx: %w", err)
|
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()
|
return tx.Commit()
|
||||||
}
|
}
|
||||||
|
|
||||||
// SamplesForActivity returns all samples for an activity, ordered by elapsed_seconds.
|
// SamplesForActivity returns all samples for an activity owned by userID,
|
||||||
func (db *DB) SamplesForActivity(ctx context.Context, activityID int64) ([]Sample, error) {
|
// ordered by elapsed_seconds.
|
||||||
|
func (db *DB) SamplesForActivity(ctx context.Context, userID, activityID int64) ([]Sample, error) {
|
||||||
rows, err := db.QueryContext(ctx, `
|
rows, err := db.QueryContext(ctx, `
|
||||||
SELECT elapsed_seconds, timestamp_ms, heart_rate, speed_mps, distance_m, elevation_m
|
SELECT activity_samples.elapsed_seconds, activity_samples.timestamp_ms, activity_samples.heart_rate,
|
||||||
FROM activity_samples WHERE activity_id = ? ORDER BY elapsed_seconds`, activityID)
|
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 {
|
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()
|
defer rows.Close()
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,11 @@ package store
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"io/fs"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -37,6 +41,10 @@ func TestMigrateIsIdempotent(t *testing.T) {
|
|||||||
func TestUpsertActivity_InsertThenUpdateIsIdempotent(t *testing.T) {
|
func TestUpsertActivity_InsertThenUpdateIsIdempotent(t *testing.T) {
|
||||||
db := openTestDB(t)
|
db := openTestDB(t)
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
userID, err := db.ProvisionUser(ctx, "test-sub", "Test")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ProvisionUser: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
a := Activity{
|
a := Activity{
|
||||||
GarminActivityID: 23554066504,
|
GarminActivityID: 23554066504,
|
||||||
@@ -47,13 +55,13 @@ func TestUpsertActivity_InsertThenUpdateIsIdempotent(t *testing.T) {
|
|||||||
RawJSON: `{"activityId":23554066504,"activityName":"Auriol - W2-5-Base Endurance","activityType":{"typeKey":"running"}}`,
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("UpsertActivity (insert): %v", err)
|
t.Fatalf("UpsertActivity (insert): %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
a.AvgHR = f(150) // simulate a re-sync with a corrected value
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("UpsertActivity (update): %v", err)
|
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)
|
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 {
|
if err != nil || !ok {
|
||||||
t.Fatalf("GetActivity: ok=%v err=%v", ok, err)
|
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)
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("ListActivities: %v", err)
|
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) {
|
func TestKindAssignment_AppendOnlyHistoryAndCurrentView(t *testing.T) {
|
||||||
db := openTestDB(t)
|
db := openTestDB(t)
|
||||||
ctx := context.Background()
|
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,
|
GarminActivityID: 1,
|
||||||
StartTimeUTC: "2026-07-11 05:00:00",
|
StartTimeUTC: "2026-07-11 05:00:00",
|
||||||
RawJSON: "{}",
|
RawJSON: "{}",
|
||||||
@@ -91,7 +133,7 @@ func TestKindAssignment_AppendOnlyHistoryAndCurrentView(t *testing.T) {
|
|||||||
t.Fatalf("UpsertActivity: %v", err)
|
t.Fatalf("UpsertActivity: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
kindID, err := db.CreateWorkoutKind(ctx, WorkoutKind{
|
kindID, err := db.CreateWorkoutKind(ctx, userID, WorkoutKind{
|
||||||
Name: "Test Assignment Custom",
|
Name: "Test Assignment Custom",
|
||||||
RuleJSON: `{"match":"all","conditions":[]}`,
|
RuleJSON: `{"match":"all","conditions":[]}`,
|
||||||
IsActive: true,
|
IsActive: true,
|
||||||
@@ -101,7 +143,7 @@ func TestKindAssignment_AppendOnlyHistoryAndCurrentView(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// First: rule engine says needs_review (ambiguous).
|
// First: rule engine says needs_review (ambiguous).
|
||||||
if _, err := db.InsertKindAssignment(ctx, KindAssignment{
|
if _, err := db.InsertKindAssignment(ctx, userID, KindAssignment{
|
||||||
ActivityID: activityID,
|
ActivityID: activityID,
|
||||||
AssignmentSource: AssignmentSourceRuleEngine,
|
AssignmentSource: AssignmentSourceRuleEngine,
|
||||||
Status: AssignmentStatusNeedsReview,
|
Status: AssignmentStatusNeedsReview,
|
||||||
@@ -110,7 +152,7 @@ func TestKindAssignment_AppendOnlyHistoryAndCurrentView(t *testing.T) {
|
|||||||
t.Fatalf("InsertKindAssignment (rule engine): %v", err)
|
t.Fatalf("InsertKindAssignment (rule engine): %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
queue, err := db.ReviewQueue(ctx)
|
queue, err := db.ReviewQueue(ctx, userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("ReviewQueue: %v", err)
|
t.Fatalf("ReviewQueue: %v", err)
|
||||||
}
|
}
|
||||||
@@ -119,7 +161,7 @@ func TestKindAssignment_AppendOnlyHistoryAndCurrentView(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Then: user manually resolves it.
|
// Then: user manually resolves it.
|
||||||
if _, err := db.InsertKindAssignment(ctx, KindAssignment{
|
if _, err := db.InsertKindAssignment(ctx, userID, KindAssignment{
|
||||||
ActivityID: activityID,
|
ActivityID: activityID,
|
||||||
WorkoutKindID: &kindID,
|
WorkoutKindID: &kindID,
|
||||||
AssignmentSource: AssignmentSourceManual,
|
AssignmentSource: AssignmentSourceManual,
|
||||||
@@ -128,7 +170,7 @@ func TestKindAssignment_AppendOnlyHistoryAndCurrentView(t *testing.T) {
|
|||||||
t.Fatalf("InsertKindAssignment (manual): %v", err)
|
t.Fatalf("InsertKindAssignment (manual): %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
queue, err = db.ReviewQueue(ctx)
|
queue, err = db.ReviewQueue(ctx, userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("ReviewQueue after resolve: %v", err)
|
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))
|
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 {
|
if err != nil || !ok {
|
||||||
t.Fatalf("CurrentAssignment: ok=%v err=%v", ok, err)
|
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)
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("AssignmentsForKind: %v", err)
|
t.Fatalf("AssignmentsForKind: %v", err)
|
||||||
}
|
}
|
||||||
@@ -165,8 +207,12 @@ func TestKindAssignment_AppendOnlyHistoryAndCurrentView(t *testing.T) {
|
|||||||
func TestReplaceLapsIsIdempotent(t *testing.T) {
|
func TestReplaceLapsIsIdempotent(t *testing.T) {
|
||||||
db := openTestDB(t)
|
db := openTestDB(t)
|
||||||
ctx := context.Background()
|
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,
|
GarminActivityID: 2,
|
||||||
StartTimeUTC: "2026-07-11 05:00:00",
|
StartTimeUTC: "2026-07-11 05:00:00",
|
||||||
RawJSON: "{}",
|
RawJSON: "{}",
|
||||||
@@ -179,15 +225,15 @@ func TestReplaceLapsIsIdempotent(t *testing.T) {
|
|||||||
{LapIndex: 1, RawJSON: "{}"},
|
{LapIndex: 1, RawJSON: "{}"},
|
||||||
{LapIndex: 2, 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)
|
t.Fatalf("ReplaceLaps (first): %v", err)
|
||||||
}
|
}
|
||||||
// Re-sync with a different set (e.g. corrected data) should fully replace, not append.
|
// 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)
|
t.Fatalf("ReplaceLaps (second): %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
got, err := db.LapsForActivity(ctx, activityID)
|
got, err := db.LapsForActivity(ctx, userID, activityID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("LapsForActivity: %v", err)
|
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))
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -31,56 +31,56 @@ type SyncRun struct {
|
|||||||
ErrorMessage *string
|
ErrorMessage *string
|
||||||
}
|
}
|
||||||
|
|
||||||
// StartSyncRun records a new in-progress sync run and returns its id.
|
// StartSyncRun records a new in-progress sync run for userID and returns its id.
|
||||||
func (db *DB) StartSyncRun(ctx context.Context, kind string) (int64, error) {
|
func (db *DB) StartSyncRun(ctx context.Context, userID int64, kind string) (int64, error) {
|
||||||
res, err := db.ExecContext(ctx, `
|
res, err := db.ExecContext(ctx, `
|
||||||
INSERT INTO sync_runs (kind, started_at, status) VALUES (?, datetime('now'), ?)`,
|
INSERT INTO sync_runs (user_id, kind, started_at, status) VALUES (?, ?, datetime('now'), ?)`,
|
||||||
kind, SyncStatusRunning)
|
userID, kind, SyncStatusRunning)
|
||||||
if err != nil {
|
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()
|
return res.LastInsertId()
|
||||||
}
|
}
|
||||||
|
|
||||||
// FinishSyncRun marks a sync run as finished, recording how many activities
|
// FinishSyncRun marks a sync run (owned by userID) as finished, recording
|
||||||
// were fetched and whether it succeeded.
|
// how many activities were fetched and whether it succeeded.
|
||||||
func (db *DB) FinishSyncRun(ctx context.Context, id int64, activitiesFetched int, errMsg *string) error {
|
func (db *DB) FinishSyncRun(ctx context.Context, userID, id int64, activitiesFetched int, errMsg *string) error {
|
||||||
status := SyncStatusSuccess
|
status := SyncStatusSuccess
|
||||||
if errMsg != nil {
|
if errMsg != nil {
|
||||||
status = SyncStatusError
|
status = SyncStatusError
|
||||||
}
|
}
|
||||||
_, err := db.ExecContext(ctx, `
|
_, err := db.ExecContext(ctx, `
|
||||||
UPDATE sync_runs SET finished_at = datetime('now'), activities_fetched = ?, status = ?, error_message = ?
|
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 {
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// LatestSyncRun returns the most recent sync run, if any.
|
// LatestSyncRun returns userID's most recent sync run, if any.
|
||||||
func (db *DB) LatestSyncRun(ctx context.Context) (SyncRun, bool, error) {
|
func (db *DB) LatestSyncRun(ctx context.Context, userID int64) (SyncRun, bool, error) {
|
||||||
row := db.QueryRowContext(ctx, `
|
row := db.QueryRowContext(ctx, `
|
||||||
SELECT id, kind, started_at, finished_at, activities_fetched, status, error_message
|
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
|
var r SyncRun
|
||||||
err := row.Scan(&r.ID, &r.Kind, &r.StartedAt, &r.FinishedAt, &r.ActivitiesFetched, &r.Status, &r.ErrorMessage)
|
err := row.Scan(&r.ID, &r.Kind, &r.StartedAt, &r.FinishedAt, &r.ActivitiesFetched, &r.Status, &r.ErrorMessage)
|
||||||
if err == sql.ErrNoRows {
|
if err == sql.ErrNoRows {
|
||||||
return SyncRun{}, false, nil
|
return SyncRun{}, false, nil
|
||||||
}
|
}
|
||||||
if err != 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
|
return r, true, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ListSyncRuns returns recent sync runs, newest first.
|
// ListSyncRuns returns userID's recent sync runs, newest first.
|
||||||
func (db *DB) ListSyncRuns(ctx context.Context, limit int) ([]SyncRun, error) {
|
func (db *DB) ListSyncRuns(ctx context.Context, userID int64, limit int) ([]SyncRun, error) {
|
||||||
rows, err := db.QueryContext(ctx, `
|
rows, err := db.QueryContext(ctx, `
|
||||||
SELECT id, kind, started_at, finished_at, activities_fetched, status, error_message
|
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 {
|
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()
|
defer rows.Close()
|
||||||
|
|
||||||
|
|||||||
@@ -13,25 +13,24 @@ type SyncState struct {
|
|||||||
BackfillComplete bool // true once backfill has reached the configured horizon (or Garmin's own history start)
|
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,
|
// GetSyncState returns the current backfill watermark for userID.
|
||||||
// created by migration 0002).
|
func (db *DB) GetSyncState(ctx context.Context, userID int64) (SyncState, error) {
|
||||||
func (db *DB) GetSyncState(ctx context.Context) (SyncState, error) {
|
|
||||||
var s SyncState
|
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)
|
Scan(&s.EarliestSyncedDate, &s.BackfillComplete)
|
||||||
if err != nil {
|
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
|
return s, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// UpdateSyncState records progress of a backfill run.
|
// UpdateSyncState records progress of a backfill run for userID.
|
||||||
func (db *DB) UpdateSyncState(ctx context.Context, earliestSyncedDate string, complete bool) error {
|
func (db *DB) UpdateSyncState(ctx context.Context, userID int64, earliestSyncedDate string, complete bool) error {
|
||||||
_, err := db.ExecContext(ctx, `
|
_, err := db.ExecContext(ctx, `
|
||||||
UPDATE sync_state SET earliest_synced_date = ?, backfill_complete = ? WHERE id = 1`,
|
UPDATE sync_state SET earliest_synced_date = ?, backfill_complete = ? WHERE user_id = ?`,
|
||||||
earliestSyncedDate, complete)
|
earliestSyncedDate, complete, userID)
|
||||||
if err != nil {
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,8 +8,12 @@ import (
|
|||||||
func TestSyncState_DefaultsAndUpdate(t *testing.T) {
|
func TestSyncState_DefaultsAndUpdate(t *testing.T) {
|
||||||
db := openTestDB(t)
|
db := openTestDB(t)
|
||||||
ctx := context.Background()
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("GetSyncState: %v", err)
|
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)
|
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)
|
t.Fatalf("UpdateSyncState: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
state, err = db.GetSyncState(ctx)
|
state, err = db.GetSyncState(ctx, userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("GetSyncState after update: %v", err)
|
t.Fatalf("GetSyncState after update: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
133
backend/internal/store/users.go
Normal file
133
backend/internal/store/users.go
Normal 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()
|
||||||
|
}
|
||||||
91
backend/internal/store/users_test.go
Normal file
91
backend/internal/store/users_test.go
Normal 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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`
|
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.
|
// 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, `
|
res, err := db.ExecContext(ctx, `
|
||||||
INSERT INTO workout_kinds (name, description, color, rule_json, priority, is_active)
|
INSERT INTO workout_kinds (user_id, name, description, color, rule_json, priority, is_active)
|
||||||
VALUES (?,?,?,?,?,?)`,
|
VALUES (?,?,?,?,?,?,?)`,
|
||||||
k.Name, k.Description, k.Color, k.RuleJSON, k.Priority, k.IsActive)
|
userID, k.Name, k.Description, k.Color, k.RuleJSON, k.Priority, k.IsActive)
|
||||||
if err != nil {
|
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()
|
return res.LastInsertId()
|
||||||
}
|
}
|
||||||
|
|
||||||
// UpdateWorkoutKind updates an existing workout kind's editable fields.
|
// UpdateWorkoutKind updates an existing workout kind's editable fields,
|
||||||
func (db *DB) UpdateWorkoutKind(ctx context.Context, k WorkoutKind) error {
|
// 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, `
|
_, err := db.ExecContext(ctx, `
|
||||||
UPDATE workout_kinds SET name=?, description=?, color=?, rule_json=?, priority=?, is_active=?, updated_at=datetime('now')
|
UPDATE workout_kinds SET name=?, description=?, color=?, rule_json=?, priority=?, is_active=?, updated_at=datetime('now')
|
||||||
WHERE id=?`,
|
WHERE id=? AND user_id=?`,
|
||||||
k.Name, k.Description, k.Color, k.RuleJSON, k.Priority, k.IsActive, k.ID)
|
k.Name, k.Description, k.Color, k.RuleJSON, k.Priority, k.IsActive, k.ID, userID)
|
||||||
if err != nil {
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetWorkoutKind fetches one workout kind by id.
|
// GetWorkoutKind fetches one workout kind by id, scoped to userID.
|
||||||
func (db *DB) GetWorkoutKind(ctx context.Context, id int64) (WorkoutKind, bool, error) {
|
func (db *DB) GetWorkoutKind(ctx context.Context, userID, id int64) (WorkoutKind, bool, error) {
|
||||||
row := db.QueryRowContext(ctx, `SELECT `+workoutKindColumns+` FROM workout_kinds WHERE id = ?`, id)
|
row := db.QueryRowContext(ctx, `SELECT `+workoutKindColumns+` FROM workout_kinds WHERE id = ? AND user_id = ?`, id, userID)
|
||||||
k, err := scanWorkoutKind(row)
|
k, err := scanWorkoutKind(row)
|
||||||
if err == sql.ErrNoRows {
|
if err == sql.ErrNoRows {
|
||||||
return WorkoutKind{}, false, nil
|
return WorkoutKind{}, false, nil
|
||||||
}
|
}
|
||||||
if err != 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
|
return k, true, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetWorkoutKindByName fetches one workout kind by its unique name.
|
// GetWorkoutKindByName fetches one workout kind by name, scoped to userID
|
||||||
func (db *DB) GetWorkoutKindByName(ctx context.Context, name string) (WorkoutKind, bool, error) {
|
// (the same name can exist for different users -- see UNIQUE(user_id, name)).
|
||||||
row := db.QueryRowContext(ctx, `SELECT `+workoutKindColumns+` FROM workout_kinds WHERE name = ?`, 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)
|
k, err := scanWorkoutKind(row)
|
||||||
if err == sql.ErrNoRows {
|
if err == sql.ErrNoRows {
|
||||||
return WorkoutKind{}, false, nil
|
return WorkoutKind{}, false, nil
|
||||||
}
|
}
|
||||||
if err != 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
|
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.
|
// (is_active=0) kinds are excluded.
|
||||||
func (db *DB) ListWorkoutKinds(ctx context.Context, activeOnly bool) ([]WorkoutKind, error) {
|
func (db *DB) ListWorkoutKinds(ctx context.Context, userID int64, activeOnly bool) ([]WorkoutKind, error) {
|
||||||
query := `SELECT ` + workoutKindColumns + ` FROM workout_kinds`
|
query := `SELECT ` + workoutKindColumns + ` FROM workout_kinds WHERE user_id = ?`
|
||||||
if activeOnly {
|
if activeOnly {
|
||||||
query += ` WHERE is_active = 1`
|
query += ` AND is_active = 1`
|
||||||
}
|
}
|
||||||
query += ` ORDER BY priority DESC, name`
|
query += ` ORDER BY priority DESC, name`
|
||||||
|
|
||||||
rows, err := db.QueryContext(ctx, query)
|
rows, err := db.QueryContext(ctx, query, userID)
|
||||||
if err != nil {
|
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()
|
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
|
// SoftDeleteWorkoutKind sets is_active=0, keeping history (kind_assignments
|
||||||
// referencing it) intact.
|
// referencing it) intact. Scoped to userID.
|
||||||
func (db *DB) SoftDeleteWorkoutKind(ctx context.Context, id int64) error {
|
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=?`, id)
|
_, 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 {
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,8 +8,12 @@ import (
|
|||||||
func TestWorkoutTaxonomy_SeededWithEightFixedTypes(t *testing.T) {
|
func TestWorkoutTaxonomy_SeededWithEightFixedTypes(t *testing.T) {
|
||||||
db := openTestDB(t)
|
db := openTestDB(t)
|
||||||
ctx := context.Background()
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("ListWorkoutKinds: %v", err)
|
t.Fatalf("ListWorkoutKinds: %v", err)
|
||||||
}
|
}
|
||||||
@@ -42,8 +46,12 @@ func TestWorkoutTaxonomy_SeededWithEightFixedTypes(t *testing.T) {
|
|||||||
func TestWorkoutTaxonomy_ListedInFixedDisplayOrder(t *testing.T) {
|
func TestWorkoutTaxonomy_ListedInFixedDisplayOrder(t *testing.T) {
|
||||||
db := openTestDB(t)
|
db := openTestDB(t)
|
||||||
ctx := context.Background()
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("ListWorkoutKinds: %v", err)
|
t.Fatalf("ListWorkoutKinds: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,9 @@ import (
|
|||||||
// WorkoutTypePace is a workout kind's user-declared target pace range and HR
|
// WorkoutTypePace is a workout kind's user-declared target pace range and HR
|
||||||
// range (percent of heart rate reserve). Informational only -- never read by
|
// range (percent of heart rate reserve). Informational only -- never read by
|
||||||
// the classification rule engine. No history: fields are overwritten in
|
// 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 {
|
type WorkoutTypePace struct {
|
||||||
WorkoutKindID int64
|
WorkoutKindID int64
|
||||||
PaceMinSecPerKm *float64
|
PaceMinSecPerKm *float64
|
||||||
@@ -24,38 +26,49 @@ func scanWorkoutTypePace(row interface{ Scan(...any) error }) (WorkoutTypePace,
|
|||||||
return p, err
|
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.
|
// GetWorkoutTypePace fetches the pace/zone row for one workout kind, scoped
|
||||||
func (db *DB) GetWorkoutTypePace(ctx context.Context, workoutKindID int64) (WorkoutTypePace, error) {
|
// to userID via a join to workout_kinds.
|
||||||
row := db.QueryRowContext(ctx, `SELECT `+workoutTypePaceColumns+` FROM workout_type_paces WHERE workout_kind_id = ?`, workoutKindID)
|
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)
|
p, err := scanWorkoutTypePace(row)
|
||||||
if err == sql.ErrNoRows {
|
if err == sql.ErrNoRows {
|
||||||
return WorkoutTypePace{WorkoutKindID: workoutKindID}, nil
|
return WorkoutTypePace{WorkoutKindID: workoutKindID}, nil
|
||||||
}
|
}
|
||||||
if err != 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
|
return p, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// UpdateWorkoutTypePace overwrites the pace/zone row for one workout kind.
|
// UpdateWorkoutTypePace overwrites the pace/zone row for one workout kind,
|
||||||
func (db *DB) UpdateWorkoutTypePace(ctx context.Context, p WorkoutTypePace) error {
|
// 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, `
|
_, 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=?
|
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=?`,
|
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)
|
p.PaceMinSecPerKm, p.PaceMaxSecPerKm, p.HRMinPctHRR, p.HRMaxPctHRR, p.WorkoutKindID, userID)
|
||||||
if err != nil {
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ListWorkoutTypePaces returns every workout kind's pace/zone row.
|
// ListWorkoutTypePaces returns every one of userID's workout kinds' pace/zone rows.
|
||||||
func (db *DB) ListWorkoutTypePaces(ctx context.Context) ([]WorkoutTypePace, error) {
|
func (db *DB) ListWorkoutTypePaces(ctx context.Context, userID int64) ([]WorkoutTypePace, error) {
|
||||||
rows, err := db.QueryContext(ctx, `SELECT `+workoutTypePaceColumns+` FROM workout_type_paces ORDER BY workout_kind_id`)
|
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 {
|
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()
|
defer rows.Close()
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,12 @@ func TestWorkoutTypePaces_SeededOnePerKindThenUpdate(t *testing.T) {
|
|||||||
db := openTestDB(t)
|
db := openTestDB(t)
|
||||||
ctx := context.Background()
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("ListWorkoutTypePaces: %v", err)
|
t.Fatalf("ListWorkoutTypePaces: %v", err)
|
||||||
}
|
}
|
||||||
@@ -29,11 +34,11 @@ func TestWorkoutTypePaces_SeededOnePerKindThenUpdate(t *testing.T) {
|
|||||||
target.HRMinPctHRR = &hrMin
|
target.HRMinPctHRR = &hrMin
|
||||||
target.HRMaxPctHRR = &hrMax
|
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)
|
t.Fatalf("UpdateWorkoutTypePace: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
got, err := db.GetWorkoutTypePace(ctx, target.WorkoutKindID)
|
got, err := db.GetWorkoutTypePace(ctx, userID, target.WorkoutKindID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("GetWorkoutTypePace: %v", err)
|
t.Fatalf("GetWorkoutTypePace: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -61,10 +61,12 @@ type Progress struct {
|
|||||||
Total int
|
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 {
|
type Service struct {
|
||||||
garmin garmin.Client
|
garmin garmin.Client
|
||||||
db *store.DB
|
db *store.DB
|
||||||
|
userID int64
|
||||||
cfg Config
|
cfg Config
|
||||||
now func() time.Time
|
now func() time.Time
|
||||||
|
|
||||||
@@ -72,13 +74,13 @@ type Service struct {
|
|||||||
progress Progress
|
progress Progress
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewService builds a Service. now defaults to time.Now if nil (tests can
|
// NewService builds a Service scoped to userID. now defaults to time.Now if
|
||||||
// override it for deterministic date windows).
|
// nil (tests can override it for deterministic date windows).
|
||||||
func NewService(g garmin.Client, db *store.DB, cfg Config, now func() time.Time) *Service {
|
func NewService(g garmin.Client, db *store.DB, userID int64, cfg Config, now func() time.Time) *Service {
|
||||||
if now == nil {
|
if now == nil {
|
||||||
now = time.Now
|
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).
|
// 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
|
// history. Widening the horizon between calls resumes further back instead
|
||||||
// of re-fetching everything.
|
// of re-fetching everything.
|
||||||
func (s *Service) Backfill(ctx context.Context) error {
|
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 {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -114,10 +116,10 @@ func (s *Service) Backfill(ctx context.Context) error {
|
|||||||
total, err := s.backfillCore(ctx)
|
total, err := s.backfillCore(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
msg := err.Error()
|
msg := err.Error()
|
||||||
s.db.FinishSyncRun(ctx, runID, total, &msg)
|
s.db.FinishSyncRun(ctx, s.userID, runID, total, &msg)
|
||||||
return err
|
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
|
// 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
|
// whatever was fetched even when an error is also returned, matching
|
||||||
// Backfill's own partial-progress-on-error behavior.
|
// Backfill's own partial-progress-on-error behavior.
|
||||||
func (s *Service) backfillCore(ctx context.Context) (int, error) {
|
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 {
|
if err != nil {
|
||||||
return 0, fmt.Errorf("load profile: %w", err)
|
return 0, fmt.Errorf("load profile: %w", err)
|
||||||
}
|
}
|
||||||
horizon := s.now().AddDate(0, 0, -profile.BackfillHorizonDays)
|
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 {
|
if err != nil {
|
||||||
return 0, err
|
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,
|
// Empty page: reached the start of this account's history,
|
||||||
// regardless of the configured horizon.
|
// regardless of the configured horizon.
|
||||||
reachedStartOfHistory = true
|
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
|
return total, err
|
||||||
}
|
}
|
||||||
break
|
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
|
return total, err
|
||||||
}
|
}
|
||||||
end = start.AddDate(0, 0, -1)
|
end = start.AddDate(0, 0, -1)
|
||||||
@@ -181,7 +183,7 @@ func (s *Service) backfillCore(ctx context.Context) (int, error) {
|
|||||||
if !reachedStartOfHistory {
|
if !reachedStartOfHistory {
|
||||||
// Reached the configured horizon (not Garmin's actual history
|
// Reached the configured horizon (not Garmin's actual history
|
||||||
// start) -- mark complete relative to that horizon.
|
// 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
|
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
|
// IncrementalSync fetches activities from just before the latest known
|
||||||
// activity (or a short recent window if none exist yet) through today.
|
// activity (or a short recent window if none exist yet) through today.
|
||||||
func (s *Service) IncrementalSync(ctx context.Context) error {
|
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 {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -200,17 +202,17 @@ func (s *Service) IncrementalSync(ctx context.Context) error {
|
|||||||
n, err := s.incrementalSyncCore(ctx)
|
n, err := s.incrementalSyncCore(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
msg := err.Error()
|
msg := err.Error()
|
||||||
s.db.FinishSyncRun(ctx, runID, n, &msg)
|
s.db.FinishSyncRun(ctx, s.userID, runID, n, &msg)
|
||||||
return err
|
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
|
// incrementalSyncCore holds IncrementalSync's actual fetch logic, without
|
||||||
// the SyncRun bookkeeping -- see backfillCore.
|
// the SyncRun bookkeeping -- see backfillCore.
|
||||||
func (s *Service) incrementalSyncCore(ctx context.Context) (int, error) {
|
func (s *Service) incrementalSyncCore(ctx context.Context) (int, error) {
|
||||||
start := s.now().AddDate(0, 0, -s.cfg.IncrementalOverlapDays)
|
start := s.now().AddDate(0, 0, -s.cfg.IncrementalOverlapDays)
|
||||||
if latest, ok, err := s.db.LatestActivityStartTime(ctx); err == nil && ok {
|
if latest, ok, err := s.db.LatestActivityStartTime(ctx, s.userID); err == nil && ok {
|
||||||
if t, err := time.Parse("2006-01-02 15:04:05", latest); err == nil {
|
if t, err := time.Parse("2006-01-02 15:04:05", latest); err == nil {
|
||||||
start = t.AddDate(0, 0, -s.cfg.IncrementalOverlapDays)
|
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
|
// would silently hide however many activities Backfill fetched. Recording
|
||||||
// one combined run makes the reported count match the whole action.
|
// one combined run makes the reported count match the whole action.
|
||||||
func (s *Service) FullSync(ctx context.Context, detailFillLimit int) error {
|
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 {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -237,7 +239,7 @@ func (s *Service) FullSync(ctx context.Context, detailFillLimit int) error {
|
|||||||
backfillCount, err := s.backfillCore(ctx)
|
backfillCount, err := s.backfillCore(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
msg := err.Error()
|
msg := err.Error()
|
||||||
s.db.FinishSyncRun(ctx, runID, backfillCount, &msg)
|
s.db.FinishSyncRun(ctx, s.userID, runID, backfillCount, &msg)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -245,17 +247,17 @@ func (s *Service) FullSync(ctx context.Context, detailFillLimit int) error {
|
|||||||
total := backfillCount + incrementalCount
|
total := backfillCount + incrementalCount
|
||||||
if err != nil {
|
if err != nil {
|
||||||
msg := err.Error()
|
msg := err.Error()
|
||||||
s.db.FinishSyncRun(ctx, runID, total, &msg)
|
s.db.FinishSyncRun(ctx, s.userID, runID, total, &msg)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := s.FillPendingDetails(ctx, detailFillLimit); err != nil {
|
if err := s.FillPendingDetails(ctx, detailFillLimit); err != nil {
|
||||||
msg := err.Error()
|
msg := err.Error()
|
||||||
s.db.FinishSyncRun(ctx, runID, total, &msg)
|
s.db.FinishSyncRun(ctx, s.userID, runID, total, &msg)
|
||||||
return err
|
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
|
// 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
|
// call performs a genuinely fresh pull from Garmin instead of resuming from
|
||||||
// wherever the previous one left off. Workout kinds are left untouched.
|
// wherever the previous one left off. Workout kinds are left untouched.
|
||||||
func (s *Service) ResetAll(ctx context.Context) error {
|
func (s *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
|
// 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) {
|
if !isRunningActivityType(a.ActivityType.TypeKey) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
exists, err := s.db.ActivityExists(ctx, a.ActivityID)
|
exists, err := s.db.ActivityExists(ctx, s.userID, a.ActivityID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, 0, err
|
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)
|
return 0, 0, fmt.Errorf("store activity %d: %w", a.ActivityID, err)
|
||||||
}
|
}
|
||||||
if !exists {
|
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
|
// Calls are made sequentially with Config.InterCallDelay between them to
|
||||||
// avoid Garmin/Cloudflare rate limiting.
|
// avoid Garmin/Cloudflare rate limiting.
|
||||||
func (s *Service) FillPendingDetails(ctx context.Context, limit int) error {
|
func (s *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 {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
profile, err := s.db.GetProfile(ctx)
|
profile, err := s.db.GetProfile(ctx, s.userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("load profile: %w", err)
|
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)
|
log.Printf("sync: get_workout_by_id(%d) for activity %d failed, continuing without target zones: %v", *a.WorkoutID, a.GarminActivityID, err)
|
||||||
} else {
|
} else {
|
||||||
targets = alignWorkoutTargets(splits.Laps, workout)
|
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
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
samples := garmin.ExtractSamples(details)
|
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
|
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
|
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 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
|
// ClassifyActivity (re)runs the rule engine for one activity against the
|
||||||
// currently active workout kinds and appends a new kind_assignments row.
|
// currently active workout kinds and appends a new kind_assignments row.
|
||||||
// Safe to call repeatedly (e.g. after editing a workout kind's rule).
|
// Safe to call repeatedly (e.g. after editing a workout kind's rule).
|
||||||
func (s *Service) ClassifyActivity(ctx context.Context, activityID int64) error {
|
func (s *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 {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if !ok {
|
if !ok {
|
||||||
return fmt.Errorf("activity %d not found", activityID)
|
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 {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
kindRows, err := s.db.ListWorkoutKinds(ctx, true)
|
kindRows, err := s.db.ListWorkoutKinds(ctx, s.userID, true)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -399,7 +401,7 @@ func (s *Service) ClassifyActivity(ctx context.Context, activityID int64) error
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("parse workout kind rules: %w", err)
|
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 {
|
if err != nil {
|
||||||
return fmt.Errorf("load profile: %w", err)
|
return fmt.Errorf("load profile: %w", err)
|
||||||
}
|
}
|
||||||
@@ -416,7 +418,7 @@ func (s *Service) ClassifyActivity(ctx context.Context, activityID int64) error
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err = s.db.InsertKindAssignment(ctx, store.KindAssignment{
|
_, err = s.db.InsertKindAssignment(ctx, s.userID, store.KindAssignment{
|
||||||
ActivityID: activityID,
|
ActivityID: activityID,
|
||||||
WorkoutKindID: result.WorkoutKindID,
|
WorkoutKindID: result.WorkoutKindID,
|
||||||
AssignmentSource: store.AssignmentSourceRuleEngine,
|
AssignmentSource: store.AssignmentSourceRuleEngine,
|
||||||
|
|||||||
@@ -28,17 +28,26 @@ func fixedNow(t time.Time) func() time.Time {
|
|||||||
return func() time.Time { return t }
|
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
|
// setBackfillHorizon sets Profile.BackfillHorizonDays, which Backfill reads
|
||||||
// fresh on every call (it's no longer part of Config).
|
// 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()
|
t.Helper()
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
profile, err := db.GetProfile(ctx)
|
profile, err := db.GetProfile(ctx, userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("GetProfile: %v", err)
|
t.Fatalf("GetProfile: %v", err)
|
||||||
}
|
}
|
||||||
profile.BackfillHorizonDays = days
|
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)
|
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) {
|
func TestBackfill_StoresActivitiesAndRecordsSyncRun(t *testing.T) {
|
||||||
db := openTestDB(t)
|
db := openTestDB(t)
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
userID := provisionTestUser(t, db)
|
||||||
|
|
||||||
m := &mock.Client{Activities: []garmin.Activity{
|
m := &mock.Client{Activities: []garmin.Activity{
|
||||||
{ActivityID: 1, ActivityName: "Morning Run", ActivityType: garmin.ActivityType{TypeKey: "running"},
|
{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},
|
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 {
|
if err := svc.Backfill(ctx); err != nil {
|
||||||
t.Fatalf("Backfill: %v", err)
|
t.Fatalf("Backfill: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
activities, err := db.ListActivities(ctx, store.ActivityFilter{})
|
activities, err := db.ListActivities(ctx, userID, store.ActivityFilter{})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("ListActivities: %v", err)
|
t.Fatalf("ListActivities: %v", err)
|
||||||
}
|
}
|
||||||
@@ -68,7 +78,7 @@ func TestBackfill_StoresActivitiesAndRecordsSyncRun(t *testing.T) {
|
|||||||
t.Errorf("GarminActivityID = %d, want 1", activities[0].GarminActivityID)
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("ListSyncRuns: %v", err)
|
t.Fatalf("ListSyncRuns: %v", err)
|
||||||
}
|
}
|
||||||
@@ -188,6 +198,7 @@ func TestTargetHRRange_CustomRangeAndZoneNumberViaKarvonen(t *testing.T) {
|
|||||||
func TestBackfill_SkipsNonRunningActivities(t *testing.T) {
|
func TestBackfill_SkipsNonRunningActivities(t *testing.T) {
|
||||||
db := openTestDB(t)
|
db := openTestDB(t)
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
userID := provisionTestUser(t, db)
|
||||||
|
|
||||||
m := &mock.Client{Activities: []garmin.Activity{
|
m := &mock.Client{Activities: []garmin.Activity{
|
||||||
{ActivityID: 1, ActivityType: garmin.ActivityType{TypeKey: "running"},
|
{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"},
|
{ActivityID: 4, ActivityType: garmin.ActivityType{TypeKey: "indoor_cycling"},
|
||||||
StartTimeGMT: "2026-07-04 06:00:00", Distance: 0, Duration: 1800},
|
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 {
|
if err := svc.Backfill(ctx); err != nil {
|
||||||
t.Fatalf("Backfill: %v", err)
|
t.Fatalf("Backfill: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
activities, err := db.ListActivities(ctx, store.ActivityFilter{})
|
activities, err := db.ListActivities(ctx, userID, store.ActivityFilter{})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("ListActivities: %v", err)
|
t.Fatalf("ListActivities: %v", err)
|
||||||
}
|
}
|
||||||
@@ -222,6 +233,7 @@ func TestBackfill_SkipsNonRunningActivities(t *testing.T) {
|
|||||||
func TestFillPendingDetailsAndClassify_EndToEnd(t *testing.T) {
|
func TestFillPendingDetailsAndClassify_EndToEnd(t *testing.T) {
|
||||||
db := openTestDB(t)
|
db := openTestDB(t)
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
userID := provisionTestUser(t, db)
|
||||||
|
|
||||||
const garminActivityID = 42
|
const garminActivityID = 42
|
||||||
m := &mock.Client{
|
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 {
|
if err := svc.Backfill(ctx); err != nil {
|
||||||
t.Fatalf("Backfill: %v", err)
|
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.
|
// 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]}]}`
|
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)
|
t.Fatalf("CreateWorkoutKind: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -260,7 +272,7 @@ func TestFillPendingDetailsAndClassify_EndToEnd(t *testing.T) {
|
|||||||
t.Fatalf("FillPendingDetails: %v", err)
|
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 {
|
if err != nil || len(activities) != 1 {
|
||||||
t.Fatalf("ListActivities: %v, %+v", err, activities)
|
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")
|
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 {
|
if err != nil || len(laps) != 1 {
|
||||||
t.Fatalf("LapsForActivity: %v, %+v", err, laps)
|
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 {
|
if err != nil || !ok {
|
||||||
t.Fatalf("CurrentAssignment: ok=%v err=%v", ok, err)
|
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) {
|
func TestFillPendingDetails_ResolvesWorkoutTargetsOntoLaps(t *testing.T) {
|
||||||
db := openTestDB(t)
|
db := openTestDB(t)
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
userID := provisionTestUser(t, db)
|
||||||
|
|
||||||
const garminActivityID = 55
|
const garminActivityID = 55
|
||||||
const workoutID = 999
|
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 {
|
if err := svc.Backfill(ctx); err != nil {
|
||||||
t.Fatalf("Backfill: %v", err)
|
t.Fatalf("Backfill: %v", err)
|
||||||
@@ -322,11 +335,11 @@ func TestFillPendingDetails_ResolvesWorkoutTargetsOntoLaps(t *testing.T) {
|
|||||||
t.Fatalf("FillPendingDetails: %v", err)
|
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 {
|
if err != nil || len(activities) != 1 {
|
||||||
t.Fatalf("ListActivities: %v, %+v", err, activities)
|
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 {
|
if err != nil || len(laps) != 1 {
|
||||||
t.Fatalf("LapsForActivity: %v, %+v", err, laps)
|
t.Fatalf("LapsForActivity: %v, %+v", err, laps)
|
||||||
}
|
}
|
||||||
@@ -341,6 +354,7 @@ func TestFillPendingDetails_ResolvesWorkoutTargetsOntoLaps(t *testing.T) {
|
|||||||
func TestFillPendingDetails_OneExtraTrailingLapKeepsOtherLapsTargets(t *testing.T) {
|
func TestFillPendingDetails_OneExtraTrailingLapKeepsOtherLapsTargets(t *testing.T) {
|
||||||
db := openTestDB(t)
|
db := openTestDB(t)
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
userID := provisionTestUser(t, db)
|
||||||
|
|
||||||
const garminActivityID = 56
|
const garminActivityID = 56
|
||||||
const workoutID = 1000
|
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 {
|
if err := svc.Backfill(ctx); err != nil {
|
||||||
t.Fatalf("Backfill: %v", err)
|
t.Fatalf("Backfill: %v", err)
|
||||||
@@ -378,8 +392,8 @@ func TestFillPendingDetails_OneExtraTrailingLapKeepsOtherLapsTargets(t *testing.
|
|||||||
t.Fatalf("FillPendingDetails: %v", err)
|
t.Fatalf("FillPendingDetails: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
activities, _ := db.ListActivities(ctx, store.ActivityFilter{})
|
activities, _ := db.ListActivities(ctx, userID, store.ActivityFilter{})
|
||||||
laps, err := db.LapsForActivity(ctx, activities[0].ID)
|
laps, err := db.LapsForActivity(ctx, userID, activities[0].ID)
|
||||||
if err != nil || len(laps) != 2 {
|
if err != nil || len(laps) != 2 {
|
||||||
t.Fatalf("LapsForActivity: %v, %+v", err, laps)
|
t.Fatalf("LapsForActivity: %v, %+v", err, laps)
|
||||||
}
|
}
|
||||||
@@ -443,13 +457,14 @@ func TestBuildMetricContext_DerivesIsRace(t *testing.T) {
|
|||||||
func TestBackfill_SecondRunIsANoOpOnceHorizonFullyCovered(t *testing.T) {
|
func TestBackfill_SecondRunIsANoOpOnceHorizonFullyCovered(t *testing.T) {
|
||||||
db := openTestDB(t)
|
db := openTestDB(t)
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
userID := provisionTestUser(t, db)
|
||||||
|
|
||||||
m := &mock.Client{Activities: []garmin.Activity{
|
m := &mock.Client{Activities: []garmin.Activity{
|
||||||
{ActivityID: 1, ActivityType: garmin.ActivityType{TypeKey: "running"}, StartTimeGMT: "2026-07-05 06:00:00", Distance: 5000, Duration: 1500},
|
{ActivityID: 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)))
|
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 {
|
if err := svc.Backfill(ctx); err != nil {
|
||||||
t.Fatalf("first Backfill: %v", err)
|
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")
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("GetSyncState: %v", err)
|
t.Fatalf("GetSyncState: %v", err)
|
||||||
}
|
}
|
||||||
@@ -479,13 +494,14 @@ func TestBackfill_SecondRunIsANoOpOnceHorizonFullyCovered(t *testing.T) {
|
|||||||
func TestResetAll_AllowsFreshBackfillAfterwards(t *testing.T) {
|
func TestResetAll_AllowsFreshBackfillAfterwards(t *testing.T) {
|
||||||
db := openTestDB(t)
|
db := openTestDB(t)
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
userID := provisionTestUser(t, db)
|
||||||
|
|
||||||
m := &mock.Client{Activities: []garmin.Activity{
|
m := &mock.Client{Activities: []garmin.Activity{
|
||||||
{ActivityID: 1, ActivityType: garmin.ActivityType{TypeKey: "running"}, StartTimeGMT: "2026-07-05 06:00:00", Distance: 5000, Duration: 1500},
|
{ActivityID: 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)))
|
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 {
|
if err := svc.Backfill(ctx); err != nil {
|
||||||
t.Fatalf("first Backfill: %v", err)
|
t.Fatalf("first Backfill: %v", err)
|
||||||
@@ -495,7 +511,7 @@ func TestResetAll_AllowsFreshBackfillAfterwards(t *testing.T) {
|
|||||||
if err := svc.ResetAll(ctx); err != nil {
|
if err := svc.ResetAll(ctx); err != nil {
|
||||||
t.Fatalf("ResetAll: %v", err)
|
t.Fatalf("ResetAll: %v", err)
|
||||||
}
|
}
|
||||||
activities, err := db.ListActivities(ctx, store.ActivityFilter{})
|
activities, err := db.ListActivities(ctx, userID, store.ActivityFilter{})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("ListActivities: %v", err)
|
t.Fatalf("ListActivities: %v", err)
|
||||||
}
|
}
|
||||||
@@ -509,7 +525,7 @@ func TestResetAll_AllowsFreshBackfillAfterwards(t *testing.T) {
|
|||||||
if m.GetActivitiesCalls <= firstCallCount {
|
if m.GetActivitiesCalls <= firstCallCount {
|
||||||
t.Errorf("expected Backfill after ResetAll to call GetActivities again (fresh pull), call count stayed at %d", m.GetActivitiesCalls)
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("ListActivities after re-backfill: %v", err)
|
t.Fatalf("ListActivities after re-backfill: %v", err)
|
||||||
}
|
}
|
||||||
@@ -521,14 +537,15 @@ func TestResetAll_AllowsFreshBackfillAfterwards(t *testing.T) {
|
|||||||
func TestBackfill_ResumesFromWatermarkWhenHorizonGrows(t *testing.T) {
|
func TestBackfill_ResumesFromWatermarkWhenHorizonGrows(t *testing.T) {
|
||||||
db := openTestDB(t)
|
db := openTestDB(t)
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
userID := provisionTestUser(t, db)
|
||||||
|
|
||||||
m := &mock.Client{Activities: []garmin.Activity{
|
m := &mock.Client{Activities: []garmin.Activity{
|
||||||
{ActivityID: 1, ActivityType: garmin.ActivityType{TypeKey: "running"}, StartTimeGMT: "2026-07-05 06:00:00", Distance: 5000, Duration: 1500},
|
{ActivityID: 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))
|
now := fixedNow(time.Date(2026, 7, 11, 0, 0, 0, 0, time.UTC))
|
||||||
|
|
||||||
svc := NewService(m, db, Config{BackfillWindowDays: 10}, now)
|
svc := NewService(m, db, userID, Config{BackfillWindowDays: 10}, now)
|
||||||
setBackfillHorizon(t, db, 10)
|
setBackfillHorizon(t, db, userID, 10)
|
||||||
if err := svc.Backfill(ctx); err != nil {
|
if err := svc.Backfill(ctx); err != nil {
|
||||||
t.Fatalf("first Backfill: %v", err)
|
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
|
// Simulate the user widening the horizon later -- should resume from the
|
||||||
// watermark (not re-fetch the already-covered recent window) but still
|
// watermark (not re-fetch the already-covered recent window) but still
|
||||||
// make progress toward the new, deeper horizon.
|
// make progress toward the new, deeper horizon.
|
||||||
setBackfillHorizon(t, db, 30)
|
setBackfillHorizon(t, db, userID, 30)
|
||||||
svc2 := NewService(m, db, Config{BackfillWindowDays: 10}, now)
|
svc2 := NewService(m, db, userID, Config{BackfillWindowDays: 10}, now)
|
||||||
if err := svc2.Backfill(ctx); err != nil {
|
if err := svc2.Backfill(ctx); err != nil {
|
||||||
t.Fatalf("second Backfill: %v", err)
|
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)
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("GetSyncState: %v", err)
|
t.Fatalf("GetSyncState: %v", err)
|
||||||
}
|
}
|
||||||
@@ -558,6 +575,7 @@ func TestBackfill_ResumesFromWatermarkWhenHorizonGrows(t *testing.T) {
|
|||||||
func TestFullSync_RecordsOneCombinedSyncRun(t *testing.T) {
|
func TestFullSync_RecordsOneCombinedSyncRun(t *testing.T) {
|
||||||
db := openTestDB(t)
|
db := openTestDB(t)
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
userID := provisionTestUser(t, db)
|
||||||
|
|
||||||
m := &mock.Client{
|
m := &mock.Client{
|
||||||
Activities: []garmin.Activity{
|
Activities: []garmin.Activity{
|
||||||
@@ -571,15 +589,15 @@ func TestFullSync_RecordsOneCombinedSyncRun(t *testing.T) {
|
|||||||
1: {ActivityID: 1}, 2: {ActivityID: 2},
|
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)))
|
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 {
|
if err := svc.FullSync(ctx, 10); err != nil {
|
||||||
t.Fatalf("FullSync: %v", err)
|
t.Fatalf("FullSync: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
runs, err := db.ListSyncRuns(ctx, 10)
|
runs, err := db.ListSyncRuns(ctx, userID, 10)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("ListSyncRuns: %v", err)
|
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)
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("ListActivities: %v", err)
|
t.Fatalf("ListActivities: %v", err)
|
||||||
}
|
}
|
||||||
@@ -618,6 +636,7 @@ func TestFullSync_RecordsOneCombinedSyncRun(t *testing.T) {
|
|||||||
func TestFillPendingDetails_ReportsLiveProgress(t *testing.T) {
|
func TestFillPendingDetails_ReportsLiveProgress(t *testing.T) {
|
||||||
db := openTestDB(t)
|
db := openTestDB(t)
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
userID := provisionTestUser(t, db)
|
||||||
|
|
||||||
m := &mock.Client{
|
m := &mock.Client{
|
||||||
Activities: []garmin.Activity{},
|
Activities: []garmin.Activity{},
|
||||||
@@ -634,7 +653,7 @@ func TestFillPendingDetails_ReportsLiveProgress(t *testing.T) {
|
|||||||
m.Details[i] = garmin.ActivityDetails{ActivityID: i}
|
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)))
|
fixedNow(time.Date(2026, 7, 11, 0, 0, 0, 0, time.UTC)))
|
||||||
if err := svc.Backfill(ctx); err != nil {
|
if err := svc.Backfill(ctx); err != nil {
|
||||||
t.Fatalf("Backfill: %v", err)
|
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)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
4691
docs/superpowers/plans/2026-07-25-per-user-profile.md
Normal file
4691
docs/superpowers/plans/2026-07-25-per-user-profile.md
Normal file
File diff suppressed because it is too large
Load Diff
129
docs/superpowers/specs/2026-07-25-per-user-profile-design.md
Normal file
129
docs/superpowers/specs/2026-07-25-per-user-profile-design.md
Normal 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.
|
||||||
43
frontend/src/CreateProfile.css
Normal file
43
frontend/src/CreateProfile.css
Normal 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;
|
||||||
|
}
|
||||||
52
frontend/src/CreateProfile.tsx
Normal file
52
frontend/src/CreateProfile.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import { useEffect, useState } from "react";
|
|||||||
import { api, BASE_URL } from "./api/client";
|
import { api, BASE_URL } from "./api/client";
|
||||||
import "./LoginGate.css";
|
import "./LoginGate.css";
|
||||||
import App from "./App";
|
import App from "./App";
|
||||||
|
import { CreateProfile } from "./CreateProfile";
|
||||||
import type { SessionInfo } from "./types/api";
|
import type { SessionInfo } from "./types/api";
|
||||||
|
|
||||||
type Status = "loading" | "authenticated" | "unauthenticated";
|
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
|
// 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
|
// 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
|
// this is the first fork -- "show the login screen" vs "show the app." A
|
||||||
// screen" and "show the app."
|
// 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() {
|
export function LoginGate() {
|
||||||
const [status, setStatus] = useState<Status>("loading");
|
const [status, setStatus] = useState<Status>("loading");
|
||||||
const [session, setSession] = useState<SessionInfo | null>(null);
|
const [session, setSession] = useState<SessionInfo | null>(null);
|
||||||
@@ -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!} />;
|
return <App session={session!} />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,6 +46,11 @@ export const api = {
|
|||||||
// browser navigation. Logout must POST (see server.go's route), which an
|
// browser navigation. Logout must POST (see server.go's route), which an
|
||||||
// <a href> can't do, hence the form.
|
// <a href> can't do, hence the form.
|
||||||
getSessionInfo: () => request<SessionInfo>("/api/session/me"),
|
getSessionInfo: () => request<SessionInfo>("/api/session/me"),
|
||||||
|
setup: (displayName: string) =>
|
||||||
|
request<{ user_id: number; display_name: string }>("/api/setup", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ display_name: displayName }),
|
||||||
|
}),
|
||||||
|
|
||||||
// Auth
|
// Auth
|
||||||
login: () => request<AuthResponse>("/api/auth/login", { method: "POST" }),
|
login: () => request<AuthResponse>("/api/auth/login", { method: "POST" }),
|
||||||
|
|||||||
@@ -189,6 +189,8 @@ export interface AuthResponse {
|
|||||||
export interface SessionInfo {
|
export interface SessionInfo {
|
||||||
name: string;
|
name: string;
|
||||||
email: string;
|
email: string;
|
||||||
|
has_profile: boolean;
|
||||||
|
display_name?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DetailFillProgress {
|
export interface DetailFillProgress {
|
||||||
|
|||||||
Reference in New Issue
Block a user