feat(store): add DeleteUser with cascading account deletion

This commit is contained in:
2026-07-26 10:17:21 +02:00
parent 65a2dc90d0
commit e8c340a00e
5 changed files with 142 additions and 12 deletions

View File

@@ -161,3 +161,46 @@ func TestIsolation_SyncStateAndRunsNeverLeakAcrossUsers(t *testing.T) {
t.Fatal("userB's FinishSyncRun call was able to mutate userA's sync run") t.Fatal("userB's FinishSyncRun call was able to mutate userA's sync run")
} }
} }
// TestIsolation_DeleteUserLeavesOtherUsersDataIntact confirms deleting one
// user's account never touches another user's profile, taxonomy, or
// activities, even though DeleteUser is a single blunt DELETE FROM users.
func TestIsolation_DeleteUserLeavesOtherUsersDataIntact(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.UpsertActivity(ctx, userB, Activity{GarminActivityID: 1, StartTimeUTC: "2026-07-11 06:00:00", RawJSON: "{}"}); err != nil {
t.Fatalf("UpsertActivity(b): %v", err)
}
if err := db.DeleteUser(ctx, userA); err != nil {
t.Fatalf("DeleteUser(a): %v", err)
}
if _, found, err := db.GetUserBySub(ctx, "sub-b"); err != nil || !found {
t.Fatalf("expected userB to survive userA's deletion, found=%v err=%v", found, err)
}
profileB, err := db.GetProfile(ctx, userB)
if err != nil {
t.Fatalf("GetProfile(b) after deleting a: %v", err)
}
if profileB.Name != "B" {
t.Errorf("userB's profile changed after deleting userA: %+v", profileB)
}
kindsB, err := db.ListWorkoutKinds(ctx, userB, false)
if err != nil || len(kindsB) != 8 {
t.Fatalf("userB's taxonomy affected by deleting userA: len=%d err=%v", len(kindsB), err)
}
activitiesB, err := db.ListActivities(ctx, userB, ActivityFilter{})
if err != nil || len(activitiesB) != 1 {
t.Fatalf("userB's activities affected by deleting userA: len=%d err=%v", len(activitiesB), err)
}
}

View File

@@ -26,7 +26,7 @@ CREATE TABLE users (
-- below) in one transaction when a new account signs up. -- below) in one transaction when a new account signs up.
CREATE TABLE profile ( CREATE TABLE profile (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id), user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
name TEXT NOT NULL DEFAULT 'Default', name TEXT NOT NULL DEFAULT 'Default',
garmin_email TEXT NOT NULL DEFAULT '', garmin_email TEXT NOT NULL DEFAULT '',
garmin_password TEXT NOT NULL DEFAULT '', garmin_password TEXT NOT NULL DEFAULT '',
@@ -82,7 +82,7 @@ CREATE TABLE profile (
-- recursive AND/OR condition tree evaluated by internal/classify. -- recursive AND/OR condition tree evaluated by internal/classify.
CREATE TABLE workout_kinds ( CREATE TABLE workout_kinds (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id), user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
name TEXT NOT NULL, name TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '', description TEXT NOT NULL DEFAULT '',
color TEXT NOT NULL DEFAULT '', color TEXT NOT NULL DEFAULT '',
@@ -100,7 +100,7 @@ CREATE TABLE workout_kinds (
-- synced activity log is the history. No user_id column of its own -- -- synced activity log is the history. No user_id column of its own --
-- ownership is checked via a JOIN to workout_kinds. -- ownership is checked via a JOIN to workout_kinds.
CREATE TABLE workout_type_paces ( CREATE TABLE workout_type_paces (
workout_kind_id INTEGER PRIMARY KEY REFERENCES workout_kinds(id), workout_kind_id INTEGER PRIMARY KEY REFERENCES workout_kinds(id) ON DELETE CASCADE,
pace_min_sec_per_km REAL, pace_min_sec_per_km REAL,
pace_max_sec_per_km REAL, pace_max_sec_per_km REAL,
hr_min_pct_hrr REAL, hr_min_pct_hrr REAL,
@@ -118,7 +118,7 @@ CREATE TABLE workout_type_paces (
-- instead of storing a redundant copy. -- instead of storing a redundant copy.
CREATE TABLE activities ( CREATE TABLE activities (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id), user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
garmin_activity_id INTEGER NOT NULL, garmin_activity_id INTEGER NOT NULL,
-- Derived at sync time from Garmin's own eventType.typeKey=="race" -- -- Derived at sync time from Garmin's own eventType.typeKey=="race" --
-- a hard fact, not a rule the user tunes (see internal/classify's -- a hard fact, not a rule the user tunes (see internal/classify's
@@ -219,7 +219,7 @@ JOIN (
-- re-walking years of already-known history on every call. -- re-walking years of already-known history on every call.
CREATE TABLE sync_state ( CREATE TABLE sync_state (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id), user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
earliest_synced_date TEXT, earliest_synced_date TEXT,
backfill_complete INTEGER NOT NULL DEFAULT 0, backfill_complete INTEGER NOT NULL DEFAULT 0,
UNIQUE(user_id) UNIQUE(user_id)
@@ -229,7 +229,7 @@ CREATE TABLE sync_state (
-- status the frontend polls. -- status the frontend polls.
CREATE TABLE sync_runs ( CREATE TABLE sync_runs (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id), user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
kind TEXT NOT NULL CHECK(kind IN ('backfill','incremental','full')), kind TEXT NOT NULL CHECK(kind IN ('backfill','incremental','full')),
started_at TEXT NOT NULL, started_at TEXT NOT NULL,
finished_at TEXT, finished_at TEXT,

View File

@@ -119,3 +119,17 @@ func (db *DB) ProvisionUser(ctx context.Context, oidcSub, displayName string) (i
return userID, tx.Commit() return userID, tx.Commit()
} }
// DeleteUser permanently deletes userID's account. Every row that belongs
// to it -- profile, workout kinds (and their paces), activities (and their
// laps/samples/kind_assignments), sync_state, and sync_runs -- cascades
// away via the ON DELETE CASCADE foreign keys in schema.sql, so this is a
// single statement rather than per-table deletes. Irreversible; the API
// layer gates this behind a UI confirmation (see
// docs/superpowers/specs/2026-07-26-profile-deletion-design.md).
func (db *DB) DeleteUser(ctx context.Context, userID int64) error {
if _, err := db.ExecContext(ctx, `DELETE FROM users WHERE id = ?`, userID); err != nil {
return fmt.Errorf("delete user %d: %w", userID, err)
}
return nil
}

View File

@@ -89,3 +89,76 @@ func TestProvisionUser_TwoUsersGetIndependentTaxonomies(t *testing.T) {
t.Fatal("expected each user's seeded kinds to be distinct rows") t.Fatal("expected each user's seeded kinds to be distinct rows")
} }
} }
func TestDeleteUser_RemovesUserAndCascadesEverything(t *testing.T) {
db := openTestDB(t)
ctx := context.Background()
userID, err := db.ProvisionUser(ctx, "delete-me", "Delete Me")
if err != nil {
t.Fatalf("ProvisionUser: %v", err)
}
activityID, err := db.UpsertActivity(ctx, userID, Activity{GarminActivityID: 1, StartTimeUTC: "2026-07-11 06:00:00", RawJSON: "{}"})
if err != nil {
t.Fatalf("UpsertActivity: %v", err)
}
kindID, err := db.CreateWorkoutKind(ctx, userID, WorkoutKind{Name: "Test Delete Kind", RuleJSON: `{"match":"all","conditions":[]}`, IsActive: true})
if err != nil {
t.Fatalf("CreateWorkoutKind: %v", err)
}
if err := db.ReplaceLaps(ctx, userID, activityID, []Lap{{LapIndex: 1}}); err != nil {
t.Fatalf("ReplaceLaps: %v", err)
}
if err := db.ReplaceActivitySamples(ctx, userID, activityID, []Sample{{ElapsedSeconds: 0}}); err != nil {
t.Fatalf("ReplaceActivitySamples: %v", err)
}
if _, err := db.InsertKindAssignment(ctx, userID, KindAssignment{
ActivityID: activityID, WorkoutKindID: &kindID, AssignmentSource: AssignmentSourceRuleEngine,
Status: AssignmentStatusAssigned, CandidateKindsJSON: "[]",
}); err != nil {
t.Fatalf("InsertKindAssignment: %v", err)
}
minPace := 300.0
if err := db.UpdateWorkoutTypePace(ctx, userID, WorkoutTypePace{WorkoutKindID: kindID, PaceMinSecPerKm: &minPace}); err != nil {
t.Fatalf("UpdateWorkoutTypePace: %v", err)
}
if err := db.UpdateSyncState(ctx, userID, "2020-01-01", true); err != nil {
t.Fatalf("UpdateSyncState: %v", err)
}
if _, err := db.StartSyncRun(ctx, userID, SyncKindBackfill); err != nil {
t.Fatalf("StartSyncRun: %v", err)
}
if err := db.DeleteUser(ctx, userID); err != nil {
t.Fatalf("DeleteUser: %v", err)
}
if _, found, err := db.GetUserBySub(ctx, "delete-me"); err != nil || found {
t.Fatalf("expected user gone after DeleteUser, found=%v err=%v", found, err)
}
checks := []struct {
query string
arg int64
}{
{`SELECT COUNT(*) FROM profile WHERE user_id = ?`, userID},
{`SELECT COUNT(*) FROM workout_kinds WHERE user_id = ?`, userID},
{`SELECT COUNT(*) FROM workout_type_paces WHERE workout_kind_id = ?`, kindID},
{`SELECT COUNT(*) FROM activities WHERE user_id = ?`, userID},
{`SELECT COUNT(*) FROM laps WHERE activity_id = ?`, activityID},
{`SELECT COUNT(*) FROM activity_samples WHERE activity_id = ?`, activityID},
{`SELECT COUNT(*) FROM kind_assignments WHERE activity_id = ?`, activityID},
{`SELECT COUNT(*) FROM sync_state WHERE user_id = ?`, userID},
{`SELECT COUNT(*) FROM sync_runs WHERE user_id = ?`, userID},
}
for _, c := range checks {
var count int
if err := db.QueryRowContext(ctx, c.query, c.arg).Scan(&count); err != nil {
t.Fatalf("count query %q: %v", c.query, err)
}
if count != 0 {
t.Errorf("query %q: got %d rows, want 0 after DeleteUser", c.query, count)
}
}
}

View File

@@ -31,7 +31,7 @@ CREATE TABLE users (
```sql ```sql
CREATE TABLE profile ( CREATE TABLE profile (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id), user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
name TEXT NOT NULL DEFAULT 'Default', name TEXT NOT NULL DEFAULT 'Default',
garmin_email TEXT NOT NULL DEFAULT '', garmin_email TEXT NOT NULL DEFAULT '',
garmin_password TEXT NOT NULL DEFAULT '', garmin_password TEXT NOT NULL DEFAULT '',
@@ -87,7 +87,7 @@ CREATE TABLE profile (
```sql ```sql
CREATE TABLE workout_kinds ( CREATE TABLE workout_kinds (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id), user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
name TEXT NOT NULL, name TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '', description TEXT NOT NULL DEFAULT '',
color TEXT NOT NULL DEFAULT '', color TEXT NOT NULL DEFAULT '',
@@ -104,7 +104,7 @@ CREATE TABLE workout_kinds (
```sql ```sql
CREATE TABLE workout_type_paces ( CREATE TABLE workout_type_paces (
workout_kind_id INTEGER PRIMARY KEY REFERENCES workout_kinds(id), workout_kind_id INTEGER PRIMARY KEY REFERENCES workout_kinds(id) ON DELETE CASCADE,
pace_min_sec_per_km REAL, pace_min_sec_per_km REAL,
pace_max_sec_per_km REAL, pace_max_sec_per_km REAL,
hr_min_pct_hrr REAL, hr_min_pct_hrr REAL,
@@ -117,7 +117,7 @@ CREATE TABLE workout_type_paces (
```sql ```sql
CREATE TABLE activities ( CREATE TABLE activities (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id), user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
garmin_activity_id INTEGER NOT NULL, garmin_activity_id INTEGER NOT NULL,
-- Derived at sync time from Garmin's own eventType.typeKey=="race" -- -- Derived at sync time from Garmin's own eventType.typeKey=="race" --
-- a hard fact, not a rule the user tunes (see internal/classify's -- a hard fact, not a rule the user tunes (see internal/classify's
@@ -227,7 +227,7 @@ CREATE INDEX idx_kind_assignments_status ON kind_assignments(status);
```sql ```sql
CREATE TABLE sync_state ( CREATE TABLE sync_state (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id), user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
earliest_synced_date TEXT, earliest_synced_date TEXT,
backfill_complete INTEGER NOT NULL DEFAULT 0, backfill_complete INTEGER NOT NULL DEFAULT 0,
UNIQUE(user_id) UNIQUE(user_id)
@@ -239,7 +239,7 @@ CREATE TABLE sync_state (
```sql ```sql
CREATE TABLE sync_runs ( CREATE TABLE sync_runs (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id), user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
kind TEXT NOT NULL CHECK(kind IN ('backfill','incremental','full')), kind TEXT NOT NULL CHECK(kind IN ('backfill','incremental','full')),
started_at TEXT NOT NULL, started_at TEXT NOT NULL,
finished_at TEXT, finished_at TEXT,