docs: add design spec for profile deletion
This commit is contained in:
142
docs/superpowers/specs/2026-07-26-profile-deletion-design.md
Normal file
142
docs/superpowers/specs/2026-07-26-profile-deletion-design.md
Normal file
@@ -0,0 +1,142 @@
|
||||
# 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 --
|
||||
the same path `garminFor` computes when building a client. Log on
|
||||
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.
|
||||
Reference in New Issue
Block a user