Merge branch 'worktree-profile-deletion'

This commit is contained in:
2026-07-26 10:22:52 +02:00
13 changed files with 392 additions and 14 deletions

View File

@@ -8,6 +8,7 @@ import (
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"net/url" "net/url"
"os"
"path/filepath" "path/filepath"
"strconv" "strconv"
"testing" "testing"
@@ -764,6 +765,85 @@ func TestProfile_RejectsInvalidHRZones(t *testing.T) {
} }
} }
func TestDeleteProfile_DeletesUserAndTerminatesGarminClient(t *testing.T) {
s, db, userID := newTestServer(t)
router := s.Router()
// Force the per-user Garmin client to be built and cached.
rec := doJSON(t, router, http.MethodPost, "/api/auth/login", nil)
if rec.Code != http.StatusOK {
t.Fatalf("auth/login status = %d, body = %s", rec.Code, rec.Body.String())
}
client, err := s.garminFor(context.Background(), userID)
if err != nil {
t.Fatalf("garminFor: %v", err)
}
mockClient, ok := client.(*mock.Client)
if !ok {
t.Fatalf("expected *mock.Client, got %T", client)
}
rec = doJSON(t, router, http.MethodDelete, "/api/profile", nil)
if rec.Code != http.StatusNoContent {
t.Fatalf("delete status = %d, body = %s", rec.Code, rec.Body.String())
}
if _, found, err := db.GetUserBySub(context.Background(), "test-user"); err != nil || found {
t.Fatalf("expected user gone after delete, found=%v err=%v", found, err)
}
if !mockClient.ClosedCalled {
t.Error("expected the cached garmin client to be Close()d on profile deletion")
}
}
func TestDeleteProfile_RejectsWhileSyncInProgress(t *testing.T) {
s, db, userID := newTestServer(t)
s.mu.Lock()
s.userSyncRunning[userID] = true
s.mu.Unlock()
rec := doJSON(t, s.Router(), http.MethodDelete, "/api/profile", nil)
if rec.Code != http.StatusConflict {
t.Fatalf("status = %d, want 409, body = %s", rec.Code, rec.Body.String())
}
if _, found, err := db.GetUserBySub(context.Background(), "test-user"); err != nil || !found {
t.Fatalf("expected user to survive a rejected delete, found=%v err=%v", found, err)
}
}
func TestDeleteProfile_RemovesTokenStoreDirectory(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() })
userID, err := db.ProvisionUser(context.Background(), "test-user", "Test User")
if err != nil {
t.Fatalf("ProvisionUser: %v", err)
}
tokenStoreRoot := t.TempDir()
userTokenDir := filepath.Join(tokenStoreRoot, strconv.FormatInt(userID, 10))
if err := os.MkdirAll(userTokenDir, 0o755); err != nil {
t.Fatalf("MkdirAll: %v", err)
}
if err := os.WriteFile(filepath.Join(userTokenDir, "session.json"), []byte("{}"), 0o644); err != nil {
t.Fatalf("WriteFile: %v", err)
}
m := &mock.Client{}
garminFactory := func(garmin.Config) garmin.Client { return m }
s := NewServer(db, garminFactory, garmin.Config{TokenStorePath: tokenStoreRoot}, appsync.Config{}, &authmock.Verifier{}, testSessionConfig)
rec := doJSON(t, s.Router(), http.MethodDelete, "/api/profile", nil)
if rec.Code != http.StatusNoContent {
t.Fatalf("delete status = %d, body = %s", rec.Code, rec.Body.String())
}
if _, err := os.Stat(userTokenDir); !os.IsNotExist(err) {
t.Fatalf("expected token store dir %q to be removed, stat err = %v", userTokenDir, err)
}
}
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)

View File

@@ -153,3 +153,31 @@ func TestIsolation_RequireProvisionedUser_BlocksDataRoutesUntilSetup(t *testing.
t.Fatalf("status = %d, want 403 (no profile provisioned yet), body = %s", rec.Code, rec.Body.String()) t.Fatalf("status = %d, want 403 (no profile provisioned yet), body = %s", rec.Code, rec.Body.String())
} }
} }
// TestIsolation_DeleteProfileOnlyDeletesOwnAccount confirms one user
// deleting their own profile never touches another user's account, even
// though DeleteUser is keyed purely by the session-resolved userID.
func TestIsolation_DeleteProfileOnlyDeletesOwnAccount(t *testing.T) {
s, db, userA := newTestServer(t) // provisions "test-user" (userA)
if _, err := db.ProvisionUser(newCtx(), "user-b", "B"); err != nil {
t.Fatalf("ProvisionUser(b): %v", err)
}
router := s.Router()
rec := doJSONAs(t, router, "user-b", http.MethodDelete, "/api/profile", nil)
if rec.Code != http.StatusNoContent {
t.Fatalf("userB delete status = %d, body = %s", rec.Code, rec.Body.String())
}
if _, found, err := db.GetUserBySub(newCtx(), "user-b"); err != nil || found {
t.Fatalf("expected userB gone after their own delete, found=%v err=%v", found, err)
}
u, found, err := db.GetUserBySub(newCtx(), "test-user")
if err != nil || !found || u.ID != userA {
t.Fatalf("expected userA to survive userB's deletion, found=%v err=%v id=%d want=%d", found, err, u.ID, userA)
}
rec = doJSON(t, router, http.MethodGet, "/api/profile", nil) // as userA
if rec.Code != http.StatusOK {
t.Fatalf("userA profile status after userB deleted themselves = %d, body = %s", rec.Code, rec.Body.String())
}
}

View File

@@ -75,3 +75,29 @@ func (s *Server) handleUpdateProfile(w http.ResponseWriter, r *http.Request) {
} }
writeJSON(w, http.StatusOK, updated) writeJSON(w, http.StatusOK, updated)
} }
// handleDeleteProfile permanently deletes the signed-in user's entire
// geniusrun account (profile, workout kinds/paces, activities and
// everything under them, sync state/runs -- see schema.sql's ON DELETE
// CASCADE from users(id)) and tears down their cached Garmin client and
// token-store directory. It does not touch the session cookie itself --
// the frontend follows a successful call with a real logout navigation
// (see docs/superpowers/specs/2026-07-26-profile-deletion-design.md).
func (s *Server) handleDeleteProfile(w http.ResponseWriter, r *http.Request) {
userID := userIDFromContext(r.Context())
s.mu.Lock()
inProgress := s.userSyncRunning[userID]
s.mu.Unlock()
if inProgress {
writeError(w, http.StatusConflict, "a sync is in progress for this account; wait for it to finish before deleting your profile")
return
}
if err := s.DB.DeleteUser(r.Context(), userID); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
s.removeGarminClient(userID)
w.WriteHeader(http.StatusNoContent)
}

View File

@@ -8,6 +8,7 @@ import (
"fmt" "fmt"
"log" "log"
"net/http" "net/http"
"os"
"path/filepath" "path/filepath"
"strconv" "strconv"
"sync" "sync"
@@ -116,6 +117,36 @@ func (s *Server) syncFor(ctx context.Context, userID int64) (*appsync.Service, e
return svc, nil return svc, nil
} }
// removeGarminClient drops userID's cached garmin.Client/sync.Service (if
// any) and every other per-user in-memory entry for userID, terminating the
// client's subprocess and best-effort removing its on-disk token-store
// directory. Called when a user's account has just been deleted from the
// DB, so nothing in memory keeps referencing a userID that no longer
// exists.
func (s *Server) removeGarminClient(userID int64) {
s.mu.Lock()
client, ok := s.userGarmin[userID]
delete(s.userGarmin, userID)
delete(s.userSync, userID)
delete(s.userAuthStatus, userID)
delete(s.userAuthMessage, userID)
delete(s.userSyncRunning, userID)
s.mu.Unlock()
if ok {
if err := client.Close(); err != nil {
log.Printf("api: close garmin client for deleted user %d: %v", userID, err)
}
}
if s.GarminBase.TokenStorePath == "" {
return
}
tokenStoreDir := filepath.Join(s.GarminBase.TokenStorePath, strconv.FormatInt(userID, 10))
if err := os.RemoveAll(tokenStoreDir); err != nil {
log.Printf("api: remove token store dir for deleted user %d: %v", userID, err)
}
}
// RunIncrementalSyncForAllUsers is called on a timer (see main.go) to sync // RunIncrementalSyncForAllUsers is called on a timer (see main.go) to sync
// every provisioned user in turn, replacing the old single-global-Service // every provisioned user in turn, replacing the old single-global-Service
// background loop. // background loop.
@@ -167,6 +198,7 @@ func (s *Server) Router() http.Handler {
r.Route("/profile", func(r chi.Router) { r.Route("/profile", func(r chi.Router) {
r.Get("/", s.handleGetProfile) r.Get("/", s.handleGetProfile)
r.Put("/", s.handleUpdateProfile) r.Put("/", s.handleUpdateProfile)
r.Delete("/", s.handleDeleteProfile)
}) })
r.Route("/auth", func(r chi.Router) { r.Route("/auth", func(r chi.Router) {

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,

View File

@@ -9,7 +9,6 @@ for that history) and remove it from here once a spec exists.
## Backlog ## Backlog
- improve first connection page: in CreateProfile.tsx, in addition to the display name, we should directly ask garmin login/password and manage MFA from there, so the synchronization experience in profile page will be easier later on - improve first connection page: in CreateProfile.tsx, in addition to the display name, we should directly ask garmin login/password and manage MFA from there, so the synchronization experience in profile page will be easier later on
- profile deletion: add a way to delete our profile with a dedicated button on the profile page (which by extension will also logout the user, to be consistent with the logic that login will trigger the profile creation)
- dedicated workouts section: add workouts table in database and create a dedicated page in UI (use an "objective" icon) - dedicated workouts section: add workouts table in database and create a dedicated page in UI (use an "objective" icon)
- improve activities/workouts download: make it modal, add progress bar, error management - improve activities/workouts download: make it modal, add progress bar, error management
- better UX when backend is not available (instead of "TypeError failed to fetch") - better UX when backend is not available (instead of "TypeError failed to fetch")

View File

@@ -231,6 +231,17 @@ button:disabled {
color: #fff; color: #fff;
} }
.danger-zone-confirm {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.danger-zone-confirm p {
margin: 0;
color: #9aa0ab;
}
.filter-pills { .filter-pills {
display: flex; display: flex;
gap: 0.5rem; gap: 0.5rem;

View File

@@ -106,6 +106,10 @@ export const api = {
getProfile: () => request<Profile>("/api/profile"), getProfile: () => request<Profile>("/api/profile"),
updateProfile: (profile: Profile) => updateProfile: (profile: Profile) =>
request<Profile>("/api/profile", { method: "PUT", body: JSON.stringify(profile) }), request<Profile>("/api/profile", { method: "PUT", body: JSON.stringify(profile) }),
// Permanently deletes the signed-in user's entire account. The caller is
// responsible for following a successful call with a real logout
// navigation -- this does not touch the session cookie (see Profile.tsx).
deleteProfile: () => request<void>("/api/profile", { method: "DELETE" }),
// Review queue -- cursor-paginated: pass the previous page's next_cursor // Review queue -- cursor-paginated: pass the previous page's next_cursor
// as `before` to fetch the next one. Fetching every activity's laps and // as `before` to fetch the next one. Fetching every activity's laps and

View File

@@ -1,5 +1,5 @@
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { api } from "../api/client"; import { api, BASE_URL } from "../api/client";
import { ColorField } from "../components/ColorField"; import { ColorField } from "../components/ColorField";
import { GarminConnection } from "../components/GarminConnection"; import { GarminConnection } from "../components/GarminConnection";
import { NullableNumberField } from "../components/NullableNumberField"; import { NullableNumberField } from "../components/NullableNumberField";
@@ -47,6 +47,9 @@ export function Profile({ onSaved }: { onSaved?: (p: ProfileType) => void }) {
const profileRef = useRef<ProfileType | null>(null); const profileRef = useRef<ProfileType | null>(null);
profileRef.current = profile; profileRef.current = profile;
const saveTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null); const saveTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
const [deleteConfirmText, setDeleteConfirmText] = useState("");
const [deleting, setDeleting] = useState(false);
useEffect(() => { useEffect(() => {
api.getProfile().then(setProfile).catch((e) => setError(String(e))); api.getProfile().then(setProfile).catch((e) => setError(String(e)));
@@ -100,6 +103,26 @@ export function Profile({ onSaved }: { onSaved?: (p: ProfileType) => void }) {
} }
} }
async function deleteAccount() {
setDeleting(true);
setError(null);
try {
await api.deleteProfile();
// Deletion doesn't clear the session cookie -- follow with a real
// logout navigation (must be a POST, same as the header's Log out
// button in App.tsx) so Keycloak's own SSO session ends too, not just
// geniusrun's local one.
const form = document.createElement("form");
form.method = "post";
form.action = `${BASE_URL}/api/session/logout`;
document.body.appendChild(form);
form.submit();
} catch (e) {
setError(String(e));
setDeleting(false);
}
}
if (!profile) { if (!profile) {
return ( return (
<div className="page"> <div className="page">
@@ -240,6 +263,51 @@ export function Profile({ onSaved }: { onSaved?: (p: ProfileType) => void }) {
</fieldset> </fieldset>
<TrainingTypesCard /> <TrainingTypesCard />
<fieldset className="kind-editor danger-zone">
<legend>Danger zone</legend>
{!deleteConfirmOpen ? (
<button type="button" className="button-danger" onClick={() => setDeleteConfirmOpen(true)}>
Delete profile
</button>
) : (
<div className="danger-zone-confirm">
<p>
This permanently deletes your account -- Garmin credentials, every custom rule, and every synced
activity -- and logs you out. This cannot be undone.
</p>
<label>
Type DELETE to confirm
<input
type="text"
value={deleteConfirmText}
onChange={(e) => setDeleteConfirmText(e.target.value)}
disabled={deleting}
/>
</label>
<div className="controls">
<button
type="button"
disabled={deleting}
onClick={() => {
setDeleteConfirmOpen(false);
setDeleteConfirmText("");
}}
>
Cancel
</button>
<button
type="button"
className="button-danger"
disabled={deleting || deleteConfirmText !== "DELETE"}
onClick={deleteAccount}
>
Permanently delete
</button>
</div>
</div>
)}
</fieldset>
</div> </div>
); );
} }