Skip to main content

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 profiles rows, not a separate staging object type
  • submitted records are created as creationSource: "community", claimState: "unclaimed", and publicationState: "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_out and pre_claim_safety call suppressions:requestProfileSuppression and write profileSuppressionRequests. Accepting one of those retracts matching profiles from discovery through a scheduled job.
  • ownership_dispute, transfer, recovery, and feedback call supportRequests:submitSupportRequest and write supportRequests, 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:

  • displayName
  • aliases
  • tags
  • outboundLinks, stamped source: "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 into outboundLinks rather than being separate fields. A VRCDN URL of any shape, including the operator panel preview URL people are handed, canonicalizes to vrcdn:<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.subtype
  • community.categoryTags

Presentation Fields​

The schema supports these owner-authored presentation fields for public pages:

  • headline
  • bio
  • about
  • avatarImageUrl
  • bannerImageUrl

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​

  • #25 should make community-submitted and unverified labels consistent across cards and pages
  • #26 expands attribution into a first rollback-capable moderation trail
  • #29 adds pre-claim suppression workflow state
  • #30 enforces accepted opt-out and suppression state across public surfaces
  • #31 and #33 add search and browse surfaces over published, publicly surfacing profiles

See also:

  • docs/backend/search-discovery.md
  • docs/backend/vocabulary-model.md