6.6 KiB
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:
// 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.
- If a sync is currently running for this user (
s.userSyncRunning[userID], the same flagbackgroundSyncsets), respond409 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. - Call
s.DB.DeleteUser(ctx, userID). - Tear down this user's cached Garmin state (new
Servermethod, e.g.removeGarminClient(userID)):- Under
s.mu, pops.userGarmin[userID]and delete the userID entry fromuserSync,userAuthStatus,userAuthMessage, anduserSyncRunning. - If a client existed, call
client.Close()outside the lock to terminate its subprocess (mirrors howhandleUpdateProfilecallsclient.UpdateCredentialsoutsides.mu). - Best-effort
os.RemoveAllonfilepath.Join(s.GarminBase.TokenStorePath, strconv.FormatInt(userID, 10))whenTokenStorePathis configured -- the same pathgarminForcomputes 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.
- Under
- 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 abutton-danger"Delete profile" button (same classGarminConnection's "Reset all" uses). Clicking it reveals an inline confirmation block (local component state, no separate modal component -- same "reveal inline" shapeClassifyControlalready 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 afetch. On failure, surface the error via the sameerrorstate /<p className="error">pattern the rest ofProfile.tsxalready 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:
500witherr.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'swindow.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 forDELETE /api/profileagainstnewTestServer+mock.Client-- success path (204, user actually gone fromGetUserBySub), and the sync-in-progress 409 (setuserSyncRunning[userID] = truefirst).- 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.