store: scope kind assignments, laps, and samples via activity ownership

None of these three tables gained their own user_id column -- they're
always accessed through a specific activity, so ownership is checked via a
join/subquery against activities.user_id instead.
This commit is contained in:
2026-07-25 17:27:51 +02:00
parent f82549524b
commit 47dbf4e446
5 changed files with 163 additions and 75 deletions

View File

@@ -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()

View File

@@ -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()

View File

@@ -21,10 +21,10 @@ func TestResetAllSyncedData_DeletesActivitiesCascadesAndRewindsWatermark(t *test
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 {
@@ -45,14 +45,14 @@ func TestResetAllSyncedData_DeletesActivitiesCascadesAndRewindsWatermark(t *test
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)
} }

View File

@@ -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()

View File

@@ -143,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,
@@ -152,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)
} }
@@ -161,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,
@@ -170,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)
} }
@@ -178,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)
} }
@@ -195,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)
} }
@@ -225,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)
} }
@@ -309,7 +309,7 @@ func TestForeignKeyEnforcementPostMigration(t *testing.T) {
} }
// Inserting a kind_assignment with a valid FK reference should succeed. // Inserting a kind_assignment with a valid FK reference should succeed.
assignmentID, err := db.InsertKindAssignment(ctx, KindAssignment{ assignmentID, err := db.InsertKindAssignment(ctx, userID, KindAssignment{
ActivityID: activityID, ActivityID: activityID,
WorkoutKindID: &seedKindID, WorkoutKindID: &seedKindID,
AssignmentSource: AssignmentSourceManual, AssignmentSource: AssignmentSourceManual,
@@ -325,7 +325,7 @@ func TestForeignKeyEnforcementPostMigration(t *testing.T) {
// Inserting a kind_assignment with a nonexistent workout_kind_id should // Inserting a kind_assignment with a nonexistent workout_kind_id should
// be rejected by the FK constraint, proving FK enforcement is ON. // be rejected by the FK constraint, proving FK enforcement is ON.
nonexistentKindID := int64(999999) nonexistentKindID := int64(999999)
_, err = db.InsertKindAssignment(ctx, KindAssignment{ _, err = db.InsertKindAssignment(ctx, userID, KindAssignment{
ActivityID: activityID, ActivityID: activityID,
WorkoutKindID: &nonexistentKindID, WorkoutKindID: &nonexistentKindID,
AssignmentSource: AssignmentSourceManual, AssignmentSource: AssignmentSourceManual,
@@ -568,6 +568,38 @@ func TestRebuildMigrationsPreserveForeignKeyReferences(t *testing.T) {
} }
} }
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 { func contains(s string, substrs ...string) bool {
lower := strings.ToLower(s) lower := strings.ToLower(s)
for _, substr := range substrs { for _, substr := range substrs {