diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 0000000..0261d7e --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,33 @@ +name: CodeQL + +on: + pull_request: + push: + branches: + - main + +permissions: + contents: read + security-events: write + +concurrency: + group: codeql-${{ github.ref }} + cancel-in-progress: true + +jobs: + analyze: + name: Analyze JavaScript and TypeScript + runs-on: ubuntu-latest + timeout-minutes: 15 + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Initialize CodeQL + uses: github/codeql-action/init@v3 + with: + languages: javascript-typescript + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v3 diff --git a/apps/web/e2e/public-routes.ts b/apps/web/e2e/public-routes.ts index 657165a..c65f70a 100644 --- a/apps/web/e2e/public-routes.ts +++ b/apps/web/e2e/public-routes.ts @@ -7,6 +7,7 @@ const screenshotDir = path.join(process.cwd(), "playwright-artifacts", "screensh export const visualProfilePaths = { personPath: "/p/playwright-dj-aurora", communityPath: "/c/playwright-afterglow-social", + worldPath: "/w/playwright-neon-harbor", } as const; export type CapturedRoute = { @@ -162,6 +163,9 @@ export async function captureRouteScreenshot(page: Page, testInfo: TestInfo, nam export async function expectHomePage(page: Page) { await expect(page.getByRole("heading", { name: /Profiles, communities/i })).toBeVisible(); + await expect(page.getByRole("heading", { name: "Worlds hosting events soon" })).toBeVisible(); + await expect(page.getByRole("heading", { name: "Neon Harbor", exact: true })).toBeVisible(); + await expect(page.getByText(/Afterglow Harbor Sessions/i)).toBeVisible(); } export async function expectSubmitPage(page: Page) { @@ -182,11 +186,26 @@ export async function expectDeploymentPage(page: Page) { export async function expectPersonProfilePage(page: Page) { await expect(page.getByRole("heading", { name: "DJ Aurora" })).toBeVisible(); await expect(page.getByText(/Community submitted/i)).toBeVisible(); + await expect(page.getByText(/Creator links/i)).toBeVisible(); + await expect(page.getByText("DJ Aurora Ko-fi", { exact: true })).toBeVisible(); + await expect(page.getByText(/World credits/i)).toBeVisible(); + await expect(page.getByText("Neon Harbor", { exact: true })).toBeVisible(); } export async function expectCommunityProfilePage(page: Page) { await expect(page.getByRole("heading", { name: "Afterglow Social" })).toBeVisible(); await expect(page.getByText("Club night", { exact: true }).first()).toBeVisible(); + await expect(page.getByText("Afterglow event archive", { exact: true })).toBeVisible(); + await expect(page.getByText("World Author", { exact: true })).toBeVisible(); +} + +export async function expectWorldProfilePage(page: Page) { + await expect(page.getByRole("heading", { name: "Neon Harbor", exact: true })).toBeVisible(); + await expect(page.getByText(/World profile/i)).toBeVisible(); + await expect(page.getByText(/Fixture owner-authored metadata/i)).toBeVisible(); + await expect(page.getByText(/Events at this world/i)).toBeVisible(); + await expect(page.getByRole("heading", { name: "Afterglow Harbor Sessions" })).toBeVisible(); + await expect(page.getByRole("heading", { name: "Neon Harbor Opening Night" })).toBeVisible(); } export const capturedRoutes: CapturedRoute[] = [ @@ -220,4 +239,9 @@ export const capturedRoutes: CapturedRoute[] = [ path: visualProfilePaths.communityPath, expectPage: expectCommunityProfilePage, }, + { + name: "world-profile", + path: visualProfilePaths.worldPath, + expectPage: expectWorldProfilePage, + }, ]; diff --git a/apps/web/src/app/_components/home-active-worlds.tsx b/apps/web/src/app/_components/home-active-worlds.tsx new file mode 100644 index 0000000..c646297 --- /dev/null +++ b/apps/web/src/app/_components/home-active-worlds.tsx @@ -0,0 +1,150 @@ +import Link from "next/link"; + +type EventSourceType = "manual" | "community" | "partner" | "import" | "ai_suggested"; + +export type PublicActiveWorld = { + slug: string; + displayName: string; + tags: string[]; + summary?: string; + heroImageUrl?: string; + upcomingEventCount: number; + activityLabel: "Hosting upcoming events"; + nextEvent: { + title: string; + startAt: number; + endAt?: number; + timezone?: string; + communityName?: string; + source: { + sourceType: EventSourceType; + label: string; + url?: string; + }; + }; +}; + +function formatEventDate(timestamp: number, timezone: string | undefined): string { + const baseOptions: Intl.DateTimeFormatOptions = { + month: "short", + day: "numeric", + hour: "numeric", + minute: "2-digit", + }; + + try { + return new Intl.DateTimeFormat("en", { + ...baseOptions, + ...(timezone ? { timeZone: timezone } : {}), + }).format(new Date(timestamp)); + } catch { + return new Intl.DateTimeFormat("en", baseOptions).format(new Date(timestamp)); + } +} + +function safeImageBackground(imageUrl: string | undefined) { + if (!imageUrl) { + return undefined; + } + + try { + const url = new URL(imageUrl); + + if (url.protocol !== "https:") { + return undefined; + } + + return { + backgroundImage: `linear-gradient(135deg, rgba(8, 18, 32, 0.72), rgba(8, 145, 178, 0.2)), url(${JSON.stringify(url.href)})`, + }; + } catch { + return undefined; + } +} + +function ActiveWorldCard({ world }: { world: PublicActiveWorld }) { + const heroStyle = safeImageBackground(world.heroImageUrl); + const tags = world.tags.slice(0, 3); + + return ( + +
+ + {world.activityLabel} + + + {world.upcomingEventCount} upcoming + +
+ +
+

{world.displayName}

+

+ {world.summary ?? "A VRDex world with confirmed upcoming event context."} +

+
+

Next event

+

{world.nextEvent.title}

+

+ {formatEventDate(world.nextEvent.startAt, world.nextEvent.timezone)} + {world.nextEvent.communityName ? ` by ${world.nextEvent.communityName}` : ""} +

+
+ {tags.length > 0 ? ( +
+ {tags.map((tag) => ( + + {tag} + + ))} +
+ ) : null} +
+ + ); +} + +export function HomeActiveWorldsSection({ + status, + worlds, +}: { + status: "live" | "missing-url" | "error"; + worlds: PublicActiveWorld[]; +}) { + return ( +
+
+
+

World discovery

+

+ Worlds hosting events soon +

+
+

+ Event-derived venue cards use confirmed VRDex event-world links. They are not live VRChat popularity, private presence, or scraped attendance. +

+
+ + {worlds.length > 0 ? ( +
+ {worlds.map((world) => ( + + ))} +
+ ) : ( +
+

No confirmed upcoming venues yet.

+

+ {status === "live" + ? "Published events will appear here after they are explicitly linked to world profiles." + : "Start the local backend to read active world data, or use fixture mode during visual review."} +

+
+ )} +
+ ); +} diff --git a/apps/web/src/app/_components/profile-public-page.tsx b/apps/web/src/app/_components/profile-public-page.tsx index 867e1c5..86df828 100644 --- a/apps/web/src/app/_components/profile-public-page.tsx +++ b/apps/web/src/app/_components/profile-public-page.tsx @@ -5,6 +5,25 @@ type ProfileTrustLabel = | "unclaimed" | "claimed_unverified" | "claimed_verified"; +type ProfileLinkType = + | "website" + | "gumroad" + | "jinxxy" + | "payhip" + | "woocommerce" + | "kofi" + | "patreon" + | "commissions" + | "generic_store" + | "other"; +type LinkSource = "owner_authored" | "reviewed" | "partner_provided"; +type WorldCreatorRole = + | "world_author" + | "builder" + | "venue_operator" + | "community_operator" + | "media_credit" + | "storefront_owner"; type PublicProfileBase = { profileType: "person" | "community"; @@ -20,6 +39,20 @@ type PublicProfileBase = { region?: string; timezone?: string; trustLabel: ProfileTrustLabel; + outboundLinks: Array<{ + type: ProfileLinkType; + label: string; + url: string; + source: LinkSource; + }>; + worldCredits: Array<{ + slug: string; + displayName: string; + roles: WorldCreatorRole[]; + tags: string[]; + summary?: string; + sourceLabel?: string; + }>; }; type PublicPersonProfile = PublicProfileBase & { @@ -103,6 +136,34 @@ function safeImageBackground(imageUrl: string | undefined, overlay = false) { } } +function safeHttpsUrl(url: string): string | null { + try { + const parsed = new URL(url); + return parsed.protocol === "https:" ? parsed.href : null; + } catch { + return null; + } +} + +function linkSourceLabel(source: LinkSource): string { + if (source === "owner_authored") { + return "Owner-authored"; + } + + if (source === "partner_provided") { + return "Partner-provided"; + } + + return "Reviewed"; +} + +function roleLabel(role: WorldCreatorRole): string { + return role + .split("_") + .map((word) => `${word[0]?.toUpperCase() ?? ""}${word.slice(1)}`) + .join(" "); +} + function PillList({ items }: { items: string[] }) { if (items.length === 0) { return

No public entries yet.

; @@ -279,6 +340,103 @@ export function ProfilePublicPage({ profile }: { profile: PublicProfile }) { +
+
+
+

+ Creator links +

+

+ Stores, commissions, and external work +

+
+

+ Owner-authored or reviewed links only. VRDex does not verify sales, fulfillment, or creator endorsement. +

+
+
+ {profile.outboundLinks.length === 0 ? ( +

No public creator/store links yet.

+ ) : ( + profile.outboundLinks.map((link) => { + const href = safeHttpsUrl(link.url); + + if (!href) { + return null; + } + + return ( + + {link.label} + + {linkSourceLabel(link.source)} external link + + + ); + }) + )} +
+
+ +
+
+
+

+ World credits +

+

+ Worlds linked to this profile +

+
+

+ Credits come from published world profiles with explicit person/community attribution. +

+
+
+ {profile.worldCredits.length === 0 ? ( +

No public world credits yet.

+ ) : ( + profile.worldCredits.map((world) => ( + + + {world.displayName} + + + {world.roles.map(roleLabel).join(", ")} + + {world.summary ? ( + + {world.summary} + + ) : null} + {world.tags.length > 0 ? ( + + {world.tags.slice(0, 3).map((tag) => ( + + {tag} + + ))} + + ) : null} + + )) + )} +
+
+

diff --git a/apps/web/src/app/_components/world-public-page.tsx b/apps/web/src/app/_components/world-public-page.tsx new file mode 100644 index 0000000..14a53af --- /dev/null +++ b/apps/web/src/app/_components/world-public-page.tsx @@ -0,0 +1,563 @@ +import Link from "next/link"; + +type WorldVisibilityStatus = "unknown" | "private" | "community_labs" | "public"; +type PlatformCompatibility = "pc" | "android" | "ios"; +type WorldCreatorRole = + | "world_author" + | "builder" + | "venue_operator" + | "community_operator" + | "media_credit" + | "storefront_owner"; +type WorldLinkType = + | "vrchat_world" + | "website" + | "gumroad" + | "jinxxy" + | "payhip" + | "woocommerce" + | "kofi" + | "patreon" + | "commissions" + | "generic_store" + | "other"; +type WorldLinkSource = "owner_authored" | "reviewed" | "partner_provided"; +type EventSourceType = "manual" | "community" | "partner" | "import" | "ai_suggested"; + +type PublicWorldEventPreview = { + title: string; + startAt: number; + endAt?: number; + timezone?: string; + communityName?: string; + summary?: string; + source: { + sourceType: EventSourceType; + label: string; + url?: string; + }; + worldAssociation: { + sourceType: EventSourceType; + confirmationState: "confirmed"; + confirmedAt?: number; + }; +}; + +export type PublicWorld = { + slug: string; + displayName: string; + tags: string[]; + summary?: string; + description?: string; + vrchatWorldId?: string; + canonicalVrchatWorldUrl?: string; + sourceUrl?: string; + visibilityStatus: WorldVisibilityStatus; + platformCompatibility: PlatformCompatibility[]; + heroImageUrl?: string; + media: Array<{ + kind: "image" | "video" | "link"; + url: string; + label?: string; + credit?: string; + }>; + creatorAttributions: Array<{ + role: WorldCreatorRole; + displayName: string; + profileSlug?: string; + profileType?: "person" | "community"; + sourceLabel?: string; + }>; + outboundLinks: Array<{ + type: WorldLinkType; + label: string; + url: string; + source: WorldLinkSource; + }>; + source?: { + sourceType: "owner" | "community" | "partner" | "moderator" | "import"; + label: string; + url?: string; + confirmedAt?: number; + }; + eventContext: { + upcoming: PublicWorldEventPreview[]; + recent: PublicWorldEventPreview[]; + }; +}; + +function safeImageBackground(imageUrl: string | undefined, overlay = false) { + if (!imageUrl) { + return undefined; + } + + try { + const url = new URL(imageUrl); + + if (url.protocol !== "https:") { + return undefined; + } + + const image = `url(${JSON.stringify(url.href)})`; + + return { + backgroundImage: overlay + ? `linear-gradient(135deg, rgba(11, 18, 32, 0.72), rgba(18, 95, 118, 0.22)), ${image}` + : image, + }; + } catch { + return undefined; + } +} + +function safeHttpsUrl(url: string): string | null { + try { + const parsed = new URL(url); + return parsed.protocol === "https:" ? parsed.href : null; + } catch { + return null; + } +} + +function visibilityLabel(status: WorldVisibilityStatus): string { + if (status === "community_labs") { + return "Community Labs"; + } + + if (status === "public") { + return "Public"; + } + + if (status === "private") { + return "Private"; + } + + return "Unknown"; +} + +function platformLabel(platform: PlatformCompatibility): string { + if (platform === "pc") { + return "PC"; + } + + if (platform === "android") { + return "Android / Quest"; + } + + return "iOS"; +} + +function roleLabel(role: WorldCreatorRole): string { + return role + .split("_") + .map((word) => `${word[0]?.toUpperCase() ?? ""}${word.slice(1)}`) + .join(" "); +} + +function linkSourceLabel(source: WorldLinkSource): string { + if (source === "owner_authored") { + return "Owner-authored"; + } + + if (source === "partner_provided") { + return "Partner-provided"; + } + + return "Reviewed"; +} + +function eventSourceLabel(source: EventSourceType): string { + if (source === "ai_suggested") { + return "AI-suggested"; + } + + return source + .split("_") + .map((word) => `${word[0]?.toUpperCase() ?? ""}${word.slice(1)}`) + .join(" "); +} + +function formatEventDate(timestamp: number, timezone: string | undefined): string { + const baseOptions: Intl.DateTimeFormatOptions = { + month: "short", + day: "numeric", + hour: "numeric", + minute: "2-digit", + }; + + try { + return new Intl.DateTimeFormat("en", { + ...baseOptions, + ...(timezone ? { timeZone: timezone } : {}), + }).format(new Date(timestamp)); + } catch { + return new Intl.DateTimeFormat("en", baseOptions).format(new Date(timestamp)); + } +} + +function initialsFor(name: string): string { + const initials = name + .split(/\s+/) + .filter(Boolean) + .slice(0, 2) + .map((part) => part[0]?.toUpperCase()) + .join(""); + + return initials || "W"; +} + +function PillList({ items }: { items: string[] }) { + if (items.length === 0) { + return

No public entries yet.

; + } + + return ( +
+ {items.map((item) => ( + + {item} + + ))} +
+ ); +} + +function EventList({ + emptyLabel, + events, +}: { + emptyLabel: string; + events: PublicWorldEventPreview[]; +}) { + if (events.length === 0) { + return

{emptyLabel}

; + } + + return ( +
+ {events.map((event) => { + const sourceUrl = event.source.url ? safeHttpsUrl(event.source.url) : null; + + return ( +
+
+ + + Confirmed venue +
+

{event.title}

+ {event.communityName ?

Hosted by {event.communityName}

: null} + {event.summary ?

{event.summary}

: null} +
+ + {eventSourceLabel(event.worldAssociation.sourceType)} association + + {sourceUrl ? ( + + {event.source.label} + + ) : ( + + {event.source.label} + + )} +
+
+ ); + })} +
+ ); +} + +export function WorldBackendNotice({ kind }: { kind: "missing-url" | "error" }) { + return ( +
+
+

World page

+

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

+

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

+ + Back to homepage + +
+
+ ); +} + +export function WorldPublicPage({ world }: { world: PublicWorld }) { + const heroStyle = safeImageBackground(world.heroImageUrl, true); + const canonicalWorldUrl = world.canonicalVrchatWorldUrl + ? safeHttpsUrl(world.canonicalVrchatWorldUrl) + : null; + const sourceUrl = world.sourceUrl ? safeHttpsUrl(world.sourceUrl) : null; + const sourceTitle = world.source?.label ?? "Unverified metadata"; + const sourceDescription = world.source + ? "World metadata is source-attributed. Creator credits and commerce links should remain reviewable." + : "World metadata is source-attributed when available."; + + return ( +
+
+ + +
+
+
+
+ + World profile + + + /w/{world.slug} + +
+ +
+
+
+ {initialsFor(world.displayName)} +
+ +
+

+ {world.displayName} +

+

+ {world.summary ?? "A public VRDex page for a VRChat world or venue."} +

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

About this world

+

Place, vibe, context

+
+ {world.description ? ( +

{world.description}

+ ) : ( +

+ Owner-authored world descriptions are supported by the world model and will appear here once populated. +

+ )} +
+
+ + +
+ +
+
+
+

+ Events at this world +

+

+ Confirmed event context +

+
+

+ These previews come from explicit event-world links, not live VRChat presence or scraped popularity. +

+
+
+
+

+ Upcoming and active +

+
+ +
+
+
+

Recent

+
+ +
+
+
+
+ +
+
+

Tags

+
+ +
+
+ +
+

Creator attribution

+
+ {world.creatorAttributions.length === 0 ? ( +

No public creator credits yet.

+ ) : ( + world.creatorAttributions.map((attribution) => { + const href = attribution.profileSlug && attribution.profileType + ? `/${attribution.profileType === "community" ? "c" : "p"}/${attribution.profileSlug}` + : null; + const content = ( + <> + {attribution.displayName} + {roleLabel(attribution.role)} + + ); + + return href ? ( + + {content} + + ) : ( +
+ {content} +
+ ); + }) + )} +
+
+
+ +
+
+

Primary links

+
+ {canonicalWorldUrl ? ( + + Open VRChat world + + ) : null} + {sourceUrl ? ( + + Source link + + ) : null} + {!canonicalWorldUrl && !sourceUrl ? ( +

No public world links yet.

+ ) : null} +
+
+ +
+

Creator links

+
+ {world.outboundLinks.length === 0 ? ( +

No public creator/store links yet.

+ ) : ( + world.outboundLinks.map((link) => { + const href = safeHttpsUrl(link.url); + if (!href) { + return null; + } + + return ( + + {link.label} + {linkSourceLabel(link.source)} + + ); + }) + )} +
+
+
+
+
+ ); +} diff --git a/apps/web/src/app/page.tsx b/apps/web/src/app/page.tsx index 76fbfd4..ca1d3ea 100644 --- a/apps/web/src/app/page.tsx +++ b/apps/web/src/app/page.tsx @@ -1,7 +1,13 @@ import Link from "next/link"; +import { HomeActiveWorldsSection } from "./_components/home-active-worlds"; import { BackendStatusCard } from "./backend-status-card"; +import { fetchHomeActiveWorlds } from "@/convex/server"; + +export const dynamic = "force-dynamic"; + +export default async function Home() { + const activeWorlds = await fetchHomeActiveWorlds(); -export default function Home() { return (
@@ -88,6 +94,8 @@ export default function Home() {
+ +

diff --git a/apps/web/src/app/w/[slug]/page.tsx b/apps/web/src/app/w/[slug]/page.tsx new file mode 100644 index 0000000..754bdd2 --- /dev/null +++ b/apps/web/src/app/w/[slug]/page.tsx @@ -0,0 +1,27 @@ +import { notFound } from "next/navigation"; + +import { WorldBackendNotice, WorldPublicPage } from "../../_components/world-public-page"; +import { fetchPublicWorldBySlug } from "@/convex/server"; + +export const dynamic = "force-dynamic"; + +type WorldPageProps = { + params: Promise<{ + slug: string; + }>; +}; + +export default async function WorldProfilePage({ params }: WorldPageProps) { + const { slug } = await params; + const result = await fetchPublicWorldBySlug(slug); + + if (result.kind === "missing-url" || result.kind === "error") { + return ; + } + + if (result.world === null) { + notFound(); + } + + return ; +} diff --git a/apps/web/src/convex/playwright-fixtures.ts b/apps/web/src/convex/playwright-fixtures.ts index 2ad095e..71000c5 100644 --- a/apps/web/src/convex/playwright-fixtures.ts +++ b/apps/web/src/convex/playwright-fixtures.ts @@ -1,7 +1,10 @@ import type { PublicProfile } from "@/app/_components/profile-public-page"; +import type { PublicActiveWorld } from "@/app/_components/home-active-worlds"; +import type { PublicWorld } from "@/app/_components/world-public-page"; const personSlug = "playwright-dj-aurora"; const communitySlug = "playwright-afterglow-social"; +const worldSlug = "playwright-neon-harbor"; const personProfile: PublicProfile = { profileType: "person", @@ -15,6 +18,30 @@ const personProfile: PublicProfile = { region: "EU", timezone: "UTC+1", trustLabel: "community_submitted", + outboundLinks: [ + { + type: "kofi", + label: "DJ Aurora Ko-fi", + url: "https://example.invalid/dj-aurora-kofi", + source: "owner_authored", + }, + { + type: "commissions", + label: "Booking inquiries", + url: "https://example.invalid/dj-aurora-bookings", + source: "reviewed", + }, + ], + worldCredits: [ + { + slug: worldSlug, + displayName: "Neon Harbor", + roles: ["media_credit"], + tags: ["Club world", "Cyberpunk", "Dance floor"], + summary: "A fixture VRChat venue page for world discovery visual review.", + sourceLabel: "Fixture attribution", + }, + ], person: { pronouns: "she/they", roleTags: ["DJ", "Producer", "Host"], @@ -33,12 +60,143 @@ const communityProfile: PublicProfile = { region: "Global", timezone: "UTC", trustLabel: "community_submitted", + outboundLinks: [ + { + type: "website", + label: "Afterglow event archive", + url: "https://example.invalid/afterglow-events", + source: "owner_authored", + }, + ], + worldCredits: [ + { + slug: worldSlug, + displayName: "Neon Harbor", + roles: ["world_author"], + tags: ["Club world", "Cyberpunk", "Dance floor"], + summary: "A fixture VRChat venue page for world discovery visual review.", + sourceLabel: "Fixture attribution", + }, + ], community: { subtype: "Club night", categoryTags: ["Music", "Dancing", "Social"], }, }; +const worldProfile: PublicWorld = { + slug: worldSlug, + displayName: "Neon Harbor", + tags: ["Club world", "Cyberpunk", "Dance floor"], + summary: "A fixture VRChat venue page for world discovery visual review.", + description: + "Neon Harbor is a deterministic fixture world used to exercise world metadata, creator attribution, public VRChat links, and owner-authored creator commerce links.", + vrchatWorldId: "wrld_00000000-0000-4000-8000-000000000001", + canonicalVrchatWorldUrl: + "https://vrchat.com/home/world/wrld_00000000-0000-4000-8000-000000000001", + sourceUrl: "https://vrchat.com/home/world/wrld_00000000-0000-4000-8000-000000000001", + visibilityStatus: "public", + platformCompatibility: ["pc", "android"], + creatorAttributions: [ + { + role: "world_author", + displayName: "Afterglow Social", + profileSlug: communitySlug, + profileType: "community", + sourceLabel: "Fixture attribution", + }, + { + role: "media_credit", + displayName: "DJ Aurora", + profileSlug: personSlug, + profileType: "person", + sourceLabel: "Fixture attribution", + }, + ], + media: [], + outboundLinks: [ + { + type: "gumroad", + label: "Example prefab pack", + url: "https://example.invalid/neon-harbor-prefab", + source: "owner_authored", + }, + { + type: "commissions", + label: "World commissions", + url: "https://example.invalid/world-commissions", + source: "reviewed", + }, + ], + source: { + sourceType: "owner", + label: "Fixture owner-authored metadata", + confirmedAt: Date.UTC(2025, 0, 1, 12, 0, 0), + }, + eventContext: { + upcoming: [ + { + title: "Afterglow Harbor Sessions", + startAt: Date.UTC(2026, 5, 14, 22, 0, 0), + endAt: Date.UTC(2026, 5, 15, 1, 0, 0), + timezone: "UTC", + communityName: "Afterglow Social", + summary: "A fixture venue night that keeps world-event context visible in screenshots.", + source: { + sourceType: "manual", + label: "Fixture event listing", + url: "https://example.invalid/events/afterglow-harbor-sessions", + }, + worldAssociation: { + sourceType: "manual", + confirmationState: "confirmed", + confirmedAt: Date.UTC(2026, 4, 1, 12, 0, 0), + }, + }, + ], + recent: [ + { + title: "Neon Harbor Opening Night", + startAt: Date.UTC(2026, 3, 18, 23, 0, 0), + timezone: "UTC", + communityName: "Afterglow Social", + summary: "A past fixture event used to exercise recent world activity presentation.", + source: { + sourceType: "community", + label: "Community-submitted event", + }, + worldAssociation: { + sourceType: "community", + confirmationState: "confirmed", + confirmedAt: Date.UTC(2026, 3, 1, 12, 0, 0), + }, + }, + ], + }, +}; + +const activeWorlds: PublicActiveWorld[] = [ + { + slug: worldSlug, + displayName: "Neon Harbor", + tags: ["Club world", "Cyberpunk", "Dance floor"], + summary: "A fixture VRChat venue page for world discovery visual review.", + upcomingEventCount: 2, + activityLabel: "Hosting upcoming events", + nextEvent: { + title: "Afterglow Harbor Sessions", + startAt: Date.UTC(2026, 5, 14, 22, 0, 0), + timezone: "UTC", + communityName: "Afterglow Social", + source: { + sourceType: "manual", + label: "Fixture event listing", + url: "https://example.invalid/events/afterglow-harbor-sessions", + }, + }, + }, +]; + export function getPlaywrightPublicProfileFixture( slug: string, profileType: "person" | "community", @@ -61,7 +219,34 @@ export function getPlaywrightPublicProfileFixture( return null; } +export function getPlaywrightPublicWorldFixture(slug: string): PublicWorld | null { + if ( + process.env.NODE_ENV === "production" || + process.env.VRDEX_ENABLE_PLAYWRIGHT_FIXTURES !== "true" + ) { + return null; + } + + if (slug === worldSlug) { + return worldProfile; + } + + return null; +} + +export function getPlaywrightActiveWorldFixtures(): PublicActiveWorld[] | null { + if ( + process.env.NODE_ENV === "production" || + process.env.VRDEX_ENABLE_PLAYWRIGHT_FIXTURES !== "true" + ) { + return null; + } + + return activeWorlds; +} + export const playwrightPublicProfilePaths = { personPath: `/p/${personSlug}`, communityPath: `/c/${communitySlug}`, + worldPath: `/w/${worldSlug}`, }; diff --git a/apps/web/src/convex/server.ts b/apps/web/src/convex/server.ts index f2a5504..44a6825 100644 --- a/apps/web/src/convex/server.ts +++ b/apps/web/src/convex/server.ts @@ -1,6 +1,10 @@ import { fetchQuery } from "convex/nextjs"; import { api } from "@convex-generated-api"; -import { getPlaywrightPublicProfileFixture } from "./playwright-fixtures"; +import { + getPlaywrightActiveWorldFixtures, + getPlaywrightPublicProfileFixture, + getPlaywrightPublicWorldFixture, +} from "./playwright-fixtures"; type PublicProfileType = "person" | "community"; @@ -58,3 +62,68 @@ export async function fetchPublicProfileBySlug(slug: string, profileType: Public }; } } + +export async function fetchPublicWorldBySlug(slug: string) { + const fixtureWorld = getPlaywrightPublicWorldFixture(slug); + + if (fixtureWorld !== null) { + return { + kind: "live" as const, + world: fixtureWorld, + }; + } + + if (!process.env.NEXT_PUBLIC_CONVEX_URL) { + return { kind: "missing-url" as const }; + } + + try { + const world = await fetchQuery(api.worlds.getPublicBySlug, { slug, now: Date.now() }); + + return { + kind: "live" as const, + world, + }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + + console.error(`Server-side Convex world fetch failed: ${message}`); + + return { + kind: "error" as const, + }; + } +} + +export async function fetchHomeActiveWorlds() { + const fixtureWorlds = getPlaywrightActiveWorldFixtures(); + + if (fixtureWorlds !== null) { + return { + kind: "live" as const, + worlds: fixtureWorlds, + }; + } + + if (!process.env.NEXT_PUBLIC_CONVEX_URL) { + return { kind: "missing-url" as const, worlds: [] }; + } + + try { + const worlds = await fetchQuery(api.worlds.listHomeActiveWorlds, { now: Date.now(), limit: 3 }); + + return { + kind: "live" as const, + worlds, + }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + + console.error(`Server-side Convex active worlds fetch failed: ${message}`); + + return { + kind: "error" as const, + worlds: [], + }; + } +} diff --git a/convex/README.md b/convex/README.md index cb82ad6..85483e4 100644 --- a/convex/README.md +++ b/convex/README.md @@ -3,13 +3,17 @@ This directory holds the initial Convex backend slice for `VRDex`. - `health.ts` exposes the placeholder public query `health:status` -- `schema.ts` defines the base `profiles` table for people and communities -- `_profileSlugs.ts` contains pure slug validation, generation, and lookup helpers +- `schema.ts` defines the base `profiles` table for people/communities and the first `worlds` table +- `_profileSlugs.ts` contains pure profile 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 +- `_profilePermissions.ts` contains pure profile 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 +- `_worldIds.ts` contains VRChat world id and canonical URL helpers +- `_worldSlugs.ts` contains pure world slug validation, generation, and lookup helpers +- `_worldPublic.ts` contains public world projection helpers +- `worlds.ts` exposes public world reads - `_generated/` contains committed Convex codegen output and should not be edited by hand - `tsconfig.json` is the Convex-managed TypeScript config for backend functions @@ -26,3 +30,5 @@ The canonical workflow notes live in `docs/backend/convex-bootstrap.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`. + +The first world-discovery planning contract lives in `docs/planning/world-discovery.md`. diff --git a/convex/_generated/api.d.ts b/convex/_generated/api.d.ts index 513e15d..e0c8b55 100644 --- a/convex/_generated/api.d.ts +++ b/convex/_generated/api.d.ts @@ -13,8 +13,15 @@ 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 _profileWorldCredits from "../_profileWorldCredits.js"; +import type * as _publicFields from "../_publicFields.js"; +import type * as _worldEvents from "../_worldEvents.js"; +import type * as _worldIds from "../_worldIds.js"; +import type * as _worldPublic from "../_worldPublic.js"; +import type * as _worldSlugs from "../_worldSlugs.js"; import type * as health from "../health.js"; import type * as profiles from "../profiles.js"; +import type * as worlds from "../worlds.js"; import type { ApiFromModules, @@ -28,8 +35,15 @@ declare const fullApi: ApiFromModules<{ _profileSlugs: typeof _profileSlugs; _profileStates: typeof _profileStates; _profileSubmissions: typeof _profileSubmissions; + _profileWorldCredits: typeof _profileWorldCredits; + _publicFields: typeof _publicFields; + _worldEvents: typeof _worldEvents; + _worldIds: typeof _worldIds; + _worldPublic: typeof _worldPublic; + _worldSlugs: typeof _worldSlugs; health: typeof health; profiles: typeof profiles; + worlds: typeof worlds; }>; /** diff --git a/convex/_profilePublic.ts b/convex/_profilePublic.ts index 5a8a035..c634cb4 100644 --- a/convex/_profilePublic.ts +++ b/convex/_profilePublic.ts @@ -1,10 +1,7 @@ import type { Doc } from "./_generated/dataModel"; +import { optionalField, safeHttpsUrl } from "./_publicFields"; 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, @@ -13,6 +10,15 @@ export function toPublicProfile(profile: Doc<"profiles">) { aliases: profile.aliases, tags: profile.tags, trustLabel: getProfileTrustLabel(profile.claimState, profile.creationSource), + outboundLinks: (profile.outboundLinks ?? []).flatMap((link) => { + const linkUrl = safeHttpsUrl(link.url); + + if (linkUrl === undefined) { + return []; + } + + return [{ ...link, url: linkUrl }]; + }), ...optionalField("headline", profile.headline), ...optionalField("bio", profile.bio), ...optionalField("about", profile.about), diff --git a/convex/_profileWorldCredits.ts b/convex/_profileWorldCredits.ts new file mode 100644 index 0000000..387a8a4 --- /dev/null +++ b/convex/_profileWorldCredits.ts @@ -0,0 +1,90 @@ +import type { Doc } from "./_generated/dataModel"; +import type { DatabaseReader } from "./_generated/server"; +import { optionalField } from "./_publicFields"; + +const PROFILE_WORLD_CREDIT_LIMIT = 6; +const PROFILE_WORLD_CREDIT_QUERY_LIMIT = 50; + +type ProfileType = "person" | "community"; +type WorldCreatorRole = + | "world_author" + | "builder" + | "venue_operator" + | "community_operator" + | "media_credit" + | "storefront_owner"; + +export type PublicProfileWorldCredit = { + slug: string; + displayName: string; + roles: WorldCreatorRole[]; + tags: string[]; + summary?: string; + sourceLabel?: string; +}; + +type ProfileReference = { + profileType: ProfileType; + slug: string; +}; + +type ProfileWorldCreditRecord = { + credit: Doc<"worldProfileCredits">; + world: Doc<"worlds">; +}; + +export function createPublicProfileWorldCredits( + records: ProfileWorldCreditRecord[], + limit = PROFILE_WORLD_CREDIT_LIMIT, +): PublicProfileWorldCredit[] { + const groups = new Map[]; world: Doc<"worlds"> }>(); + + for (const { credit, world } of records) { + if (world.publicationState !== "published") { + continue; + } + + const current = groups.get(world.slug) ?? { credits: [], world }; + current.credits.push(credit); + groups.set(world.slug, current); + } + + return [...groups.values()] + .map(({ credits, world }) => ({ + slug: world.slug, + displayName: world.displayName, + roles: [...new Set(credits.map((credit) => credit.role))], + tags: world.tags, + ...optionalField("summary", world.summary), + ...optionalField("sourceLabel", credits.find((credit) => credit.sourceLabel)?.sourceLabel), + })) + .sort((first, second) => first.displayName.localeCompare(second.displayName)) + .slice(0, Math.max(1, Math.min(limit, PROFILE_WORLD_CREDIT_LIMIT))); +} + +export async function getPublicProfileWorldCredits( + db: DatabaseReader, + profile: ProfileReference, +): Promise { + const credits = await db + .query("worldProfileCredits") + .withIndex("by_profileType_profileSlug", (query) => + query.eq("profileType", profile.profileType).eq("profileSlug", profile.slug), + ) + .take(PROFILE_WORLD_CREDIT_QUERY_LIMIT); + const records = ( + await Promise.all( + credits.map(async (credit) => { + const world = await db.get(credit.worldId); + + if (world === null) { + return null; + } + + return { credit, world }; + }), + ) + ).filter((record): record is ProfileWorldCreditRecord => record !== null); + + return createPublicProfileWorldCredits(records); +} diff --git a/convex/_publicFields.ts b/convex/_publicFields.ts new file mode 100644 index 0000000..2e84af0 --- /dev/null +++ b/convex/_publicFields.ts @@ -0,0 +1,16 @@ +export function optionalField(key: string, value: T | undefined): Record { + return value === undefined ? {} : { [key]: value }; +} + +export function safeHttpsUrl(value: string | undefined): string | undefined { + if (value === undefined) { + return undefined; + } + + try { + const url = new URL(value); + return url.protocol === "https:" ? url.href : undefined; + } catch { + return undefined; + } +} diff --git a/convex/_worldEvents.ts b/convex/_worldEvents.ts new file mode 100644 index 0000000..2d2329c --- /dev/null +++ b/convex/_worldEvents.ts @@ -0,0 +1,286 @@ +import type { Doc, Id } from "./_generated/dataModel"; +import type { DatabaseReader } from "./_generated/server"; +import { optionalField, safeHttpsUrl } from "./_publicFields"; + +const WORLD_EVENT_SECTION_LIMIT = 4; +const ACTIVE_WORLD_QUERY_EVENT_LIMIT = 50; +const ACTIVE_WORLD_ASSOCIATION_LIMIT = 20; +const ACTIVE_WORLD_MAX_LIMIT = 6; +const WORLD_EVENT_QUERY_ASSOCIATION_LIMIT = 50; + +type PublicEventSourceType = "manual" | "community" | "partner" | "import" | "ai_suggested"; + +type PublicWorldEventRecord = { + event: Doc<"events">; + association: Doc<"eventWorlds">; +}; + +type PublicActiveWorldRecord = PublicWorldEventRecord & { + world: Doc<"worlds">; +}; + +export type PublicWorldEventPreview = { + title: string; + startAt: number; + endAt?: number; + timezone?: string; + communityName?: string; + summary?: string; + source: { + sourceType: PublicEventSourceType; + label: string; + url?: string; + }; + worldAssociation: { + sourceType: PublicEventSourceType; + confirmationState: "confirmed"; + confirmedAt?: number; + }; +}; + +export type PublicWorldEventContext = { + upcoming: PublicWorldEventPreview[]; + recent: PublicWorldEventPreview[]; +}; + +export type PublicActiveWorldPreview = { + slug: string; + displayName: string; + tags: string[]; + summary?: string; + heroImageUrl?: string; + upcomingEventCount: number; + activityLabel: "Hosting upcoming events"; + nextEvent: { + title: string; + startAt: number; + endAt?: number; + timezone?: string; + communityName?: string; + source: { + sourceType: PublicEventSourceType; + label: string; + url?: string; + }; + }; +}; + +function eventEndsAt(event: PublicWorldEventPreview): number { + return event.endAt ?? event.startAt; +} + +function eventRecordEndsAt(event: Doc<"events">): number { + return event.endAt ?? event.startAt; +} + +function toPublicWorldEventPreview( + record: PublicWorldEventRecord, +): PublicWorldEventPreview | null { + const { association, event } = record; + + if (event.publicationState !== "published" || association.confirmationState !== "confirmed") { + return null; + } + + const sourceUrl = safeHttpsUrl(event.sourceUrl); + + return { + title: event.title, + startAt: event.startAt, + source: { + sourceType: event.sourceType, + label: event.sourceLabel, + ...optionalField("url", sourceUrl), + }, + worldAssociation: { + sourceType: association.sourceType, + confirmationState: "confirmed", + ...optionalField("confirmedAt", association.confirmedAt), + }, + ...optionalField("endAt", event.endAt), + ...optionalField("timezone", event.timezone), + ...optionalField("communityName", event.communityName), + ...optionalField("summary", event.summary), + }; +} + +export function createPublicWorldEventContext( + records: PublicWorldEventRecord[], + now: number, +): PublicWorldEventContext { + const previewsByEventId = new Map(); + + for (const record of records) { + const preview = toPublicWorldEventPreview(record); + + if (preview !== null && !previewsByEventId.has(record.event._id)) { + previewsByEventId.set(record.event._id, preview); + } + } + + const previews = [...previewsByEventId.values()]; + + const upcoming = previews + .filter((event) => eventEndsAt(event) >= now) + .sort((first, second) => first.startAt - second.startAt) + .slice(0, WORLD_EVENT_SECTION_LIMIT); + + const recent = previews + .filter((event) => eventEndsAt(event) < now) + .sort((first, second) => second.startAt - first.startAt) + .slice(0, WORLD_EVENT_SECTION_LIMIT); + + return { upcoming, recent }; +} + +export function createPublicActiveWorldPreviews( + records: PublicActiveWorldRecord[], + now: number, + limit: number, +): PublicActiveWorldPreview[] { + const limitWithinBounds = Math.max(1, Math.min(limit, ACTIVE_WORLD_MAX_LIMIT)); + const groups = new Map; events: Map> }>(); + + for (const { association, event, world } of records) { + if ( + world.publicationState !== "published" || + event.publicationState !== "published" || + association.confirmationState !== "confirmed" || + eventRecordEndsAt(event) < now + ) { + continue; + } + + const current = groups.get(world.slug) ?? { world, events: new Map>() }; + current.events.set(event._id, event); + groups.set(world.slug, current); + } + + return [...groups.values()] + .flatMap(({ events, world }) => { + const sortedEvents = [...events.values()].sort((first, second) => first.startAt - second.startAt); + const nextEvent = sortedEvents[0]; + + if (nextEvent === undefined) { + return []; + } + + const sourceUrl = safeHttpsUrl(nextEvent.sourceUrl); + const heroImageUrl = safeHttpsUrl(world.heroImageUrl); + + return [ + { + slug: world.slug, + displayName: world.displayName, + tags: world.tags, + upcomingEventCount: sortedEvents.length, + activityLabel: "Hosting upcoming events" as const, + nextEvent: { + title: nextEvent.title, + startAt: nextEvent.startAt, + source: { + sourceType: nextEvent.sourceType, + label: nextEvent.sourceLabel, + ...optionalField("url", sourceUrl), + }, + ...optionalField("endAt", nextEvent.endAt), + ...optionalField("timezone", nextEvent.timezone), + ...optionalField("communityName", nextEvent.communityName), + }, + ...optionalField("summary", world.summary), + ...optionalField("heroImageUrl", heroImageUrl), + }, + ]; + }) + .sort((first, second) => first.nextEvent.startAt - second.nextEvent.startAt) + .slice(0, limitWithinBounds); +} + +export async function getPublicWorldEventContext( + db: DatabaseReader, + worldId: Id<"worlds">, + now: number, +): Promise { + const futureAssociations = await db + .query("eventWorlds") + .withIndex("by_worldId_confirmationState_eventStartAt", (query) => + query.eq("worldId", worldId).eq("confirmationState", "confirmed").gte("eventStartAt", now), + ) + .take(WORLD_EVENT_QUERY_ASSOCIATION_LIMIT); + const previousAssociations = await db + .query("eventWorlds") + .withIndex("by_worldId_confirmationState_eventStartAt", (query) => + query.eq("worldId", worldId).eq("confirmationState", "confirmed").lt("eventStartAt", now), + ) + .order("desc") + .take(WORLD_EVENT_QUERY_ASSOCIATION_LIMIT); + const associations = [...futureAssociations, ...previousAssociations]; + + const records = ( + await Promise.all( + associations.map(async (association) => { + const event = await db.get(association.eventId); + + if (event === null) { + return null; + } + + return { event, association }; + }), + ) + ).filter((record): record is PublicWorldEventRecord => record !== null); + + return createPublicWorldEventContext(records, now); +} + +export async function getPublicActiveWorlds( + db: DatabaseReader, + now: number, + limit: number, +): Promise { + const futureEvents = await db + .query("events") + .withIndex("by_publicationState_startAt", (query) => + query.eq("publicationState", "published").gte("startAt", now), + ) + .take(ACTIVE_WORLD_QUERY_EVENT_LIMIT); + const currentEventCandidates = await db + .query("events") + .withIndex("by_publicationState_startAt", (query) => + query.eq("publicationState", "published").lt("startAt", now), + ) + .order("desc") + .take(ACTIVE_WORLD_QUERY_EVENT_LIMIT); + const events = [ + ...futureEvents, + ...currentEventCandidates.filter((event) => eventRecordEndsAt(event) >= now), + ]; + + const recordGroups = await Promise.all( + events.map(async (event) => { + const associations = await db + .query("eventWorlds") + .withIndex("by_eventId", (query) => query.eq("eventId", event._id)) + .filter((query) => query.eq(query.field("confirmationState"), "confirmed")) + .take(ACTIVE_WORLD_ASSOCIATION_LIMIT); + + return Promise.all( + associations.map(async (association) => { + const world = await db.get(association.worldId); + + if (world === null) { + return null; + } + + return { association, event, world }; + }), + ); + }), + ); + + const records = recordGroups + .flat() + .filter((record): record is PublicActiveWorldRecord => record !== null); + + return createPublicActiveWorldPreviews(records, now, limit); +} diff --git a/convex/_worldIds.ts b/convex/_worldIds.ts new file mode 100644 index 0000000..50d2311 --- /dev/null +++ b/convex/_worldIds.ts @@ -0,0 +1,26 @@ +export const VRCHAT_WORLD_ID_PATTERN = + /^wrld_[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +export function isValidVrchatWorldId(worldId: string): boolean { + return VRCHAT_WORLD_ID_PATTERN.test(worldId.trim()); +} + +export function toCanonicalVrchatWorldUrl(worldId: string): string | null { + const trimmed = worldId.trim(); + + if (!isValidVrchatWorldId(trimmed)) { + return null; + } + + return `https://vrchat.com/home/world/${trimmed}`; +} + +export function toVrchatWorldShortUrl(worldId: string): string | null { + const trimmed = worldId.trim(); + + if (!isValidVrchatWorldId(trimmed)) { + return null; + } + + return `https://vrch.at/${trimmed}`; +} diff --git a/convex/_worldPublic.ts b/convex/_worldPublic.ts new file mode 100644 index 0000000..b96c98f --- /dev/null +++ b/convex/_worldPublic.ts @@ -0,0 +1,57 @@ +import type { Doc } from "./_generated/dataModel"; +import { optionalField, safeHttpsUrl } from "./_publicFields"; + +export function toPublicWorld(world: Doc<"worlds">) { + const sourceUrl = safeHttpsUrl(world.sourceUrl); + const heroImageUrl = safeHttpsUrl(world.heroImageUrl); + const canonicalVrchatWorldUrl = safeHttpsUrl(world.canonicalVrchatWorldUrl); + const sourceAttributionUrl = safeHttpsUrl(world.sourceAttribution?.url); + const source = world.sourceAttribution + ? { + sourceType: world.sourceAttribution.sourceType, + label: world.sourceAttribution.label, + ...optionalField("url", sourceAttributionUrl), + ...optionalField("confirmedAt", world.sourceAttribution.confirmedAt), + } + : undefined; + + return { + slug: world.slug, + displayName: world.displayName, + tags: world.tags, + visibilityStatus: world.visibilityStatus, + platformCompatibility: world.platformCompatibility, + media: world.media.flatMap((media) => { + const mediaUrl = safeHttpsUrl(media.url); + + if (mediaUrl === undefined) { + return []; + } + + return [{ ...media, url: mediaUrl }]; + }), + creatorAttributions: world.creatorAttributions.map((attribution) => ({ + role: attribution.role, + displayName: attribution.displayName, + ...optionalField("profileSlug", attribution.profileSlug), + ...optionalField("profileType", attribution.profileType), + ...optionalField("sourceLabel", attribution.sourceLabel), + })), + outboundLinks: world.outboundLinks.flatMap((link) => { + const linkUrl = safeHttpsUrl(link.url); + + if (linkUrl === undefined) { + return []; + } + + return [{ ...link, url: linkUrl }]; + }), + ...optionalField("source", source), + ...optionalField("summary", world.summary), + ...optionalField("description", world.description), + ...optionalField("vrchatWorldId", world.vrchatWorldId), + ...optionalField("canonicalVrchatWorldUrl", canonicalVrchatWorldUrl), + ...optionalField("sourceUrl", sourceUrl), + ...optionalField("heroImageUrl", heroImageUrl), + }; +} diff --git a/convex/_worldSlugs.ts b/convex/_worldSlugs.ts new file mode 100644 index 0000000..36e4886 --- /dev/null +++ b/convex/_worldSlugs.ts @@ -0,0 +1,181 @@ +import type { Id } from "./_generated/dataModel"; +import type { DatabaseReader } from "./_generated/server"; +import { + PROFILE_SLUG_MAX_LENGTH, + PROFILE_SLUG_MIN_LENGTH, + PROFILE_SLUG_PATTERN, + normalizeProfileSlugInput, +} from "./_profileSlugs"; + +export const WORLD_SLUG_MIN_LENGTH = PROFILE_SLUG_MIN_LENGTH; +export const WORLD_SLUG_MAX_LENGTH = PROFILE_SLUG_MAX_LENGTH; +export const WORLD_SLUG_PATTERN = PROFILE_SLUG_PATTERN; +export const WORLD_SLUG_FALLBACK_BASE = "world-page"; + +export const RESERVED_WORLD_SLUGS = [ + "about", + "account", + "admin", + "api", + "auth", + "billing", + "blog", + "c", + "cards", + "communities", + "community", + "contact", + "dashboard", + "docs", + "e", + "events", + "help", + "login", + "logout", + "me", + "moderation", + "p", + "people", + "person", + "pricing", + "privacy", + "profile", + "profiles", + "search", + "settings", + "signup", + "support", + "terms", + "vrdex", + "w", + "world", + "worlds", +] as const; + +export type WorldSlugValidationReason = + | "empty" + | "too_short" + | "too_long" + | "invalid_format" + | "reserved"; + +export type WorldSlugValidationResult = + | { ok: true; slug: string } + | { ok: false; reason: WorldSlugValidationReason }; + +export type WorldSlugAvailabilityResult = + | { available: true; slug: string } + | { available: false; slug: string; reason: "invalid" | "reserved" | "taken" }; + +const RESERVED_WORLD_SLUG_SET = new Set(RESERVED_WORLD_SLUGS); + +export function normalizeWorldSlugInput(input: string): string { + return normalizeProfileSlugInput(input); +} + +export function validateWorldSlug(slug: string): WorldSlugValidationResult { + if (slug.length === 0) { + return { ok: false, reason: "empty" }; + } + + if (slug.length < WORLD_SLUG_MIN_LENGTH) { + return { ok: false, reason: "too_short" }; + } + + if (slug.length > WORLD_SLUG_MAX_LENGTH) { + return { ok: false, reason: "too_long" }; + } + + if (!WORLD_SLUG_PATTERN.test(slug)) { + return { ok: false, reason: "invalid_format" }; + } + + if (RESERVED_WORLD_SLUG_SET.has(slug)) { + return { ok: false, reason: "reserved" }; + } + + return { ok: true, slug }; +} + +export function toWorldSlug(input: string): WorldSlugValidationResult { + return validateWorldSlug(normalizeWorldSlugInput(input)); +} + +export function createWorldSlugBase(input: string): string { + let slug = normalizeWorldSlugInput(input) || WORLD_SLUG_FALLBACK_BASE; + + for (let attempt = 0; attempt < 5; attempt += 1) { + if (slug.length < WORLD_SLUG_MIN_LENGTH || RESERVED_WORLD_SLUG_SET.has(slug)) { + slug = `${slug}-world`; + } + + if (slug.length > WORLD_SLUG_MAX_LENGTH) { + slug = slug.slice(0, WORLD_SLUG_MAX_LENGTH).replace(/-+$/g, ""); + } + + const validated = validateWorldSlug(slug); + if (validated.ok) { + return validated.slug; + } + } + + return WORLD_SLUG_FALLBACK_BASE; +} + +export function createWorldSlugCandidate(base: string, attempt: number): string { + if (attempt <= 1) { + return base; + } + + const suffix = `-${attempt}`; + const maxBaseLength = WORLD_SLUG_MAX_LENGTH - suffix.length; + return `${base.slice(0, maxBaseLength).replace(/-+$/g, "")}${suffix}`; +} + +export async function getWorldBySlug(db: DatabaseReader, slug: string) { + return await db.query("worlds").withIndex("by_slug", (q) => q.eq("slug", slug)).unique(); +} + +export async function checkWorldSlugAvailability( + db: DatabaseReader, + slug: string, + excludingWorldId?: Id<"worlds">, +): Promise { + const validation = validateWorldSlug(slug); + + if (!validation.ok) { + return { + available: false, + slug, + reason: validation.reason === "reserved" ? "reserved" : "invalid", + }; + } + + const existingWorld = await getWorldBySlug(db, validation.slug); + + if (existingWorld !== null && existingWorld._id !== excludingWorldId) { + return { available: false, slug: validation.slug, reason: "taken" }; + } + + return { available: true, slug: validation.slug }; +} + +export async function findAvailableWorldSlug( + db: DatabaseReader, + input: string, + options: { excludingWorldId?: Id<"worlds">; maxAttempts?: number } = {}, +): Promise { + const base = createWorldSlugBase(input); + const maxAttempts = options.maxAttempts ?? 50; + + for (let attempt = 1; attempt <= maxAttempts; attempt += 1) { + const candidate = createWorldSlugCandidate(base, attempt); + const availability = await checkWorldSlugAvailability(db, candidate, options.excludingWorldId); + + if (availability.available) { + return availability.slug; + } + } + + throw new Error(`Unable to find an available world slug for "${base}".`); +} diff --git a/convex/profiles.ts b/convex/profiles.ts index 527f260..ada578b 100644 --- a/convex/profiles.ts +++ b/convex/profiles.ts @@ -5,6 +5,7 @@ import { canReadProfile } from "./_profilePermissions"; import { toPublicProfile } from "./_profilePublic"; import { findAvailableProfileSlug, getProfileBySlug, validateProfileSlug } from "./_profileSlugs"; import { sanitizeCommunitySubmissionProfileInput } from "./_profileSubmissions"; +import { getPublicProfileWorldCredits } from "./_profileWorldCredits"; const profileType = v.union(v.literal("person"), v.literal("community")); @@ -44,7 +45,13 @@ export const getPublicBySlug = query({ return null; } - return toPublicProfile(profile); + return { + ...toPublicProfile(profile), + worldCredits: await getPublicProfileWorldCredits(ctx.db, { + profileType: profile.profileType, + slug: profile.slug, + }), + }; }, }); @@ -93,6 +100,7 @@ export const submitCommunityProfile = mutation({ sortName: input.sortName, aliases: input.aliases, tags: input.tags, + outboundLinks: [], claimState: "unclaimed" as const, publicationState: "published" as const, creationSource: "community" as const, diff --git a/convex/schema.ts b/convex/schema.ts index 505b5c2..3c1dc39 100644 --- a/convex/schema.ts +++ b/convex/schema.ts @@ -20,6 +20,81 @@ const creationSource = v.union( v.literal("moderator"), ); +const sourceType = v.union( + v.literal("owner"), + v.literal("community"), + v.literal("partner"), + v.literal("moderator"), + v.literal("import"), +); + +const profileType = v.union(v.literal("person"), v.literal("community")); + +const worldVisibilityStatus = v.union( + v.literal("unknown"), + v.literal("private"), + v.literal("community_labs"), + v.literal("public"), +); + +const platformCompatibility = v.union(v.literal("pc"), v.literal("android"), v.literal("ios")); + +const worldCreatorRole = v.union( + v.literal("world_author"), + v.literal("builder"), + v.literal("venue_operator"), + v.literal("community_operator"), + v.literal("media_credit"), + v.literal("storefront_owner"), +); + +const worldLinkType = v.union( + v.literal("vrchat_world"), + v.literal("website"), + v.literal("gumroad"), + v.literal("jinxxy"), + v.literal("payhip"), + v.literal("woocommerce"), + v.literal("kofi"), + v.literal("patreon"), + v.literal("commissions"), + v.literal("generic_store"), + v.literal("other"), +); + +const profileLinkType = v.union( + v.literal("website"), + v.literal("gumroad"), + v.literal("jinxxy"), + v.literal("payhip"), + v.literal("woocommerce"), + v.literal("kofi"), + v.literal("patreon"), + v.literal("commissions"), + v.literal("generic_store"), + v.literal("other"), +); + +const linkSource = v.union( + v.literal("owner_authored"), + v.literal("reviewed"), + v.literal("partner_provided"), +); + +const eventSourceType = v.union( + v.literal("manual"), + v.literal("community"), + v.literal("partner"), + v.literal("import"), + v.literal("ai_suggested"), +); + +const eventWorldConfirmationState = v.union( + v.literal("unconfirmed"), + v.literal("confirmed"), + v.literal("disputed"), +); + const sharedProfileFields = { slug: v.string(), displayName: v.string(), @@ -31,6 +106,16 @@ const sharedProfileFields = { about: v.optional(v.string()), avatarImageUrl: v.optional(v.string()), bannerImageUrl: v.optional(v.string()), + outboundLinks: v.optional( + v.array( + v.object({ + type: profileLinkType, + label: v.string(), + url: v.string(), + source: linkSource, + }), + ), + ), region: v.optional(v.string()), timezone: v.optional(v.string()), claimState, @@ -81,4 +166,106 @@ export default defineSchema({ .index("by_claimState_profileType", ["claimState", "profileType"]) .index("by_creationSource_claimState", ["creationSource", "claimState"]) .index("by_profileType_sortName", ["profileType", "sortName"]), + worlds: defineTable({ + slug: v.string(), + displayName: v.string(), + sortName: v.string(), + tags: v.array(v.string()), + summary: v.optional(v.string()), + description: v.optional(v.string()), + vrchatWorldId: v.optional(v.string()), + canonicalVrchatWorldUrl: v.optional(v.string()), + sourceUrl: v.optional(v.string()), + visibilityStatus: worldVisibilityStatus, + platformCompatibility: v.array(platformCompatibility), + heroImageUrl: v.optional(v.string()), + media: v.array( + v.object({ + kind: v.union(v.literal("image"), v.literal("video"), v.literal("link")), + url: v.string(), + label: v.optional(v.string()), + credit: v.optional(v.string()), + }), + ), + creatorAttributions: v.array( + v.object({ + role: worldCreatorRole, + displayName: v.string(), + profileId: v.optional(v.id("profiles")), + profileSlug: v.optional(v.string()), + profileType: v.optional(v.union(v.literal("person"), v.literal("community"))), + sourceLabel: v.optional(v.string()), + }), + ), + outboundLinks: v.array( + v.object({ + type: worldLinkType, + label: v.string(), + url: v.string(), + source: linkSource, + }), + ), + publicationState, + creationSource, + sourceAttribution: v.optional( + v.object({ + sourceType, + label: v.string(), + url: v.optional(v.string()), + submittedAt: v.optional(v.number()), + confirmedAt: v.optional(v.number()), + }), + ), + publishedAt: v.optional(v.number()), + updatedAt: v.number(), + }) + .index("by_slug", ["slug"]) + .index("by_vrchatWorldId", ["vrchatWorldId"]) + .index("by_publicationState_sortName", ["publicationState", "sortName"]), + events: defineTable({ + title: v.string(), + sortTitle: v.string(), + startAt: v.number(), + endAt: v.optional(v.number()), + timezone: v.optional(v.string()), + communityProfileId: v.optional(v.id("profiles")), + communityName: v.optional(v.string()), + summary: v.optional(v.string()), + sourceType: eventSourceType, + sourceLabel: v.string(), + sourceUrl: v.optional(v.string()), + publicationState, + updatedAt: v.number(), + }) + .index("by_publicationState_startAt", ["publicationState", "startAt"]) + .index("by_communityProfileId_startAt", ["communityProfileId", "startAt"]), + eventWorlds: defineTable({ + eventId: v.id("events"), + worldId: v.id("worlds"), + eventStartAt: v.number(), + sourceType: eventSourceType, + confidence: v.number(), + confirmationState: eventWorldConfirmationState, + confirmedAt: v.optional(v.number()), + notes: v.optional(v.string()), + updatedAt: v.number(), + }) + .index("by_worldId", ["worldId"]) + .index("by_eventId", ["eventId"]) + .index("by_worldId_confirmationState", ["worldId", "confirmationState"]) + .index("by_worldId_confirmationState_eventStartAt", [ + "worldId", + "confirmationState", + "eventStartAt", + ]), + worldProfileCredits: defineTable({ + worldId: v.id("worlds"), + profileSlug: v.string(), + profileType, + role: worldCreatorRole, + sourceLabel: v.optional(v.string()), + updatedAt: v.number(), + }) + .index("by_profileType_profileSlug", ["profileType", "profileSlug"]) + .index("by_worldId", ["worldId"]), }); diff --git a/convex/worlds.ts b/convex/worlds.ts new file mode 100644 index 0000000..c6af524 --- /dev/null +++ b/convex/worlds.ts @@ -0,0 +1,39 @@ +import { v } from "convex/values"; + +import { query } from "./_generated/server"; +import { getPublicActiveWorlds, getPublicWorldEventContext } from "./_worldEvents"; +import { toPublicWorld } from "./_worldPublic"; +import { getWorldBySlug, validateWorldSlug } from "./_worldSlugs"; + +export const getPublicBySlug = query({ + args: { + slug: v.string(), + now: v.number(), + }, + handler: async (ctx, args) => { + const validation = validateWorldSlug(args.slug); + + if (!validation.ok) { + return null; + } + + const world = await getWorldBySlug(ctx.db, validation.slug); + + if (world === null || world.publicationState !== "published") { + return null; + } + + return { + ...toPublicWorld(world), + eventContext: await getPublicWorldEventContext(ctx.db, world._id, args.now), + }; + }, +}); + +export const listHomeActiveWorlds = query({ + args: { + now: v.number(), + limit: v.optional(v.number()), + }, + handler: async (ctx, args) => getPublicActiveWorlds(ctx.db, args.now, args.limit ?? 3), +}); diff --git a/docs/backend/profile-schema.md b/docs/backend/profile-schema.md index 71ff438..133c7c0 100644 --- a/docs/backend/profile-schema.md +++ b/docs/backend/profile-schema.md @@ -2,7 +2,7 @@ ## Status Note -This doc captures the durable profile schema foundation started in `#9` and extended through `#10`, `#11`, `#12`, `#13`, `#22`, and `#23`. +This doc captures the durable profile schema foundation started in `#9` and extended through `#10`, `#11`, `#12`, `#13`, `#22`, `#23`, and `#82`. 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. @@ -16,7 +16,8 @@ The schema is intentionally narrow. It establishes one shared `profiles` table f - account/user references are deferred until auth and claim issues define the account model - 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 +- normalized alias, asset, and rich authored block tables are deferred to later profile presentation issues +- profile outbound links are currently inline typed external links; normalized link tables remain a later scaling option - avatar and banner fields are URL placeholders for later controlled owner or concierge inputs, not ordinary community-submitted fields ## `profiles` Table @@ -39,6 +40,7 @@ Core presentation fields: - `bannerImageUrl`: optional banner image URL for controlled future owner or concierge inputs - `region`: optional location or scene region text - `timezone`: optional time zone text +- `outboundLinks`: optional inline typed external links for owner-authored, reviewed, or partner-provided profile storefront/contact links State fields: @@ -101,5 +103,6 @@ The first write path is `profiles:submitCommunityProfile`. It requires `ctx.auth - `#13` defines claim-state transitions and trust labeling behavior - `#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 +- `#82` added inline typed external links for first-slice creator commerce/profile links, with public `https` filtering - `#27` adds field-level visibility controls - `#31` adds public search behavior and any search-specific indexing diff --git a/docs/planning/README.md b/docs/planning/README.md index 1036639..c0a9fbb 100644 --- a/docs/planning/README.md +++ b/docs/planning/README.md @@ -7,6 +7,8 @@ - `docs/planning/architecture.md` - suggested system design, data model, and integrations - `docs/planning/prd.md` - product requirements draft for v1 and near-term expansion - `docs/planning/agent-integration-surface.md` - external agent-consumable VRDex skill, API, website navigation, and MCP direction +- `docs/planning/world-discovery.md` - world pages, creator attribution, active-world discovery, and creator-commerce boundaries +- `docs/planning/marketplace-api-research.md` - marketplace sync gate, provider posture, and disallowed storefront data - `docs/planning/engineering-strategy.md` - stack, testing, verification, and agentic factory plan - `docs/planning/docs-strategy.md` - Docusaurus and source-of-truth documentation plan - `docs/planning/epics.md` - phased epic breakdown for v0.5, v1, and v1.5 diff --git a/docs/planning/architecture.md b/docs/planning/architecture.md index 0f8244e..21bf294 100644 --- a/docs/planning/architecture.md +++ b/docs/planning/architecture.md @@ -9,10 +9,12 @@ Build a small platform, not just a website: - Discord bot for lookup commands - background workers for verification and sync jobs -Design assumption: the core directory has two first-class record types, `person` and `community`. +Design assumption: the profile directory has two first-class profile types, `person` and `community`. Design assumption: profiles are both identity records and customizable public pages. +Design assumption: worlds are separate domain records that can link to profiles, events, media, and creator-commerce links without becoming a third profile type. + ## Suggested stack ### App stack @@ -152,7 +154,7 @@ Slug rule: ### `profile_links` - normalized list of external links -- type examples: `twitch`, `soundcloud`, `mixcloud`, `booking_email`, `discord_user`, `discord_server`, `website`, `vrchat_group` +- type examples: `twitch`, `soundcloud`, `mixcloud`, `booking_email`, `discord_user`, `discord_server`, `website`, `vrchat_group`, `gumroad`, `jinxxy`, `payhip`, `woocommerce`, `commissions`, `generic_store` ### `profile_blocks` @@ -258,6 +260,36 @@ Related policy recommendation: - owner-entered and concierge-confirmed data can support richer fields - freeform public-submitted bio text should be avoided or strongly constrained in v1 +### `worlds` + +- public world records for VRChat worlds and event venues +- separate from `profiles` so person/community profile assumptions stay simple +- likely fields: `slug`, `displayName`, `sortName`, `summary`, `description`, `vrchatWorldId`, `canonicalVrchatWorldUrl`, `sourceUrl`, `visibilityStatus`, `platformCompatibility`, `publicationState`, `creationSource`, `createdAt`, and `updatedAt` +- supports public route `/w/` + +### `world_media` + +- hero image, screenshots, trailer/video links, or embeds +- should preserve source and permission policy +- avoid copying VRChat or creator media without a clear rights/source decision + +### `world_links` + +- owner-authored or reviewed outbound links for a world +- supports social, website, VRChat world link, and commerce/storefront links +- should not imply VRDex endorsement or verified sales + +### `world_creator_attributions` + +- connects worlds to person/community profiles or source text +- role labels can include `world_author`, `builder`, `venue_operator`, `community_operator`, `media_credit`, and `storefront_owner` +- attribution needs provenance, confidence, review state, and correction/dispute paths + +### `world_sources` + +- records whether world facts came from owner entry, community submission, concierge setup, partner data, manual review, or future API-compatible sync +- all imported/submitted facts should retain source and confirmation metadata + ### `events` - canonical event records shown on community pages and derived into person-facing participation views @@ -270,6 +302,10 @@ Likely near-term additions: - platform compatibility hints - optional canonical event-level stream/watch metadata +Current recommendation: + +- once world pages exist, prefer an explicit event-world association over storing world context only as a raw event field + ### `billing_customers` - app user or organization to Stripe customer mapping @@ -292,6 +328,13 @@ Likely near-term additions: - raw source references from partner sync, manual entry, VRChat calendar, or AI extraction - preserves provenance for trust and debugging +### `event_worlds` + +- associates events to world records +- includes source, confidence, confirmation/review metadata, and optional notes +- enables world pages to derive upcoming/recent event views +- enables Home discovery modules to feature active worlds from VRDex event data without live presence tracking + ### `event_participants` - associates person profiles to events @@ -640,6 +683,7 @@ AI should assist matching and extraction, not silently publish uncertain facts. - Do not make the product depend on VRCTL / vrc.tl access - Do not plan around scraping blocked sites - Treat third-party event data as optional enrichment only +- Do not base world discovery on private instance presence, scraped live player counts, or fixed-interval VRChat API polling ## Public surfaces to build early @@ -647,6 +691,7 @@ AI should assist matching and extraction, not silently publish uncertain facts. - `/p/` for people - `/c/` for communities +- `/w/` for worlds Optional later: @@ -658,6 +703,9 @@ Optional later: - `GET /api/search?q=` - `GET /api/cards/:slug` - `GET /api/communities/:slug` +- `GET /api/worlds/:slug` +- `GET /api/worlds/:slug/events` +- `GET /api/worlds/active` - `GET /api/people/:slug` - `GET /api/people/:slug/events` - `GET /api/communities/:slug/events` @@ -746,6 +794,8 @@ Deferred from this slice: - notifications and approval settings - richer event and participant structure - world linkage and previews +- world pages, creator attribution, and event-derived active-world discovery +- creator commerce links and marketplace integration research - stream/media normalization - premium insights polish diff --git a/docs/planning/dependency-map.md b/docs/planning/dependency-map.md index b06d824..d63e9df 100644 --- a/docs/planning/dependency-map.md +++ b/docs/planning/dependency-map.md @@ -48,6 +48,11 @@ This file records the current hard and soft dependency assumptions across the dr - hard dependents: #37, #38, #39, #40, #42 +### #79 World discovery and creator attribution lane + +- soft dependencies: #7, #35, #36, #39, #76, #77, #78 +- hard dependents: #84, #81, #80, #82, #83 + ## Core issues ### #9 Create base profile schema for people and communities @@ -226,3 +231,29 @@ This file records the current hard and soft dependency assumptions across the dr - hard dependencies: #37 - soft dependencies: #39 + +## World discovery issues + +### #84 Define world profile schema and public world page + +- soft dependencies: #36, #79 +- hard dependents: #81, #80 + +### #81 Connect events to worlds and derive active-world views + +- hard dependencies: #84 +- soft dependencies: #7, #35, #36, #79 +- hard dependents: #80 + +### #80 Add Home page Hot Worlds / Active Venues module + +- hard dependencies: #81 +- soft dependencies: #84 + +### #82 Add creator commerce links to profiles and world pages + +- soft dependencies: #22, #36, #79, #84 + +### #83 Research marketplace API integrations for creator storefronts + +- soft dependencies: #39, #77, #79, #82 diff --git a/docs/planning/epics.md b/docs/planning/epics.md index b65b17b..0f37445 100644 --- a/docs/planning/epics.md +++ b/docs/planning/epics.md @@ -340,6 +340,38 @@ Acceptance criteria: - stream and world metadata can power better event presentation - the data model supports common VR club operational patterns +### EPIC-15b World discovery and creator attribution + +Purpose: + +- make event worlds and venue spaces visible as first-class discovery surfaces + +Includes: + +- public world pages +- creator attribution and role labels +- event-world associations +- event-derived active-world and venue discovery +- owner-authored creator commerce links +- marketplace integration research before API sync + +Acceptance criteria: + +- a world can have a public page with provenance-backed metadata +- events can link to worlds and worlds can derive upcoming/recent event views +- Home can feature active worlds from explicit event data or curated/reviewed sources +- creator commerce links are displayed without implying endorsement or verified sales +- marketplace API sync is gated by terms, consent, auth, and privacy review + +Related issues: + +- `#79` +- `#84` +- `#81` +- `#80` +- `#82` +- `#83` + ### EPIC-16 AI-assisted extraction and matching Purpose: diff --git a/docs/planning/issue-seeding.md b/docs/planning/issue-seeding.md index 1a9aeb2..ca7f8a3 100644 --- a/docs/planning/issue-seeding.md +++ b/docs/planning/issue-seeding.md @@ -151,3 +151,9 @@ That order keeps the product coherent while still giving you something demoable - publish portable VRDex skill for external partner agents - prototype standalone VRDex MCP read tools after public API shape stabilizes - evaluate optional VRChat MCP bridge tools for cross-context workflows +- add world discovery and creator attribution lane (`#79`) +- define world profile schema and public world page (`#84`) +- connect events to worlds and derive active-world views (`#81`) +- add Home page Hot Worlds / Active Venues module (`#80`) +- add creator commerce links to profiles and world pages (`#82`) +- research marketplace API integrations for creator storefronts (`#83`) diff --git a/docs/planning/marketplace-api-research.md b/docs/planning/marketplace-api-research.md new file mode 100644 index 0000000..b97ed3b --- /dev/null +++ b/docs/planning/marketplace-api-research.md @@ -0,0 +1,139 @@ +# Marketplace API Research Gate + +## Status + +Current recommendation for `#83`. This is a product and engineering gate before any marketplace API sync work is opened. + +This document does not authorize implementation. Before coding an integration, re-check the current official API documentation, platform terms, OAuth/application review requirements, rate limits, and data-use restrictions for the specific provider. + +## Locked Decision + +VRDex starts with typed owner-authored, reviewed, or partner-provided external links for creator storefronts. API sync is deferred. + +Reasons: + +- creator storefront links are enough for the first public profile and world-page value proposition +- public storefront display does not need buyer, order, payout, license, tax, or webhook payload data +- marketplace APIs vary significantly in OAuth support, product/listing coverage, commercial terms, and operational risk +- self-hostable VRDex deployments need a connector model before per-provider secrets, polling, webhooks, and disconnect behavior are safe to ship + +## Provider Posture + +### Gumroad + +Current recommendation: best first candidate for a future opt-in storefront sync. + +Why: + +- appears to have an API/OAuth shape better suited to creator-authorized access than simple scraped links +- product/listing display is plausibly useful without touching private transaction data + +Required future checks: + +- current OAuth scopes and application setup +- whether product/listing reads are available without sales or purchaser data +- webhook terms and whether webhooks are needed at all for display-only sync +- rate limits, caching expectations, and disconnect/token-deletion requirements + +### Jinxxy / Jinxie + +Current recommendation: owner-authored links only. + +Why: + +- do not build against unofficial or unstable endpoints +- require official API documentation or written integration guidance before sync work + +Required future checks: + +- official API availability and terms +- creator authorization model +- allowed public listing fields + +### Payhip + +Current recommendation: owner-authored links only for v1. + +Why: + +- available API shape should be revalidated before assuming it supports general public product/listing sync +- do not add a connector unless it can avoid private buyer/order/license data entirely + +Required future checks: + +- whether a product/listing read path exists for display-only use +- scope separation between listing data and order/license/customer data +- rate limits and webhook data minimization + +### WooCommerce + +Current recommendation: defer until VRDex has a general external-store connector model. + +Why: + +- every WooCommerce store is its own host, auth boundary, plugin surface, and reliability/security risk +- connector UX needs per-store URL validation, credential storage, disconnect behavior, throttling, and failure states +- self-hosted and creator-owned stores can have varied privacy and performance constraints + +Required future checks: + +- minimum scopes for product-only reads +- store URL verification and SSRF protections +- credential encryption and rotation +- backoff/circuit breakers per store + +## Allowed Public Sync Fields + +Candidate display-only fields, if a future provider explicitly supports them: + +- product/listing title +- product/listing URL +- short description or excerpt +- public price display string when already public +- public thumbnail URL when license/terms allow display +- provider name +- last synced timestamp +- creator opt-in state + +## Disallowed Data + +Do not request, store, or expose these fields for public storefront display unless a separate privacy-reviewed feature explicitly requires them: + +- buyer names +- buyer emails +- buyer IP addresses +- order ids +- license keys +- payout data +- tax data +- refund/dispute data +- raw webhook payloads +- transaction rows +- per-buyer analytics + +## Future Connector Requirements + +Any future marketplace sync issue must include: + +- explicit creator opt-in +- least-privilege OAuth or credential flow +- encrypted server-side credential storage +- disconnect flow that deletes stored credentials and stops future sync +- conservative polling with jitter and backoff, unless webhooks are justified +- provider-specific rate-limit handling +- last-synced labels in public UI +- moderation/review escape hatch for unsafe or misleading listings +- tests that assert disallowed transaction/customer fields are not persisted +- docs for self-host operators explaining required provider app setup + +## Non-Goals For First Slice + +- checkout +- fulfillment +- taxes +- refunds +- license delivery +- sales analytics +- buyer identity +- marketplace account management +- cross-provider inventory normalization diff --git a/docs/planning/prd.md b/docs/planning/prd.md index 1ccdff8..7d5317d 100644 --- a/docs/planning/prd.md +++ b/docs/planning/prd.md @@ -29,7 +29,7 @@ Current recommendation: ## One-line pitch -VRDex is a VRChat-first directory and profile platform for people and communities, combining verified identity, customizable public pages, and event presence in one place. +VRDex is a VRChat-first directory and profile platform for people, communities, and the worlds where events happen, combining verified identity, customizable public pages, event presence, and creator attribution in one place. ## Why now @@ -61,6 +61,7 @@ VRDex becomes the canonical public directory for the VRChat scene: - community members can seed missing entries - partner systems can point to one canonical page - fans can see where someone is playing next +- world creators and venue operators can get attribution when their spaces host the scene ## Primary audiences @@ -89,6 +90,13 @@ VRDex becomes the canonical public directory for the VRChat scene: - fans - community members +### World creators + +- world builders +- venue operators +- asset and prefab creators +- creator storefront owners + ## Core product pillars ### 1. Identity @@ -133,6 +141,17 @@ Candidate direction: - community activity summaries - source-aware event listings +### 4b. World discovery + +Candidate direction: + +- public world pages +- event-world associations +- creator attribution and role labels +- event-derived active-world and venue discovery +- owner-authored creator commerce links +- marketplace API research gated behind consent, privacy, and terms review + ### 5. Integrations - Discord @@ -214,6 +233,16 @@ Current recommendation: - support handoff invite acceptance flow - support a first-run wizard for newly claimed or handed-off profiles - support opt-out controls for people or communities that do not want to be listed +- support typed creator commerce links where owner-authored or reviewed + +### Worlds + +- create public world records separately from person/community profiles +- store VRChat world id and canonical world URL when known +- render public world pages with media, tags, attribution, and outbound links +- associate worlds with events using source/confidence metadata +- derive upcoming/recent event views for worlds +- support correction or review paths for disputed attribution Current recommendation for ordinary community submissions: @@ -340,6 +369,7 @@ Current recommendation: - person-facing event participation views can still exist in the UI where useful - minimum event structure should include title, community, start time, optional end time, source, optional link, and optional notes - event/location modeling should leave room for VRChat world linkage, platform compatibility hints, and DJ slot breakdowns +- event-world links should become explicit associations once world pages exist, not only freeform event metadata - stream/watch links should use typed media link categories in v1, while still allowing generic/other links and multiple links where needed - when a claimed person is added to an event association in v1, they should receive a passive in-app notification - claimed people should be able to choose whether they are simply notified when added; stronger approval gates can land later @@ -357,6 +387,8 @@ Candidate direction: - filter by genres / vibe tags - filter by verification state - browse upcoming events +- browse worlds hosting upcoming events +- feature active venues from explicit event-world data or curated/reviewed sources Candidate later direction: @@ -388,6 +420,8 @@ Examples: - Discord handle: public, imported from Discord - VRChat group: public, verified by proof code - upcoming event: partner-confirmed or AI-suggested pending review +- world creator attribution: owner-entered, partner-provided, or reviewed +- commerce link: owner-authored or reviewed, without implying VRDex endorsement or verified sales Authority levels should also differ by origin: @@ -421,6 +455,8 @@ Streaming and world-awareness direction: - events should eventually support VRChat world linkage - world linkage can enable world previews and platform hints such as PC-only or Quest-compatible guidance +- world linkage should also power creator attribution, world pages, and event-derived active-world discovery +- early Hot Worlds / Active Venues surfaces should use explicit event-world associations, curated picks, or partner-provided data with review rather than scraped global popularity - DJ/media links may need multiple variants, especially VRCDN PC vs Quest behavior - a later restreamer or one-link stream-routing workflow may need current/next source status, live checks, operator preview, direct Twitch/watch-link access, and scheduled/manual switching - more advanced stream/player knowledge is valuable, but can land after the core event model exists @@ -431,6 +467,7 @@ Candidate direction: - an avatar showcase viewer for profiles, ideally using a non-rippable or harder-to-rip representation instead of shipping a raw avatar model to the browser - the most promising direction is likely an imposter-style or sprite-angle-rendered pipeline, potentially generated through a companion creator/VCC workflow +- live or 3D-rendered world previews for world pages, only after the basic world display and event graph prove useful Interview later: diff --git a/docs/planning/product-spec.md b/docs/planning/product-spec.md index 00c2835..89ec416 100644 --- a/docs/planning/product-spec.md +++ b/docs/planning/product-spec.md @@ -8,7 +8,7 @@ Working domain: `vrdex.net` ## Product thesis -VRChat scene participants need one canonical, public, claimable profile system for both people and communities that other people can trust and reuse, with enough customization and link depth to replace ad-hoc link pages. +VRChat scene participants need one canonical, public, claimable profile system for both people and communities that other people can trust and reuse, with enough customization and link depth to replace ad-hoc link pages. World discovery extends that identity graph by showing where events happen, who built those spaces, and how creators can link to their work. ## Primary users @@ -66,6 +66,15 @@ They want a canonical page for: They want to add missing performers even before those performers sign up themselves. +### World builders and venue operators + +They want people to understand: + +- what world or venue an event uses +- who built or operates that world +- what other events happen there +- where to find store, commission, product, or portfolio links + ## Product principles 1. Profiles are public by default; ownership is explicit @@ -77,6 +86,7 @@ They want to add missing performers even before those performers sign up themsel 7. Every profile field can be hidden by its owner after claim 8. Visual customization should feel expressive without breaking usability 9. Agents and partner systems should be first-class API/docs consumers, not forced to scrape or infer product rules from undocumented conventions +10. Worlds can become a first-class discovery lane without becoming unreviewed popularity scraping Current recommendation on terminology: @@ -264,6 +274,8 @@ Search and browse should also support: - who is playing soon - communities by genre / vibe - performers by upcoming events +- worlds hosting upcoming events +- curated or event-derived active venues ### 6. Discord bot integration @@ -336,6 +348,33 @@ Important future-aware extensions: - DJ slot breakdowns within a larger event - stream/watch link modeling +Event-world direction: + +- worlds should become separate public records rather than being stored only as event text +- event-world links need source, confidence, and confirmation metadata +- world pages can derive upcoming and recent event views from those links +- active-world surfaces should start from explicit event-world associations, curated picks, or reviewed partner data instead of scraped popularity + +### 8a. World discovery and creator attribution + +World pages should support: + +- display name, summary, tags, and media +- VRChat world id and canonical VRChat world URL when known +- creator attribution to people or communities with role labels +- venue/community association where appropriate +- upcoming or recent events derived from event-world associations +- owner-authored outbound links +- source/provenance for every imported or submitted fact + +Current recommendation: + +- keep `world` as a separate domain object, not a third profile type inside person/community profile assumptions +- preserve provenance and review state for creator attribution +- avoid live instance/player-count claims in the first slice +- avoid copying creator media unless rights/source policy is clear +- see `docs/planning/world-discovery.md` + Streaming and media direction: - some events need multiple media links with different compatibility behavior @@ -501,6 +540,8 @@ This is explicitly low priority relative to identity, claims, communities, and e - replacing VRC Pop's live scene visualization - replacing VRCLinking's role-sync depth - any dependency on VRCTL / vrc.tl access +- scraped world popularity, private instance presence, or user-level attendance tracking +- marketplace API sync, sales analytics, or checkout inside VRDex before a separate privacy-reviewed integration design exists - unconstrained HTML/CSS profile editing in v1 ## Suggested profile fields @@ -551,6 +592,7 @@ This is explicitly low priority relative to identity, claims, communities, and e - Bandcamp - X / Bluesky / Instagram optional - custom links with labels +- creator commerce links such as Gumroad, Jinxxy/Jinxie, Payhip, WooCommerce/personal store, Ko-fi, Patreon, commissions, or generic product/store links ### VRChat-specific @@ -653,6 +695,8 @@ Public read APIs should eventually support: - profile card JSON for bot responses - embeddable link previews - events feed for a person or community +- world lookup by slug or VRChat world id +- event-derived active-world and world-event feeds - agent-oriented compact responses for common profile, event, and media-link lookups Current recommendation: diff --git a/docs/planning/world-discovery.md b/docs/planning/world-discovery.md new file mode 100644 index 0000000..1bca54e --- /dev/null +++ b/docs/planning/world-discovery.md @@ -0,0 +1,214 @@ +# World Discovery And Creator Attribution + +## Status + +Current recommendation. This lane was seeded after `#36` made world linkage an event extension but before worlds became first-class product surface. + +Related issues: + +- `#79` - world discovery and creator attribution epic +- `#84` - world profile schema and public world page +- `#81` - event-world associations and active-world views +- `#80` - Home page Hot Worlds / Active Venues module +- `#82` - creator commerce links on profiles and world pages +- `#83` - marketplace API integration research + +## Product Bet + +VRChat events are not only about people and communities. They happen in places built by creators, operated by venues, and reused across scenes. + +VRDex should make worlds visible enough that a fan can answer: + +- where is this event happening? +- who built or operates that world? +- what other events happen there? +- where can I find the creator's public work, store, or commission links? + +This is a discovery lane, not a replacement for VRChat's own world pages or creator storefronts. + +## Principles + +- Worlds should be separate domain records, not a third `profileType` squeezed into person/community profile assumptions. +- Every world fact needs provenance, especially creator attribution, platform compatibility, media, and commerce links. +- Early activity and Hot Worlds surfaces should be event-derived, curated, owner-authored, or partner-provided with review. +- Do not depend on scraped global popularity, private instance presence, fixed-interval VRChat polling, or blocked sites. +- Do not imply VRChat endorsement, VRDex endorsement, verified sales, or verified ownership unless the source supports that claim. +- Marketplace integrations should start as owner-authored external links before any API sync. + +## World Profile Foundation + +Candidate first slice for `#84`: + +- `slug` +- display name +- short summary or description +- `vrchatWorldId` when known, using the `wrld_...` identifier format +- canonical VRChat URL, preferably `https://vrchat.com/home/world/` +- optional `https://vrch.at/` display link +- source URL provided by the owner, community, or partner +- tags and vibe labels +- optional visibility/status hint such as `unknown`, `private`, `community_labs`, or `public` +- optional platform compatibility hints when sourced +- media such as hero image, screenshots, trailer/video links, or embeds +- owner-authored outbound links +- creator attribution links to person/community profiles +- source/provenance fields for each imported or submitted fact + +World pages should be reachable at a stable public route such as `/w/`. + +## Creator Attribution + +Creator attribution should use explicit roles instead of one ambiguous owner field. + +Candidate role labels: + +- `world_author` +- `builder` +- `venue_operator` +- `community_operator` +- `media_credit` +- `storefront_owner` + +Attribution must be owner-entered, partner-provided, or reviewed. Automatic inference from posters, names, screenshots, or event text is not authoritative enough for public creator credit. + +Locked decision for the first reciprocal profile slice: + +- person and community profile pages can show world credits derived from published world creator attributions +- reciprocal profile credits require indexed `profileSlug` and `profileType` records; display-name-only credits remain world-local source text +- draft worlds and non-public world records do not appear on public profile world-credit sections + +## Event-World Graph + +Candidate first slice for `#81`: + +- an event can link to a world profile +- the event-world link carries source, confidence, and confirmation metadata +- world pages can derive upcoming and recent events from those links +- active-world queries can sort by upcoming event count, next event time, recency, or curated feature status +- event-derived activity should be labeled honestly, such as `Upcoming on VRDex`, `Active soon`, `Curated`, or `Partner-provided` + +Locked decision for the first implementation slice: + +- public world pages only render published events through confirmed event-world associations +- unconfirmed, disputed, draft, or private event-world records are not public world-page activity +- event source URLs are filtered to `https` before publication +- event-world confidence is stored for review/ranking work, but public copy should emphasize confirmation state instead of implying attendance or live popularity + +Do not call early results global popularity unless the ranking is backed by safe, documented, permissioned data. + +## Home Discovery Module + +Candidate first slice for `#80`: + +- show Hot Worlds, Active Venues, or Worlds Hosting Events Soon on Home +- each card can include world title, media, creator attribution, event context, and a call to action +- empty states should still make the product feel intentional when there are no world/event records +- the first ranking rule should be explicit event-world associations, not live VRChat data + +Locked decision for the first implementation slice: + +- Home labels this module `Worlds hosting events soon`, not `Hot Worlds` or `Live now` +- cards are built from published events with confirmed event-world links +- the first sort is earliest next event time, not global popularity or attendance +- unconfirmed, disputed, draft, or private records do not contribute to Home world activity + +Safer labels for v1: + +- `Active soon` +- `Hosting upcoming events` +- `Featured worlds` +- `Curated venues` + +Riskier labels to avoid until justified: + +- `Most popular in VRChat` +- `Live now` +- `Trending globally` +- `Top by attendance` + +## Creator Commerce Links + +Candidate first slice for `#82`: + +- support typed owner-authored commerce links on person, community, and world pages +- include generic commerce links for unsupported storefronts +- distinguish owner-authored links from imported, reviewed, or partner-provided links +- render commerce links without implying VRDex endorsement or verified sales + +Locked decision for the first implementation slice: + +- person and community profile links are typed external links, not checkout or marketplace sync +- profile and world link publication filters out non-`https` URLs +- public copy labels owner-authored, reviewed, or partner-provided links without implying sales verification + +Candidate link types: + +- Gumroad +- Jinxxy/Jinxie +- Payhip +- WooCommerce or personal store +- Ko-fi +- Patreon +- commissions +- generic product/store link + +Commerce links are a presentation feature first. Checkout, fulfillment, taxes, refunds, licensing, and sales analytics are non-goals for the first slice. + +## Marketplace API Research Summary + +Current recommendation for `#83`: + +Detailed gate: `docs/planning/marketplace-api-research.md`. + +- Gumroad is the best first API-sync candidate because it has OAuth scopes and product/listing read endpoints. +- Use minimum scopes for storefront display. Do not request or store sales, payout, tax, buyer, license, or order data for a public storefront card. +- Jinxxy/Jinxie should stay owner-authored links until official API docs or written integration guidance are available. +- Payhip should stay owner-authored links for storefront sync because the available API appears narrow and does not provide general product/listing reads. +- WooCommerce has mature APIs, but every store is its own security and performance boundary. Defer until VRDex has a general external-store connector model. + +Any future sync must support explicit creator opt-in, encrypted server-side credential storage, disconnect/token deletion, conservative polling/backoff, last-synced labels, and tests that prevent sensitive transaction fields from being persisted. + +Disallowed fields for public storefront sync unless a separate privacy-reviewed feature exists: + +- buyer names +- buyer emails +- IP addresses +- order ids +- license keys +- payout data +- tax data +- raw webhook payloads +- transaction rows + +## VRChat Data Boundary + +World pages can store and link public VRChat world identifiers when provided by owners, communities, partners, or reviewed submissions. + +VRDex should not require undocumented VRChat API usage for core world discovery. If future API-compatible fetching is added, it needs: + +- no VRChat credentials, cookies, or service-account shortcuts for public discovery +- clear `User-Agent` +- aggressive caching +- jittered schedules +- backoff on `429` and errors +- disableable/self-host-configurable sync +- documented source and timestamp labels + +Unsafe early data sources: + +- scraped live player counts +- private or group instance presence +- user-level attendance/presence +- fixed-interval VRChat API polling +- hidden group membership inference +- creator/operator inference from names or media without review +- copied media without permission or source policy + +## Recommended Sequence + +1. Land this planning doc and wire it into the planning index, product spec, PRD, architecture, epics, issue seeding, and dependency map. +2. Implement `#84` with a separate `worlds` model and `/w/` public page. +3. Implement `#81` with event-world associations and derived world upcoming-event views. +4. Implement `#80` using explicit event-world data and honest labels. +5. Implement `#82` as typed owner-authored commerce links. +6. Treat `#83` as a research gate before any marketplace API sync issue is opened. diff --git a/docs/testing/playwright-visual-preview.md b/docs/testing/playwright-visual-preview.md index c72b8cc..3ee9084 100644 --- a/docs/testing/playwright-visual-preview.md +++ b/docs/testing/playwright-visual-preview.md @@ -18,6 +18,7 @@ The visual suite starts a local Convex backend and Next dev server by default. P - `/deployment` - `/p/playwright-dj-aurora` - `/c/playwright-afterglow-social` +- `/w/playwright-neon-harbor` Screenshots are written to `apps/web/playwright-artifacts/screenshots` and attached to the Playwright report. diff --git a/tests/backend/profile-foundation.test.ts b/tests/backend/profile-foundation.test.ts index ce119ca..479da98 100644 --- a/tests/backend/profile-foundation.test.ts +++ b/tests/backend/profile-foundation.test.ts @@ -17,6 +17,7 @@ import { sanitizeCommunitySubmissionProfileInput, sanitizeProfileTextList, } from "../../convex/_profileSubmissions"; +import { createPublicProfileWorldCredits } from "../../convex/_profileWorldCredits"; import { canTransitionProfileClaimState, getProfileTrustLabel, @@ -230,6 +231,20 @@ describe("public profile projection", () => { sortName: "dj celine", aliases: [], tags: ["House"], + outboundLinks: [ + { + type: "kofi", + label: "DJ Celine Ko-fi", + url: "https://example.invalid/dj-celine-kofi", + source: "owner_authored", + }, + { + type: "other", + label: "Unsafe link", + url: "http://example.invalid/unsafe", + source: "reviewed", + }, + ], claimState: "unclaimed", publicationState: "published", creationSource: "community", @@ -254,5 +269,58 @@ describe("public profile projection", () => { assert.equal("sourceAttribution" in publicProfile, false); assert.equal("creationSource" in publicProfile, false); assert.equal(publicProfile.trustLabel, "community_submitted"); + assert.equal(publicProfile.outboundLinks.length, 1); + assert.equal(publicProfile.outboundLinks[0]?.url, "https://example.invalid/dj-celine-kofi"); + }); +}); + +describe("public profile world credits", () => { + it("derives reciprocal credits from indexed published-world attribution records", () => { + const publishedWorld = { + slug: "neon-harbor", + displayName: "Neon Harbor", + sortName: "neon harbor", + tags: ["Club world"], + summary: "A VRChat venue.", + visibilityStatus: "public", + platformCompatibility: ["pc"], + media: [], + creatorAttributions: [], + outboundLinks: [], + publicationState: "published", + creationSource: "self", + updatedAt: 1, + } as Doc<"worlds">; + const draftWorld = { + ...publishedWorld, + slug: "draft-world", + displayName: "Draft World", + publicationState: "draft_private", + } as Doc<"worlds">; + const worldAuthorCredit = { + worldId: "world123", + profileSlug: "afterglow-social", + profileType: "community", + role: "world_author", + sourceLabel: "Reviewed attribution", + updatedAt: 1, + } as unknown as Doc<"worldProfileCredits">; + const storefrontCredit = { + ...worldAuthorCredit, + role: "storefront_owner", + } as unknown as Doc<"worldProfileCredits">; + + const credits = createPublicProfileWorldCredits( + [ + { credit: worldAuthorCredit, world: draftWorld }, + { credit: worldAuthorCredit, world: publishedWorld }, + { credit: storefrontCredit, world: publishedWorld }, + ], + ); + + assert.equal(credits.length, 1); + assert.equal(credits[0]?.slug, "neon-harbor"); + assert.deepEqual(credits[0]?.roles, ["world_author", "storefront_owner"]); + assert.equal(credits[0]?.sourceLabel, "Reviewed attribution"); }); }); diff --git a/tests/backend/world-foundation.test.ts b/tests/backend/world-foundation.test.ts new file mode 100644 index 0000000..e20a0d1 --- /dev/null +++ b/tests/backend/world-foundation.test.ts @@ -0,0 +1,502 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; + +import type { Doc } from "../../convex/_generated/dataModel"; +import { isValidVrchatWorldId, toCanonicalVrchatWorldUrl } from "../../convex/_worldIds"; +import { createPublicActiveWorldPreviews, createPublicWorldEventContext } from "../../convex/_worldEvents"; +import { toPublicWorld } from "../../convex/_worldPublic"; +import { + createWorldSlugBase, + createWorldSlugCandidate, + normalizeWorldSlugInput, + toWorldSlug, + validateWorldSlug, + WORLD_SLUG_MAX_LENGTH, +} from "../../convex/_worldSlugs"; + +describe("world slug helpers", () => { + it("normalizes world names into strict ASCII slug candidates", () => { + assert.equal(normalizeWorldSlugInput(" Neon Harbor & Friends!! "), "neon-harbor-and-friends"); + }); + + it("validates canonical world slug rules", () => { + assert.deepEqual(validateWorldSlug("neon-harbor"), { + ok: true, + slug: "neon-harbor", + }); + assert.deepEqual(validateWorldSlug("Neon-Harbor"), { + ok: false, + reason: "invalid_format", + }); + assert.deepEqual(validateWorldSlug("worlds"), { + ok: false, + reason: "reserved", + }); + }); + + it("turns freeform input into a valid world slug result", () => { + assert.deepEqual(toWorldSlug("Neon Harbor"), { + ok: true, + slug: "neon-harbor", + }); + }); + + it("generates safe bases and retry candidates", () => { + assert.equal(createWorldSlugBase("vr"), "vr-world"); + assert.equal(createWorldSlugBase("worlds"), "worlds-world"); + assert.equal(createWorldSlugBase("!!!"), "world-page"); + + const base = "a".repeat(WORLD_SLUG_MAX_LENGTH); + const candidate = createWorldSlugCandidate(base, 12); + + assert.equal(candidate.length, WORLD_SLUG_MAX_LENGTH); + assert.equal(candidate.endsWith("-12"), true); + }); +}); + +describe("VRChat world id helpers", () => { + it("accepts VRChat world ids and derives canonical URLs", () => { + const worldId = "wrld_00000000-0000-4000-8000-000000000001"; + + assert.equal(isValidVrchatWorldId(worldId), true); + assert.equal( + toCanonicalVrchatWorldUrl(worldId), + "https://vrchat.com/home/world/wrld_00000000-0000-4000-8000-000000000001", + ); + }); + + it("rejects invalid world ids", () => { + assert.equal(isValidVrchatWorldId("world_00000000-0000-4000-8000-000000000001"), false); + assert.equal(toCanonicalVrchatWorldUrl("wrld_not-a-uuid"), null); + }); +}); + +describe("public world projection", () => { + it("omits raw source and profile ids while preserving public attribution", () => { + const world = { + slug: "neon-harbor", + displayName: "Neon Harbor", + sortName: "neon harbor", + tags: ["Club world"], + summary: "A VRChat venue.", + vrchatWorldId: "wrld_00000000-0000-4000-8000-000000000001", + canonicalVrchatWorldUrl: + "https://vrchat.com/home/world/wrld_00000000-0000-4000-8000-000000000001", + sourceUrl: "http://example.invalid/source", + visibilityStatus: "public", + platformCompatibility: ["pc"], + heroImageUrl: "http://example.invalid/hero.png", + media: [ + { + kind: "image", + url: "https://example.invalid/screenshot.png", + }, + { + kind: "image", + url: "http://example.invalid/unsafe.png", + }, + ], + creatorAttributions: [ + { + role: "world_author", + displayName: "Afterglow Social", + profileId: "profile123", + profileSlug: "afterglow-social", + profileType: "community", + sourceLabel: "Owner-authored", + }, + ], + outboundLinks: [ + { + type: "gumroad", + label: "Prefab pack", + url: "https://example.invalid/prefab", + source: "owner_authored", + }, + { + type: "other", + label: "Unsafe link", + url: "http://example.invalid/unsafe", + source: "reviewed", + }, + ], + publicationState: "published", + creationSource: "self", + sourceAttribution: { + sourceType: "owner", + label: "Owner-authored metadata", + url: "https://example.invalid/source", + submittedAt: 1, + confirmedAt: 2, + }, + publishedAt: 1, + updatedAt: 2, + } as Doc<"worlds">; + + const publicWorld = toPublicWorld(world); + + assert.equal("creationSource" in publicWorld, false); + assert.equal("sourceAttribution" in publicWorld, false); + assert.equal("profileId" in publicWorld.creatorAttributions[0], false); + assert.equal(publicWorld.creatorAttributions[0]?.profileSlug, "afterglow-social"); + assert.equal(publicWorld.source?.label, "Owner-authored metadata"); + assert.equal(publicWorld.source?.confirmedAt, 2); + assert.equal("sourceUrl" in publicWorld, false); + assert.equal("heroImageUrl" in publicWorld, false); + assert.equal(publicWorld.media.length, 1); + assert.equal(publicWorld.outboundLinks.length, 1); + }); + + it("omits source instead of returning undefined when source attribution is absent", () => { + const world = { + slug: "neon-harbor", + displayName: "Neon Harbor", + sortName: "neon harbor", + tags: [], + visibilityStatus: "public", + platformCompatibility: ["pc"], + media: [], + creatorAttributions: [], + outboundLinks: [], + publicationState: "published", + creationSource: "self", + updatedAt: 1, + } as Doc<"worlds">; + + const publicWorld = toPublicWorld(world); + + assert.equal("source" in publicWorld, false); + }); +}); + +describe("public world event context", () => { + it("publishes confirmed event-world links as upcoming and recent event previews", () => { + const now = Date.UTC(2026, 4, 24, 12, 0, 0); + const association = { + eventId: "event123", + worldId: "world123", + sourceType: "manual", + confidence: 1, + confirmationState: "confirmed", + confirmedAt: now - 1_000, + updatedAt: now, + } as unknown as Doc<"eventWorlds">; + + const upcomingEvent = { + _id: "event123", + title: "Afterglow Harbor Sessions", + sortTitle: "afterglow harbor sessions", + startAt: now + 86_400_000, + endAt: now + 90_000_000, + timezone: "UTC", + communityName: "Afterglow Social", + summary: "A confirmed fixture event.", + sourceType: "manual", + sourceLabel: "Fixture event listing", + sourceUrl: "https://example.invalid/events/afterglow-harbor-sessions", + publicationState: "published", + updatedAt: now, + } as unknown as Doc<"events">; + + const recentEvent = { + _id: "event456", + title: "Neon Harbor Opening Night", + sortTitle: "neon harbor opening night", + startAt: now - 86_400_000, + communityName: "Afterglow Social", + sourceType: "community", + sourceLabel: "Community-submitted event", + sourceUrl: "http://example.invalid/unsafe-event", + publicationState: "published", + updatedAt: now, + } as unknown as Doc<"events">; + + const context = createPublicWorldEventContext( + [ + { event: recentEvent, association }, + { event: upcomingEvent, association }, + ], + now, + ); + + assert.deepEqual( + context.upcoming.map((event) => event.title), + ["Afterglow Harbor Sessions"], + ); + assert.deepEqual( + context.recent.map((event) => event.title), + ["Neon Harbor Opening Night"], + ); + assert.equal( + context.upcoming[0]?.source.url, + "https://example.invalid/events/afterglow-harbor-sessions", + ); + assert.equal("url" in context.recent[0]!.source, false); + assert.equal(context.upcoming[0]?.worldAssociation.confirmationState, "confirmed"); + }); + + it("deduplicates duplicate confirmed associations for the same world event preview", () => { + const now = Date.UTC(2026, 4, 24, 12, 0, 0); + const event = { + _id: "event123", + title: "Afterglow Harbor Sessions", + sortTitle: "afterglow harbor sessions", + startAt: now + 86_400_000, + sourceType: "manual", + sourceLabel: "Fixture event listing", + publicationState: "published", + updatedAt: now, + } as unknown as Doc<"events">; + const manualAssociation = { + eventId: "event123", + worldId: "world123", + eventStartAt: event.startAt, + sourceType: "manual", + confidence: 1, + confirmationState: "confirmed", + updatedAt: now, + } as unknown as Doc<"eventWorlds">; + const partnerAssociation = { + ...manualAssociation, + sourceType: "partner", + } as unknown as Doc<"eventWorlds">; + + const context = createPublicWorldEventContext( + [ + { event, association: manualAssociation }, + { event, association: partnerAssociation }, + ], + now, + ); + + assert.equal(context.upcoming.length, 1); + assert.equal(context.upcoming[0]?.title, "Afterglow Harbor Sessions"); + }); + + it("omits unconfirmed associations and unpublished events from public world pages", () => { + const now = Date.UTC(2026, 4, 24, 12, 0, 0); + const publishedEvent = { + _id: "event123", + title: "Unreviewed Venue Guess", + sortTitle: "unreviewed venue guess", + startAt: now + 86_400_000, + sourceType: "ai_suggested", + sourceLabel: "AI-suggested match", + publicationState: "published", + updatedAt: now, + } as unknown as Doc<"events">; + const draftEvent = { + ...publishedEvent, + title: "Draft Event", + publicationState: "draft_private", + } as unknown as Doc<"events">; + const unconfirmedAssociation = { + eventId: "event123", + worldId: "world123", + sourceType: "ai_suggested", + confidence: 0.8, + confirmationState: "unconfirmed", + updatedAt: now, + } as unknown as Doc<"eventWorlds">; + const confirmedAssociation = { + ...unconfirmedAssociation, + sourceType: "manual", + confirmationState: "confirmed", + } as unknown as Doc<"eventWorlds">; + + const context = createPublicWorldEventContext( + [ + { event: publishedEvent, association: unconfirmedAssociation }, + { event: draftEvent, association: confirmedAssociation }, + ], + now, + ); + + assert.equal(context.upcoming.length, 0); + assert.equal(context.recent.length, 0); + }); +}); + +describe("public active world previews", () => { + it("groups confirmed future event-world records into honest home-page venue cards", () => { + const now = Date.UTC(2026, 4, 24, 12, 0, 0); + const world = { + slug: "neon-harbor", + displayName: "Neon Harbor", + sortName: "neon harbor", + tags: ["Club world", "Cyberpunk"], + summary: "A VRChat venue.", + heroImageUrl: "http://example.invalid/unsafe-hero.png", + visibilityStatus: "public", + platformCompatibility: ["pc"], + media: [], + creatorAttributions: [], + outboundLinks: [], + publicationState: "published", + creationSource: "self", + updatedAt: now, + } as unknown as Doc<"worlds">; + const association = { + eventId: "event123", + worldId: "world123", + sourceType: "manual", + confidence: 1, + confirmationState: "confirmed", + updatedAt: now, + } as unknown as Doc<"eventWorlds">; + const laterEvent = { + _id: "event456", + title: "Afterglow Late Set", + sortTitle: "afterglow late set", + startAt: now + 172_800_000, + sourceType: "manual", + sourceLabel: "Fixture event listing", + publicationState: "published", + updatedAt: now, + } as unknown as Doc<"events">; + const nextEvent = { + ...laterEvent, + _id: "event123", + title: "Afterglow Harbor Sessions", + sortTitle: "afterglow harbor sessions", + startAt: now + 86_400_000, + communityName: "Afterglow Social", + sourceUrl: "http://example.invalid/unsafe-event", + } as unknown as Doc<"events">; + + const previews = createPublicActiveWorldPreviews( + [ + { association, event: laterEvent, world }, + { association, event: nextEvent, world }, + ], + now, + 3, + ); + + assert.equal(previews.length, 1); + assert.equal(previews[0]?.displayName, "Neon Harbor"); + assert.equal(previews[0]?.activityLabel, "Hosting upcoming events"); + assert.equal(previews[0]?.upcomingEventCount, 2); + assert.equal(previews[0]?.nextEvent.title, "Afterglow Harbor Sessions"); + assert.equal("heroImageUrl" in previews[0]!, false); + assert.equal("url" in previews[0]!.nextEvent.source, false); + }); + + it("deduplicates duplicate event-world association rows for the same event and world", () => { + const now = Date.UTC(2026, 4, 24, 12, 0, 0); + const world = { + slug: "neon-harbor", + displayName: "Neon Harbor", + sortName: "neon harbor", + tags: [], + visibilityStatus: "public", + platformCompatibility: ["pc"], + media: [], + creatorAttributions: [], + outboundLinks: [], + publicationState: "published", + creationSource: "self", + updatedAt: now, + } as unknown as Doc<"worlds">; + const event = { + _id: "event123", + title: "Afterglow Harbor Sessions", + sortTitle: "afterglow harbor sessions", + startAt: now + 86_400_000, + sourceType: "manual", + sourceLabel: "Fixture event listing", + publicationState: "published", + updatedAt: now, + } as unknown as Doc<"events">; + const manualAssociation = { + eventId: "event123", + worldId: "world123", + eventStartAt: event.startAt, + sourceType: "manual", + confidence: 1, + confirmationState: "confirmed", + updatedAt: now, + } as unknown as Doc<"eventWorlds">; + const partnerAssociation = { + ...manualAssociation, + sourceType: "partner", + } as unknown as Doc<"eventWorlds">; + + const previews = createPublicActiveWorldPreviews( + [ + { association: manualAssociation, event, world }, + { association: partnerAssociation, event, world }, + ], + now, + 3, + ); + + assert.equal(previews.length, 1); + assert.equal(previews[0]?.upcomingEventCount, 1); + }); + + it("excludes draft worlds, draft events, past events, and unconfirmed associations", () => { + const now = Date.UTC(2026, 4, 24, 12, 0, 0); + const world = { + slug: "neon-harbor", + displayName: "Neon Harbor", + sortName: "neon harbor", + tags: [], + visibilityStatus: "public", + platformCompatibility: ["pc"], + media: [], + creatorAttributions: [], + outboundLinks: [], + publicationState: "published", + creationSource: "self", + updatedAt: now, + } as unknown as Doc<"worlds">; + const event = { + _id: "event123", + title: "Unreviewed Venue Guess", + sortTitle: "unreviewed venue guess", + startAt: now + 86_400_000, + sourceType: "ai_suggested", + sourceLabel: "AI-suggested match", + publicationState: "published", + updatedAt: now, + } as unknown as Doc<"events">; + const confirmedAssociation = { + eventId: "event123", + worldId: "world123", + sourceType: "manual", + confidence: 1, + confirmationState: "confirmed", + updatedAt: now, + } as unknown as Doc<"eventWorlds">; + const unconfirmedAssociation = { + ...confirmedAssociation, + confirmationState: "unconfirmed", + } as unknown as Doc<"eventWorlds">; + + const previews = createPublicActiveWorldPreviews( + [ + { association: unconfirmedAssociation, event, world }, + { + association: confirmedAssociation, + event: { ...event, publicationState: "draft_private" } as unknown as Doc<"events">, + world, + }, + { + association: confirmedAssociation, + event: { ...event, startAt: now - 86_400_000 } as unknown as Doc<"events">, + world, + }, + { + association: confirmedAssociation, + event, + world: { ...world, publicationState: "draft_private" } as unknown as Doc<"worlds">, + }, + ], + now, + 3, + ); + + assert.equal(previews.length, 0); + }); +});