From 01c816965197191ceeacc748108b3aeb09abba84 Mon Sep 17 00:00:00 2001 From: BASICBIT Date: Mon, 18 May 2026 07:26:59 -0400 Subject: [PATCH 1/3] feat: add community profile submission pages --- README.md | 6 +- .../app/_components/profile-public-page.tsx | 313 ++++++++++++++++++ apps/web/src/app/c/[slug]/page.tsx | 27 ++ apps/web/src/app/p/[slug]/page.tsx | 27 ++ apps/web/src/app/page.tsx | 44 ++- apps/web/src/app/submit/page.tsx | 54 +++ .../app/submit/profile-submission-form.tsx | 223 +++++++++++++ apps/web/src/convex/server.ts | 25 ++ convex/README.md | 5 +- convex/_generated/api.d.ts | 6 + convex/_profilePublic.ts | 45 +++ convex/_profileSubmissions.ts | 214 ++++++++++++ convex/profiles.ts | 134 ++++++++ convex/schema.ts | 14 + docs/backend/community-submissions.md | 58 ++++ docs/backend/profile-access-and-claims.md | 6 +- docs/backend/profile-schema.md | 16 +- docs/backend/profile-slugs.md | 2 +- docs/planning/architecture.md | 5 +- tests/backend/profile-foundation.test.ts | 114 +++++++ 20 files changed, 1303 insertions(+), 35 deletions(-) create mode 100644 apps/web/src/app/_components/profile-public-page.tsx create mode 100644 apps/web/src/app/c/[slug]/page.tsx create mode 100644 apps/web/src/app/p/[slug]/page.tsx create mode 100644 apps/web/src/app/submit/page.tsx create mode 100644 apps/web/src/app/submit/profile-submission-form.tsx create mode 100644 convex/_profilePublic.ts create mode 100644 convex/_profileSubmissions.ts create mode 100644 convex/profiles.ts create mode 100644 docs/backend/community-submissions.md diff --git a/README.md b/README.md index 4a3de18..3f3cc59 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ - `AGENTS.md` - repo-wide agent rules and durable workflow defaults - `AGENTS.local.md.example` - local operator preference template for `AGENTS.local.md` - `apps/web` - initial `Next.js` web application scaffold -- `convex` - initial Convex backend functions, base profile schema, and generated API types +- `convex` - Convex backend functions, profile schema, community submissions, and generated API types - `docs/README.md` - docs entry point - `docs/planning/` - product, architecture, roadmap, backlog, and issue-planning docs - `docs/agentic/` - software-factory, onboarding, and agent workflow docs @@ -38,7 +38,9 @@ The first server-side `Next.js -> Convex` baseline now lives at `/server-status` The first product schema table is `profiles`, covering the shared durable record for both people and communities. See `docs/backend/profile-schema.md` for the current field and state contract. -Profile slug, permission, and claim-state contracts live in `docs/backend/profile-slugs.md` and `docs/backend/profile-access-and-claims.md`. +Profile slug, permission, claim-state, and community-submission contracts live in `docs/backend/profile-slugs.md`, `docs/backend/profile-access-and-claims.md`, and `docs/backend/community-submissions.md`. + +Community-submitted public profiles now start at `/submit`. Person profile pages render at `/p/` and community profile pages render at `/c/`. `pnpm verify` is the full repo verification pass and now includes the local Convex bootstrap checks. If you are iterating on the web app only, use `pnpm verify:web` for the lighter web-only path. diff --git a/apps/web/src/app/_components/profile-public-page.tsx b/apps/web/src/app/_components/profile-public-page.tsx new file mode 100644 index 0000000..16d7233 --- /dev/null +++ b/apps/web/src/app/_components/profile-public-page.tsx @@ -0,0 +1,313 @@ +import Link from "next/link"; + +type ProfileTrustLabel = + | "community_submitted" + | "unclaimed" + | "claimed_unverified" + | "claimed_verified"; + +type PublicProfileBase = { + profileType: "person" | "community"; + slug: string; + displayName: string; + aliases: string[]; + tags: string[]; + headline?: string; + bio?: string; + about?: string; + avatarImageUrl?: string; + bannerImageUrl?: string; + region?: string; + timezone?: string; + creationSource: string; + trustLabel: ProfileTrustLabel; +}; + +type PublicPersonProfile = PublicProfileBase & { + profileType: "person"; + person: { + pronouns?: string; + roleTags: string[]; + }; +}; + +type PublicCommunityProfile = PublicProfileBase & { + profileType: "community"; + community: { + subtype?: string; + categoryTags: string[]; + }; +}; + +export type PublicProfile = PublicPersonProfile | PublicCommunityProfile; + +function trustLabelCopy(label: ProfileTrustLabel) { + if (label === "claimed_verified") { + return { + title: "Verified owner", + description: "This profile is controlled by its owner and has stronger verification.", + }; + } + + if (label === "claimed_unverified") { + return { + title: "Claimed", + description: "This profile is owner-controlled, with stronger verification still pending.", + }; + } + + if (label === "community_submitted") { + return { + title: "Community submitted", + description: "This profile was added by a signed-in community member and has not been claimed yet.", + }; + } + + return { + title: "Unclaimed", + description: "No owner authority has been attached to this profile yet.", + }; +} + +function initialsFor(name: string): string { + const initials = name + .split(/\s+/) + .filter(Boolean) + .slice(0, 2) + .map((part) => part[0]?.toUpperCase()) + .join(""); + + return initials || "VR"; +} + +function safeImageBackground(imageUrl: string | undefined, overlay = false) { + if (!imageUrl) { + return undefined; + } + + try { + const url = new URL(imageUrl); + + if (url.protocol !== "https:" && url.protocol !== "http:") { + return undefined; + } + + const image = `url(${JSON.stringify(url.href)})`; + + return { + backgroundImage: overlay + ? `linear-gradient(135deg, rgba(22, 17, 15, 0.58), rgba(214, 106, 77, 0.2)), ${image}` + : image, + }; + } catch { + return undefined; + } +} + +function PillList({ items }: { items: string[] }) { + if (items.length === 0) { + return

No public entries yet.

; + } + + return ( +
+ {items.map((item) => ( + + {item} + + ))} +
+ ); +} + +export function ProfileBackendNotice({ kind }: { kind: "missing-url" | "error" }) { + return ( +
+
+

+ Public profile +

+

+ {kind === "missing-url" ? "Convex URL not configured" : "Profile read failed"} +

+

+ {kind === "missing-url" + ? "Run the local backend bootstrap before loading public profile pages from this worktree." + : "Start the local Convex backend and reload this page once the profile query is reachable."} +

+ + Back to homepage + +
+
+ ); +} + +export function ProfilePublicPage({ profile }: { profile: PublicProfile }) { + const trust = trustLabelCopy(profile.trustLabel); + const isPerson = profile.profileType === "person"; + const routePrefix = isPerson ? "p" : "c"; + const typeLabel = isPerson ? "Person profile" : "Community profile"; + const typeItems: string[] = isPerson + ? profile.person.roleTags + : [profile.community.subtype, ...profile.community.categoryTags].filter( + (item): item is string => Boolean(item), + ); + const bannerStyle = safeImageBackground(profile.bannerImageUrl, true); + const avatarStyle = safeImageBackground(profile.avatarImageUrl); + + return ( +
+
+ + +
+
+
+
+ + {typeLabel} + + + /{routePrefix}/{profile.slug} + +
+ +
+
+
+ {!avatarStyle ? initialsFor(profile.displayName) : null} +
+ +
+

+ {profile.displayName} +

+

+ {profile.headline ?? + profile.bio ?? + (isPerson + ? "A public VRDex identity page for a VRChat scene person." + : "A public VRDex home for a VRChat scene community.")} +

+
+
+ + +
+
+
+
+ +
+
+

+ About +

+

+ {isPerson ? "Public identity" : "Community home"} +

+
+ {profile.bio ?

{profile.bio}

: null} + {profile.about ?

{profile.about}

: null} + {!profile.bio && !profile.about ? ( +

+ Owner-authored bio and about sections are supported by the profile model and will appear here once populated. +

+ ) : null} +
+
+ + +
+ +
+
+

+ {isPerson ? "Roles" : "Community focus"} +

+
+ +
+
+ +
+

+ Tags +

+
+ +
+
+ +
+

+ Aliases +

+
+ +
+
+
+
+
+ ); +} diff --git a/apps/web/src/app/c/[slug]/page.tsx b/apps/web/src/app/c/[slug]/page.tsx new file mode 100644 index 0000000..bb188cb --- /dev/null +++ b/apps/web/src/app/c/[slug]/page.tsx @@ -0,0 +1,27 @@ +import { notFound } from "next/navigation"; + +import { ProfileBackendNotice, ProfilePublicPage } from "../../_components/profile-public-page"; +import { fetchPublicProfileBySlug } from "@/convex/server"; + +export const dynamic = "force-dynamic"; + +type ProfilePageProps = { + params: Promise<{ + slug: string; + }>; +}; + +export default async function CommunityProfilePage({ params }: ProfilePageProps) { + const { slug } = await params; + const result = await fetchPublicProfileBySlug(slug, "community"); + + if (result.kind === "missing-url" || result.kind === "error") { + return ; + } + + if (result.profile === null) { + notFound(); + } + + return ; +} diff --git a/apps/web/src/app/p/[slug]/page.tsx b/apps/web/src/app/p/[slug]/page.tsx new file mode 100644 index 0000000..9bd7508 --- /dev/null +++ b/apps/web/src/app/p/[slug]/page.tsx @@ -0,0 +1,27 @@ +import { notFound } from "next/navigation"; + +import { ProfileBackendNotice, ProfilePublicPage } from "../../_components/profile-public-page"; +import { fetchPublicProfileBySlug } from "@/convex/server"; + +export const dynamic = "force-dynamic"; + +type ProfilePageProps = { + params: Promise<{ + slug: string; + }>; +}; + +export default async function PersonProfilePage({ params }: ProfilePageProps) { + const { slug } = await params; + const result = await fetchPublicProfileBySlug(slug, "person"); + + if (result.kind === "missing-url" || result.kind === "error") { + return ; + } + + if (result.profile === null) { + notFound(); + } + + return ; +} diff --git a/apps/web/src/app/page.tsx b/apps/web/src/app/page.tsx index 30d92a1..253c140 100644 --- a/apps/web/src/app/page.tsx +++ b/apps/web/src/app/page.tsx @@ -18,23 +18,19 @@ export default function Home() { Profiles, communities, and scene presence for VRChat.

- This is the first{" "} - Next.js surface for - VRDex. The app shell is now wired to the initial Convex backend - without prematurely locking in product flows that still belong to - follow-on issues. + VRDex now has the first submit-to-public-profile path: signed-in + community members can seed unclaimed people and communities, and + published profiles render at their canonical URLs.

- - Next.js docs - + Add a profile +
Next issues
-
#9, #56, #59
+
#25, #31, #33
@@ -92,12 +88,12 @@ export default function Home() { Now in place

- Client and server baselines + Public profile routes

- The repo now has one reactive client read and one explicit server-side - read pattern under apps/web, - ready to support later profile and auth work without mixing patterns. + Person pages live under /p/<slug>, + community pages live under /c/<slug>, + and both render shared identity, presentation, and trust state.

@@ -106,12 +102,12 @@ export default function Home() { Deliberately deferred

- Auth, billing, and deployment wiring + Owner authority still separate

- The app now talks to the placeholder backend path, while identity - providers, billing posture, and hosted deployment setup stay in - their own follow-on issues. + Community submissions create unclaimed profiles only. Rich claim, + auth-provider setup, billing posture, and moderation workflows stay + in their own follow-on issues.

@@ -120,12 +116,12 @@ export default function Home() { Immediate follow-on

- Define the profile data foundation + Search and trust labels

- The next meaningful milestone is establishing the first durable - profile schema so public pages and claim flows can build on typed - domain records instead of the bootstrap health placeholder. + The next meaningful milestone is turning these public records into + searchable cards with consistent community-submitted and unverified + labeling across discovery surfaces.

diff --git a/apps/web/src/app/submit/page.tsx b/apps/web/src/app/submit/page.tsx new file mode 100644 index 0000000..8201b06 --- /dev/null +++ b/apps/web/src/app/submit/page.tsx @@ -0,0 +1,54 @@ +import Link from "next/link"; + +import { ProfileSubmissionForm } from "./profile-submission-form"; + +export default function SubmitProfilePage() { + return ( +
+
+ + +
+
+
+
+

+ Community submissions +

+

+ Add a missing VRChat scene profile. +

+

+ This first submission flow creates ordinary unclaimed profiles for people and communities. It keeps the field set narrow so community entries are useful without pretending to be owner-authored pages. +

+
+ +
+

+ Safe by default +

+

+ Submission requires Convex auth, stores source attribution for later moderation, generates the canonical slug server-side, and publishes with an unclaimed trust state. +

+
+
+ +
+ +
+
+
+
+
+ ); +} diff --git a/apps/web/src/app/submit/profile-submission-form.tsx b/apps/web/src/app/submit/profile-submission-form.tsx new file mode 100644 index 0000000..056d048 --- /dev/null +++ b/apps/web/src/app/submit/profile-submission-form.tsx @@ -0,0 +1,223 @@ +"use client"; + +import Link from "next/link"; +import { FormEvent, useState, useTransition } from "react"; +import { useMutation } from "convex/react"; +import { api } from "@convex-generated-api"; + +const convexUrl = process.env.NEXT_PUBLIC_CONVEX_URL; + +type ProfileType = "person" | "community"; + +type SubmissionStatus = + | { kind: "idle" } + | { kind: "submitting" } + | { + kind: "success"; + result: { + profilePath: string; + slug: string; + }; + } + | { kind: "error"; message: string }; + +function splitList(value: FormDataEntryValue | null): string[] { + if (typeof value !== "string") { + return []; + } + + return value + .split(",") + .map((item) => item.trim()) + .filter(Boolean); +} + +function stringField(value: FormDataEntryValue | null): string { + return typeof value === "string" ? value : ""; +} + +function DisabledSubmissionPanel() { + return ( +
+

+ Submission flow +

+

+ Convex URL not configured +

+

+ Run pnpm bootstrap:backend:local before testing profile submission locally. The mutation also requires a signed-in Convex identity, so anonymous writes stay blocked. +

+
+ ); +} + +function ConnectedSubmissionForm() { + const submitProfile = useMutation(api.profiles.submitCommunityProfile); + const [profileType, setProfileType] = useState("person"); + const [status, setStatus] = useState({ kind: "idle" }); + const [, startTransition] = useTransition(); + + async function onSubmit(event: FormEvent) { + event.preventDefault(); + + const form = event.currentTarget; + const formData = new FormData(form); + const selectedType = stringField(formData.get("profileType")) as ProfileType; + + setStatus({ kind: "submitting" }); + + try { + const sharedPayload = { + profileType: selectedType, + displayName: stringField(formData.get("displayName")), + aliases: splitList(formData.get("aliases")), + tags: splitList(formData.get("tags")), + }; + const result = await submitProfile( + selectedType === "person" + ? { + ...sharedPayload, + profileType: "person", + person: { + roleTags: splitList(formData.get("roleTags")), + }, + } + : { + ...sharedPayload, + profileType: "community", + community: { + subtype: stringField(formData.get("subtype")), + categoryTags: splitList(formData.get("categoryTags")), + }, + }, + ); + + form.reset(); + setProfileType("person"); + startTransition(() => setStatus({ kind: "success", result })); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + startTransition(() => setStatus({ kind: "error", message })); + } + } + + const isSubmitting = status.kind === "submitting"; + + return ( +
+
+ + + +
+ +
+ + + +
+ + {profileType === "person" ? ( + + ) : ( +
+ + + +
+ )} + +
+ Community submissions intentionally skip custom slugs, freeform bios, about text, image URLs, private contact details, and claim signals. VRDex generates the slug and marks the profile as unclaimed until an owner claim flow exists. +
+ +
+ + + {status.kind === "success" ? ( + + View /{status.result.slug} + + ) : null} +
+ + {status.kind === "error" ? ( +

+ {status.message} +

+ ) : null} +
+ ); +} + +export function ProfileSubmissionForm() { + if (!convexUrl) { + return ; + } + + return ; +} diff --git a/apps/web/src/convex/server.ts b/apps/web/src/convex/server.ts index 7dc740e..fd23f63 100644 --- a/apps/web/src/convex/server.ts +++ b/apps/web/src/convex/server.ts @@ -1,6 +1,8 @@ import { fetchQuery } from "convex/nextjs"; import { api } from "@convex-generated-api"; +type PublicProfileType = "person" | "community"; + export async function fetchBackendStatus() { if (!process.env.NEXT_PUBLIC_CONVEX_URL) { return { kind: "missing-url" as const }; @@ -23,3 +25,26 @@ export async function fetchBackendStatus() { }; } } + +export async function fetchPublicProfileBySlug(slug: string, profileType: PublicProfileType) { + if (!process.env.NEXT_PUBLIC_CONVEX_URL) { + return { kind: "missing-url" as const }; + } + + try { + const profile = await fetchQuery(api.profiles.getPublicBySlug, { slug, profileType }); + + return { + kind: "live" as const, + profile, + }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + + console.error(`Server-side Convex profile fetch failed: ${message}`); + + return { + kind: "error" as const, + }; + } +} diff --git a/convex/README.md b/convex/README.md index 7e1463a..cb82ad6 100644 --- a/convex/README.md +++ b/convex/README.md @@ -7,6 +7,9 @@ This directory holds the initial Convex backend slice for `VRDex`. - `_profileSlugs.ts` contains pure slug validation, generation, and lookup helpers - `_profileStates.ts` contains pure claim-state and trust-label helpers - `_profilePermissions.ts` contains pure read/write permission baseline helpers +- `_profilePublic.ts` contains public profile projection helpers +- `_profileSubmissions.ts` contains community submission sanitization helpers +- `profiles.ts` exposes public profile reads and authenticated community submission mutations - `_generated/` contains committed Convex codegen output and should not be edited by hand - `tsconfig.json` is the Convex-managed TypeScript config for backend functions @@ -20,6 +23,6 @@ Use the repo-root scripts for local work: The canonical workflow notes live in `docs/backend/convex-bootstrap.md`. -The profile schema contract lives in `docs/backend/profile-schema.md`. +The profile schema and community submission contracts live in `docs/backend/profile-schema.md` and `docs/backend/community-submissions.md`. The slug, permission, and claim-state contracts live in `docs/backend/profile-slugs.md` and `docs/backend/profile-access-and-claims.md`. diff --git a/convex/_generated/api.d.ts b/convex/_generated/api.d.ts index 87f1fca..513e15d 100644 --- a/convex/_generated/api.d.ts +++ b/convex/_generated/api.d.ts @@ -9,9 +9,12 @@ */ import type * as _profilePermissions from "../_profilePermissions.js"; +import type * as _profilePublic from "../_profilePublic.js"; import type * as _profileSlugs from "../_profileSlugs.js"; import type * as _profileStates from "../_profileStates.js"; +import type * as _profileSubmissions from "../_profileSubmissions.js"; import type * as health from "../health.js"; +import type * as profiles from "../profiles.js"; import type { ApiFromModules, @@ -21,9 +24,12 @@ import type { declare const fullApi: ApiFromModules<{ _profilePermissions: typeof _profilePermissions; + _profilePublic: typeof _profilePublic; _profileSlugs: typeof _profileSlugs; _profileStates: typeof _profileStates; + _profileSubmissions: typeof _profileSubmissions; health: typeof health; + profiles: typeof profiles; }>; /** diff --git a/convex/_profilePublic.ts b/convex/_profilePublic.ts new file mode 100644 index 0000000..d68c53f --- /dev/null +++ b/convex/_profilePublic.ts @@ -0,0 +1,45 @@ +import type { Doc } from "./_generated/dataModel"; +import { getProfileTrustLabel } from "./_profileStates"; + +function optionalField(key: string, value: T | undefined): Record { + return value === undefined ? {} : { [key]: value }; +} + +export function toPublicProfile(profile: Doc<"profiles">) { + const shared = { + profileType: profile.profileType, + slug: profile.slug, + displayName: profile.displayName, + aliases: profile.aliases, + tags: profile.tags, + creationSource: profile.creationSource, + trustLabel: getProfileTrustLabel(profile.claimState, profile.creationSource), + ...optionalField("headline", profile.headline), + ...optionalField("bio", profile.bio), + ...optionalField("about", profile.about), + ...optionalField("avatarImageUrl", profile.avatarImageUrl), + ...optionalField("bannerImageUrl", profile.bannerImageUrl), + ...optionalField("region", profile.region), + ...optionalField("timezone", profile.timezone), + }; + + if (profile.profileType === "person") { + return { + ...shared, + profileType: "person" as const, + person: { + ...optionalField("pronouns", profile.person.pronouns), + roleTags: profile.person.roleTags, + }, + }; + } + + return { + ...shared, + profileType: "community" as const, + community: { + ...optionalField("subtype", profile.community.subtype), + categoryTags: profile.community.categoryTags, + }, + }; +} diff --git a/convex/_profileSubmissions.ts b/convex/_profileSubmissions.ts new file mode 100644 index 0000000..10e8fba --- /dev/null +++ b/convex/_profileSubmissions.ts @@ -0,0 +1,214 @@ +export const PROFILE_DISPLAY_NAME_MIN_LENGTH = 2; +export const PROFILE_DISPLAY_NAME_MAX_LENGTH = 80; +export const PROFILE_ALIAS_MAX_COUNT = 8; +export const PROFILE_ALIAS_MAX_LENGTH = 60; +export const PROFILE_TAG_MAX_COUNT = 12; +export const PROFILE_TAG_MAX_LENGTH = 32; +export const PROFILE_SUBTYPE_MAX_LENGTH = 40; + +type ProfileSubmissionProfileType = "person" | "community"; + +export type CommunitySubmissionProfileInput = { + profileType: ProfileSubmissionProfileType; + displayName: string; + aliases?: string[]; + tags?: string[]; + person?: { + roleTags?: string[]; + }; + community?: { + subtype?: string; + categoryTags?: string[]; + }; +}; + +export type SanitizedCommunitySubmissionProfileInput = + | { + profileType: "person"; + displayName: string; + sortName: string; + aliases: string[]; + tags: string[]; + person: { + roleTags: string[]; + }; + } + | { + profileType: "community"; + displayName: string; + sortName: string; + aliases: string[]; + tags: string[]; + community: { + subtype?: string; + categoryTags: string[]; + }; + }; + +export function normalizeProfileInlineText(input: string): string { + return input.trim().replace(/\s+/g, " "); +} + +export function createProfileSortName(displayName: string): string { + const asciiSortName = normalizeProfileInlineText( + displayName + .normalize("NFKD") + .replace(/[\u0300-\u036f]/g, "") + .toLowerCase() + .replace(/[^a-z0-9]+/g, " "), + ); + + return asciiSortName || normalizeProfileInlineText(displayName).toLowerCase(); +} + +function requireBoundedText( + input: string, + fieldName: string, + minLength: number, + maxLength: number, +): string { + const value = normalizeProfileInlineText(input); + + if (value.length < minLength) { + throw new Error(`${fieldName} must be at least ${minLength} characters.`); + } + + if (value.length > maxLength) { + throw new Error(`${fieldName} must be ${maxLength} characters or fewer.`); + } + + return value; +} + +function optionalBoundedText( + input: string | undefined, + fieldName: string, + maxLength: number, +): string | undefined { + if (input === undefined) { + return undefined; + } + + const value = normalizeProfileInlineText(input); + + if (value.length === 0) { + return undefined; + } + + if (value.length > maxLength) { + throw new Error(`${fieldName} must be ${maxLength} characters or fewer.`); + } + + return value; +} + +export function sanitizeProfileTextList( + input: string[] | undefined, + fieldName: string, + options: { maxItems: number; maxLength: number }, +): string[] { + const values: string[] = []; + const seen = new Set(); + + for (const item of input ?? []) { + const value = normalizeProfileInlineText(item); + + if (value.length === 0) { + continue; + } + + if (value.length > options.maxLength) { + throw new Error(`${fieldName} entries must be ${options.maxLength} characters or fewer.`); + } + + const key = value.toLowerCase(); + if (seen.has(key)) { + continue; + } + + if (values.length >= options.maxItems) { + throw new Error(`${fieldName} can include at most ${options.maxItems} entries.`); + } + + seen.add(key); + values.push(value); + } + + return values; +} + +function hasPersonSubmissionFields(input: CommunitySubmissionProfileInput["person"]): boolean { + return (input?.roleTags ?? []).some((value) => normalizeProfileInlineText(value).length > 0); +} + +function hasCommunitySubmissionFields( + input: CommunitySubmissionProfileInput["community"], +): boolean { + return ( + optionalBoundedText(input?.subtype, "Community subtype", PROFILE_SUBTYPE_MAX_LENGTH) !== undefined || + (input?.categoryTags ?? []).some((value) => normalizeProfileInlineText(value).length > 0) + ); +} + +export function sanitizeCommunitySubmissionProfileInput( + input: CommunitySubmissionProfileInput, +): SanitizedCommunitySubmissionProfileInput { + const displayName = requireBoundedText( + input.displayName, + "Display name", + PROFILE_DISPLAY_NAME_MIN_LENGTH, + PROFILE_DISPLAY_NAME_MAX_LENGTH, + ); + + const shared = { + displayName, + sortName: createProfileSortName(displayName), + aliases: sanitizeProfileTextList(input.aliases, "Aliases", { + maxItems: PROFILE_ALIAS_MAX_COUNT, + maxLength: PROFILE_ALIAS_MAX_LENGTH, + }), + tags: sanitizeProfileTextList(input.tags, "Tags", { + maxItems: PROFILE_TAG_MAX_COUNT, + maxLength: PROFILE_TAG_MAX_LENGTH, + }), + }; + + if (input.profileType === "person") { + if (hasCommunitySubmissionFields(input.community)) { + throw new Error("Community fields cannot be submitted for a person profile."); + } + + return { + ...shared, + profileType: "person", + person: { + roleTags: sanitizeProfileTextList(input.person?.roleTags, "Role tags", { + maxItems: PROFILE_TAG_MAX_COUNT, + maxLength: PROFILE_TAG_MAX_LENGTH, + }), + }, + }; + } + + if (hasPersonSubmissionFields(input.person)) { + throw new Error("Person fields cannot be submitted for a community profile."); + } + + const subtype = optionalBoundedText( + input.community?.subtype, + "Community subtype", + PROFILE_SUBTYPE_MAX_LENGTH, + ); + + return { + ...shared, + profileType: "community", + community: { + ...(subtype ? { subtype } : {}), + categoryTags: sanitizeProfileTextList(input.community?.categoryTags, "Category tags", { + maxItems: PROFILE_TAG_MAX_COUNT, + maxLength: PROFILE_TAG_MAX_LENGTH, + }), + }, + }; +} diff --git a/convex/profiles.ts b/convex/profiles.ts new file mode 100644 index 0000000..527f260 --- /dev/null +++ b/convex/profiles.ts @@ -0,0 +1,134 @@ +import { v } from "convex/values"; + +import { mutation, query } from "./_generated/server"; +import { canReadProfile } from "./_profilePermissions"; +import { toPublicProfile } from "./_profilePublic"; +import { findAvailableProfileSlug, getProfileBySlug, validateProfileSlug } from "./_profileSlugs"; +import { sanitizeCommunitySubmissionProfileInput } from "./_profileSubmissions"; + +const profileType = v.union(v.literal("person"), v.literal("community")); + +function optionalIdentityDisplayName(name: string | undefined): string | undefined { + const trimmed = name?.trim(); + + if (!trimmed) { + return undefined; + } + + return trimmed.slice(0, 120); +} + +export const getPublicBySlug = query({ + args: { + slug: v.string(), + profileType: v.optional(profileType), + }, + handler: async (ctx, args) => { + const validation = validateProfileSlug(args.slug); + + if (!validation.ok) { + return null; + } + + const profile = await getProfileBySlug(ctx.db, validation.slug); + + if (profile === null) { + return null; + } + + if (args.profileType !== undefined && profile.profileType !== args.profileType) { + return null; + } + + if (!canReadProfile("public", profile)) { + return null; + } + + return toPublicProfile(profile); + }, +}); + +export const submitCommunityProfile = mutation({ + args: { + profileType, + displayName: v.string(), + aliases: v.optional(v.array(v.string())), + tags: v.optional(v.array(v.string())), + person: v.optional( + v.object({ + roleTags: v.optional(v.array(v.string())), + }), + ), + community: v.optional( + v.object({ + subtype: v.optional(v.string()), + categoryTags: v.optional(v.array(v.string())), + }), + ), + }, + handler: async (ctx, args) => { + const identity = await ctx.auth.getUserIdentity(); + + if (identity === null) { + throw new Error("Profile submissions require a signed-in user."); + } + + const input = sanitizeCommunitySubmissionProfileInput(args); + const now = Date.now(); + const slug = await findAvailableProfileSlug(ctx.db, input.displayName); + const displayName = optionalIdentityDisplayName(identity.name); + const sourceAttribution = { + submittedAt: now, + submitter: { + tokenIdentifier: identity.tokenIdentifier, + issuer: identity.issuer, + subject: identity.subject, + ...(displayName !== undefined ? { displayName } : {}), + }, + }; + + const sharedFields = { + slug, + displayName: input.displayName, + sortName: input.sortName, + aliases: input.aliases, + tags: input.tags, + claimState: "unclaimed" as const, + publicationState: "published" as const, + creationSource: "community" as const, + publishedAt: now, + updatedAt: now, + sourceAttribution, + }; + + if (input.profileType === "person") { + const profileId = await ctx.db.insert("profiles", { + ...sharedFields, + profileType: "person", + person: { + roleTags: input.person.roleTags, + }, + }); + + return { + profileId, + profileType: "person" as const, + slug, + profilePath: `/p/${slug}`, + }; + } + + const profileId = await ctx.db.insert("profiles", { + ...sharedFields, + profileType: "community", + community: input.community, + }); + + return { + profileId, + profileType: "community" as const, + slug, + profilePath: `/c/${slug}`, + }; + }, +}); diff --git a/convex/schema.ts b/convex/schema.ts index 01d9192..505b5c2 100644 --- a/convex/schema.ts +++ b/convex/schema.ts @@ -28,11 +28,25 @@ const sharedProfileFields = { tags: v.array(v.string()), headline: v.optional(v.string()), bio: v.optional(v.string()), + about: v.optional(v.string()), + avatarImageUrl: v.optional(v.string()), + bannerImageUrl: v.optional(v.string()), region: v.optional(v.string()), timezone: v.optional(v.string()), claimState, publicationState, creationSource, + sourceAttribution: v.optional( + v.object({ + submittedAt: v.number(), + submitter: v.object({ + tokenIdentifier: v.string(), + issuer: v.string(), + subject: v.string(), + displayName: v.optional(v.string()), + }), + }), + ), // Mutations must set claimedAt/publishedAt with state transitions // and patch updatedAt on every profile write. claimedAt: v.optional(v.number()), diff --git a/docs/backend/community-submissions.md b/docs/backend/community-submissions.md new file mode 100644 index 0000000..355eee2 --- /dev/null +++ b/docs/backend/community-submissions.md @@ -0,0 +1,58 @@ +# 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 + +## Public Routes + +- `/submit`: first community-facing submission form +- `/p/`: public person profile page +- `/c/`: public community profile page + +Both public profile routes read through `profiles:getPublicBySlug`, require `publicationState: "published"`, verify the requested route type matches the stored `profileType`, and return a public projection that omits source-attribution identifiers. + +## Allowed Submission Fields + +Shared fields: + +- `displayName` +- `aliases` +- `tags` + +Person-specific fields: + +- `person.roleTags` + +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. Future owner, concierge, moderation, import, or claim flows can populate them with stricter validation and audit behavior. + +## Follow-On Boundaries + +- `#25` should make community-submitted and unverified labels consistent across cards and pages +- `#26` should expand attribution into a rollback-capable moderation trail +- `#29` should add pre-claim suppression workflow state +- `#31` and `#33` should add search and browse surfaces over published profiles diff --git a/docs/backend/profile-access-and-claims.md b/docs/backend/profile-access-and-claims.md index 6a14f88..f512ce5 100644 --- a/docs/backend/profile-access-and-claims.md +++ b/docs/backend/profile-access-and-claims.md @@ -4,7 +4,7 @@ This doc captures the permission and claim-state baseline for `#12` and `#13`. -It intentionally does not add auth, account records, public write mutations, OAuth claim flows, VRChat proof-code verification, moderation UI, role delegation, or ownership transfer. +It intentionally does not add account records, OAuth claim flows, VRChat proof-code verification, moderation UI, role delegation, or ownership transfer. ## Read Baseline @@ -17,7 +17,7 @@ It intentionally does not add auth, account records, public write mutations, OAu Ordinary public users cannot edit profiles. -Community submitters may populate only a narrow safe field set for unclaimed profiles once submission flows exist: +Community submitters may populate only a narrow safe field set for unclaimed profiles through `profiles:submitCommunityProfile`: - `displayName` - `aliases` @@ -27,6 +27,8 @@ Community submitters may populate only a narrow safe field set for unclaimed pro Community submitters must not set fields that imply verified authority, private contact details, billing state, ownership, custom slugs, or sensitive visibility choices. Profile creation can still generate an initial slug from submitted display text. +The current public mutation requires a Convex authenticated identity and stores source attribution, but it does not introduce a durable account table or ownership link. Freeform bios, about text, avatar URLs, banner URLs, private contact details, and custom slugs are intentionally outside the ordinary community-submission field set. + Claimed owners may edit normal profile fields after a claim attaches authority to the existing profile record. This baseline assumes claimed owners can edit identity, presentation, slug, tags, and type-specific profile fields, subject to future field-level visibility and abuse controls. Moderators may override profile fields later for safety, corrections, and abuse handling. The moderation UI and detailed audit model are deferred. diff --git a/docs/backend/profile-schema.md b/docs/backend/profile-schema.md index 88efa2a..f50f72b 100644 --- a/docs/backend/profile-schema.md +++ b/docs/backend/profile-schema.md @@ -2,9 +2,9 @@ ## Status Note -This doc captures the durable profile schema foundation started in `#9` and extended through `#10`, `#11`, `#12`, and `#13`. +This doc captures the durable profile schema foundation started in `#9` and extended through `#10`, `#11`, `#12`, `#13`, `#22`, and `#23`. -The schema is intentionally narrow. It establishes one shared `profiles` table for people and communities without introducing auth/account links, public write mutations, full claim flows, normalized link tables, asset tables, or public profile pages. +The schema is intentionally narrow. It establishes one shared `profiles` table for people and communities without introducing account tables, full claim flows, normalized link tables, asset tables, or advanced moderation workflows. ## Locked Decisions @@ -15,7 +15,9 @@ The schema is intentionally narrow. It establishes one shared `profiles` table f - community-submitted unclaimed records are represented by `creationSource: "community"` plus `claimState: "unclaimed"` - account/user references are deferred until auth and claim issues define the account model - public write mutations are deferred until auth and permissions are wired +- the community submission mutation requires a Convex authenticated identity before writing - normalized alias, link, asset, and rich authored block tables are deferred to later profile presentation issues +- avatar and banner fields are URL placeholders for later controlled owner or concierge inputs, not ordinary community-submitted fields ## `profiles` Table @@ -32,6 +34,9 @@ Core presentation fields: - `headline`: optional short label or one-line positioning statement - `bio`: optional short public bio +- `about`: optional longer owner-authored about section +- `avatarImageUrl`: optional display/avatar image URL for controlled future owner or concierge inputs +- `bannerImageUrl`: optional banner image URL for controlled future owner or concierge inputs - `region`: optional location or scene region text - `timezone`: optional time zone text @@ -43,6 +48,7 @@ State fields: - `claimedAt`: optional claim timestamp, present only after claim authority is established - `publishedAt`: optional publication timestamp, present once a profile has been published - `updatedAt`: application-maintained update timestamp that every profile mutation must refresh +- `sourceAttribution`: optional inline source metadata for community-submitted records Type-specific fields: @@ -76,6 +82,8 @@ Convex schema validation cannot enforce conditional timestamp invariants, so pro - set `publishedAt` when `publicationState` becomes `"published"` - patch `updatedAt` on every profile write +The first write path is `profiles:submitCommunityProfile`. It requires `ctx.auth.getUserIdentity()` to return a signed-in identity, generates the slug server-side, publishes the profile as `creationSource: "community"` plus `claimState: "unclaimed"`, and stores narrow source attribution for later moderation and display decisions. + ## Initial Indexes - `by_slug`: canonical profile lookup and mutation-enforced slug uniqueness @@ -91,7 +99,7 @@ Convex schema validation cannot enforce conditional timestamp invariants, so pro - `#11` adds type-aware person/community fields and documents shared vs type-specific data - `#12` defines read/write permission behavior - `#13` defines claim-state transitions and trust labeling behavior -- `#22` adds presentation assets and owner-authored content sections -- `#23` adds community submission flows and source attribution details +- `#22` added presentation fields and public-page rendering for avatar/banner, short bio, and longer about content +- `#23` added the authenticated community submission mutation and source attribution details - `#27` adds field-level visibility controls - `#31` adds public search behavior and any search-specific indexing diff --git a/docs/backend/profile-slugs.md b/docs/backend/profile-slugs.md index a35fa98..0d1ea5a 100644 --- a/docs/backend/profile-slugs.md +++ b/docs/backend/profile-slugs.md @@ -38,7 +38,7 @@ Initial slug generation starts from a display name or owner-provided text: Convex does not enforce unique indexes at the schema layer. Profile slug uniqueness is enforced by mutations using the `by_slug` index before insert or update. -No public profile write mutations exist yet. Future mutations that create or update profiles must: +`profiles:submitCommunityProfile` now creates initial public slugs for authenticated community submissions. Mutations that create or update profiles must: - normalize and validate the candidate slug - reject invalid or reserved slugs diff --git a/docs/planning/architecture.md b/docs/planning/architecture.md index de27719..0f8244e 100644 --- a/docs/planning/architecture.md +++ b/docs/planning/architecture.md @@ -94,7 +94,10 @@ Implementation status: - `#11` adds discriminated person/community type-specific fields - `#12` documents the profile read/write permission baseline - `#13` documents claim-state transitions and trust-label helpers -- account/user links, assets, public profile pages, and search-specific indexes are deferred to follow-on issues +- `#22` adds first owner-authored presentation fields for public pages +- `#23` adds the authenticated community submission mutation and source attribution baseline +- `#19` and `#21` add first public person and community profile routes +- account/user links, asset upload tables, moderation trails, and search-specific indexes are deferred to follow-on issues Suggested fields: diff --git a/tests/backend/profile-foundation.test.ts b/tests/backend/profile-foundation.test.ts index 74fd683..d3fcb7d 100644 --- a/tests/backend/profile-foundation.test.ts +++ b/tests/backend/profile-foundation.test.ts @@ -1,6 +1,7 @@ import assert from "node:assert/strict"; import { describe, it } from "node:test"; +import type { Doc } from "../../convex/_generated/dataModel"; import { createProfileSlugBase, createProfileSlugCandidate, @@ -10,6 +11,12 @@ import { validateProfileSlug, } from "../../convex/_profileSlugs"; import { canEditProfileField, canReadProfile } from "../../convex/_profilePermissions"; +import { toPublicProfile } from "../../convex/_profilePublic"; +import { + createProfileSortName, + sanitizeCommunitySubmissionProfileInput, + sanitizeProfileTextList, +} from "../../convex/_profileSubmissions"; import { canTransitionProfileClaimState, getProfileTrustLabel, @@ -134,3 +141,110 @@ describe("profile claim-state helpers", () => { assert.throws(() => requireProfileClaimStateTransition("claimed_verified", "unclaimed")); }); }); + +describe("profile submission helpers", () => { + it("normalizes sort names and community submission lists", () => { + assert.equal(createProfileSortName(" DJ Céline "), "dj celine"); + assert.deepEqual( + sanitizeProfileTextList([" House ", "house", "Trance", ""], "Tags", { + maxItems: 4, + maxLength: 16, + }), + ["House", "Trance"], + ); + }); + + it("sanitizes person submissions to the narrow public field set", () => { + assert.deepEqual( + sanitizeCommunitySubmissionProfileInput({ + profileType: "person", + displayName: " DJ Celine ", + aliases: ["Celine", "celine"], + tags: ["House"], + person: { + roleTags: ["DJ", "VJ"], + }, + }), + { + profileType: "person", + displayName: "DJ Celine", + sortName: "dj celine", + aliases: ["Celine"], + tags: ["House"], + person: { + roleTags: ["DJ", "VJ"], + }, + }, + ); + }); + + it("sanitizes community submissions and rejects mismatched type-specific fields", () => { + assert.deepEqual( + sanitizeCommunitySubmissionProfileInput({ + profileType: "community", + displayName: "Nocturne VR", + tags: ["Events"], + community: { + subtype: " Club ", + categoryTags: ["Music", "music"], + }, + }), + { + profileType: "community", + displayName: "Nocturne VR", + sortName: "nocturne vr", + aliases: [], + tags: ["Events"], + community: { + subtype: "Club", + categoryTags: ["Music"], + }, + }, + ); + + assert.throws(() => + sanitizeCommunitySubmissionProfileInput({ + profileType: "person", + displayName: "DJ Celine", + community: { + subtype: "Club", + }, + }), + ); + }); +}); + +describe("public profile projection", () => { + it("omits source attribution identifiers from public profile results", () => { + const profile = { + profileType: "person", + slug: "dj-celine", + displayName: "DJ Celine", + sortName: "dj celine", + aliases: [], + tags: ["House"], + claimState: "unclaimed", + publicationState: "published", + creationSource: "community", + publishedAt: 1, + updatedAt: 1, + sourceAttribution: { + submittedAt: 1, + submitter: { + tokenIdentifier: "issuer|subject", + issuer: "issuer", + subject: "subject", + displayName: "Submitter", + }, + }, + person: { + roleTags: ["DJ"], + }, + } as Doc<"profiles">; + + const publicProfile = toPublicProfile(profile); + + assert.equal("sourceAttribution" in publicProfile, false); + assert.equal(publicProfile.trustLabel, "community_submitted"); + }); +}); From 2de4b7ce39abfa6d47202febf708d84f0381ac1b Mon Sep 17 00:00:00 2001 From: BASICBIT Date: Mon, 18 May 2026 07:46:50 -0400 Subject: [PATCH 2/3] fix: address profile submission review feedback --- .../app/_components/profile-public-page.tsx | 4 +- .../app/submit/profile-submission-form.tsx | 51 +++++++++++++++++-- convex/_profilePublic.ts | 1 - convex/_profileSubmissions.ts | 6 ++- docs/backend/community-submissions.md | 2 + tests/backend/profile-foundation.test.ts | 24 ++++++--- 6 files changed, 72 insertions(+), 16 deletions(-) diff --git a/apps/web/src/app/_components/profile-public-page.tsx b/apps/web/src/app/_components/profile-public-page.tsx index 16d7233..867e1c5 100644 --- a/apps/web/src/app/_components/profile-public-page.tsx +++ b/apps/web/src/app/_components/profile-public-page.tsx @@ -19,7 +19,6 @@ type PublicProfileBase = { bannerImageUrl?: string; region?: string; timezone?: string; - creationSource: string; trustLabel: ProfileTrustLabel; }; @@ -88,7 +87,7 @@ function safeImageBackground(imageUrl: string | undefined, overlay = false) { try { const url = new URL(imageUrl); - if (url.protocol !== "https:" && url.protocol !== "http:") { + if (url.protocol !== "https:") { return undefined; } @@ -197,6 +196,7 @@ export function ProfilePublicPage({ profile }: { profile: PublicProfile }) {
{!avatarStyle ? initialsFor(profile.displayName) : null} diff --git a/apps/web/src/app/submit/profile-submission-form.tsx b/apps/web/src/app/submit/profile-submission-form.tsx index 056d048..52555c3 100644 --- a/apps/web/src/app/submit/profile-submission-form.tsx +++ b/apps/web/src/app/submit/profile-submission-form.tsx @@ -6,6 +6,7 @@ import { useMutation } from "convex/react"; import { api } from "@convex-generated-api"; const convexUrl = process.env.NEXT_PUBLIC_CONVEX_URL; +const submissionsAuthReady = process.env.NEXT_PUBLIC_VRDEX_SUBMISSIONS_AUTH_READY === "true"; type ProfileType = "person" | "community"; @@ -21,6 +22,31 @@ type SubmissionStatus = } | { kind: "error"; message: string }; +const userSafeErrorPatterns = [ + /Profile submissions require a signed-in user\./, + /Display name must be at least \d+ characters\./, + /Display name must be \d+ characters or fewer\./, + /(?:Aliases|Tags|Role tags|Category tags) items must be \d+ characters or fewer\./, + /(?:Aliases|Tags|Role tags|Category tags) can include at most \d+ entries\./, + /Community subtype must be \d+ characters or fewer\./, + /Community fields cannot be submitted for a person profile\./, + /Person fields cannot be submitted for a community profile\./, +]; + +function submissionErrorMessage(error: unknown): string { + const message = error instanceof Error ? error.message : String(error); + + for (const pattern of userSafeErrorPatterns) { + const match = message.match(pattern); + + if (match) { + return match[0]; + } + } + + return "Profile submission failed. Please try again once the backend is reachable."; +} + function splitList(value: FormDataEntryValue | null): string[] { if (typeof value !== "string") { return []; @@ -52,6 +78,22 @@ function DisabledSubmissionPanel() { ); } +function SignInRequiredSubmissionPanel() { + return ( +
+

+ Submission flow +

+

+ Sign-in required +

+

+ The backend submission mutation is ready, but the public form stays locked until Convex auth is wired into the web app. This avoids exposing a form that can only fail for anonymous visitors. +

+
+ ); +} + function ConnectedSubmissionForm() { const submitProfile = useMutation(api.profiles.submitCommunityProfile); const [profileType, setProfileType] = useState("person"); @@ -97,8 +139,7 @@ function ConnectedSubmissionForm() { setProfileType("person"); startTransition(() => setStatus({ kind: "success", result })); } catch (error) { - const message = error instanceof Error ? error.message : String(error); - startTransition(() => setStatus({ kind: "error", message })); + startTransition(() => setStatus({ kind: "error", message: submissionErrorMessage(error) })); } } @@ -200,7 +241,7 @@ function ConnectedSubmissionForm() { className="inline-flex items-center justify-center rounded-full border border-border bg-surface-strong px-5 py-3 text-sm font-medium" href={status.result.profilePath} > - View /{status.result.slug} + View {status.result.profilePath} ) : null}
@@ -219,5 +260,9 @@ export function ProfileSubmissionForm() { return ; } + if (!submissionsAuthReady) { + return ; + } + return ; } diff --git a/convex/_profilePublic.ts b/convex/_profilePublic.ts index d68c53f..5a8a035 100644 --- a/convex/_profilePublic.ts +++ b/convex/_profilePublic.ts @@ -12,7 +12,6 @@ export function toPublicProfile(profile: Doc<"profiles">) { displayName: profile.displayName, aliases: profile.aliases, tags: profile.tags, - creationSource: profile.creationSource, trustLabel: getProfileTrustLabel(profile.claimState, profile.creationSource), ...optionalField("headline", profile.headline), ...optionalField("bio", profile.bio), diff --git a/convex/_profileSubmissions.ts b/convex/_profileSubmissions.ts index 10e8fba..64c49c7 100644 --- a/convex/_profileSubmissions.ts +++ b/convex/_profileSubmissions.ts @@ -118,7 +118,7 @@ export function sanitizeProfileTextList( } if (value.length > options.maxLength) { - throw new Error(`${fieldName} entries must be ${options.maxLength} characters or fewer.`); + throw new Error(`${fieldName} items must be ${options.maxLength} characters or fewer.`); } const key = value.toLowerCase(); @@ -144,8 +144,10 @@ function hasPersonSubmissionFields(input: CommunitySubmissionProfileInput["perso function hasCommunitySubmissionFields( input: CommunitySubmissionProfileInput["community"], ): boolean { + const subtype = normalizeProfileInlineText(input?.subtype ?? ""); + return ( - optionalBoundedText(input?.subtype, "Community subtype", PROFILE_SUBTYPE_MAX_LENGTH) !== undefined || + subtype.length > 0 || (input?.categoryTags ?? []).some((value) => normalizeProfileInlineText(value).length > 0) ); } diff --git a/docs/backend/community-submissions.md b/docs/backend/community-submissions.md index 355eee2..4a3f1e9 100644 --- a/docs/backend/community-submissions.md +++ b/docs/backend/community-submissions.md @@ -19,6 +19,8 @@ This doc captures the first community-submitted profile flow for `#23`, plus the - `/p/`: public person profile page - `/c/`: public community profile page +The `/submit` UI currently shows a sign-in-required state until Convex auth is wired into the web app. The backend mutation is already auth-gated and can only write for callers with a Convex identity. + Both public profile routes read through `profiles:getPublicBySlug`, require `publicationState: "published"`, verify the requested route type matches the stored `profileType`, and return a public projection that omits source-attribution identifiers. ## Allowed Submission Fields diff --git a/tests/backend/profile-foundation.test.ts b/tests/backend/profile-foundation.test.ts index d3fcb7d..ce119ca 100644 --- a/tests/backend/profile-foundation.test.ts +++ b/tests/backend/profile-foundation.test.ts @@ -152,6 +152,11 @@ describe("profile submission helpers", () => { }), ["House", "Trance"], ); + + assert.throws( + () => sanitizeProfileTextList(["x".repeat(17)], "Tags", { maxItems: 4, maxLength: 16 }), + /Tags items must be 16 characters or fewer/, + ); }); it("sanitizes person submissions to the narrow public field set", () => { @@ -202,14 +207,16 @@ describe("profile submission helpers", () => { }, ); - assert.throws(() => - sanitizeCommunitySubmissionProfileInput({ - profileType: "person", - displayName: "DJ Celine", - community: { - subtype: "Club", - }, - }), + assert.throws( + () => + sanitizeCommunitySubmissionProfileInput({ + profileType: "person", + displayName: "DJ Celine", + community: { + subtype: "x".repeat(50), + }, + }), + /Community fields cannot be submitted for a person profile/, ); }); }); @@ -245,6 +252,7 @@ describe("public profile projection", () => { const publicProfile = toPublicProfile(profile); assert.equal("sourceAttribution" in publicProfile, false); + assert.equal("creationSource" in publicProfile, false); assert.equal(publicProfile.trustLabel, "community_submitted"); }); }); From 0a73a318b171ff02e769b961949ba00ec15d8356 Mon Sep 17 00:00:00 2001 From: BASICBIT Date: Mon, 18 May 2026 08:00:40 -0400 Subject: [PATCH 3/3] docs: clarify submission write exception --- docs/backend/profile-schema.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/backend/profile-schema.md b/docs/backend/profile-schema.md index f50f72b..71ff438 100644 --- a/docs/backend/profile-schema.md +++ b/docs/backend/profile-schema.md @@ -14,7 +14,7 @@ The schema is intentionally narrow. It establishes one shared `profiles` table f - claim state, publication state, and creation provenance are separate fields - community-submitted unclaimed records are represented by `creationSource: "community"` plus `claimState: "unclaimed"` - account/user references are deferred until auth and claim issues define the account model -- public write mutations are deferred until auth and permissions are wired +- most public write mutations are deferred until auth and permissions are wired; `profiles:submitCommunityProfile` is the current auth-gated exception - the community submission mutation requires a Convex authenticated identity before writing - normalized alias, link, asset, and rich authored block tables are deferred to later profile presentation issues - avatar and banner fields are URL placeholders for later controlled owner or concierge inputs, not ordinary community-submitted fields