diff --git a/docs/superpowers/specs/2026-07-26-profile-deletion-design.md b/docs/superpowers/specs/2026-07-26-profile-deletion-design.md new file mode 100644 index 0000000..8eb9fcc --- /dev/null +++ b/docs/superpowers/specs/2026-07-26-profile-deletion-design.md @@ -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("/api/profile", { + method: "DELETE" })`. +- `Profile.tsx`: a final `
` + ("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 + `
` 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 / + `

` 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.