diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index 7c1bc65..756fc61 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -10,6 +10,7 @@ import { registerContributionRoutes } from './routes/contributions.js'; import { registerHealthRoute } from './routes/health.js'; import { registerLoreRoutes } from './routes/lore.js'; import { registerProposalRoutes } from './routes/proposals.js'; +import { registerProfileRoutes } from './routes/profiles.js'; import { registerWorldRoutes } from './routes/worlds.js'; export async function buildApp() { @@ -52,6 +53,7 @@ export async function buildApp() { await registerLoreRoutes(app); await registerContributionRoutes(app); await registerProposalRoutes(app); + await registerProfileRoutes(app); return app; } diff --git a/apps/api/src/lib/user-profiles.ts b/apps/api/src/lib/user-profiles.ts new file mode 100644 index 0000000..417085c --- /dev/null +++ b/apps/api/src/lib/user-profiles.ts @@ -0,0 +1,212 @@ +import { normalizeHiveUsername } from './hive-username.js'; + +export type ReputationLevel = { + badge: string; + label: string; + nextScore: number | null; +}; + +export type UserProfileDatabase = { + user: { + findUnique(args: unknown): Promise<{ + avatarUrl: string | null; + bio: string | null; + createdAt: Date; + displayName: string | null; + hiveUsername: string; + id: string; + normalizedHiveUsername: string; + contributionDrafts: Array<{ + id: string; + kind: string; + proposal: { id: string; status: string; submittedAt: Date | null } | null; + status: string; + submittedAt: Date | null; + title: string; + updatedAt: Date; + world: { id: string; slug: string; title: string }; + }>; + loreEntries: Array<{ + id: string; + loreType: string; + slug: string; + status: string; + title: string; + updatedAt: Date; + world: { id: string; slug: string; title: string }; + }>; + reputationSnapshots: Array<{ + breakdown: unknown; + calculatedAt: Date; + calculationVersion: string; + score: { toString(): string } | number | string; + }>; + votes: Array<{ + choice: string; + createdAt: Date; + proposal: { + id: string; + status: string; + title: string; + world: { id: string; slug: string; title: string }; + }; + }>; + } | null>; + }; +}; + +export function getReputationLevel(score: number): ReputationLevel { + if (score >= 250) { + return { badge: 'Worldsmith', label: 'Worldsmith', nextScore: null }; + } + + if (score >= 100) { + return { badge: 'Canon Keeper', label: 'Canon Keeper', nextScore: 250 }; + } + + if (score >= 25) { + return { badge: 'Contributor', label: 'Contributor', nextScore: 100 }; + } + + return { badge: 'Seedling', label: 'Seedling', nextScore: 25 }; +} + +function scoreToNumber(score: { toString(): string } | number | string) { + return Number(score.toString()); +} + +function serializeDate(value: Date | null) { + return value?.toISOString() ?? null; +} + +export async function getUserProfile(database: UserProfileDatabase, username: string) { + const normalizedHiveUsername = normalizeHiveUsername(username); + const user = await database.user.findUnique({ + where: { normalizedHiveUsername }, + select: { + avatarUrl: true, + bio: true, + createdAt: true, + displayName: true, + hiveUsername: true, + id: true, + normalizedHiveUsername: true, + contributionDrafts: { + // Public endpoint: never expose unsubmitted drafts (ContributionStatus.DRAFT). + where: { status: 'SUBMITTED' }, + orderBy: { updatedAt: 'desc' }, + select: { + id: true, + kind: true, + proposal: { select: { id: true, status: true, submittedAt: true } }, + status: true, + submittedAt: true, + title: true, + updatedAt: true, + world: { select: { id: true, slug: true, title: true } }, + }, + take: 10, + }, + loreEntries: { + // Public endpoint: only canon is public. DRAFT/SUBMITTED/APPROVED_FOR_PUBLICATION + // and ARCHIVED entries stay private to the author. + where: { status: 'PUBLISHED_CANON' }, + orderBy: { updatedAt: 'desc' }, + select: { + id: true, + loreType: true, + slug: true, + status: true, + title: true, + updatedAt: true, + world: { select: { id: true, slug: true, title: true } }, + }, + take: 10, + }, + reputationSnapshots: { + orderBy: { calculatedAt: 'desc' }, + select: { + breakdown: true, + calculatedAt: true, + calculationVersion: true, + score: true, + }, + take: 1, + }, + votes: { + orderBy: { createdAt: 'desc' }, + select: { + choice: true, + createdAt: true, + proposal: { + select: { + id: true, + status: true, + title: true, + world: { select: { id: true, slug: true, title: true } }, + }, + }, + }, + take: 10, + }, + }, + }); + + if (!user) { + return null; + } + + const snapshot = user.reputationSnapshots[0] ?? null; + const score = snapshot ? scoreToNumber(snapshot.score) : 0; + + return { + user: { + avatarUrl: user.avatarUrl, + bio: user.bio, + createdAt: user.createdAt.toISOString(), + displayName: user.displayName, + hiveUsername: user.hiveUsername, + id: user.id, + normalizedHiveUsername: user.normalizedHiveUsername, + }, + reputation: { + breakdown: snapshot?.breakdown ?? null, + calculatedAt: serializeDate(snapshot?.calculatedAt ?? null), + calculationVersion: snapshot?.calculationVersion ?? null, + level: getReputationLevel(score), + score, + }, + history: { + contributions: user.contributionDrafts.map((draft) => ({ + id: draft.id, + kind: draft.kind, + proposal: draft.proposal + ? { + id: draft.proposal.id, + status: draft.proposal.status, + submittedAt: serializeDate(draft.proposal.submittedAt), + } + : null, + status: draft.status, + submittedAt: serializeDate(draft.submittedAt), + title: draft.title, + updatedAt: draft.updatedAt.toISOString(), + world: draft.world, + })), + loreEntries: user.loreEntries.map((entry) => ({ + id: entry.id, + loreType: entry.loreType, + slug: entry.slug, + status: entry.status, + title: entry.title, + updatedAt: entry.updatedAt.toISOString(), + world: entry.world, + })), + votes: user.votes.map((vote) => ({ + choice: vote.choice, + createdAt: vote.createdAt.toISOString(), + proposal: vote.proposal, + })), + }, + }; +} diff --git a/apps/api/src/routes/profiles.test.ts b/apps/api/src/routes/profiles.test.ts new file mode 100644 index 0000000..735ca29 --- /dev/null +++ b/apps/api/src/routes/profiles.test.ts @@ -0,0 +1,257 @@ +import assert from 'node:assert/strict'; +import { describe, test } from 'node:test'; + +import Fastify from 'fastify'; + +import type { UserProfileDatabase } from '../lib/user-profiles.js'; +import { registerProfileRoutes } from './profiles.js'; + +const user = { + avatarUrl: null, + bio: 'Builder of small myths.', + createdAt: new Date('2026-08-01T10:00:00.000Z'), + displayName: 'Mira Vale', + hiveUsername: 'mira-vale.dev', + id: 'user-1', + normalizedHiveUsername: 'mira-vale.dev', +}; + +function createDatabase(): UserProfileDatabase { + return { + user: { + async findUnique(args) { + const query = args as { + where?: { normalizedHiveUsername?: string }; + select?: { + contributionDrafts?: { where?: { status?: string } }; + loreEntries?: { where?: { status?: string } }; + }; + }; + + // Honour the nested relation filters the way Prisma would, so that a + // missing `where` in the query surfaces as leaked rows in the response. + const applyStatusFilter = ( + rows: T[], + filter: { status?: string } | undefined, + ) => (filter?.status ? rows.filter((row) => row.status === filter.status) : rows); + + if (query.where?.normalizedHiveUsername !== user.normalizedHiveUsername) { + return null; + } + + return { + ...user, + contributionDrafts: applyStatusFilter( + [ + { + id: 'draft-1', + kind: 'LORE', + proposal: { + id: 'proposal-1', + status: 'VOTING', + submittedAt: new Date('2026-08-03T12:00:00.000Z'), + }, + status: 'SUBMITTED', + submittedAt: new Date('2026-08-03T12:00:00.000Z'), + title: 'The amber gate opens', + updatedAt: new Date('2026-08-03T12:00:00.000Z'), + world: { id: 'world-1', slug: 'ember-city', title: 'Ember City' }, + }, + { + id: 'draft-secret', + kind: 'LORE', + proposal: null, + status: 'DRAFT', + submittedAt: null, + title: 'UNSUBMITTED private draft', + updatedAt: new Date('2026-08-07T12:00:00.000Z'), + world: { id: 'world-1', slug: 'ember-city', title: 'Ember City' }, + }, + ], + query.select?.contributionDrafts?.where, + ), + loreEntries: applyStatusFilter( + [ + { + id: 'entry-1', + loreType: 'LOCATION', + slug: 'amber-gate', + status: 'PUBLISHED_CANON', + title: 'Amber Gate', + updatedAt: new Date('2026-08-04T12:00:00.000Z'), + world: { id: 'world-1', slug: 'ember-city', title: 'Ember City' }, + }, + { + id: 'entry-secret', + loreType: 'LOCATION', + slug: 'unpublished-vault', + status: 'DRAFT', + title: 'UNPUBLISHED private lore', + updatedAt: new Date('2026-08-08T12:00:00.000Z'), + world: { id: 'world-1', slug: 'ember-city', title: 'Ember City' }, + }, + ], + query.select?.loreEntries?.where, + ), + reputationSnapshots: [ + { + breakdown: { signals: { canonizedContributions: 1 } }, + calculatedAt: new Date('2026-08-05T12:00:00.000Z'), + calculationVersion: 'hivelore-reputation-v1', + score: 125, + }, + ], + votes: [ + { + choice: 'APPROVE', + createdAt: new Date('2026-08-06T12:00:00.000Z'), + proposal: { + id: 'proposal-2', + status: 'APPROVED_FOR_PUBLICATION', + title: 'A foundry oath', + world: { id: 'world-1', slug: 'ember-city', title: 'Ember City' }, + }, + }, + ], + }; + }, + }, + }; +} + +async function buildTestApp(database = createDatabase()) { + const app = Fastify(); + await registerProfileRoutes(app, { database }); + + return app; +} + +describe('profile routes', () => { + test('returns reputation, badge, and contribution history for a Hive user', async () => { + const app = await buildTestApp(); + + const response = await app.inject({ + method: 'GET', + url: '/profiles/Mira-Vale.dev', + }); + + assert.equal(response.statusCode, 200); + assert.deepEqual(response.json(), { + history: { + contributions: [ + { + id: 'draft-1', + kind: 'LORE', + proposal: { + id: 'proposal-1', + status: 'VOTING', + submittedAt: '2026-08-03T12:00:00.000Z', + }, + status: 'SUBMITTED', + submittedAt: '2026-08-03T12:00:00.000Z', + title: 'The amber gate opens', + updatedAt: '2026-08-03T12:00:00.000Z', + world: { id: 'world-1', slug: 'ember-city', title: 'Ember City' }, + }, + ], + loreEntries: [ + { + id: 'entry-1', + loreType: 'LOCATION', + slug: 'amber-gate', + status: 'PUBLISHED_CANON', + title: 'Amber Gate', + updatedAt: '2026-08-04T12:00:00.000Z', + world: { id: 'world-1', slug: 'ember-city', title: 'Ember City' }, + }, + ], + votes: [ + { + choice: 'APPROVE', + createdAt: '2026-08-06T12:00:00.000Z', + proposal: { + id: 'proposal-2', + status: 'APPROVED_FOR_PUBLICATION', + title: 'A foundry oath', + world: { id: 'world-1', slug: 'ember-city', title: 'Ember City' }, + }, + }, + ], + }, + reputation: { + breakdown: { signals: { canonizedContributions: 1 } }, + calculatedAt: '2026-08-05T12:00:00.000Z', + calculationVersion: 'hivelore-reputation-v1', + level: { + badge: 'Canon Keeper', + label: 'Canon Keeper', + nextScore: 250, + }, + score: 125, + }, + user: { + avatarUrl: null, + bio: 'Builder of small myths.', + createdAt: '2026-08-01T10:00:00.000Z', + displayName: 'Mira Vale', + hiveUsername: 'mira-vale.dev', + id: 'user-1', + normalizedHiveUsername: 'mira-vale.dev', + }, + }); + }); + + test('never exposes unsubmitted drafts or non-canon lore on the public profile', async () => { + const app = await buildTestApp(); + + const response = await app.inject({ + method: 'GET', + url: '/profiles/mira-vale.dev', + }); + + assert.equal(response.statusCode, 200); + + const body = response.json(); + const serialised = JSON.stringify(body); + + // The endpoint is unauthenticated, so private work must never appear. + assert.ok( + !serialised.includes('UNSUBMITTED private draft'), + 'unsubmitted contribution draft leaked on a public profile', + ); + assert.ok( + !serialised.includes('UNPUBLISHED private lore'), + 'non-canon lore entry leaked on a public profile', + ); + assert.ok(!serialised.includes('draft-secret'), 'unsubmitted draft id leaked'); + assert.ok(!serialised.includes('entry-secret'), 'non-canon lore id leaked'); + + // and the legitimately public rows are still present + assert.ok(serialised.includes('The amber gate opens')); + assert.ok(serialised.includes('Amber Gate')); + }); + + test('returns 404 for an unknown profile', async () => { + const app = await buildTestApp(); + + const response = await app.inject({ + method: 'GET', + url: '/profiles/unknown.dev', + }); + + assert.equal(response.statusCode, 404); + assert.deepEqual(response.json(), { error: 'Profile not found.' }); + }); + + test('rejects invalid Hive usernames', async () => { + const app = await buildTestApp(); + + const response = await app.inject({ + method: 'GET', + url: '/profiles/not_valid', + }); + + assert.equal(response.statusCode, 400); + assert.deepEqual(response.json(), { error: 'Invalid Hive username.' }); + }); +}); diff --git a/apps/api/src/routes/profiles.ts b/apps/api/src/routes/profiles.ts new file mode 100644 index 0000000..8a2b2b5 --- /dev/null +++ b/apps/api/src/routes/profiles.ts @@ -0,0 +1,51 @@ +import type { FastifyInstance } from 'fastify'; +import { z } from 'zod'; + +import { InvalidHiveUsernameError } from '../lib/hive-username.js'; +import { getUserProfile, type UserProfileDatabase } from '../lib/user-profiles.js'; + +const profileParamsSchema = z.object({ + username: z.string().min(1).max(64), +}); + +type RegisterProfileRoutesOptions = { + database?: UserProfileDatabase; +}; + +export async function registerProfileRoutes( + app: FastifyInstance, + options: RegisterProfileRoutesOptions = {}, +) { + const database = + options.database ?? ((await import('../lib/prisma.js')).prisma as UserProfileDatabase); + + app.get('/profiles/:username', async (request, reply) => { + const params = profileParamsSchema.safeParse(request.params); + + if (!params.success) { + return reply.code(400).send({ + error: 'Invalid profile route.', + }); + } + + try { + const profile = await getUserProfile(database, params.data.username); + + if (!profile) { + return reply.code(404).send({ + error: 'Profile not found.', + }); + } + + return profile; + } catch (error) { + if (error instanceof InvalidHiveUsernameError) { + return reply.code(400).send({ + error: 'Invalid Hive username.', + }); + } + + throw error; + } + }); +} diff --git a/apps/web/src/app/profile/[username]/page.tsx b/apps/web/src/app/profile/[username]/page.tsx index 07be111..a621378 100644 --- a/apps/web/src/app/profile/[username]/page.tsx +++ b/apps/web/src/app/profile/[username]/page.tsx @@ -1,4 +1,11 @@ -import { PlaceholderPage } from '@/components/layout/placeholder-page'; +import Link from 'next/link'; +import { notFound } from 'next/navigation'; + +import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; +import { Badge } from '@/components/ui/badge'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { ApiError } from '@/lib/api/errors'; +import { getUserProfile } from '@/lib/api/profiles'; type ProfilePageProps = { params: Promise<{ username: string }>; @@ -6,6 +13,185 @@ type ProfilePageProps = { export default async function ProfilePage({ params }: ProfilePageProps) { const { username } = await params; + let profile: Awaited>; + + try { + profile = await getUserProfile(username); + } catch (error) { + if (error instanceof ApiError && error.status === 404) { + notFound(); + } + + return ( + + Profile could not load + + {error instanceof ApiError + ? (error.body?.error ?? 'Unable to load this profile.') + : 'Unable to load this profile.'} + + + ); + } + + const displayName = profile.user.displayName ?? profile.user.hiveUsername; + const totalActivity = + profile.history.contributions.length + + profile.history.loreEntries.length + + profile.history.votes.length; + + return ( +
+
+
+

+ Contributor profile +

+

+ {displayName} +

+

@{profile.user.hiveUsername}

+ {profile.user.bio ? ( +

{profile.user.bio}

+ ) : null} +
+ + + + Reputation + Recognition only; voting weight is unchanged. + + +
{profile.reputation.score}
+
+ {profile.reputation.level.badge} + {profile.reputation.level.nextScore ? ( + Next at {profile.reputation.level.nextScore} + ) : null} +
+

+ {profile.reputation.calculatedAt + ? `Updated ${new Date(profile.reputation.calculatedAt).toLocaleDateString()}` + : 'No reputation snapshot yet.'} +

+
+
+
+ +
+ + +

Contributions

+

{profile.history.contributions.length}

+
+
+ + +

Canon entries

+

{profile.history.loreEntries.length}

+
+
+ + +

Votes

+

{profile.history.votes.length}

+
+
+
+ +
+ + {profile.history.contributions.map((contribution) => ( +
  • +
    + {contribution.kind} + + {contribution.status} + +
    +

    {contribution.title}

    +

    + + {contribution.world.title} + {' '} + · updated {new Date(contribution.updatedAt).toLocaleDateString()} +

    +
  • + ))} +
    + + + {profile.history.loreEntries.map((entry) => ( +
  • +
    + {entry.loreType} + {entry.status} +
    +

    {entry.title}

    +

    + + {entry.world.title} + {' '} + · updated {new Date(entry.updatedAt).toLocaleDateString()} +

    +
  • + ))} +
    +
    + + + {profile.history.votes.map((vote) => ( +
  • +
    + {vote.choice} + {vote.proposal.status} +
    +

    {vote.proposal.title}

    +

    + {vote.proposal.world.title} · voted {new Date(vote.createdAt).toLocaleDateString()} +

    +
  • + ))} +
    + + {totalActivity === 0 ? ( + + +

    + This contributor has no public contribution history yet. +

    +
    +
    + ) : null} +
    + ); +} + +function ProfileList({ children, title }: { children: React.ReactNode; title: string }) { + const items = Array.isArray(children) ? children.filter(Boolean) : children; + const isEmpty = Array.isArray(items) && items.length === 0; - return ; + return ( +
    +

    {title}

    + {isEmpty ? ( + + +

    Nothing to show yet.

    +
    +
    + ) : ( +
      {items}
    + )} +
    + ); } diff --git a/apps/web/src/lib/api/profiles.ts b/apps/web/src/lib/api/profiles.ts new file mode 100644 index 0000000..f87a528 --- /dev/null +++ b/apps/web/src/lib/api/profiles.ts @@ -0,0 +1,59 @@ +import { apiClient } from '@/lib/api/client'; + +export type UserProfile = { + user: { + avatarUrl: string | null; + bio: string | null; + createdAt: string; + displayName: string | null; + hiveUsername: string; + id: string; + normalizedHiveUsername: string; + }; + reputation: { + breakdown: unknown; + calculatedAt: string | null; + calculationVersion: string | null; + level: { + badge: string; + label: string; + nextScore: number | null; + }; + score: number; + }; + history: { + contributions: Array<{ + id: string; + kind: string; + proposal: { id: string; status: string; submittedAt: string | null } | null; + status: string; + submittedAt: string | null; + title: string; + updatedAt: string; + world: { id: string; slug: string; title: string }; + }>; + loreEntries: Array<{ + id: string; + loreType: string; + slug: string; + status: string; + title: string; + updatedAt: string; + world: { id: string; slug: string; title: string }; + }>; + votes: Array<{ + choice: string; + createdAt: string; + proposal: { + id: string; + status: string; + title: string; + world: { id: string; slug: string; title: string }; + }; + }>; + }; +}; + +export function getUserProfile(username: string) { + return apiClient.get(`/profiles/${encodeURIComponent(username)}`); +}