Onboarding Data Model
Scope
Onboarding v1 separates an authenticated account and its minimal profiles row from product onboarding completion. A profile may exist while requires_onboarding is still true.
The UI remains presentation-only. It receives state and actions from the app container, which calls BarbelicApi and the auth/profile domain.
Profile State
profiles stores the resumable state:
onboarding_version: current data contract version. The initial version is1.onboarding_step: stable semantic step key such asprofileorconsents.onboarding_draft: a bounded JSON object merged on each progress save.onboarding_completed_at: null until the current onboarding is complete.onboarding_completion_source:userorlegacy_backfill.onboarding_updated_at: last progress write time.
No public RPC writes progress without completing: only complete_onboarding sets these fields. The draft merge itself still exists, in the private apply_onboarding_progress helper that complete_onboarding drives.
Nickname Policy
display_name is a visible display name, not a globally unique account handle. Duplicates are intentionally allowed in v1. The unique public handle lives in a separate column (profiles.handle, see below) instead of retroactively making display names unique.
Handle (@handle) — 2026-08-23 (20260821470000)
profiles.handle is the public account id friends search for (feed friend search matches it alongside display_name). It is nullable until the user sets one, unique (profiles_handle_key), and written only through set_profile_handle_v1(p_handle) or complete_onboarding(payload.handle).
- normalization (
normalize_profile_handle_v1): strip leading@and outer whitespace, lowercase; the result must match^[a-z0-9_]{3,20}$(22023 otherwise) - a handle owned by another profile is rejected with 23505
Handle already taken; insidecomplete_onboardingthat rolls the whole completion back - setting the same handle again is idempotent; the profile tab lets a user set or change it later (client copy:
LG_PROFILE_HANDLE_TAKEN/LG_PROFILE_HANDLE_INVALID)
Server validation applies these rules:
- trim outer whitespace and collapse internal whitespace
- 1 to 24 characters after normalization
- reject control characters
- reject the placeholder
새 리프터at onboarding completion
The state RPC returns the same policy under nickname_policy so the UI can perform immediate client-side feedback without becoming the source of truth.
Consent History
user_consents is an append-preserving history table. One active row may exist for each user, consent type, and document version. Withdrawal timestamps the active row; accepting again inserts a new row instead of erasing the previous history.
The current Barbelic consent requirement publishes these identifiers. Consent document versions are independent from the onboarding data-contract version:
| Consent type | Version | Required |
|---|---|---|
terms_of_service | v1 | yes |
privacy_policy | v1 | yes |
sensitive_data_processing | v1 | yes |
profile_sharing | v1 | no |
sensitive_sharing | v1 | no |
(marketing is not collected at launch. Requirement source: onboarding_consent_requirements(), launch edition switch Barbelic#1640, app v0.19.4.)
The required consent records map to immutable, publicly reachable documents:
| Consent type | Version | Effective date | Public document |
|---|---|---|---|
terms_of_service | v1 | 2026-09-15 | /legal/terms-v1.html |
privacy_policy | v1 | 2026-09-15 | /legal/privacy-v1.html |
sensitive_data_processing | v1 | 2026-09-15 | /legal/consent-sensitive-v1.html |
profile_sharing | v1 | 2026-09-15 | /legal/consent-sharing-v1.html |
sensitive_sharing | v1 | 2026-09-15 | /legal/consent-sharing-sensitive-v1.html |
The launch edition v1 (Barbelic#1640, 2026-09-15) replaced the pre-launch drafts v1~v6; the app serves these files itself from public/legal/ (Barbelic#1643). The client resolves the exact required version returned by get_onboarding_state(); it does not silently substitute another version. Accounts that accepted a draft version are test accounts only and are asked to consent again on their next sign-in; no migration rewrites profiles or fabricates user_consents rows for them.
The mapping is centralized in src/react/legalDocuments.ts. Publishing revised copy requires a new document version and a matching consent requirement instead of replacing the content behind an already accepted version.
Withdrawing a currently required consent invalidates a user-completed onboarding and returns the user to the consents step. Grandfathered users are not given fabricated consent rows.
Existing User Backfill
Profiles created before 2026-07-13 00:00:00+09 are marked complete only when they already have a non-empty, non-placeholder display name. Their completion source is legacy_backfill; no terms acceptance is inferred or inserted.
The fixed cutoff makes the migration safe to rerun without accidentally completing users who joined after onboarding v1 was introduced. Existing users without usable profile information remain incomplete and can resume at the profile step.
RPC Contracts
get_onboarding_state()
Returns profile progress, completion state, nickname policy, current consent requirements, and at most 50 consent-history rows for auth.uid().
Retired: save_onboarding_progress(payload)
Dropped in 20260820120000 (owner decision 2026-08-20): onboarding is short enough that an interrupted run is restarted rather than resumed, so no public draft-saving RPC remains. complete_onboarding accepts the same payload shape, documented here:
{
"display_name": "Barbelic User",
"current_step": "consents",
"draft": { "preferred_unit": "kg" },
"consents": [
{
"consent_type": "terms_of_service",
"document_version": "v2",
"accepted": true
}
]
}Draft objects are merged by the private helper, so a later partial payload does not erase an already-stored draft. The full payload is capped at 32 KiB and the draft at 16 KiB.
complete_onboarding(payload)
Applies the same optional progress payload, validates the nickname and all required current consent versions, then marks onboarding complete. Repeated calls are idempotent for an already completed profile.
withdraw_user_consent(type, version)
Withdraws only the caller's active matching consent. It is idempotent when no active row exists.
All public RPCs derive ownership from auth.uid() and accept no user-id parameter. Private helpers are security definer, use an empty search_path, and have no authenticated execute grant.