Community Submissions
Status Note
This doc captures the first community-submitted profile flow for #23, plus the presentation-field boundary needed by #22, #19, and #21.
Locked Decisions
- ordinary community submissions require a signed-in Convex identity before any profile write
- submitted records are normal
profilesrows, not a separate staging object type - submitted records are created as
creationSource: "community",claimState: "unclaimed", andpublicationState: "published" - profile slugs are generated server-side from the submitted display name
- submitters cannot provide custom slugs, claim state, publication state, owner fields, freeform bios, about sections, image URLs, private contact details, or trust labels
- source attribution is stored inline for later moderation and display decisions without creating an account table yet
- community-submitted records start with
publicSurfacingState: "public"unless later opt-out or moderation suppression changes that state
Public Routes
/submit: first community-facing submission form/support: contact, dispute, transfer, recovery, opt-out, and safety-review intake/<slug>: public person or community profile page
The /submit route is protected by the middleware and redirects a signed-out visitor to /sign-in. Clerk authenticates the browser and the backend mutation stays auth-gated, writing only for callers Convex resolves to a users row.
The root route reads through profiles:getPublicBySlug, requires publicationState: "published" plus publicSurfacingState: "public", and returns a public projection that omits source-attribution identifiers. It passes no profileType: with one route serving both kinds there is no route-claimed type left to check the record against, so the stored profileType decides what renders.
Public source display is sanitized to labels such as Community submitted and submitted date. Submitter token identifiers, issuer, subject, and display name are not exposed publicly in this slice.
The /support intake
Unlike /submit, this route is deliberately open to signed-out visitors.
Recovery is "I lost access to the account that holds my profile", so requiring a
session would exclude the case that needs it most. A session is attached to the
request when one exists.
One selector, two destinations, because the two halves have different consequences:
owner_opt_outandpre_claim_safetycallsuppressions:requestProfileSuppressionand writeprofileSuppressionRequests. Accepting one of those retracts matching profiles from discovery through a scheduled job.ownership_dispute,transfer,recovery, andfeedbackcallsupportRequests:submitSupportRequestand writesupportRequests, which has no automation behind it at all.
They are kept apart so a feedback row can never be one operator action away from opting a profile out.
Both mutations resolve the profile field through readProfileSlugFromInput, so
a pasted profile link works on every topic. Text that names no profile is
refused rather than dropped: discarding the only identifier on a dispute without
saying so is how one arrives unactionable.
supportRequests carries no lifecycle state. The hourly digest
(supportRequestDigest:sendSupportDigest, see
ses-auth-email.md) is the read path and the
operator mailbox is the workflow. notifiedAt unset means "not yet mailed", and
the digest sends before it stamps, so a failure between the two costs a
duplicate email rather than a lost request.
Allowed Submission Fields
Shared fields:
displayNamealiasestagsoutboundLinks, stampedsource: "community_submitted"rather than owner-authored, because the submitter is adding somebody else's profile
The shared browser editor detects supported link providers from the URL hostname. Unrecognized URLs use the website type and can have a custom label. Existing links retain their type and metadata when their destination is unchanged; API clients continue to supply explicit link types.
Aliases use individual inputs so commas within a name remain part of the name. The profile editor suggests browser-supported timezones and offers an explicit local-timezone action without changing the saved value on load. Dedicated VRCDN input accepts a username or a stream URL; bare usernames become canonical VRCDN references in the submitted payload.
profiles:previewProfileFromBrowser is an authenticated read-only preview. It
checks the same editable-field permissions, loaded revision, suppression rules,
and input normalization as saving,
then applies the public field projection to the draft without writing a profile,
search document, or audit event. The browser renders the result on demand; saving
still uses the existing revision-checked mutation.
The embedded preview omits site navigation and private record controls. Owner previews of profiles that are not publicly readable serve projected media through the existing authenticated asset route, so private profiles can preview images without making them public. The public field and asset filters still apply; preview does not include hidden assets. These private-profile previews omit the public logo ZIP download. Publicly readable profiles retain public asset URLs and ZIP downloads, including when media editing is disabled. Community contributors keep public asset URLs and cannot use the owner asset route.
Person-specific fields:
person.roleTags, collected as checkboxes over a fixed vocabulary with a freeform field beside it for anything outside the list. Selecting a streaming role reveals dedicated stream and Twitch inputs, which fold intooutboundLinksrather than being separate fields. A VRCDN URL of any shape, including the operator panel preview URL people are handed, canonicalizes tovrcdn:<streamId>. VRCDN publishes no page for a stream, so the identifier is what gets stored and each surface derives the endpoint it needs from it.
Community-specific fields:
community.subtypecommunity.categoryTags
Presentation Fields
The schema supports these owner-authored presentation fields for public pages:
headlinebioaboutavatarImageUrlbannerImageUrl
Ordinary community submissions do not set those fields in this slice. Owner, concierge, moderation, import, or claim flows can populate them only with stricter validation and audit behavior.
Editing an existing unclaimed profile is a wider set than creating one, and the
rule there is information about the person versus the record itself — see
Profile Access And Claims.
Headline, bio, region and timezone are information about the person and are
editable there; appearance choices and the slug are not. timezone and the
focus items carry one extra condition, because the profile page does not render
them in every state and editing a field means being shown its current value
first — the same section says which.
Media is proposed through a separate reviewed path rather than edited directly. For the first release, a verified-email contributor can offer one person profile image or one community primary logo for an unclaimed public profile. Processing keeps the candidate private; only a superadmin decision, or the active owner after a claim transition, can create a normal public asset.
The contribution form records an HTTPS source, credit, accessibility text, and an optional reviewer note. Moderation and audit are the trust boundary; VRDex does not collect rights or likeness categories or a performative attestation checkbox. Pending and rejected media never appears in public profile, event, search, or asset routes.
New community-submitted profiles explicitly start with region unlisted and
exact timezone private. Existing profiles keep their current effective public
defaults unless an owner changes them; this slice does not retroactively hide
fields.
Implementation Boundaries
#25should make community-submitted and unverified labels consistent across cards and pages#26expands attribution into a first rollback-capable moderation trail#29adds pre-claim suppression workflow state#30enforces accepted opt-out and suppression state across public surfaces#31and#33add search and browse surfaces over published, publicly surfacing profiles
See also:
docs/backend/search-discovery.mddocs/backend/vocabulary-model.md