2026-07-26 10:06:04 +02:00
|
|
|
# Profile deletion
|
|
|
|
|
|
|
|
|
|
Status: approved, not yet implemented.
|
|
|
|
|
|
|
|
|
|
## Problem
|
|
|
|
|
|
|
|
|
|
`docs/IDEAS.md` backlog item: "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)".
|
|
|
|
|
|
|
|
|
|
Today there is no way for a user to remove their geniusrun account. The
|
|
|
|
|
closest existing feature, "Reset all" (`GarminConnection.tsx` /
|
|
|
|
|
`POST /api/sync/reset`), only wipes synced activity data and rewinds the
|
|
|
|
|
backfill watermark -- it leaves the `users` row, `profile`, Garmin
|
|
|
|
|
credentials, and custom workout-kind rules untouched. Profile deletion is a
|
|
|
|
|
strictly bigger, irreversible operation: the entire account for the signed-in
|
|
|
|
|
OIDC subject, gone, followed by a real logout.
|
|
|
|
|
|
|
|
|
|
## Design
|
|
|
|
|
|
|
|
|
|
### Data model: cascading deletes
|
|
|
|
|
|
|
|
|
|
The schema has FK columns pointing at `users(id)` (`profile.user_id`,
|
|
|
|
|
`workout_kinds.user_id`, `activities.user_id`, `sync_state.user_id`,
|
|
|
|
|
`sync_runs.user_id`) and at `workout_kinds(id)`
|
|
|
|
|
(`workout_type_paces.workout_kind_id`), none of which currently cascade.
|
|
|
|
|
`laps`/`activity_samples`/`kind_assignments` already cascade off `activities`
|
|
|
|
|
(`ON DELETE CASCADE`), which is what lets `ResetAllSyncedData` get away with
|
|
|
|
|
a single `DELETE FROM activities`. SQLite's `foreign_keys` pragma is already
|
|
|
|
|
enabled on every connection (`db.go`'s `_pragma=foreign_keys(1)`), so the same
|
|
|
|
|
mechanism works for the rest of the schema.
|
|
|
|
|
|
|
|
|
|
`schema.sql` is edited directly (no migration history, per project
|
|
|
|
|
convention) to add `ON DELETE CASCADE` to:
|
|
|
|
|
|
|
|
|
|
- `profile.user_id REFERENCES users(id)`
|
|
|
|
|
- `workout_kinds.user_id REFERENCES users(id)`
|
|
|
|
|
- `workout_type_paces.workout_kind_id REFERENCES workout_kinds(id)`
|
|
|
|
|
- `activities.user_id REFERENCES users(id)`
|
|
|
|
|
- `sync_state.user_id REFERENCES users(id)`
|
|
|
|
|
- `sync_runs.user_id REFERENCES users(id)`
|
|
|
|
|
|
|
|
|
|
With that in place, deleting a user becomes one statement:
|
|
|
|
|
|
|
|
|
|
```go
|
|
|
|
|
// store/users.go
|
|
|
|
|
func (db *DB) DeleteUser(ctx context.Context, userID int64) error {
|
|
|
|
|
_, err := db.ExecContext(ctx, `DELETE FROM users WHERE id = ?`, userID)
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
After this change, regenerate `docs/DATABASE.md` (`go run ./cmd/dumpschema`).
|
|
|
|
|
|
|
|
|
|
### API: `DELETE /api/profile`
|
|
|
|
|
|
|
|
|
|
New handler alongside the existing `GET`/`PUT /api/profile` routes, inside
|
|
|
|
|
the `requireProvisionedUser` group (`server.go`). `userID` comes from
|
|
|
|
|
`userIDFromContext`, same as every other handler -- no client-supplied id.
|
|
|
|
|
|
|
|
|
|
1. If a sync is currently running for this user (`s.userSyncRunning[userID]`,
|
|
|
|
|
the same flag `backgroundSync` sets), respond `409 Conflict` ("a sync is
|
|
|
|
|
in progress for this account; wait for it to finish before deleting your
|
|
|
|
|
profile") rather than deleting rows out from under an in-flight write.
|
|
|
|
|
2. Call `s.DB.DeleteUser(ctx, userID)`.
|
|
|
|
|
3. Tear down this user's cached Garmin state (new `Server` method, e.g.
|
|
|
|
|
`removeGarminClient(userID)`):
|
|
|
|
|
- Under `s.mu`, pop `s.userGarmin[userID]` and delete the userID entry
|
|
|
|
|
from `userSync`, `userAuthStatus`, `userAuthMessage`, and
|
|
|
|
|
`userSyncRunning`.
|
|
|
|
|
- If a client existed, call `client.Close()` outside the lock to
|
|
|
|
|
terminate its subprocess (mirrors how `handleUpdateProfile` calls
|
|
|
|
|
`client.UpdateCredentials` outside `s.mu`).
|
|
|
|
|
- Best-effort `os.RemoveAll` on `filepath.Join(s.GarminBase.TokenStorePath,
|
|
|
|
|
strconv.FormatInt(userID, 10))` when `TokenStorePath` is configured --
|
2026-08-04 16:04:18 +02:00
|
|
|
the same path `clientFor` computes when building a client. Log on
|
2026-07-26 10:06:04 +02:00
|
|
|
error; this is cleanup of an already-orphaned directory, not something
|
|
|
|
|
that should fail the request that already deleted the DB row.
|
|
|
|
|
4. Respond `204 No Content`.
|
|
|
|
|
|
|
|
|
|
### Frontend
|
|
|
|
|
|
|
|
|
|
- `api/client.ts`: `deleteProfile: () => request<void>("/api/profile", {
|
|
|
|
|
method: "DELETE" })`.
|
|
|
|
|
- `Profile.tsx`: a final `<fieldset className="kind-editor danger-zone">`
|
|
|
|
|
("Danger zone") containing a `button-danger` "Delete profile" button
|
|
|
|
|
(same class `GarminConnection`'s "Reset all" uses). Clicking it reveals an
|
|
|
|
|
inline confirmation block (local component state, no separate modal
|
|
|
|
|
component -- same "reveal inline" shape `ClassifyControl` already uses for
|
|
|
|
|
its dropdown):
|
|
|
|
|
- Explanatory copy: this permanently deletes the account -- Garmin
|
|
|
|
|
credentials, every custom rule, every synced activity -- and logs the
|
|
|
|
|
user out. Cannot be undone.
|
|
|
|
|
- A text input; a "Permanently delete" button stays disabled until the
|
|
|
|
|
input's value is exactly `DELETE`.
|
|
|
|
|
- A "Cancel" button collapses the block back.
|
|
|
|
|
- On confirm: call `api.deleteProfile()`. On success, build a real
|
|
|
|
|
`<form method="post" action="{BASE_URL}/api/session/logout">` via the DOM
|
|
|
|
|
and submit it immediately -- the same POST-navigation the header's Log out
|
|
|
|
|
button already performs (`App.tsx`), required because logout must be a
|
|
|
|
|
real browser navigation through Keycloak's end-session redirect, not a
|
|
|
|
|
`fetch`. On failure, surface the error via the same `error` state /
|
|
|
|
|
`<p className="error">` pattern the rest of `Profile.tsx` already uses,
|
|
|
|
|
and leave the confirmation block open so the user can retry.
|
|
|
|
|
|
|
|
|
|
### Error handling
|
|
|
|
|
|
|
|
|
|
- Sync-in-progress: `409`, clear message, surfaced like every other API
|
|
|
|
|
error on this page.
|
|
|
|
|
- DB/teardown errors: `500` with `err.Error()`, same as the rest of this
|
|
|
|
|
codebase's handlers.
|
|
|
|
|
- The type-`DELETE`-to-confirm gate is UI-only -- same trust boundary as
|
|
|
|
|
every other destructive action in this app (e.g. Reset all's
|
|
|
|
|
`window.confirm`). The backend does not require a second confirmation
|
|
|
|
|
token.
|
|
|
|
|
|
|
|
|
|
### Testing
|
|
|
|
|
|
|
|
|
|
- `backend/internal/store`: `TestDeleteUser` -- provision a user, give them
|
|
|
|
|
activities (with laps/samples/kind_assignments), workout kinds (with
|
|
|
|
|
paces), sync_state, and sync_runs; delete; assert every row is gone.
|
|
|
|
|
Adversarial isolation check per this repo's convention
|
|
|
|
|
(`isolation_test.go`): provision two users, delete one, assert the other's
|
|
|
|
|
rows (profile, workout kinds, activities, etc.) are untouched.
|
|
|
|
|
- `backend/internal/api`: handler test for `DELETE /api/profile` against
|
|
|
|
|
`newTestServer` + `mock.Client` -- success path (204, user actually gone
|
|
|
|
|
from `GetUserBySub`), and the sync-in-progress 409 (set
|
|
|
|
|
`userSyncRunning[userID] = true` first).
|
|
|
|
|
- No frontend test suite exists yet (per `CLAUDE.md`) -- manually
|
|
|
|
|
smoke-tested in the browser: delete flow ends up back at the login/create
|
|
|
|
|
profile screen, a fresh login for that OIDC subject lands on "Create your
|
|
|
|
|
profile" again (proving the account is truly gone, not just logged out).
|
|
|
|
|
|
|
|
|
|
## Out of scope
|
|
|
|
|
|
|
|
|
|
- Any "export my data before deleting" flow -- not requested.
|
|
|
|
|
- Re-authentication (password/MFA re-entry) before deletion -- this app has
|
|
|
|
|
no such step anywhere else (e.g. Reset all), so it isn't introduced here
|
|
|
|
|
either.
|
|
|
|
|
- Admin-initiated deletion of another user's account -- there is no admin UI
|
|
|
|
|
in this app.
|