Files
geniusrun/docs/superpowers/specs/2026-07-26-profile-deletion-design.md
Christophe Vila e2b2bf9611 refactor: merge internal/sync into internal/garmin, regroup api files and routes
Garmin auth/sync routes move under /api/garmin/*; sync.Service becomes
garmin.Sync with garmin.SyncConfig/ClientConfig; applog becomes
internal/log; the test mock moves into the garmin package as MockClient
(breaking the test-only import cycle the merge created); stale test
URLs and type names updated to match.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 16:04:18 +02:00

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.

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