From 2e83a8f64e008281bf068a1d5eafc22934f441f5 Mon Sep 17 00:00:00 2001 From: Perla Imad Date: Wed, 5 Aug 2026 19:07:05 +0300 Subject: [PATCH 01/22] Implement world seed and hub backend --- README.md | 8 + .../migration.sql | 22 + apps/api/prisma/schema.prisma | 21 + apps/api/prisma/seed.ts | 25 + apps/api/src/app.ts | 2 + apps/api/src/lib/worlds.ts | 505 ++++++++++++++++ apps/api/src/routes/worlds.test.ts | 555 ++++++++++++++++++ apps/api/src/routes/worlds.ts | 230 ++++++++ 8 files changed, 1368 insertions(+) create mode 100644 apps/api/prisma/migrations/20260805123000_add_world_seed/migration.sql create mode 100644 apps/api/src/lib/worlds.ts create mode 100644 apps/api/src/routes/worlds.test.ts create mode 100644 apps/api/src/routes/worlds.ts diff --git a/README.md b/README.md index b4df504..4e191dd 100644 --- a/README.md +++ b/README.md @@ -317,6 +317,14 @@ Implemented authentication endpoints: Google auth routes are not registered as a default login path. With `GOOGLE_AUTH_ENABLED=false`, `/auth/google/*` returns a not-enabled response. Enabling Google currently exposes the guarded boundary only; Google OAuth account linking and automatic Hive provisioning remain deferred until secure non-custodial account creation and key delivery are specified. +Implemented world endpoints: + +- `POST /worlds`: requires authentication, creates a mutable off-chain World Seed and initial `WorldBibleVersion` in one transaction, and assigns the authenticated user an active `FOUNDER` membership. The route never accepts a client-supplied founder or role and does not publish to Hive. +- `GET /worlds`: browses discoverable worlds with pagination and basic title/description, genre, and tone filters. +- `GET /worlds/:worldId`: returns one world with its founder, seed, and current latest World Bible version. +- `GET /worlds/:worldId/hub`: returns the World Hub aggregate for the frontend, including world seed, current bible preview, simple lore/proposal stats, and latest lore entry summaries. +- `PATCH /worlds/:worldId`: requires `EDIT_INITIAL_CANON` world permission and updates mutable off-chain world title/description, seed, and latest initial bible content only. It does not create proposals, canonize content, or publish to Hive. + --- # Reputation & Rewards diff --git a/apps/api/prisma/migrations/20260805123000_add_world_seed/migration.sql b/apps/api/prisma/migrations/20260805123000_add_world_seed/migration.sql new file mode 100644 index 0000000..d69a67b --- /dev/null +++ b/apps/api/prisma/migrations/20260805123000_add_world_seed/migration.sql @@ -0,0 +1,22 @@ +CREATE TABLE "WorldSeed" ( + "id" TEXT NOT NULL, + "worldId" TEXT NOT NULL, + "premise" TEXT NOT NULL, + "genre" TEXT NOT NULL, + "tone" TEXT NOT NULL, + "mainConflict" TEXT NOT NULL, + "startingLocation" TEXT, + "firstCharacters" JSONB, + "firstFactions" JSONB, + "firstHistoricalEvent" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "WorldSeed_pkey" PRIMARY KEY ("id") +); + +CREATE UNIQUE INDEX "WorldSeed_worldId_key" ON "WorldSeed"("worldId"); +CREATE INDEX "WorldSeed_genre_idx" ON "WorldSeed"("genre"); +CREATE INDEX "WorldSeed_tone_idx" ON "WorldSeed"("tone"); + +ALTER TABLE "WorldSeed" ADD CONSTRAINT "WorldSeed_worldId_fkey" FOREIGN KEY ("worldId") REFERENCES "World"("id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index 04b5691..9d952bd 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -294,6 +294,7 @@ model World { founder User @relation("WorldFounder", fields: [founderId], references: [id], onDelete: Restrict) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt + seed WorldSeed? bibleVersions WorldBibleVersion[] loreEntries LoreEntry[] loreRelationships LoreRelationship[] @@ -307,6 +308,26 @@ model World { @@index([createdAt]) } +model WorldSeed { + id String @id @default(cuid()) + worldId String @unique + world World @relation(fields: [worldId], references: [id], onDelete: Restrict) + premise String + genre String + tone String + mainConflict String + startingLocation String? + firstCharacters Json? + firstFactions Json? + firstHistoricalEvent String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + /// Mutable off-chain world seed. Hive publication requires a future approved publication flow. + + @@index([genre]) + @@index([tone]) +} + model WorldMembership { id String @id @default(cuid()) worldId String diff --git a/apps/api/prisma/seed.ts b/apps/api/prisma/seed.ts index 2da8d6a..8311856 100644 --- a/apps/api/prisma/seed.ts +++ b/apps/api/prisma/seed.ts @@ -75,6 +75,31 @@ async function main() { userId: founder.id, }); + await prisma.worldSeed.upsert({ + where: { + worldId: world.id, + }, + update: { + genre: 'Fantasy', + mainConflict: 'Weather, memory, and craft compete to define what the islands remember.', + premise: + 'A fictional chain of floating islands where memory, weather, and craft are tightly intertwined.', + tone: 'Luminous mystery', + }, + create: { + worldId: world.id, + premise: + 'A fictional chain of floating islands where memory, weather, and craft are tightly intertwined.', + genre: 'Fantasy', + tone: 'Luminous mystery', + mainConflict: 'Weather, memory, and craft compete to define what the islands remember.', + startingLocation: 'Mirror Lighthouse', + firstCharacters: ['Ember Quill', 'Mira Vale'], + firstFactions: ['Cartographers Guild'], + firstHistoricalEvent: 'The first reflected storm appears before its clouds arrive.', + }, + }); + await prisma.worldBibleVersion.upsert({ where: { worldId_versionNumber: { diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index 8517ee9..52f13c3 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -4,6 +4,7 @@ import Fastify from 'fastify'; import { env } from './config/env.js'; import { registerAuthRoutes } from './routes/auth.js'; import { registerHealthRoute } from './routes/health.js'; +import { registerWorldRoutes } from './routes/worlds.js'; export async function buildApp() { const app = Fastify({ @@ -32,6 +33,7 @@ export async function buildApp() { await registerHealthRoute(app); await registerAuthRoutes(app); + await registerWorldRoutes(app); return app; } diff --git a/apps/api/src/lib/worlds.ts b/apps/api/src/lib/worlds.ts new file mode 100644 index 0000000..87f2f9d --- /dev/null +++ b/apps/api/src/lib/worlds.ts @@ -0,0 +1,505 @@ +import type { Prisma, PrismaClient } from '../generated/prisma/client.js'; +import { + LoreStatus, + ProposalStatus, + WorldAuditAction, + WorldRole, +} from '../generated/prisma/enums.js'; + +export type WorldSeedInput = { + premise: string; + genre: string; + tone: string; + mainConflict: string; + startingLocation?: string | undefined; + firstCharacters?: string[] | undefined; + firstFactions?: string[] | undefined; + firstHistoricalEvent?: string | undefined; +}; + +export type WorldBibleInput = { + content: Prisma.InputJsonValue; + changeSummary?: string | undefined; +}; + +export type CreateWorldInput = { + creatorId: string; + title: string; + description: string; + seed: WorldSeedInput; + bible: WorldBibleInput; +}; + +export type UpdateWorldInput = { + worldId: string; + title?: string | undefined; + description?: string | undefined; + seed?: Partial | undefined; + bible?: WorldBibleInput | undefined; +}; + +export type WorldDatabase = Pick< + PrismaClient, + | '$transaction' + | 'loreEntry' + | 'proposal' + | 'world' + | 'worldAuditLog' + | 'worldBibleVersion' + | 'worldMembership' + | 'worldSeed' +>; + +const worldInclude = { + bibleVersions: { + orderBy: { + versionNumber: 'desc', + }, + take: 1, + }, + founder: { + select: { + avatarUrl: true, + displayName: true, + hiveUsername: true, + id: true, + normalizedHiveUsername: true, + }, + }, + seed: true, +} as const; + +type WorldWithRelations = Prisma.WorldGetPayload<{ + include: typeof worldInclude; +}>; + +export function createSlug(title: string) { + const slug = title + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') + .slice(0, 80); + + return slug || 'world'; +} + +function normalizeSeedInput(seed: WorldSeedInput) { + return { + firstCharacters: seed.firstCharacters?.map((value) => value.trim()).filter(Boolean) ?? [], + firstFactions: seed.firstFactions?.map((value) => value.trim()).filter(Boolean) ?? [], + firstHistoricalEvent: seed.firstHistoricalEvent?.trim() || null, + genre: seed.genre.trim(), + mainConflict: seed.mainConflict.trim(), + premise: seed.premise.trim(), + startingLocation: seed.startingLocation?.trim() || null, + tone: seed.tone.trim(), + }; +} + +function serializeSeed(seed: NonNullable) { + return { + createdAt: seed.createdAt.toISOString(), + firstCharacters: Array.isArray(seed.firstCharacters) ? seed.firstCharacters : [], + firstFactions: Array.isArray(seed.firstFactions) ? seed.firstFactions : [], + firstHistoricalEvent: seed.firstHistoricalEvent, + genre: seed.genre, + id: seed.id, + mainConflict: seed.mainConflict, + premise: seed.premise, + startingLocation: seed.startingLocation, + tone: seed.tone, + updatedAt: seed.updatedAt.toISOString(), + }; +} + +function serializeBibleVersion(version: WorldWithRelations['bibleVersions'][number] | null) { + if (!version) { + return null; + } + + return { + changeSummary: version.changeSummary, + content: version.content, + createdAt: version.createdAt.toISOString(), + creatorId: version.creatorId, + hiveReferenceId: version.hiveReferenceId, + id: version.id, + publishedAt: version.publishedAt?.toISOString() ?? null, + updatedAt: version.updatedAt.toISOString(), + versionNumber: version.versionNumber, + }; +} + +export function serializeWorld(world: WorldWithRelations) { + return { + createdAt: world.createdAt.toISOString(), + currentBibleVersion: serializeBibleVersion(world.bibleVersions[0] ?? null), + description: world.description, + founder: world.founder, + founderId: world.founderId, + id: world.id, + seed: world.seed ? serializeSeed(world.seed) : null, + slug: world.slug, + title: world.title, + updatedAt: world.updatedAt.toISOString(), + }; +} + +export type SerializedWorld = ReturnType; + +async function generateUniqueWorldSlug(database: Pick, title: string) { + const baseSlug = createSlug(title); + let slug = baseSlug; + + for (let suffix = 2; suffix <= 50; suffix += 1) { + const existing = await database.world.findUnique({ + where: { + slug, + }, + select: { + id: true, + }, + }); + + if (!existing) { + return slug; + } + + slug = `${baseSlug}-${suffix}`; + } + + throw new Error('Unable to create a unique world slug.'); +} + +export async function createWorld(database: WorldDatabase, input: CreateWorldInput) { + const slug = await generateUniqueWorldSlug(database, input.title); + const seed = normalizeSeedInput(input.seed); + + const world = await database.$transaction(async (transaction) => { + const createdWorld = await transaction.world.create({ + data: { + description: input.description.trim(), + founderId: input.creatorId, + slug, + title: input.title.trim(), + }, + }); + + await transaction.worldSeed.create({ + data: { + firstCharacters: seed.firstCharacters, + firstFactions: seed.firstFactions, + firstHistoricalEvent: seed.firstHistoricalEvent, + genre: seed.genre, + mainConflict: seed.mainConflict, + premise: seed.premise, + startingLocation: seed.startingLocation, + tone: seed.tone, + worldId: createdWorld.id, + }, + }); + + await transaction.worldBibleVersion.create({ + data: { + changeSummary: input.bible.changeSummary?.trim() || 'Initial world bible.', + content: input.bible.content, + creatorId: input.creatorId, + versionNumber: 1, + worldId: createdWorld.id, + }, + }); + + const membership = await transaction.worldMembership.create({ + data: { + grantedById: input.creatorId, + role: WorldRole.FOUNDER, + userId: input.creatorId, + worldId: createdWorld.id, + }, + }); + + await transaction.worldAuditLog.create({ + data: { + action: WorldAuditAction.ROLE_ASSIGNED, + actorId: input.creatorId, + metadata: { + role: WorldRole.FOUNDER, + }, + targetId: membership.id, + targetType: 'WORLD_MEMBERSHIP', + worldId: createdWorld.id, + }, + }); + + return transaction.world.findUniqueOrThrow({ + include: worldInclude, + where: { + id: createdWorld.id, + }, + }); + }); + + return serializeWorld(world); +} + +export async function listWorlds( + database: WorldDatabase, + input: { + genre?: string | undefined; + page?: number | undefined; + pageSize?: number | undefined; + q?: string | undefined; + tone?: string | undefined; + }, +) { + const page = input.page ?? 1; + const pageSize = input.pageSize ?? 20; + const where: Prisma.WorldWhereInput = {}; + + if (input.q) { + where.OR = [ + { + title: { + contains: input.q, + mode: 'insensitive', + }, + }, + { + description: { + contains: input.q, + mode: 'insensitive', + }, + }, + ]; + } + + if (input.genre || input.tone) { + where.seed = { + ...(input.genre + ? { + genre: { + equals: input.genre, + mode: 'insensitive', + }, + } + : {}), + ...(input.tone + ? { + tone: { + equals: input.tone, + mode: 'insensitive', + }, + } + : {}), + }; + } + + const [worlds, total] = await Promise.all([ + database.world.findMany({ + include: worldInclude, + orderBy: [ + { + createdAt: 'desc', + }, + { + id: 'desc', + }, + ], + skip: (page - 1) * pageSize, + take: pageSize, + where, + }), + database.world.count({ + where, + }), + ]); + + return { + pagination: { + page, + pageSize, + total, + }, + worlds: worlds.map(serializeWorld), + }; +} + +export async function getWorld(database: WorldDatabase, worldId: string) { + const world = await database.world.findUnique({ + include: worldInclude, + where: { + id: worldId, + }, + }); + + return world ? serializeWorld(world) : null; +} + +export async function getWorldHub(database: WorldDatabase, worldId: string) { + const world = await getWorld(database, worldId); + + if (!world) { + return null; + } + + const [canonLoreCount, draftLoreCount, activeProposalCount, latestLoreEntries] = + await Promise.all([ + database.loreEntry.count({ + where: { + status: LoreStatus.PUBLISHED_CANON, + worldId, + }, + }), + database.loreEntry.count({ + where: { + status: LoreStatus.DRAFT, + worldId, + }, + }), + database.proposal.count({ + where: { + status: { + in: [ProposalStatus.SUBMITTED, ProposalStatus.VOTING], + }, + worldId, + }, + }), + database.loreEntry.findMany({ + orderBy: [ + { + updatedAt: 'desc', + }, + { + id: 'desc', + }, + ], + select: { + id: true, + loreType: true, + slug: true, + status: true, + title: true, + updatedAt: true, + }, + take: 6, + where: { + worldId, + }, + }), + ]); + + return { + latestLoreEntries: latestLoreEntries.map((entry) => ({ + ...entry, + updatedAt: entry.updatedAt.toISOString(), + })), + stats: { + activeProposalCount, + canonLoreCount, + draftLoreCount, + }, + world, + }; +} + +export async function updateWorld(database: WorldDatabase, input: UpdateWorldInput) { + const world = await database.$transaction(async (transaction) => { + const current = await transaction.world.findUnique({ + include: { + bibleVersions: { + orderBy: { + versionNumber: 'desc', + }, + take: 1, + }, + seed: true, + }, + where: { + id: input.worldId, + }, + }); + + if (!current) { + return null; + } + + if (input.title || input.description) { + await transaction.world.update({ + data: { + ...(input.description ? { description: input.description.trim() } : {}), + ...(input.title ? { title: input.title.trim() } : {}), + }, + where: { + id: input.worldId, + }, + }); + } + + if (input.seed) { + const currentSeed = current.seed; + + if (!currentSeed) { + throw new Error('World seed is missing.'); + } + + const normalizedSeed = normalizeSeedInput({ + firstCharacters: + input.seed.firstCharacters ?? + (Array.isArray(currentSeed.firstCharacters) + ? currentSeed.firstCharacters.filter( + (value): value is string => typeof value === 'string', + ) + : []), + firstFactions: + input.seed.firstFactions ?? + (Array.isArray(currentSeed.firstFactions) + ? currentSeed.firstFactions.filter( + (value): value is string => typeof value === 'string', + ) + : []), + firstHistoricalEvent: + input.seed.firstHistoricalEvent ?? currentSeed.firstHistoricalEvent ?? undefined, + genre: input.seed.genre ?? currentSeed.genre, + mainConflict: input.seed.mainConflict ?? currentSeed.mainConflict, + premise: input.seed.premise ?? currentSeed.premise, + startingLocation: input.seed.startingLocation ?? currentSeed.startingLocation ?? undefined, + tone: input.seed.tone ?? currentSeed.tone, + }); + + await transaction.worldSeed.update({ + data: normalizedSeed, + where: { + worldId: input.worldId, + }, + }); + } + + if (input.bible) { + const currentBible = current.bibleVersions[0]; + + if (!currentBible) { + throw new Error('World bible version is missing.'); + } + + await transaction.worldBibleVersion.update({ + data: { + changeSummary: input.bible.changeSummary?.trim() || currentBible.changeSummary, + content: input.bible.content, + }, + where: { + id: currentBible.id, + }, + }); + } + + return transaction.world.findUniqueOrThrow({ + include: worldInclude, + where: { + id: input.worldId, + }, + }); + }); + + return world ? serializeWorld(world) : null; +} diff --git a/apps/api/src/routes/worlds.test.ts b/apps/api/src/routes/worlds.test.ts new file mode 100644 index 0000000..d55b173 --- /dev/null +++ b/apps/api/src/routes/worlds.test.ts @@ -0,0 +1,555 @@ +import assert from 'node:assert/strict'; +import { describe, test } from 'node:test'; + +import Fastify from 'fastify'; + +import { env } from '../config/env.js'; +import { + LoreStatus, + PlatformRole, + ProposalStatus, + WorldAuditAction, + WorldRole, +} from '../generated/prisma/enums.js'; +import { signAccessToken } from '../lib/auth-crypto.js'; +import { registerWorldRoutes } from './worlds.js'; + +const founder = { + avatarUrl: null, + displayName: 'Ember Quill', + hiveUsername: 'emberquill.dev', + id: 'user-1', + normalizedHiveUsername: 'emberquill.dev', +}; + +const contributor = { + avatarUrl: null, + displayName: 'Mira Vale', + hiveUsername: 'mira-vale.dev', + id: 'user-2', + normalizedHiveUsername: 'mira-vale.dev', +}; + +function authHeader(userId = founder.id) { + const token = signAccessToken( + { + hiveUsername: founder.hiveUsername, + normalizedHiveUsername: founder.normalizedHiveUsername, + platformRole: PlatformRole.USER, + sid: 'session-1', + sub: userId, + }, + { + audience: env.AUTH_JWT_AUDIENCE, + issuer: env.AUTH_JWT_ISSUER, + secret: env.AUTH_JWT_SECRET, + ttlSeconds: 900, + }, + ); + + return { + authorization: `Bearer ${token}`, + }; +} + +type StoredWorld = { + id: string; + slug: string; + title: string; + description: string; + founderId: string; + founder: typeof founder; + createdAt: Date; + updatedAt: Date; +}; + +type StoredWorldSeed = { + id: string; + worldId: string; + premise: string; + genre: string; + tone: string; + mainConflict: string; + startingLocation: string | null; + firstCharacters: unknown; + firstFactions: unknown; + firstHistoricalEvent: string | null; + createdAt: Date; + updatedAt: Date; +}; + +type StoredBibleVersion = { + id: string; + worldId: string; + versionNumber: number; + creatorId: string; + content: unknown; + changeSummary: string | null; + publishedAt: Date | null; + hiveReferenceId: string | null; + createdAt: Date; + updatedAt: Date; +}; + +function createWorldRecord(input: { + id: string; + slug: string; + title: string; + description: string; + founderId?: string; +}): StoredWorld { + return { + createdAt: new Date('2026-08-05T00:00:00.000Z'), + description: input.description, + founder: input.founderId === contributor.id ? contributor : founder, + founderId: input.founderId ?? founder.id, + id: input.id, + slug: input.slug, + title: input.title, + updatedAt: new Date('2026-08-05T00:00:00.000Z'), + }; +} + +function createDatabase() { + const worlds: StoredWorld[] = []; + const seeds: StoredWorldSeed[] = []; + const bibleVersions: StoredBibleVersion[] = []; + const memberships: Array<{ + id: string; + worldId: string; + userId: string; + role: WorldRole; + revokedAt: Date | null; + }> = []; + const auditLogs: unknown[] = []; + const loreEntries: Array<{ + id: string; + worldId: string; + title: string; + slug: string; + loreType: string; + status: LoreStatus; + updatedAt: Date; + }> = []; + const proposals: Array<{ + id: string; + worldId: string; + status: ProposalStatus; + }> = []; + + function includeWorld(world: StoredWorld) { + return { + ...world, + bibleVersions: bibleVersions + .filter((version) => version.worldId === world.id) + .sort((left, right) => right.versionNumber - left.versionNumber) + .slice(0, 1), + founder: world.founder, + seed: seeds.find((seed) => seed.worldId === world.id) ?? null, + }; + } + + const database = { + async $transaction(this: unknown, callback: (transaction: never) => Promise) { + return callback(this as never); + }, + loreEntry: { + async count(args: { where: { worldId: string; status?: LoreStatus } }) { + return loreEntries.filter( + (entry) => + entry.worldId === args.where.worldId && + (!args.where.status || entry.status === args.where.status), + ).length; + }, + async findMany(args: { where: { worldId: string }; take: number }) { + return loreEntries + .filter((entry) => entry.worldId === args.where.worldId) + .slice(0, args.take); + }, + }, + proposal: { + async count(args: { where: { worldId: string; status: { in: ProposalStatus[] } } }) { + return proposals.filter( + (proposal) => + proposal.worldId === args.where.worldId && + args.where.status.in.includes(proposal.status), + ).length; + }, + }, + world: { + async count() { + return worlds.length; + }, + async create(args: { + data: { + description: string; + founderId: string; + slug: string; + title: string; + }; + }) { + const world = createWorldRecord({ + description: args.data.description, + founderId: args.data.founderId, + id: `world-${worlds.length + 1}`, + slug: args.data.slug, + title: args.data.title, + }); + worlds.push(world); + return world; + }, + async findMany(args: { take: number; skip: number }) { + return worlds.slice(args.skip, args.skip + args.take).map(includeWorld); + }, + async findUnique(args: { where: { id?: string; slug?: string } }) { + const world = worlds.find((candidate) => { + if (args.where.id) { + return candidate.id === args.where.id; + } + + return candidate.slug === args.where.slug; + }); + + return world ? includeWorld(world) : null; + }, + async findUniqueOrThrow(args: { where: { id: string } }) { + const world = worlds.find((candidate) => candidate.id === args.where.id); + + if (!world) { + throw new Error('Missing world.'); + } + + return includeWorld(world); + }, + async update(args: { + data: Partial>; + where: { id: string }; + }) { + const world = worlds.find((candidate) => candidate.id === args.where.id); + + if (!world) { + throw new Error('Missing world.'); + } + + Object.assign(world, args.data, { + updatedAt: new Date('2026-08-05T00:01:00.000Z'), + }); + + return world; + }, + }, + worldAuditLog: { + async create(args: { data: unknown }) { + auditLogs.push(args.data); + return args.data; + }, + }, + worldBibleVersion: { + async create(args: { + data: { + changeSummary: string; + content: unknown; + creatorId: string; + versionNumber: number; + worldId: string; + }; + }) { + const version = { + ...args.data, + createdAt: new Date('2026-08-05T00:00:00.000Z'), + hiveReferenceId: null, + id: `bible-${bibleVersions.length + 1}`, + publishedAt: null, + updatedAt: new Date('2026-08-05T00:00:00.000Z'), + }; + bibleVersions.push(version); + return version; + }, + async update(args: { + data: Partial>; + where: { id: string }; + }) { + const version = bibleVersions.find((candidate) => candidate.id === args.where.id); + + if (!version) { + throw new Error('Missing bible version.'); + } + + Object.assign(version, args.data, { + updatedAt: new Date('2026-08-05T00:01:00.000Z'), + }); + + return version; + }, + }, + worldMembership: { + async create(args: { + data: { + grantedById: string; + role: WorldRole; + userId: string; + worldId: string; + }; + }) { + const membership = { + id: `membership-${memberships.length + 1}`, + revokedAt: null, + ...args.data, + }; + memberships.push(membership); + return membership; + }, + async findUnique(args: { + where: { worldId_userId: { worldId: string; userId: string }; revokedAt?: null }; + }) { + return ( + memberships.find( + (membership) => + membership.worldId === args.where.worldId_userId.worldId && + membership.userId === args.where.worldId_userId.userId && + !membership.revokedAt, + ) ?? null + ); + }, + }, + worldSeed: { + async create(args: { + data: { + firstCharacters: string[]; + firstFactions: string[]; + firstHistoricalEvent: string | null; + genre: string; + mainConflict: string; + premise: string; + startingLocation: string | null; + tone: string; + worldId: string; + }; + }) { + const seed = { + ...args.data, + createdAt: new Date('2026-08-05T00:00:00.000Z'), + id: `seed-${seeds.length + 1}`, + updatedAt: new Date('2026-08-05T00:00:00.000Z'), + }; + seeds.push(seed); + return seed; + }, + async update(args: { data: Partial; where: { worldId: string } }) { + const seed = seeds.find((candidate) => candidate.worldId === args.where.worldId); + + if (!seed) { + throw new Error('Missing seed.'); + } + + Object.assign(seed, args.data, { + updatedAt: new Date('2026-08-05T00:01:00.000Z'), + }); + + return seed; + }, + }, + }; + + return { + auditLogs, + bibleVersions, + database, + loreEntries, + memberships, + proposals, + seeds, + worlds, + }; +} + +async function createApp(database: ReturnType['database']) { + const app = Fastify(); + await registerWorldRoutes(app, { + database: database as never, + }); + return app; +} + +const worldPayload = { + bible: { + changeSummary: 'Initial bible.', + content: { + rules: ['No false canon.'], + style: 'Mythic', + }, + }, + description: 'A realm of moonlit archives.', + seed: { + firstCharacters: ['Archivist Lume'], + firstFactions: ['Moon Scribes'], + firstHistoricalEvent: 'The first archive opens.', + genre: 'Fantasy', + mainConflict: 'Memory fades whenever the moons align.', + premise: 'A world where moonlight writes history before people can.', + startingLocation: 'The Glass Archive', + tone: 'Mystic wonder', + }, + title: 'Moon Archive', +}; + +describe('world routes', () => { + test('creates a world atomically with seed, initial bible, founder membership, and audit log', async () => { + const state = createDatabase(); + const app = await createApp(state.database); + const response = await app.inject({ + headers: authHeader(), + method: 'POST', + payload: worldPayload, + url: '/worlds', + }); + + assert.equal(response.statusCode, 201); + assert.equal(response.json().world.title, 'Moon Archive'); + assert.equal(response.json().world.seed.genre, 'Fantasy'); + assert.equal(response.json().world.currentBibleVersion.versionNumber, 1); + assert.equal(response.json().world.currentBibleVersion.publishedAt, null); + assert.equal(state.worlds.length, 1); + assert.equal(state.seeds.length, 1); + assert.equal(state.bibleVersions.length, 1); + assert.equal(state.memberships[0]?.role, WorldRole.FOUNDER); + assert.deepEqual(state.auditLogs[0], { + action: WorldAuditAction.ROLE_ASSIGNED, + actorId: founder.id, + metadata: { + role: WorldRole.FOUNDER, + }, + targetId: 'membership-1', + targetType: 'WORLD_MEMBERSHIP', + worldId: 'world-1', + }); + await app.close(); + }); + + test('requires authentication to create a world and ignores client founder role data', async () => { + const state = createDatabase(); + const app = await createApp(state.database); + const response = await app.inject({ + method: 'POST', + payload: { + ...worldPayload, + founderId: contributor.id, + role: WorldRole.CURATOR, + }, + url: '/worlds', + }); + + assert.equal(response.statusCode, 401); + assert.equal(state.worlds.length, 0); + await app.close(); + }); + + test('browses, retrieves, and opens a world hub response', async () => { + const state = createDatabase(); + const app = await createApp(state.database); + await app.inject({ + headers: authHeader(), + method: 'POST', + payload: worldPayload, + url: '/worlds', + }); + state.loreEntries.push({ + id: 'lore-1', + loreType: 'CHARACTER', + slug: 'archivist-lume', + status: LoreStatus.PUBLISHED_CANON, + title: 'Archivist Lume', + updatedAt: new Date('2026-08-05T00:02:00.000Z'), + worldId: 'world-1', + }); + state.proposals.push({ + id: 'proposal-1', + status: ProposalStatus.SUBMITTED, + worldId: 'world-1', + }); + + const listResponse = await app.inject({ + method: 'GET', + url: '/worlds?q=moon&page=1&pageSize=10', + }); + const getResponse = await app.inject({ + method: 'GET', + url: '/worlds/world-1', + }); + const hubResponse = await app.inject({ + method: 'GET', + url: '/worlds/world-1/hub', + }); + + assert.equal(listResponse.statusCode, 200); + assert.equal(listResponse.json().pagination.total, 1); + assert.equal(getResponse.statusCode, 200); + assert.equal(getResponse.json().world.seed.mainConflict, worldPayload.seed.mainConflict); + assert.equal(hubResponse.statusCode, 200); + assert.equal(hubResponse.json().stats.canonLoreCount, 1); + assert.equal(hubResponse.json().stats.activeProposalCount, 1); + assert.equal(hubResponse.json().latestLoreEntries[0].id, 'lore-1'); + await app.close(); + }); + + test('allows a founder to update mutable off-chain world seed and bible data', async () => { + const state = createDatabase(); + const app = await createApp(state.database); + await app.inject({ + headers: authHeader(), + method: 'POST', + payload: worldPayload, + url: '/worlds', + }); + + const response = await app.inject({ + headers: authHeader(), + method: 'PATCH', + payload: { + bible: { + changeSummary: 'Refined initial bible.', + content: { + rules: ['Keep the moon archive coherent.'], + }, + }, + seed: { + tone: 'Quiet awe', + }, + title: 'Moon Archive Revised', + }, + url: '/worlds/world-1', + }); + + assert.equal(response.statusCode, 200); + assert.equal(response.json().world.title, 'Moon Archive Revised'); + assert.equal(response.json().world.seed.tone, 'Quiet awe'); + assert.deepEqual(response.json().world.currentBibleVersion.content, { + rules: ['Keep the moon archive coherent.'], + }); + assert.equal(response.json().world.currentBibleVersion.publishedAt, null); + await app.close(); + }); + + test('rejects world updates without founder permissions', async () => { + const state = createDatabase(); + const app = await createApp(state.database); + await app.inject({ + headers: authHeader(), + method: 'POST', + payload: worldPayload, + url: '/worlds', + }); + + const response = await app.inject({ + headers: authHeader(contributor.id), + method: 'PATCH', + payload: { + title: 'Unauthorized', + }, + url: '/worlds/world-1', + }); + + assert.equal(response.statusCode, 403); + await app.close(); + }); +}); diff --git a/apps/api/src/routes/worlds.ts b/apps/api/src/routes/worlds.ts new file mode 100644 index 0000000..3456c05 --- /dev/null +++ b/apps/api/src/routes/worlds.ts @@ -0,0 +1,230 @@ +import type { FastifyInstance } from 'fastify'; +import { z } from 'zod'; + +import { env } from '../config/env.js'; +import { authenticateRequest, requireSession } from '../lib/auth-middleware.js'; +import { prisma } from '../lib/prisma.js'; +import { + createWorld, + getWorld, + getWorldHub, + listWorlds, + updateWorld, + type WorldDatabase, + type WorldSeedInput, +} from '../lib/worlds.js'; +import { WORLD_PERMISSIONS } from '../lib/world-permissions.js'; +import { authorizeWorldPermission } from '../lib/world-authorization.js'; +import type { WorldMembershipLookup } from '../lib/world-authorization.js'; + +const paramsSchema = z.object({ + worldId: z.string().min(1), +}); + +const stringListSchema = z.array(z.string().trim().min(1).max(120)).max(20); + +const worldSeedSchema = z.object({ + firstCharacters: stringListSchema.optional(), + firstFactions: stringListSchema.optional(), + firstHistoricalEvent: z.string().trim().min(1).max(1_000).optional(), + genre: z.string().trim().min(1).max(80), + mainConflict: z.string().trim().min(1).max(2_000), + premise: z.string().trim().min(1).max(3_000), + startingLocation: z.string().trim().min(1).max(500).optional(), + tone: z.string().trim().min(1).max(80), +}); + +const worldBibleSchema = z.object({ + changeSummary: z.string().trim().min(1).max(500).optional(), + content: z.unknown(), +}); + +const createWorldSchema = z.object({ + bible: worldBibleSchema, + description: z.string().trim().min(1).max(1_000), + seed: worldSeedSchema, + title: z.string().trim().min(1).max(160), +}); + +const updateWorldSchema = z + .object({ + bible: worldBibleSchema.optional(), + description: z.string().trim().min(1).max(1_000).optional(), + seed: worldSeedSchema.partial().optional(), + title: z.string().trim().min(1).max(160).optional(), + }) + .refine((value) => Object.keys(value).length > 0, { + message: 'At least one editable world field is required.', + }); + +const listWorldsSchema = z.object({ + genre: z.string().trim().min(1).max(80).optional(), + page: z.coerce.number().int().positive().default(1), + pageSize: z.coerce.number().int().positive().max(100).default(20), + q: z.string().trim().min(1).max(120).optional(), + tone: z.string().trim().min(1).max(80).optional(), +}); + +type RegisterWorldRoutesOptions = { + database?: WorldDatabase & WorldMembershipLookup; +}; + +function authOptions() { + return { + audience: env.AUTH_JWT_AUDIENCE, + issuer: env.AUTH_JWT_ISSUER, + jwtSecret: env.AUTH_JWT_SECRET, + }; +} + +export async function registerWorldRoutes( + app: FastifyInstance, + options: RegisterWorldRoutesOptions = {}, +) { + const database = options.database ?? prisma; + + app.post( + '/worlds', + { + preHandler: requireSession(authOptions()), + }, + async (request, reply) => { + const body = createWorldSchema.safeParse(request.body); + + if (!body.success) { + return reply.code(400).send({ + error: 'Invalid world payload.', + }); + } + + const authenticatedUser = authenticateRequest(request, authOptions()); + + if (!authenticatedUser) { + return reply.code(401).send({ + error: 'Authentication required.', + }); + } + + const world = await createWorld(database, { + ...body.data, + bible: { + ...body.data.bible, + content: body.data.bible.content as never, + }, + creatorId: authenticatedUser.id, + }); + + return reply.code(201).send({ + world, + }); + }, + ); + + app.get('/worlds', async (request, reply) => { + const query = listWorldsSchema.safeParse(request.query); + + if (!query.success) { + return reply.code(400).send({ + error: 'Invalid worlds query.', + }); + } + + return listWorlds(database, query.data); + }); + + app.get('/worlds/:worldId', async (request, reply) => { + const params = paramsSchema.safeParse(request.params); + + if (!params.success) { + return reply.code(400).send({ + error: 'Invalid world route.', + }); + } + + const world = await getWorld(database, params.data.worldId); + + if (!world) { + return reply.code(404).send({ + error: 'World not found.', + }); + } + + return { + world, + }; + }); + + app.get('/worlds/:worldId/hub', async (request, reply) => { + const params = paramsSchema.safeParse(request.params); + + if (!params.success) { + return reply.code(400).send({ + error: 'Invalid world route.', + }); + } + + const hub = await getWorldHub(database, params.data.worldId); + + if (!hub) { + return reply.code(404).send({ + error: 'World not found.', + }); + } + + return hub; + }); + + app.patch( + '/worlds/:worldId', + { + preHandler: requireSession(authOptions()), + }, + async (request, reply) => { + const params = paramsSchema.safeParse(request.params); + const body = updateWorldSchema.safeParse(request.body); + + if (!params.success || !body.success) { + return reply.code(400).send({ + error: 'Invalid world payload.', + }); + } + + const authorized = await authorizeWorldPermission( + request, + reply, + params.data.worldId, + WORLD_PERMISSIONS.EDIT_INITIAL_CANON, + database, + ); + + if (!authorized) { + return; + } + + const world = await updateWorld(database, { + ...(body.data.description ? { description: body.data.description } : {}), + ...(body.data.seed ? { seed: body.data.seed as Partial } : {}), + ...(body.data.title ? { title: body.data.title } : {}), + ...(body.data.bible + ? { + bible: { + ...body.data.bible, + content: body.data.bible.content as never, + }, + } + : {}), + worldId: params.data.worldId, + }); + + if (!world) { + return reply.code(404).send({ + error: 'World not found.', + }); + } + + return { + world, + }; + }, + ); +} From 4cf0a6e4642794ce95be2a9a7a60964f08569d4f Mon Sep 17 00:00:00 2001 From: Perla Imad Date: Wed, 5 Aug 2026 19:14:13 +0300 Subject: [PATCH 02/22] Harden world hub and bible validation --- README.md | 2 +- apps/api/src/lib/worlds.ts | 79 ++++++++++++++---------------- apps/api/src/routes/worlds.test.ts | 44 ++++++++++++++++- apps/api/src/routes/worlds.ts | 41 ++++++++++++++-- 4 files changed, 117 insertions(+), 49 deletions(-) diff --git a/README.md b/README.md index 4e191dd..be1d228 100644 --- a/README.md +++ b/README.md @@ -322,7 +322,7 @@ Implemented world endpoints: - `POST /worlds`: requires authentication, creates a mutable off-chain World Seed and initial `WorldBibleVersion` in one transaction, and assigns the authenticated user an active `FOUNDER` membership. The route never accepts a client-supplied founder or role and does not publish to Hive. - `GET /worlds`: browses discoverable worlds with pagination and basic title/description, genre, and tone filters. - `GET /worlds/:worldId`: returns one world with its founder, seed, and current latest World Bible version. -- `GET /worlds/:worldId/hub`: returns the World Hub aggregate for the frontend, including world seed, current bible preview, simple lore/proposal stats, and latest lore entry summaries. +- `GET /worlds/:worldId/hub`: returns the World Hub aggregate for the frontend, including world seed, current bible preview, simple canon/proposal stats, and latest canon lore entry summaries. - `PATCH /worlds/:worldId`: requires `EDIT_INITIAL_CANON` world permission and updates mutable off-chain world title/description, seed, and latest initial bible content only. It does not create proposals, canonize content, or publish to Hive. --- diff --git a/apps/api/src/lib/worlds.ts b/apps/api/src/lib/worlds.ts index 87f2f9d..a6bfcc1 100644 --- a/apps/api/src/lib/worlds.ts +++ b/apps/api/src/lib/worlds.ts @@ -343,51 +343,45 @@ export async function getWorldHub(database: WorldDatabase, worldId: string) { return null; } - const [canonLoreCount, draftLoreCount, activeProposalCount, latestLoreEntries] = - await Promise.all([ - database.loreEntry.count({ - where: { - status: LoreStatus.PUBLISHED_CANON, - worldId, - }, - }), - database.loreEntry.count({ - where: { - status: LoreStatus.DRAFT, - worldId, - }, - }), - database.proposal.count({ - where: { - status: { - in: [ProposalStatus.SUBMITTED, ProposalStatus.VOTING], - }, - worldId, + const [canonLoreCount, activeProposalCount, latestLoreEntries] = await Promise.all([ + database.loreEntry.count({ + where: { + status: LoreStatus.PUBLISHED_CANON, + worldId, + }, + }), + database.proposal.count({ + where: { + status: { + in: [ProposalStatus.SUBMITTED, ProposalStatus.VOTING], }, - }), - database.loreEntry.findMany({ - orderBy: [ - { - updatedAt: 'desc', - }, - { - id: 'desc', - }, - ], - select: { - id: true, - loreType: true, - slug: true, - status: true, - title: true, - updatedAt: true, + worldId, + }, + }), + database.loreEntry.findMany({ + orderBy: [ + { + updatedAt: 'desc', }, - take: 6, - where: { - worldId, + { + id: 'desc', }, - }), - ]); + ], + select: { + id: true, + loreType: true, + slug: true, + status: true, + title: true, + updatedAt: true, + }, + take: 6, + where: { + status: LoreStatus.PUBLISHED_CANON, + worldId, + }, + }), + ]); return { latestLoreEntries: latestLoreEntries.map((entry) => ({ @@ -397,7 +391,6 @@ export async function getWorldHub(database: WorldDatabase, worldId: string) { stats: { activeProposalCount, canonLoreCount, - draftLoreCount, }, world, }; diff --git a/apps/api/src/routes/worlds.test.ts b/apps/api/src/routes/worlds.test.ts index d55b173..2419868 100644 --- a/apps/api/src/routes/worlds.test.ts +++ b/apps/api/src/routes/worlds.test.ts @@ -161,9 +161,13 @@ function createDatabase() { (!args.where.status || entry.status === args.where.status), ).length; }, - async findMany(args: { where: { worldId: string }; take: number }) { + async findMany(args: { where: { worldId: string; status?: LoreStatus }; take: number }) { return loreEntries - .filter((entry) => entry.worldId === args.where.worldId) + .filter( + (entry) => + entry.worldId === args.where.worldId && + (!('status' in args.where) || entry.status === args.where.status), + ) .slice(0, args.take); }, }, @@ -444,6 +448,26 @@ describe('world routes', () => { await app.close(); }); + test('rejects non-json world bible content', async () => { + const state = createDatabase(); + const app = await createApp(state.database); + const response = await app.inject({ + headers: authHeader(), + method: 'POST', + payload: { + ...worldPayload, + bible: { + changeSummary: 'Missing content should not pass as undefined.', + }, + }, + url: '/worlds', + }); + + assert.equal(response.statusCode, 400); + assert.equal(state.worlds.length, 0); + await app.close(); + }); + test('browses, retrieves, and opens a world hub response', async () => { const state = createDatabase(); const app = await createApp(state.database); @@ -462,6 +486,15 @@ describe('world routes', () => { updatedAt: new Date('2026-08-05T00:02:00.000Z'), worldId: 'world-1', }); + state.loreEntries.push({ + id: 'draft-lore', + loreType: 'FACTION', + slug: 'hidden-draft', + status: LoreStatus.DRAFT, + title: 'Hidden Draft', + updatedAt: new Date('2026-08-05T00:03:00.000Z'), + worldId: 'world-1', + }); state.proposals.push({ id: 'proposal-1', status: ProposalStatus.SUBMITTED, @@ -488,7 +521,14 @@ describe('world routes', () => { assert.equal(hubResponse.statusCode, 200); assert.equal(hubResponse.json().stats.canonLoreCount, 1); assert.equal(hubResponse.json().stats.activeProposalCount, 1); + assert.equal('draftLoreCount' in hubResponse.json().stats, false); assert.equal(hubResponse.json().latestLoreEntries[0].id, 'lore-1'); + assert.equal( + hubResponse + .json() + .latestLoreEntries.some((entry: { id: string }) => entry.id === 'draft-lore'), + false, + ); await app.close(); }); diff --git a/apps/api/src/routes/worlds.ts b/apps/api/src/routes/worlds.ts index 3456c05..85dae37 100644 --- a/apps/api/src/routes/worlds.ts +++ b/apps/api/src/routes/worlds.ts @@ -2,6 +2,7 @@ import type { FastifyInstance } from 'fastify'; import { z } from 'zod'; import { env } from '../config/env.js'; +import type { Prisma } from '../generated/prisma/client.js'; import { authenticateRequest, requireSession } from '../lib/auth-middleware.js'; import { prisma } from '../lib/prisma.js'; import { @@ -23,6 +24,40 @@ const paramsSchema = z.object({ const stringListSchema = z.array(z.string().trim().min(1).max(120)).max(20); +function isJsonValue(value: unknown): value is Prisma.InputJsonValue { + if (value === null) { + return true; + } + + if (typeof value === 'string' || typeof value === 'boolean') { + return true; + } + + if (typeof value === 'number') { + return Number.isFinite(value); + } + + if (Array.isArray(value)) { + return value.every(isJsonValue); + } + + if (typeof value === 'object') { + const prototype = Object.getPrototypeOf(value); + + if (prototype !== Object.prototype && prototype !== null) { + return false; + } + + return Object.values(value).every(isJsonValue); + } + + return false; +} + +const jsonValueSchema = z.custom(isJsonValue, { + message: 'World bible content must be valid JSON.', +}); + const worldSeedSchema = z.object({ firstCharacters: stringListSchema.optional(), firstFactions: stringListSchema.optional(), @@ -36,7 +71,7 @@ const worldSeedSchema = z.object({ const worldBibleSchema = z.object({ changeSummary: z.string().trim().min(1).max(500).optional(), - content: z.unknown(), + content: jsonValueSchema, }); const createWorldSchema = z.object({ @@ -109,7 +144,7 @@ export async function registerWorldRoutes( ...body.data, bible: { ...body.data.bible, - content: body.data.bible.content as never, + content: body.data.bible.content, }, creatorId: authenticatedUser.id, }); @@ -209,7 +244,7 @@ export async function registerWorldRoutes( ? { bible: { ...body.data.bible, - content: body.data.bible.content as never, + content: body.data.bible.content, }, } : {}), From ccb21de4e1503d29b6f6eeafa859510acc0bb415 Mon Sep 17 00:00:00 2001 From: Perla Imad Date: Wed, 5 Aug 2026 21:00:12 +0300 Subject: [PATCH 03/22] Add structured contribution draft workflow --- README.md | 9 + .../migration.sql | 70 ++ apps/api/prisma/schema.prisma | 121 ++- apps/api/prisma/seed.ts | 83 ++ apps/api/src/app.ts | 2 + apps/api/src/lib/contributions.ts | 755 ++++++++++++++ apps/api/src/routes/contributions.test.ts | 942 ++++++++++++++++++ apps/api/src/routes/contributions.ts | 430 ++++++++ 8 files changed, 2376 insertions(+), 36 deletions(-) create mode 100644 apps/api/prisma/migrations/20260805170000_add_contribution_drafts/migration.sql create mode 100644 apps/api/src/lib/contributions.ts create mode 100644 apps/api/src/routes/contributions.test.ts create mode 100644 apps/api/src/routes/contributions.ts diff --git a/README.md b/README.md index be1d228..0789cf3 100644 --- a/README.md +++ b/README.md @@ -325,6 +325,15 @@ Implemented world endpoints: - `GET /worlds/:worldId/hub`: returns the World Hub aggregate for the frontend, including world seed, current bible preview, simple canon/proposal stats, and latest canon lore entry summaries. - `PATCH /worlds/:worldId`: requires `EDIT_INITIAL_CANON` world permission and updates mutable off-chain world title/description, seed, and latest initial bible content only. It does not create proposals, canonize content, or publish to Hive. +Implemented contribution endpoints: + +- `POST /worlds/:worldId/contributions`: requires authentication and `CREATE_LORE_DRAFT` world permission, creates an author-owned structured contribution draft for lore or story content, and optionally links it to a lore entry in the same world. +- `GET /worlds/:worldId/contributions`: requires authentication and lists only the authenticated writer's contributions in the world with page/pageSize pagination and optional status/kind filters. +- `GET /worlds/:worldId/contributions/:contributionId`: returns an authenticated writer's own contribution draft or submitted contribution without exposing other writers' private drafts. +- `PATCH /worlds/:worldId/contributions/:contributionId`: requires `EDIT_OWN_DRAFT`, updates only mutable draft fields, and rejects submitted contributions. +- `DELETE /worlds/:worldId/contributions/:contributionId`: requires `EDIT_OWN_DRAFT`, deletes only draft contributions, and never deletes submitted proposals. +- `POST /worlds/:worldId/contributions/:contributionId/submit`: requires `SUBMIT_PROPOSAL`, atomically locks the draft, creates exactly one submitted proposal with an immutable `proposedContent` snapshot, and does not publish to Hive. + --- # Reputation & Rewards diff --git a/apps/api/prisma/migrations/20260805170000_add_contribution_drafts/migration.sql b/apps/api/prisma/migrations/20260805170000_add_contribution_drafts/migration.sql new file mode 100644 index 0000000..2f95fad --- /dev/null +++ b/apps/api/prisma/migrations/20260805170000_add_contribution_drafts/migration.sql @@ -0,0 +1,70 @@ +-- CreateEnum +CREATE TYPE "ContributionKind" AS ENUM ('LORE', 'STORY'); + +-- CreateEnum +CREATE TYPE "ContributionStatus" AS ENUM ('DRAFT', 'SUBMITTED'); + +-- AlterEnum +ALTER TYPE "ProposalType" ADD VALUE 'ADD_STORY'; + +-- AlterEnum +ALTER TYPE "WorldAuditAction" ADD VALUE 'CONTRIBUTION_CREATED'; +ALTER TYPE "WorldAuditAction" ADD VALUE 'CONTRIBUTION_UPDATED'; +ALTER TYPE "WorldAuditAction" ADD VALUE 'CONTRIBUTION_DELETED'; +ALTER TYPE "WorldAuditAction" ADD VALUE 'CONTRIBUTION_SUBMITTED'; +ALTER TYPE "WorldAuditAction" ADD VALUE 'PROPOSAL_CREATED'; + +-- AlterTable +ALTER TABLE "Proposal" ADD COLUMN "contributionKind" "ContributionKind"; + +-- CreateTable +CREATE TABLE "ContributionDraft" ( + "id" TEXT NOT NULL, + "worldId" TEXT NOT NULL, + "authorId" TEXT NOT NULL, + "targetLoreEntryId" TEXT, + "kind" "ContributionKind" NOT NULL, + "title" TEXT NOT NULL, + "summary" TEXT, + "content" JSONB NOT NULL, + "status" "ContributionStatus" NOT NULL DEFAULT 'DRAFT', + "proposalId" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + "submittedAt" TIMESTAMP(3), + + CONSTRAINT "ContributionDraft_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "ContributionDraft_proposalId_key" ON "ContributionDraft"("proposalId"); + +-- CreateIndex +CREATE INDEX "ContributionDraft_worldId_idx" ON "ContributionDraft"("worldId"); + +-- CreateIndex +CREATE INDEX "ContributionDraft_authorId_idx" ON "ContributionDraft"("authorId"); + +-- CreateIndex +CREATE INDEX "ContributionDraft_worldId_status_idx" ON "ContributionDraft"("worldId", "status"); + +-- CreateIndex +CREATE INDEX "ContributionDraft_worldId_authorId_status_idx" ON "ContributionDraft"("worldId", "authorId", "status"); + +-- CreateIndex +CREATE INDEX "ContributionDraft_targetLoreEntryId_idx" ON "ContributionDraft"("targetLoreEntryId"); + +-- CreateIndex +CREATE INDEX "Proposal_worldId_contributionKind_idx" ON "Proposal"("worldId", "contributionKind"); + +-- AddForeignKey +ALTER TABLE "ContributionDraft" ADD CONSTRAINT "ContributionDraft_worldId_fkey" FOREIGN KEY ("worldId") REFERENCES "World"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "ContributionDraft" ADD CONSTRAINT "ContributionDraft_authorId_fkey" FOREIGN KEY ("authorId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "ContributionDraft" ADD CONSTRAINT "ContributionDraft_targetLoreEntryId_fkey" FOREIGN KEY ("targetLoreEntryId") REFERENCES "LoreEntry"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "ContributionDraft" ADD CONSTRAINT "ContributionDraft_proposalId_fkey" FOREIGN KEY ("proposalId") REFERENCES "Proposal"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index 9d952bd..07028b0 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -29,10 +29,21 @@ enum LoreStatus { enum ProposalType { ADD_LORE + ADD_STORY UPDATE_LORE UPDATE_WORLD_BIBLE } +enum ContributionKind { + LORE + STORY +} + +enum ContributionStatus { + DRAFT + SUBMITTED +} + enum ProposalStatus { DRAFT SUBMITTED @@ -129,6 +140,11 @@ enum WorldAuditAction { MODERATION_ACTION CANON_STATUS_EXECUTED AI_WARNING_RESOLVED + CONTRIBUTION_CREATED + CONTRIBUTION_UPDATED + CONTRIBUTION_DELETED + CONTRIBUTION_SUBMITTED + PROPOSAL_CREATED } enum AuthProvider { @@ -168,6 +184,7 @@ model User { bibleVersions WorldBibleVersion[] @relation("WorldBibleVersionCreator") loreEntries LoreEntry[] @relation("LoreEntryAuthor") proposals Proposal[] @relation("ProposalAuthor") + contributionDrafts ContributionDraft[] @relation("ContributionDraftAuthor") votes AppVote[] @relation("AppVoteVoter") moderationReports ModerationReport[] @relation("ModerationReporter") moderationReviews ModerationReport[] @relation("ModerationReviewer") @@ -286,23 +303,24 @@ model RcDelegation { } model World { - id String @id @default(cuid()) - slug String @unique - title String - description String - founderId String - founder User @relation("WorldFounder", fields: [founderId], references: [id], onDelete: Restrict) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - seed WorldSeed? - bibleVersions WorldBibleVersion[] - loreEntries LoreEntry[] - loreRelationships LoreRelationship[] - proposals Proposal[] - moderationReports ModerationReport[] - searchIndexes SearchIndex[] - memberships WorldMembership[] - auditLogs WorldAuditLog[] + id String @id @default(cuid()) + slug String @unique + title String + description String + founderId String + founder User @relation("WorldFounder", fields: [founderId], references: [id], onDelete: Restrict) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + seed WorldSeed? + bibleVersions WorldBibleVersion[] + loreEntries LoreEntry[] + loreRelationships LoreRelationship[] + proposals Proposal[] + contributionDrafts ContributionDraft[] + moderationReports ModerationReport[] + searchIndexes SearchIndex[] + memberships WorldMembership[] + auditLogs WorldAuditLog[] @@index([founderId]) @@index([createdAt]) @@ -391,26 +409,27 @@ model WorldBibleVersion { } model LoreEntry { - id String @id @default(cuid()) - worldId String - world World @relation(fields: [worldId], references: [id], onDelete: Restrict) - authorId String - author User @relation("LoreEntryAuthor", fields: [authorId], references: [id], onDelete: Restrict) - title String - slug String - loreType LoreType - content Json - status LoreStatus @default(DRAFT) - publishedAt DateTime? - hiveReferenceId String? @unique + id String @id @default(cuid()) + worldId String + world World @relation(fields: [worldId], references: [id], onDelete: Restrict) + authorId String + author User @relation("LoreEntryAuthor", fields: [authorId], references: [id], onDelete: Restrict) + title String + slug String + loreType LoreType + content Json + status LoreStatus @default(DRAFT) + publishedAt DateTime? + hiveReferenceId String? @unique /// Published canon must be confirmed through an indexed HiveReference, not only this mutable row. - hiveReference HiveReference? @relation(fields: [hiveReferenceId], references: [id], onDelete: SetNull) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - outgoingRelations LoreRelationship[] @relation("LoreRelationshipSource") - incomingRelations LoreRelationship[] @relation("LoreRelationshipTarget") - targetProposals Proposal[] @relation("ProposalTargetLoreEntry") - resultingProposals Proposal[] @relation("ProposalResultingLoreEntry") + hiveReference HiveReference? @relation(fields: [hiveReferenceId], references: [id], onDelete: SetNull) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + outgoingRelations LoreRelationship[] @relation("LoreRelationshipSource") + incomingRelations LoreRelationship[] @relation("LoreRelationshipTarget") + targetProposals Proposal[] @relation("ProposalTargetLoreEntry") + targetContributionDrafts ContributionDraft[] @relation("ContributionDraftTargetLoreEntry") + resultingProposals Proposal[] @relation("ProposalResultingLoreEntry") @@unique([worldId, slug]) @@index([worldId, status]) @@ -445,6 +464,7 @@ model Proposal { authorId String author User @relation("ProposalAuthor", fields: [authorId], references: [id], onDelete: Restrict) proposalType ProposalType + contributionKind ContributionKind? status ProposalStatus @default(DRAFT) title String summary String @@ -462,12 +482,41 @@ model Proposal { publishedAt DateTime? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt + contributionDraft ContributionDraft? votes AppVote[] aiReports AIReport[] moderationReports ModerationReport[] @@index([worldId, status]) @@index([authorId]) + @@index([worldId, contributionKind]) + @@index([targetLoreEntryId]) +} + +model ContributionDraft { + id String @id @default(cuid()) + worldId String + world World @relation(fields: [worldId], references: [id], onDelete: Cascade) + authorId String + author User @relation("ContributionDraftAuthor", fields: [authorId], references: [id], onDelete: Cascade) + targetLoreEntryId String? + targetLoreEntry LoreEntry? @relation("ContributionDraftTargetLoreEntry", fields: [targetLoreEntryId], references: [id], onDelete: SetNull) + kind ContributionKind + title String + summary String? + content Json + status ContributionStatus @default(DRAFT) + proposalId String? @unique + /// Submitted drafts are locked; Proposal.proposedContent stores the immutable reviewer snapshot. + proposal Proposal? @relation(fields: [proposalId], references: [id], onDelete: SetNull) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + submittedAt DateTime? + + @@index([worldId]) + @@index([authorId]) + @@index([worldId, status]) + @@index([worldId, authorId, status]) @@index([targetLoreEntryId]) } diff --git a/apps/api/prisma/seed.ts b/apps/api/prisma/seed.ts index 8311856..d999c72 100644 --- a/apps/api/prisma/seed.ts +++ b/apps/api/prisma/seed.ts @@ -3,6 +3,7 @@ import 'dotenv/config'; import { PrismaPg } from '@prisma/adapter-pg'; import { PrismaClient } from '../src/generated/prisma/client.js'; +import { WorldRole } from '../src/generated/prisma/enums.js'; import { ensureActiveFounderMembership } from '../src/lib/founder-memberships.js'; const databaseUrl = process.env.DATABASE_URL; @@ -75,6 +76,26 @@ async function main() { userId: founder.id, }); + await prisma.worldMembership.upsert({ + where: { + worldId_userId: { + worldId: world.id, + userId: contributor.id, + }, + }, + update: { + role: WorldRole.CONTRIBUTOR, + grantedById: founder.id, + revokedAt: null, + }, + create: { + worldId: world.id, + userId: contributor.id, + role: WorldRole.CONTRIBUTOR, + grantedById: founder.id, + }, + }); + await prisma.worldSeed.upsert({ where: { worldId: world.id, @@ -270,6 +291,68 @@ async function main() { }, }); + await prisma.contributionDraft.upsert({ + where: { + id: 'dev-contribution-glass-archipelago-001', + }, + update: { + kind: 'LORE', + title: 'Draft: Lighthouse Keeper Oath', + summary: 'Development-only structured contribution draft.', + content: { + type: 'doc', + content: [ + { + type: 'heading', + attrs: { level: 1 }, + content: [{ type: 'text', text: 'Lighthouse Keeper Oath' }], + }, + { + type: 'paragraph', + content: [ + { + type: 'text', + text: 'Keepers swear to report reflected storms before dawn bells sound.', + }, + ], + }, + ], + }, + status: 'DRAFT', + proposalId: null, + submittedAt: null, + targetLoreEntryId: lighthouse.id, + }, + create: { + id: 'dev-contribution-glass-archipelago-001', + worldId: world.id, + authorId: contributor.id, + kind: 'LORE', + title: 'Draft: Lighthouse Keeper Oath', + summary: 'Development-only structured contribution draft.', + content: { + type: 'doc', + content: [ + { + type: 'heading', + attrs: { level: 1 }, + content: [{ type: 'text', text: 'Lighthouse Keeper Oath' }], + }, + { + type: 'paragraph', + content: [ + { + type: 'text', + text: 'Keepers swear to report reflected storms before dawn bells sound.', + }, + ], + }, + ], + }, + targetLoreEntryId: lighthouse.id, + }, + }); + await prisma.aIReport.upsert({ where: { id: 'dev-ai-report-glass-archipelago-001' }, update: { diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index 52f13c3..944811f 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -3,6 +3,7 @@ import Fastify from 'fastify'; import { env } from './config/env.js'; import { registerAuthRoutes } from './routes/auth.js'; +import { registerContributionRoutes } from './routes/contributions.js'; import { registerHealthRoute } from './routes/health.js'; import { registerWorldRoutes } from './routes/worlds.js'; @@ -34,6 +35,7 @@ export async function buildApp() { await registerHealthRoute(app); await registerAuthRoutes(app); await registerWorldRoutes(app); + await registerContributionRoutes(app); return app; } diff --git a/apps/api/src/lib/contributions.ts b/apps/api/src/lib/contributions.ts new file mode 100644 index 0000000..675109b --- /dev/null +++ b/apps/api/src/lib/contributions.ts @@ -0,0 +1,755 @@ +import type { Prisma, PrismaClient } from '../generated/prisma/client.js'; +import { + ContributionKind, + ContributionStatus, + ProposalStatus, + ProposalType, + WorldAuditAction, +} from '../generated/prisma/enums.js'; + +const STRUCTURED_DOCUMENT_MAX_BYTES = 100 * 1024; + +export class ContributionError extends Error { + constructor( + public readonly statusCode: number, + message: string, + ) { + super(message); + } +} + +export type ContributionDatabase = Pick< + PrismaClient, + | '$transaction' + | 'contributionDraft' + | 'loreEntry' + | 'proposal' + | 'world' + | 'worldAuditLog' + | 'worldMembership' +>; + +export type StructuredDocument = Prisma.InputJsonObject; + +export type CreateContributionInput = { + worldId: string; + authorId: string; + kind: ContributionKind; + title: string; + summary?: string | undefined; + targetLoreEntryId?: string | undefined; + content: unknown; +}; + +export type UpdateContributionInput = { + worldId: string; + contributionId: string; + authorId: string; + kind?: ContributionKind | undefined; + title?: string | undefined; + summary?: string | null | undefined; + targetLoreEntryId?: string | null | undefined; + content?: unknown; +}; + +export type ListContributionInput = { + worldId: string; + authorId: string; + status?: ContributionStatus | undefined; + kind?: ContributionKind | undefined; + page?: number | undefined; + pageSize?: number | undefined; +}; + +const contributionInclude = { + proposal: true, + targetLoreEntry: { + select: { + id: true, + loreType: true, + slug: true, + title: true, + worldId: true, + }, + }, +} as const; + +type ContributionWithRelations = Prisma.ContributionDraftGetPayload<{ + include: typeof contributionInclude; +}>; + +type ProposalRecord = Prisma.ProposalGetPayload>; + +function serializeDate(value: Date | null) { + return value?.toISOString() ?? null; +} + +export function serializeProposal(proposal: ProposalRecord | null) { + if (!proposal) { + return null; + } + + return { + approvedAt: serializeDate(proposal.approvedAt), + authorId: proposal.authorId, + contributionKind: proposal.contributionKind, + createdAt: proposal.createdAt.toISOString(), + id: proposal.id, + proposedContent: proposal.proposedContent, + proposalType: proposal.proposalType, + publishedAt: serializeDate(proposal.publishedAt), + rejectedAt: serializeDate(proposal.rejectedAt), + resultingBibleVersionId: proposal.resultingBibleVersionId, + resultingLoreEntryId: proposal.resultingLoreEntryId, + status: proposal.status, + submittedAt: serializeDate(proposal.submittedAt), + summary: proposal.summary, + targetLoreEntryId: proposal.targetLoreEntryId, + title: proposal.title, + updatedAt: proposal.updatedAt.toISOString(), + votingStartedAt: serializeDate(proposal.votingStartedAt), + worldId: proposal.worldId, + }; +} + +export function serializeContribution(contribution: ContributionWithRelations) { + return { + authorId: contribution.authorId, + content: contribution.content, + createdAt: contribution.createdAt.toISOString(), + id: contribution.id, + kind: contribution.kind, + proposal: serializeProposal(contribution.proposal), + proposalId: contribution.proposalId, + status: contribution.status, + submittedAt: serializeDate(contribution.submittedAt), + summary: contribution.summary, + targetLoreEntry: contribution.targetLoreEntry, + targetLoreEntryId: contribution.targetLoreEntryId, + title: contribution.title, + updatedAt: contribution.updatedAt.toISOString(), + worldId: contribution.worldId, + }; +} + +function isPlainObject(value: unknown): value is Record { + return ( + typeof value === 'object' && + value !== null && + !Array.isArray(value) && + (Object.getPrototypeOf(value) === Object.prototype || Object.getPrototypeOf(value) === null) + ); +} + +function assertJsonByteLimit(value: unknown) { + const serialized = JSON.stringify(value); + + if (!serialized || Buffer.byteLength(serialized, 'utf8') > STRUCTURED_DOCUMENT_MAX_BYTES) { + throw new ContributionError(400, 'Structured content exceeds the 100 KB limit.'); + } +} + +export function validateStructuredDocument(value: unknown): StructuredDocument { + assertJsonByteLimit(value); + + if (!isPlainObject(value)) { + throw new ContributionError(400, 'Structured content must be a JSON object.'); + } + + if (value.type !== 'doc') { + throw new ContributionError(400, 'Structured content root type must be "doc".'); + } + + if ('content' in value && !Array.isArray(value.content)) { + throw new ContributionError(400, 'Structured content root content must be an array.'); + } + + return value as StructuredDocument; +} + +export function hasMeaningfulText(value: unknown): boolean { + if (Array.isArray(value)) { + return value.some(hasMeaningfulText); + } + + if (!isPlainObject(value)) { + return false; + } + + if (typeof value.text === 'string' && value.text.trim().length > 0) { + return true; + } + + return Object.values(value).some(hasMeaningfulText); +} + +function normalizeTitle(title: string) { + const normalized = title.trim(); + + if (!normalized) { + throw new ContributionError(400, 'Contribution title is required.'); + } + + if (normalized.length > 200) { + throw new ContributionError(400, 'Contribution title must be at most 200 characters.'); + } + + return normalized; +} + +function normalizeSummary(summary: string | null | undefined) { + const normalized = summary?.trim() || null; + + if (normalized && normalized.length > 1_000) { + throw new ContributionError(400, 'Contribution summary must be at most 1000 characters.'); + } + + return normalized; +} + +async function assertWorldExists(database: Pick, worldId: string) { + const world = await database.world.findUnique({ + select: { + id: true, + }, + where: { + id: worldId, + }, + }); + + if (!world) { + throw new ContributionError(404, 'World not found.'); + } +} + +async function assertTargetLoreEntry( + database: Pick, + worldId: string, + targetLoreEntryId: string | null | undefined, +) { + if (!targetLoreEntryId) { + return null; + } + + const target = await database.loreEntry.findUnique({ + select: { + id: true, + worldId: true, + }, + where: { + id: targetLoreEntryId, + }, + }); + + if (!target) { + throw new ContributionError(404, 'Target lore entry not found.'); + } + + if (target.worldId !== worldId) { + throw new ContributionError(400, 'Target lore entry must belong to the same world.'); + } + + return target; +} + +function proposalTypeForContribution(kind: ContributionKind, hasTargetLoreEntry: boolean) { + if (kind === ContributionKind.STORY) { + return ProposalType.ADD_STORY; + } + + return hasTargetLoreEntry ? ProposalType.UPDATE_LORE : ProposalType.ADD_LORE; +} + +async function createContributionAuditLog( + database: Pick, + input: { + action: (typeof WorldAuditAction)[keyof typeof WorldAuditAction]; + actorId: string; + worldId: string; + targetId: string; + targetType: string; + metadata?: Prisma.InputJsonObject | undefined; + }, +) { + await database.worldAuditLog.create({ + data: { + action: input.action, + actorId: input.actorId, + metadata: input.metadata ?? {}, + targetId: input.targetId, + targetType: input.targetType, + worldId: input.worldId, + }, + }); +} + +export async function createContribution( + database: ContributionDatabase, + input: CreateContributionInput, +) { + const title = normalizeTitle(input.title); + const summary = normalizeSummary(input.summary); + const content = validateStructuredDocument(input.content); + + await assertWorldExists(database, input.worldId); + await assertTargetLoreEntry(database, input.worldId, input.targetLoreEntryId); + + const contribution = await database.$transaction(async (transaction) => { + const created = await transaction.contributionDraft.create({ + data: { + authorId: input.authorId, + content, + kind: input.kind, + summary, + targetLoreEntryId: input.targetLoreEntryId || null, + title, + worldId: input.worldId, + }, + include: contributionInclude, + }); + + await createContributionAuditLog(transaction, { + action: WorldAuditAction.CONTRIBUTION_CREATED, + actorId: input.authorId, + metadata: { + kind: input.kind, + targetLoreEntryId: input.targetLoreEntryId ?? null, + }, + targetId: created.id, + targetType: 'CONTRIBUTION_DRAFT', + worldId: input.worldId, + }); + + return created; + }); + + return serializeContribution(contribution); +} + +export async function listOwnedContributions( + database: ContributionDatabase, + input: ListContributionInput, +) { + const page = input.page ?? 1; + const pageSize = input.pageSize ?? 20; + const where: Prisma.ContributionDraftWhereInput = { + authorId: input.authorId, + ...(input.kind ? { kind: input.kind } : {}), + ...(input.status ? { status: input.status } : {}), + worldId: input.worldId, + }; + + const [contributions, total] = await Promise.all([ + database.contributionDraft.findMany({ + include: contributionInclude, + orderBy: [ + { + updatedAt: 'desc', + }, + { + id: 'desc', + }, + ], + skip: (page - 1) * pageSize, + take: pageSize, + where, + }), + database.contributionDraft.count({ + where, + }), + ]); + + return { + contributions: contributions.map(serializeContribution), + pagination: { + page, + pageSize, + total, + }, + }; +} + +async function resolveMissingOrLockedContribution( + database: Pick, + input: { + worldId: string; + contributionId: string; + authorId: string; + lockedMessage: string; + }, +) { + const current = await database.contributionDraft.findFirst({ + select: { + status: true, + }, + where: { + authorId: input.authorId, + id: input.contributionId, + worldId: input.worldId, + }, + }); + + if (!current) { + throw new ContributionError(404, 'Contribution not found.'); + } + + if (current.status !== ContributionStatus.DRAFT) { + throw new ContributionError(409, input.lockedMessage); + } + + throw new ContributionError(409, 'Concurrent contribution update conflict.'); +} + +async function findOwnedContributionOrThrow( + database: Pick, + input: { + worldId: string; + contributionId: string; + authorId: string; + }, +) { + const contribution = await database.contributionDraft.findFirst({ + include: contributionInclude, + where: { + authorId: input.authorId, + id: input.contributionId, + worldId: input.worldId, + }, + }); + + if (!contribution) { + throw new ContributionError(404, 'Contribution not found.'); + } + + return contribution; +} + +export async function getOwnedContribution( + database: ContributionDatabase, + input: { + worldId: string; + contributionId: string; + authorId: string; + }, +) { + const contribution = await database.contributionDraft.findFirst({ + include: contributionInclude, + where: { + authorId: input.authorId, + id: input.contributionId, + worldId: input.worldId, + }, + }); + + return contribution ? serializeContribution(contribution) : null; +} + +export async function updateContribution( + database: ContributionDatabase, + input: UpdateContributionInput, +) { + await findOwnedContributionOrThrow(database, input); + + const data: Prisma.ContributionDraftUpdateManyMutationInput = {}; + const changedFields: string[] = []; + let targetLoreEntryId: string | null | undefined; + + if (input.kind) { + data.kind = input.kind; + changedFields.push('kind'); + } + + if (input.title !== undefined) { + data.title = normalizeTitle(input.title); + changedFields.push('title'); + } + + if (input.summary !== undefined) { + data.summary = normalizeSummary(input.summary); + changedFields.push('summary'); + } + + if (input.content !== undefined) { + data.content = validateStructuredDocument(input.content); + changedFields.push('content'); + } + + if (input.targetLoreEntryId !== undefined) { + await assertTargetLoreEntry(database, input.worldId, input.targetLoreEntryId); + targetLoreEntryId = input.targetLoreEntryId; + changedFields.push('targetLoreEntryId'); + } + + if (Object.keys(data).length === 0) { + data.updatedAt = new Date(); + } + + const contribution = await database.$transaction(async (transaction) => { + const updated = await transaction.contributionDraft.updateMany({ + data, + where: { + authorId: input.authorId, + id: input.contributionId, + status: ContributionStatus.DRAFT, + worldId: input.worldId, + }, + }); + + if (updated.count !== 1) { + await resolveMissingOrLockedContribution(transaction, { + ...input, + lockedMessage: 'Submitted contributions cannot be edited.', + }); + } + + if (targetLoreEntryId !== undefined) { + await transaction.contributionDraft.update({ + data: { + targetLoreEntry: + targetLoreEntryId === null + ? { + disconnect: true, + } + : { + connect: { + id: targetLoreEntryId, + }, + }, + }, + where: { + id: input.contributionId, + }, + }); + } + + const current = await transaction.contributionDraft.findFirst({ + include: contributionInclude, + where: { + authorId: input.authorId, + id: input.contributionId, + worldId: input.worldId, + }, + }); + + if (!current) { + throw new ContributionError(404, 'Contribution not found.'); + } + + await createContributionAuditLog(transaction, { + action: WorldAuditAction.CONTRIBUTION_UPDATED, + actorId: input.authorId, + metadata: { + changedFields: changedFields.sort(), + }, + targetId: current.id, + targetType: 'CONTRIBUTION_DRAFT', + worldId: input.worldId, + }); + + return current; + }); + + return serializeContribution(contribution); +} + +export async function deleteContribution( + database: ContributionDatabase, + input: { + worldId: string; + contributionId: string; + authorId: string; + }, +) { + await database.$transaction(async (transaction) => { + const existing = await transaction.contributionDraft.findFirst({ + where: { + authorId: input.authorId, + id: input.contributionId, + worldId: input.worldId, + }, + }); + + if (!existing) { + throw new ContributionError(404, 'Contribution not found.'); + } + + if (existing.status !== ContributionStatus.DRAFT) { + throw new ContributionError(409, 'Submitted contributions cannot be deleted.'); + } + + const deleted = await transaction.contributionDraft.deleteMany({ + where: { + authorId: input.authorId, + id: input.contributionId, + status: ContributionStatus.DRAFT, + worldId: input.worldId, + }, + }); + + if (deleted.count !== 1) { + await resolveMissingOrLockedContribution(transaction, { + ...input, + lockedMessage: 'Submitted contributions cannot be deleted.', + }); + } + + await createContributionAuditLog(transaction, { + action: WorldAuditAction.CONTRIBUTION_DELETED, + actorId: input.authorId, + metadata: { + kind: existing.kind, + targetLoreEntryId: existing.targetLoreEntryId, + }, + targetId: existing.id, + targetType: 'CONTRIBUTION_DRAFT', + worldId: input.worldId, + }); + }); +} + +export async function submitContribution( + database: ContributionDatabase, + input: { + worldId: string; + contributionId: string; + authorId: string; + }, +) { + const result = await database.$transaction(async (transaction) => { + const existing = await transaction.contributionDraft.findFirst({ + include: contributionInclude, + where: { + authorId: input.authorId, + id: input.contributionId, + worldId: input.worldId, + }, + }); + + if (!existing) { + throw new ContributionError(404, 'Contribution not found.'); + } + + if (existing.status !== ContributionStatus.DRAFT) { + if (existing.proposal) { + return { + alreadySubmitted: true, + contribution: existing, + proposal: existing.proposal, + }; + } + + throw new ContributionError(409, 'Contribution already submitted.'); + } + + normalizeTitle(existing.title); + const structuredContent = validateStructuredDocument(existing.content); + + if (!hasMeaningfulText(existing.content)) { + throw new ContributionError(400, 'Contribution content must include meaningful text.'); + } + + await assertWorldExists(transaction, input.worldId); + await assertTargetLoreEntry(transaction, input.worldId, existing.targetLoreEntryId); + + const submittedAt = new Date(); + const transition = await transaction.contributionDraft.updateMany({ + data: { + status: ContributionStatus.SUBMITTED, + submittedAt, + }, + where: { + authorId: input.authorId, + id: input.contributionId, + status: ContributionStatus.DRAFT, + worldId: input.worldId, + }, + }); + + if (transition.count !== 1) { + const submitted = await transaction.contributionDraft.findFirst({ + include: contributionInclude, + where: { + authorId: input.authorId, + id: input.contributionId, + worldId: input.worldId, + }, + }); + + if (submitted?.proposal) { + return { + alreadySubmitted: true, + contribution: submitted, + proposal: submitted.proposal, + }; + } + + throw new ContributionError(409, 'Concurrent submission conflict.'); + } + + // Proposal.proposedContent is the immutable reviewer snapshot; submitted + // drafts are locked so draft endpoints cannot mutate active voting content. + const proposal = await transaction.proposal.create({ + data: { + authorId: input.authorId, + contributionKind: existing.kind, + proposedContent: structuredContent, + proposalType: proposalTypeForContribution( + existing.kind, + Boolean(existing.targetLoreEntryId), + ), + status: ProposalStatus.SUBMITTED, + submittedAt, + summary: existing.summary ?? '', + targetLoreEntryId: existing.targetLoreEntryId, + title: existing.title, + worldId: input.worldId, + }, + }); + + const contribution = await transaction.contributionDraft.update({ + data: { + proposalId: proposal.id, + }, + include: contributionInclude, + where: { + id: existing.id, + }, + }); + + await createContributionAuditLog(transaction, { + action: WorldAuditAction.PROPOSAL_CREATED, + actorId: input.authorId, + metadata: { + contributionId: contribution.id, + contributionKind: contribution.kind, + proposalType: proposal.proposalType, + }, + targetId: proposal.id, + targetType: 'PROPOSAL', + worldId: input.worldId, + }); + + await createContributionAuditLog(transaction, { + action: WorldAuditAction.CONTRIBUTION_SUBMITTED, + actorId: input.authorId, + metadata: { + proposalId: proposal.id, + }, + targetId: contribution.id, + targetType: 'CONTRIBUTION_DRAFT', + worldId: input.worldId, + }); + + return { + alreadySubmitted: false, + contribution, + proposal, + }; + }); + + return { + alreadySubmitted: result.alreadySubmitted, + contribution: serializeContribution(result.contribution), + proposal: serializeProposal(result.proposal), + }; +} diff --git a/apps/api/src/routes/contributions.test.ts b/apps/api/src/routes/contributions.test.ts new file mode 100644 index 0000000..7d0e459 --- /dev/null +++ b/apps/api/src/routes/contributions.test.ts @@ -0,0 +1,942 @@ +import assert from 'node:assert/strict'; +import { describe, test } from 'node:test'; + +import Fastify from 'fastify'; + +import { env } from '../config/env.js'; +import { + ContributionKind, + ContributionStatus, + PlatformRole, + ProposalStatus, + ProposalType, + WorldAuditAction, + WorldRole, +} from '../generated/prisma/enums.js'; +import { signAccessToken } from '../lib/auth-crypto.js'; +import { + createContribution, + deleteContribution, + submitContribution, + updateContribution, +} from '../lib/contributions.js'; +import { registerContributionRoutes } from './contributions.js'; + +const author = { + hiveUsername: 'mira-vale.dev', + id: 'user-author', + normalizedHiveUsername: 'mira-vale.dev', +}; + +const otherUser = { + hiveUsername: 'emberquill.dev', + id: 'user-other', + normalizedHiveUsername: 'emberquill.dev', +}; + +function authHeader(user = author) { + const token = signAccessToken( + { + hiveUsername: user.hiveUsername, + normalizedHiveUsername: user.normalizedHiveUsername, + platformRole: PlatformRole.USER, + sid: `session-${user.id}`, + sub: user.id, + }, + { + audience: env.AUTH_JWT_AUDIENCE, + issuer: env.AUTH_JWT_ISSUER, + secret: env.AUTH_JWT_SECRET, + ttlSeconds: 900, + }, + ); + + return { + authorization: `Bearer ${token}`, + }; +} + +const structuredDoc = { + type: 'doc', + content: [ + { + type: 'paragraph', + content: [{ type: 'text', text: 'The northern gate fell during the third winter.' }], + }, + ], +}; + +const emptyDoc = { + type: 'doc', + content: [ + { + type: 'paragraph', + content: [{ type: 'text', text: ' ' }], + }, + ], +}; + +type StoredContribution = { + id: string; + worldId: string; + authorId: string; + targetLoreEntryId: string | null; + kind: ContributionKind; + title: string; + summary: string | null; + content: unknown; + status: ContributionStatus; + proposalId: string | null; + createdAt: Date; + updatedAt: Date; + submittedAt: Date | null; +}; + +type StoredProposal = { + id: string; + worldId: string; + authorId: string; + proposalType: ProposalType; + contributionKind: ContributionKind; + status: ProposalStatus; + title: string; + summary: string; + proposedContent: unknown; + targetLoreEntryId: string | null; + resultingLoreEntryId: string | null; + resultingBibleVersionId: string | null; + submittedAt: Date | null; + votingStartedAt: Date | null; + approvedAt: Date | null; + rejectedAt: Date | null; + publishedAt: Date | null; + createdAt: Date; + updatedAt: Date; +}; + +function now() { + return new Date('2026-08-05T12:00:00.000Z'); +} + +function createContributionRecord(input: Partial = {}): StoredContribution { + return { + authorId: author.id, + content: structuredDoc, + createdAt: now(), + id: `contribution-${Math.random().toString(36).slice(2)}`, + kind: ContributionKind.LORE, + proposalId: null, + status: ContributionStatus.DRAFT, + submittedAt: null, + summary: 'A short summary.', + targetLoreEntryId: 'lore-1', + title: 'The Fall of the Northern Gate', + updatedAt: now(), + worldId: 'world-1', + ...input, + }; +} + +function createDatabase() { + const worlds = [{ id: 'world-1' }, { id: 'world-2' }]; + const loreEntries = [ + { + id: 'lore-1', + loreType: 'LOCATION', + slug: 'north-gate', + title: 'North Gate', + worldId: 'world-1', + }, + { + id: 'lore-2', + loreType: 'EVENT', + slug: 'other-world-event', + title: 'Other Event', + worldId: 'world-2', + }, + ]; + const memberships = [ + { + id: 'membership-author', + revokedAt: null, + role: WorldRole.CONTRIBUTOR, + userId: author.id, + worldId: 'world-1', + }, + { + id: 'membership-other', + revokedAt: null, + role: WorldRole.READER, + userId: otherUser.id, + worldId: 'world-1', + }, + ]; + const contributions: StoredContribution[] = []; + const proposals: StoredProposal[] = []; + const auditLogs: unknown[] = []; + let failNextProposalCreate = false; + let failNextAuditCreate = false; + let submitBeforeNextDeleteMany = false; + let submitBeforeNextUpdateMany = false; + let transactionQueue = Promise.resolve(); + const externallySubmittedContributionIds = new Set(); + + function includeContribution(contribution: StoredContribution) { + return { + ...contribution, + proposal: contribution.proposalId + ? (proposals.find((proposal) => proposal.id === contribution.proposalId) ?? null) + : null, + targetLoreEntry: contribution.targetLoreEntryId + ? (loreEntries.find((entry) => entry.id === contribution.targetLoreEntryId) ?? null) + : null, + }; + } + + const database: Record = {}; + + Object.assign(database, { + async $transaction(callback: (transaction: unknown) => Promise) { + const previousTransaction = transactionQueue; + let releaseTransaction!: () => void; + transactionQueue = new Promise((resolve) => { + releaseTransaction = resolve; + }); + + await previousTransaction; + + const contributionSnapshot = contributions.map((contribution) => ({ ...contribution })); + const proposalSnapshot = proposals.map((proposal) => ({ ...proposal })); + const auditSnapshot = [...auditLogs]; + + try { + return await callback(database); + } catch (error) { + contributions.splice(0, contributions.length, ...contributionSnapshot); + proposals.splice(0, proposals.length, ...proposalSnapshot); + auditLogs.splice(0, auditLogs.length, ...auditSnapshot); + + for (const contributionId of externallySubmittedContributionIds) { + const contribution = contributions.find((candidate) => candidate.id === contributionId); + + if (contribution) { + contribution.status = ContributionStatus.SUBMITTED; + contribution.submittedAt = now(); + } + } + + externallySubmittedContributionIds.clear(); + throw error; + } finally { + releaseTransaction(); + } + }, + contributionDraft: { + async create(args: { data: Partial; include: unknown }) { + const contribution = createContributionRecord({ + id: `contribution-${contributions.length + 1}`, + proposalId: null, + status: ContributionStatus.DRAFT, + submittedAt: null, + ...args.data, + }); + contributions.push(contribution); + return includeContribution(contribution); + }, + async delete(args: { where: { id: string } }) { + const index = contributions.findIndex((contribution) => contribution.id === args.where.id); + const [deleted] = contributions.splice(index, 1); + return deleted; + }, + async deleteMany(args: { where: Partial }) { + if (submitBeforeNextDeleteMany) { + const contribution = contributions.find( + (candidate) => + candidate.id === args.where.id && candidate.authorId === args.where.authorId, + ); + + if (contribution) { + contribution.status = ContributionStatus.SUBMITTED; + contribution.submittedAt = now(); + externallySubmittedContributionIds.add(contribution.id); + } + + submitBeforeNextDeleteMany = false; + } + + const before = contributions.length; + + for (let index = contributions.length - 1; index >= 0; index -= 1) { + const contribution = contributions[index]!; + + if ( + Object.entries(args.where).every( + ([key, value]) => contribution[key as keyof StoredContribution] === value, + ) + ) { + contributions.splice(index, 1); + } + } + + return { count: before - contributions.length }; + }, + async findFirst(args: { where: Partial; include?: unknown }) { + const contribution = + contributions.find((candidate) => + Object.entries(args.where).every(([key, value]) => { + if (value && typeof value === 'object' && 'lt' in value) { + return String(candidate[key as keyof StoredContribution]) < String(value.lt); + } + + return candidate[key as keyof StoredContribution] === value; + }), + ) ?? null; + + return contribution ? includeContribution(contribution) : null; + }, + async findMany(args: { + where: Partial & { id?: { lt: string } }; + skip?: number; + take: number; + }) { + return contributions + .filter((candidate) => + Object.entries(args.where).every(([key, value]) => { + if (key === 'id' && value && typeof value === 'object' && 'lt' in value) { + return candidate.id < String(value.lt); + } + + return candidate[key as keyof StoredContribution] === value; + }), + ) + .sort((left, right) => { + const updatedAtOrder = right.updatedAt.getTime() - left.updatedAt.getTime(); + + if (updatedAtOrder !== 0) { + return updatedAtOrder; + } + + return right.id.localeCompare(left.id); + }) + .slice(args.skip ?? 0, (args.skip ?? 0) + args.take) + .map(includeContribution); + }, + async count(args: { where: Partial }) { + return contributions.filter((candidate) => + Object.entries(args.where).every( + ([key, value]) => candidate[key as keyof StoredContribution] === value, + ), + ).length; + }, + async update(args: { + data: Partial; + where: { id: string }; + include?: unknown; + }) { + const contribution = contributions.find((candidate) => candidate.id === args.where.id); + + if (!contribution) { + throw new Error('Contribution missing.'); + } + + if ('targetLoreEntry' in args.data) { + const relation = args.data.targetLoreEntry as + { connect: { id: string } } | { disconnect: true }; + contribution.targetLoreEntryId = 'disconnect' in relation ? null : relation.connect.id; + delete args.data.targetLoreEntry; + } + + Object.assign(contribution, args.data, { + updatedAt: now(), + }); + + return includeContribution(contribution); + }, + async updateMany(args: { + data: Partial; + where: Partial; + }) { + if (submitBeforeNextUpdateMany) { + const contribution = contributions.find( + (candidate) => + candidate.id === args.where.id && candidate.authorId === args.where.authorId, + ); + + if (contribution) { + contribution.status = ContributionStatus.SUBMITTED; + contribution.submittedAt = now(); + externallySubmittedContributionIds.add(contribution.id); + } + + submitBeforeNextUpdateMany = false; + } + + let count = 0; + + for (const contribution of contributions) { + if ( + Object.entries(args.where).every( + ([key, value]) => contribution[key as keyof StoredContribution] === value, + ) + ) { + Object.assign(contribution, args.data, { + updatedAt: now(), + }); + count += 1; + } + } + + return { count }; + }, + }, + loreEntry: { + async findUnique(args: { where: { id: string } }) { + return loreEntries.find((entry) => entry.id === args.where.id) ?? null; + }, + }, + proposal: { + async create(args: { data: Omit }) { + if (failNextProposalCreate) { + failNextProposalCreate = false; + throw new Error('Injected proposal create failure.'); + } + + const proposal: StoredProposal = { + ...args.data, + approvedAt: args.data.approvedAt ?? null, + createdAt: now(), + id: `proposal-${proposals.length + 1}`, + publishedAt: args.data.publishedAt ?? null, + rejectedAt: args.data.rejectedAt ?? null, + resultingBibleVersionId: args.data.resultingBibleVersionId ?? null, + resultingLoreEntryId: args.data.resultingLoreEntryId ?? null, + updatedAt: now(), + votingStartedAt: args.data.votingStartedAt ?? null, + }; + proposals.push(proposal); + return proposal; + }, + }, + world: { + async findUnique(args: { where: { id: string } }) { + return worlds.find((world) => world.id === args.where.id) ?? null; + }, + }, + worldAuditLog: { + async create(args: { data: unknown }) { + if (failNextAuditCreate) { + failNextAuditCreate = false; + throw new Error('Injected audit failure.'); + } + + auditLogs.push(args.data); + return args.data; + }, + }, + worldMembership: { + async findUnique(args: { + where: { worldId_userId: { worldId: string; userId: string }; revokedAt: null }; + }) { + return ( + memberships.find( + (membership) => + membership.worldId === args.where.worldId_userId.worldId && + membership.userId === args.where.worldId_userId.userId && + !membership.revokedAt, + ) ?? null + ); + }, + }, + }); + + return { + auditLogs, + contributions, + database, + failNextProposalCreate() { + failNextProposalCreate = true; + }, + failNextAuditCreate() { + failNextAuditCreate = true; + }, + loreEntries, + memberships, + proposals, + submitBeforeNextDeleteMany() { + submitBeforeNextDeleteMany = true; + }, + submitBeforeNextUpdateMany() { + submitBeforeNextUpdateMany = true; + }, + }; +} + +async function createApp(database: ReturnType['database']) { + const app = Fastify(); + await registerContributionRoutes(app, { + database: database as never, + }); + return app; +} + +describe('contribution routes', () => { + test('authenticated contributor can create lore and story drafts with session-derived author', async () => { + const state = createDatabase(); + const app = await createApp(state.database); + + const loreResponse = await app.inject({ + headers: authHeader(), + method: 'POST', + payload: { + content: structuredDoc, + kind: ContributionKind.LORE, + targetLoreEntryId: 'lore-1', + title: ' The Fall of the Northern Gate ', + }, + url: '/worlds/world-1/contributions', + }); + const storyResponse = await app.inject({ + headers: authHeader(), + method: 'POST', + payload: { + content: structuredDoc, + kind: ContributionKind.STORY, + title: 'A Winter Road', + }, + url: '/worlds/world-1/contributions', + }); + const protectedFieldResponse = await app.inject({ + headers: authHeader(), + method: 'POST', + payload: { + authorId: otherUser.id, + content: structuredDoc, + kind: ContributionKind.STORY, + proposalId: 'proposal-injected', + title: 'A Winter Road', + }, + url: '/worlds/world-1/contributions', + }); + + assert.equal(loreResponse.statusCode, 201); + assert.equal(loreResponse.json().contribution.authorId, author.id); + assert.equal(loreResponse.json().contribution.title, 'The Fall of the Northern Gate'); + assert.equal(storyResponse.statusCode, 201); + assert.equal(storyResponse.json().contribution.kind, ContributionKind.STORY); + assert.equal(protectedFieldResponse.statusCode, 400); + assert.equal(state.contributions.length, 2); + await app.close(); + }); + + test('rejects unauthenticated users, users without world permission, invalid docs, oversized docs, missing content, missing worlds, and mismatched targets', async () => { + const state = createDatabase(); + const app = await createApp(state.database); + const oversizedDoc = { + type: 'doc', + content: [ + { + type: 'paragraph', + content: [{ type: 'text', text: 'x'.repeat(101 * 1024) }], + }, + ], + }; + + const unauthenticated = await app.inject({ + method: 'POST', + payload: { content: structuredDoc, kind: ContributionKind.LORE, title: 'Gate' }, + url: '/worlds/world-1/contributions', + }); + const forbidden = await app.inject({ + headers: authHeader(otherUser), + method: 'POST', + payload: { content: structuredDoc, kind: ContributionKind.LORE, title: 'Gate' }, + url: '/worlds/world-1/contributions', + }); + const invalidDoc = await app.inject({ + headers: authHeader(), + method: 'POST', + payload: { + content: { type: 'html', content: '

Nope

' }, + kind: ContributionKind.LORE, + title: 'Gate', + }, + url: '/worlds/world-1/contributions', + }); + const oversized = await app.inject({ + headers: authHeader(), + method: 'POST', + payload: { + content: oversizedDoc, + kind: ContributionKind.LORE, + title: 'Gate', + }, + url: '/worlds/world-1/contributions', + }); + const missingContent = await app.inject({ + headers: authHeader(), + method: 'POST', + payload: { + kind: ContributionKind.LORE, + title: 'Gate', + }, + url: '/worlds/world-1/contributions', + }); + const missingWorld = await app.inject({ + headers: authHeader(), + method: 'POST', + payload: { + content: structuredDoc, + kind: ContributionKind.LORE, + title: 'Gate', + }, + url: '/worlds/world-missing/contributions', + }); + const mismatchedTarget = await app.inject({ + headers: authHeader(), + method: 'POST', + payload: { + content: structuredDoc, + kind: ContributionKind.LORE, + targetLoreEntryId: 'lore-2', + title: 'Gate', + }, + url: '/worlds/world-1/contributions', + }); + + assert.equal(unauthenticated.statusCode, 401); + assert.equal(forbidden.statusCode, 403); + assert.equal(invalidDoc.statusCode, 400); + assert.equal(oversized.statusCode, 400); + assert.equal(missingContent.statusCode, 400); + assert.equal(missingWorld.statusCode, 404); + assert.equal(mismatchedTarget.statusCode, 400); + await app.close(); + }); + + test('author can list and read their draft while unauthorized routes do not reveal private data', async () => { + const state = createDatabase(); + state.memberships.find((membership) => membership.userId === otherUser.id)!.role = + WorldRole.CONTRIBUTOR; + state.memberships.push({ + id: 'membership-author-world-2', + revokedAt: null, + role: WorldRole.CONTRIBUTOR, + userId: author.id, + worldId: 'world-2', + }); + state.contributions.push(createContributionRecord({ id: 'draft-1' })); + state.contributions.push(createContributionRecord({ authorId: otherUser.id, id: 'draft-2' })); + const app = await createApp(state.database); + + const listResponse = await app.inject({ + headers: authHeader(), + method: 'GET', + url: '/worlds/world-1/contributions?status=DRAFT&kind=LORE&page=1&pageSize=1', + }); + const readResponse = await app.inject({ + headers: authHeader(), + method: 'GET', + url: '/worlds/world-1/contributions/draft-1', + }); + const otherReadResponse = await app.inject({ + headers: authHeader(otherUser), + method: 'GET', + url: '/worlds/world-1/contributions/draft-1', + }); + const wrongWorldResponse = await app.inject({ + headers: authHeader(), + method: 'GET', + url: '/worlds/world-2/contributions/draft-1', + }); + + assert.equal(listResponse.statusCode, 200); + assert.deepEqual(listResponse.json().pagination, { + page: 1, + pageSize: 1, + total: 1, + }); + assert.deepEqual( + listResponse.json().contributions.map((contribution: { id: string }) => contribution.id), + ['draft-1'], + ); + assert.equal(readResponse.statusCode, 200); + assert.equal(otherReadResponse.statusCode, 404); + assert.equal(wrongWorldResponse.statusCode, 404); + await app.close(); + }); + + test('author can update and delete draft, but protected fields and submitted rows are rejected', async () => { + const state = createDatabase(); + state.contributions.push(createContributionRecord({ id: 'draft-1' })); + state.contributions.push( + createContributionRecord({ + id: 'submitted-1', + proposalId: 'proposal-1', + status: ContributionStatus.SUBMITTED, + submittedAt: now(), + }), + ); + const app = await createApp(state.database); + + const updateResponse = await app.inject({ + headers: authHeader(), + method: 'PATCH', + payload: { + content: structuredDoc, + summary: 'Updated.', + targetLoreEntryId: null, + title: 'Updated Gate', + }, + url: '/worlds/world-1/contributions/draft-1', + }); + const protectedFieldResponse = await app.inject({ + headers: authHeader(), + method: 'PATCH', + payload: { + authorId: otherUser.id, + title: 'No', + }, + url: '/worlds/world-1/contributions/draft-1', + }); + const submittedUpdateResponse = await app.inject({ + headers: authHeader(), + method: 'PATCH', + payload: { title: 'No' }, + url: '/worlds/world-1/contributions/submitted-1', + }); + const invalidContentResponse = await app.inject({ + headers: authHeader(), + method: 'PATCH', + payload: { content: { type: 'doc', content: {} } }, + url: '/worlds/world-1/contributions/draft-1', + }); + const deleteResponse = await app.inject({ + headers: authHeader(), + method: 'DELETE', + url: '/worlds/world-1/contributions/draft-1', + }); + const submittedDeleteResponse = await app.inject({ + headers: authHeader(), + method: 'DELETE', + url: '/worlds/world-1/contributions/submitted-1', + }); + + assert.equal(updateResponse.statusCode, 200); + assert.equal(updateResponse.json().contribution.title, 'Updated Gate'); + assert.equal(updateResponse.json().contribution.targetLoreEntryId, null); + assert.equal(protectedFieldResponse.statusCode, 400); + assert.equal(submittedUpdateResponse.statusCode, 409); + assert.equal(invalidContentResponse.statusCode, 400); + assert.equal(deleteResponse.statusCode, 204); + assert.equal(submittedDeleteResponse.statusCode, 409); + await app.close(); + }); + + test('update and delete reject drafts that are submitted by a concurrent operation', async () => { + const updateState = createDatabase(); + updateState.contributions.push(createContributionRecord({ id: 'draft-update-race' })); + updateState.submitBeforeNextUpdateMany(); + + await assert.rejects( + updateContribution(updateState.database as never, { + authorId: author.id, + contributionId: 'draft-update-race', + title: 'Should Not Land', + worldId: 'world-1', + }), + (error: unknown) => + error instanceof Error && error.message === 'Submitted contributions cannot be edited.', + ); + + assert.equal(updateState.contributions[0]?.title, 'The Fall of the Northern Gate'); + assert.equal(updateState.contributions[0]?.status, ContributionStatus.SUBMITTED); + + const deleteState = createDatabase(); + deleteState.contributions.push(createContributionRecord({ id: 'draft-delete-race' })); + deleteState.submitBeforeNextDeleteMany(); + + await assert.rejects( + deleteContribution(deleteState.database as never, { + authorId: author.id, + contributionId: 'draft-delete-race', + worldId: 'world-1', + }), + (error: unknown) => + error instanceof Error && error.message === 'Submitted contributions cannot be deleted.', + ); + + assert.equal(deleteState.contributions.length, 1); + assert.equal(deleteState.contributions[0]?.status, ContributionStatus.SUBMITTED); + }); + + test('another user cannot update or delete an author private draft', async () => { + const state = createDatabase(); + state.memberships.find((membership) => membership.userId === otherUser.id)!.role = + WorldRole.CONTRIBUTOR; + state.contributions.push(createContributionRecord({ id: 'draft-1' })); + const app = await createApp(state.database); + + const updateResponse = await app.inject({ + headers: authHeader(otherUser), + method: 'PATCH', + payload: { title: 'No' }, + url: '/worlds/world-1/contributions/draft-1', + }); + const deleteResponse = await app.inject({ + headers: authHeader(otherUser), + method: 'DELETE', + url: '/worlds/world-1/contributions/draft-1', + }); + + assert.equal(updateResponse.statusCode, 404); + assert.equal(deleteResponse.statusCode, 404); + await app.close(); + }); + + test('valid draft submission creates exactly one proposal and locks immutable proposed content', async () => { + const state = createDatabase(); + state.contributions.push(createContributionRecord({ id: 'draft-1' })); + const app = await createApp(state.database); + + const submitResponse = await app.inject({ + headers: authHeader(), + method: 'POST', + url: '/worlds/world-1/contributions/draft-1/submit', + }); + const duplicateResponse = await app.inject({ + headers: authHeader(), + method: 'POST', + url: '/worlds/world-1/contributions/draft-1/submit', + }); + + assert.equal(submitResponse.statusCode, 201); + assert.equal(duplicateResponse.statusCode, 200); + assert.equal(state.proposals.length, 1); + assert.equal(state.contributions[0]?.status, ContributionStatus.SUBMITTED); + assert.equal(state.contributions[0]?.proposalId, 'proposal-1'); + assert.equal(submitResponse.json().proposal.authorId, author.id); + assert.equal(submitResponse.json().proposal.status, ProposalStatus.SUBMITTED); + assert.equal(submitResponse.json().proposal.proposalType, ProposalType.UPDATE_LORE); + assert.deepEqual(submitResponse.json().proposal.proposedContent, structuredDoc); + assert.equal( + state.auditLogs.some( + (entry) => + (entry as { action?: WorldAuditAction }).action === + WorldAuditAction.CONTRIBUTION_SUBMITTED, + ), + true, + ); + await app.close(); + }); + + test('create rollback leaves no contribution when audit logging fails', async () => { + const state = createDatabase(); + state.failNextAuditCreate(); + + await assert.rejects( + createContribution(state.database as never, { + authorId: author.id, + content: structuredDoc, + kind: ContributionKind.LORE, + title: 'Rollback Draft', + worldId: 'world-1', + }), + ); + + assert.equal(state.contributions.length, 0); + assert.equal(state.auditLogs.length, 0); + }); + + test('story draft submission creates an ADD_STORY proposal', async () => { + const state = createDatabase(); + state.contributions.push( + createContributionRecord({ + id: 'story-1', + kind: ContributionKind.STORY, + targetLoreEntryId: null, + }), + ); + const app = await createApp(state.database); + + const response = await app.inject({ + headers: authHeader(), + method: 'POST', + url: '/worlds/world-1/contributions/story-1/submit', + }); + + assert.equal(response.statusCode, 201); + assert.equal(response.json().proposal.proposalType, ProposalType.ADD_STORY); + assert.equal(response.json().proposal.contributionKind, ContributionKind.STORY); + await app.close(); + }); + + test('submission rejects empty content, missing submit permission, and mismatched target lore', async () => { + const state = createDatabase(); + state.contributions.push(createContributionRecord({ content: emptyDoc, id: 'empty-1' })); + state.contributions.push( + createContributionRecord({ id: 'mismatch-1', targetLoreEntryId: 'lore-2' }), + ); + const app = await createApp(state.database); + + const emptyResponse = await app.inject({ + headers: authHeader(), + method: 'POST', + url: '/worlds/world-1/contributions/empty-1/submit', + }); + const forbiddenResponse = await app.inject({ + headers: authHeader(otherUser), + method: 'POST', + url: '/worlds/world-1/contributions/empty-1/submit', + }); + const mismatchResponse = await app.inject({ + headers: authHeader(), + method: 'POST', + url: '/worlds/world-1/contributions/mismatch-1/submit', + }); + + assert.equal(emptyResponse.statusCode, 400); + assert.equal(forbiddenResponse.statusCode, 403); + assert.equal(mismatchResponse.statusCode, 400); + assert.equal(state.proposals.length, 0); + await app.close(); + }); + + test('concurrent submissions and rollback do not create duplicate or partial proposal state', async () => { + const concurrentState = createDatabase(); + concurrentState.contributions.push(createContributionRecord({ id: 'draft-1' })); + const [first, second] = await Promise.allSettled([ + submitContribution(concurrentState.database as never, { + authorId: author.id, + contributionId: 'draft-1', + worldId: 'world-1', + }), + submitContribution(concurrentState.database as never, { + authorId: author.id, + contributionId: 'draft-1', + worldId: 'world-1', + }), + ]); + + assert.equal(concurrentState.proposals.length, 1); + assert.equal( + [first, second].filter((result) => result.status === 'fulfilled').length >= 1, + true, + ); + + const rollbackState = createDatabase(); + rollbackState.contributions.push(createContributionRecord({ id: 'draft-rollback' })); + rollbackState.failNextProposalCreate(); + + await assert.rejects( + submitContribution(rollbackState.database as never, { + authorId: author.id, + contributionId: 'draft-rollback', + worldId: 'world-1', + }), + ); + + assert.equal(rollbackState.proposals.length, 0); + assert.equal(rollbackState.contributions[0]?.status, ContributionStatus.DRAFT); + assert.equal(rollbackState.contributions[0]?.proposalId, null); + }); +}); diff --git a/apps/api/src/routes/contributions.ts b/apps/api/src/routes/contributions.ts new file mode 100644 index 0000000..8229b6c --- /dev/null +++ b/apps/api/src/routes/contributions.ts @@ -0,0 +1,430 @@ +import type { FastifyInstance, FastifyReply } from 'fastify'; +import { z } from 'zod'; + +import { env } from '../config/env.js'; +import { ContributionKind, ContributionStatus } from '../generated/prisma/enums.js'; +import { requireSession } from '../lib/auth-middleware.js'; +import { + ContributionError, + createContribution, + deleteContribution, + getOwnedContribution, + listOwnedContributions, + submitContribution, + updateContribution, + type ContributionDatabase, +} from '../lib/contributions.js'; +import { prisma } from '../lib/prisma.js'; +import { authorizeWorldPermission } from '../lib/world-authorization.js'; +import { WORLD_PERMISSIONS } from '../lib/world-permissions.js'; +import type { WorldMembershipLookup } from '../lib/world-authorization.js'; + +const routeParamsSchema = z.object({ + contributionId: z.string().min(1).optional(), + worldId: z.string().min(1), +}); + +const contributionKindSchema = z.enum([ContributionKind.LORE, ContributionKind.STORY]); +const contributionStatusSchema = z.enum([ContributionStatus.DRAFT, ContributionStatus.SUBMITTED]); + +const createContributionBodySchema = z + .object({ + content: z.unknown(), + kind: contributionKindSchema, + summary: z.string().trim().max(1_000).optional(), + targetLoreEntryId: z.string().trim().min(1).optional(), + title: z.string().trim().min(1).max(200), + }) + .strict(); + +const updateContributionBodySchema = z + .object({ + content: z.unknown().optional(), + kind: contributionKindSchema.optional(), + summary: z.string().trim().max(1_000).nullable().optional(), + targetLoreEntryId: z.string().trim().min(1).nullable().optional(), + title: z.string().trim().min(1).max(200).optional(), + }) + .strict() + .refine((value) => Object.keys(value).length > 0, { + message: 'At least one editable contribution field is required.', + }); + +const listContributionsQuerySchema = z.object({ + kind: contributionKindSchema.optional(), + page: z.coerce.number().int().positive().default(1), + pageSize: z.coerce.number().int().positive().max(100).default(20), + status: contributionStatusSchema.optional(), +}); + +type RegisterContributionRoutesOptions = { + database?: ContributionDatabase & WorldMembershipLookup; +}; + +function authOptions() { + return { + audience: env.AUTH_JWT_AUDIENCE, + issuer: env.AUTH_JWT_ISSUER, + jwtSecret: env.AUTH_JWT_SECRET, + }; +} + +function handleContributionError(error: unknown, reply: FastifyReply) { + if (error instanceof ContributionError) { + return reply.code(error.statusCode).send({ + error: error.message, + }); + } + + throw error; +} + +async function ensureWorldExists( + database: Pick, + worldId: string, + reply: FastifyReply, +) { + const world = await database.world.findUnique({ + select: { + id: true, + }, + where: { + id: worldId, + }, + }); + + if (!world) { + await reply.code(404).send({ + error: 'World not found.', + }); + + return false; + } + + return true; +} + +export async function registerContributionRoutes( + app: FastifyInstance, + options: RegisterContributionRoutesOptions = {}, +) { + const database = options.database ?? prisma; + + app.post( + '/worlds/:worldId/contributions', + { + preHandler: requireSession(authOptions()), + }, + async (request, reply) => { + const params = routeParamsSchema.safeParse(request.params); + const body = createContributionBodySchema.safeParse(request.body); + + if (!params.success || !body.success) { + return reply.code(400).send({ + error: 'Invalid contribution payload.', + }); + } + + const worldExists = await ensureWorldExists(database, params.data.worldId, reply); + + if (!worldExists) { + return; + } + + const authorized = await authorizeWorldPermission( + request, + reply, + params.data.worldId, + WORLD_PERMISSIONS.CREATE_LORE_DRAFT, + database, + ); + + if (!authorized) { + return; + } + + if (!request.user) { + return reply.code(401).send({ + error: 'Authentication required.', + }); + } + + try { + const contribution = await createContribution(database, { + ...body.data, + authorId: request.user.id, + worldId: params.data.worldId, + }); + + return reply.code(201).send({ + contribution, + }); + } catch (error) { + return handleContributionError(error, reply); + } + }, + ); + + app.get( + '/worlds/:worldId/contributions', + { + preHandler: requireSession(authOptions()), + }, + async (request, reply) => { + const params = routeParamsSchema.safeParse(request.params); + const query = listContributionsQuerySchema.safeParse(request.query); + + if (!params.success || !query.success) { + return reply.code(400).send({ + error: 'Invalid contributions query.', + }); + } + + const worldExists = await ensureWorldExists(database, params.data.worldId, reply); + + if (!worldExists) { + return; + } + + const authorized = await authorizeWorldPermission( + request, + reply, + params.data.worldId, + WORLD_PERMISSIONS.CREATE_LORE_DRAFT, + database, + ); + + if (!authorized) { + return; + } + + if (!request.user) { + return reply.code(401).send({ + error: 'Authentication required.', + }); + } + + return listOwnedContributions(database, { + ...query.data, + authorId: request.user.id, + worldId: params.data.worldId, + }); + }, + ); + + app.get( + '/worlds/:worldId/contributions/:contributionId', + { + preHandler: requireSession(authOptions()), + }, + async (request, reply) => { + const params = routeParamsSchema.required().safeParse(request.params); + + if (!params.success) { + return reply.code(400).send({ + error: 'Invalid contribution route.', + }); + } + + const worldExists = await ensureWorldExists(database, params.data.worldId, reply); + + if (!worldExists) { + return; + } + + const authorized = await authorizeWorldPermission( + request, + reply, + params.data.worldId, + WORLD_PERMISSIONS.CREATE_LORE_DRAFT, + database, + ); + + if (!authorized) { + return; + } + + if (!request.user) { + return reply.code(401).send({ + error: 'Authentication required.', + }); + } + + const contribution = await getOwnedContribution(database, { + authorId: request.user.id, + contributionId: params.data.contributionId, + worldId: params.data.worldId, + }); + + if (!contribution) { + return reply.code(404).send({ + error: 'Contribution not found.', + }); + } + + return { + contribution, + }; + }, + ); + + app.patch( + '/worlds/:worldId/contributions/:contributionId', + { + preHandler: requireSession(authOptions()), + }, + async (request, reply) => { + const params = routeParamsSchema.required().safeParse(request.params); + const body = updateContributionBodySchema.safeParse(request.body); + + if (!params.success || !body.success) { + return reply.code(400).send({ + error: 'Invalid contribution payload.', + }); + } + + const worldExists = await ensureWorldExists(database, params.data.worldId, reply); + + if (!worldExists) { + return; + } + + const authorized = await authorizeWorldPermission( + request, + reply, + params.data.worldId, + WORLD_PERMISSIONS.EDIT_OWN_DRAFT, + database, + ); + + if (!authorized) { + return; + } + + if (!request.user) { + return reply.code(401).send({ + error: 'Authentication required.', + }); + } + + try { + const contribution = await updateContribution(database, { + ...body.data, + authorId: request.user.id, + contributionId: params.data.contributionId, + worldId: params.data.worldId, + }); + + return { + contribution, + }; + } catch (error) { + return handleContributionError(error, reply); + } + }, + ); + + app.delete( + '/worlds/:worldId/contributions/:contributionId', + { + preHandler: requireSession(authOptions()), + }, + async (request, reply) => { + const params = routeParamsSchema.required().safeParse(request.params); + + if (!params.success) { + return reply.code(400).send({ + error: 'Invalid contribution route.', + }); + } + + const worldExists = await ensureWorldExists(database, params.data.worldId, reply); + + if (!worldExists) { + return; + } + + const authorized = await authorizeWorldPermission( + request, + reply, + params.data.worldId, + WORLD_PERMISSIONS.EDIT_OWN_DRAFT, + database, + ); + + if (!authorized) { + return; + } + + if (!request.user) { + return reply.code(401).send({ + error: 'Authentication required.', + }); + } + + try { + await deleteContribution(database, { + authorId: request.user.id, + contributionId: params.data.contributionId, + worldId: params.data.worldId, + }); + + return reply.code(204).send(); + } catch (error) { + return handleContributionError(error, reply); + } + }, + ); + + app.post( + '/worlds/:worldId/contributions/:contributionId/submit', + { + preHandler: requireSession(authOptions()), + }, + async (request, reply) => { + const params = routeParamsSchema.required().safeParse(request.params); + + if (!params.success) { + return reply.code(400).send({ + error: 'Invalid contribution route.', + }); + } + + const worldExists = await ensureWorldExists(database, params.data.worldId, reply); + + if (!worldExists) { + return; + } + + const authorized = await authorizeWorldPermission( + request, + reply, + params.data.worldId, + WORLD_PERMISSIONS.SUBMIT_PROPOSAL, + database, + ); + + if (!authorized) { + return; + } + + if (!request.user) { + return reply.code(401).send({ + error: 'Authentication required.', + }); + } + + try { + const result = await submitContribution(database, { + authorId: request.user.id, + contributionId: params.data.contributionId, + worldId: params.data.worldId, + }); + + return reply.code(result.alreadySubmitted ? 200 : 201).send(result); + } catch (error) { + return handleContributionError(error, reply); + } + }, + ); +} From 0f7b1200c2914515fc81ad150f3141d3cb73d455 Mon Sep 17 00:00:00 2001 From: Perla Imad Date: Wed, 5 Aug 2026 19:07:05 +0300 Subject: [PATCH 04/22] Implement world seed and hub backend --- README.md | 8 + .../migration.sql | 22 + apps/api/prisma/schema.prisma | 21 + apps/api/prisma/seed.ts | 25 + apps/api/src/app.ts | 2 + apps/api/src/lib/worlds.ts | 505 ++++++++++++++++ apps/api/src/routes/worlds.test.ts | 555 ++++++++++++++++++ apps/api/src/routes/worlds.ts | 230 ++++++++ 8 files changed, 1368 insertions(+) create mode 100644 apps/api/prisma/migrations/20260805123000_add_world_seed/migration.sql create mode 100644 apps/api/src/lib/worlds.ts create mode 100644 apps/api/src/routes/worlds.test.ts create mode 100644 apps/api/src/routes/worlds.ts diff --git a/README.md b/README.md index 2b691ca..61a60ab 100644 --- a/README.md +++ b/README.md @@ -319,6 +319,14 @@ Auth challenge, verification, and refresh endpoints use Fastify rate limiting ba Google auth routes are not registered as a default login path. With `GOOGLE_AUTH_ENABLED=false`, `/auth/google/*` returns a not-enabled response. Enabling Google currently exposes the guarded boundary only; Google OAuth account linking and automatic Hive provisioning remain deferred until secure non-custodial account creation and key delivery are specified. +Implemented world endpoints: + +- `POST /worlds`: requires authentication, creates a mutable off-chain World Seed and initial `WorldBibleVersion` in one transaction, and assigns the authenticated user an active `FOUNDER` membership. The route never accepts a client-supplied founder or role and does not publish to Hive. +- `GET /worlds`: browses discoverable worlds with pagination and basic title/description, genre, and tone filters. +- `GET /worlds/:worldId`: returns one world with its founder, seed, and current latest World Bible version. +- `GET /worlds/:worldId/hub`: returns the World Hub aggregate for the frontend, including world seed, current bible preview, simple lore/proposal stats, and latest lore entry summaries. +- `PATCH /worlds/:worldId`: requires `EDIT_INITIAL_CANON` world permission and updates mutable off-chain world title/description, seed, and latest initial bible content only. It does not create proposals, canonize content, or publish to Hive. + --- # Reputation & Rewards diff --git a/apps/api/prisma/migrations/20260805123000_add_world_seed/migration.sql b/apps/api/prisma/migrations/20260805123000_add_world_seed/migration.sql new file mode 100644 index 0000000..d69a67b --- /dev/null +++ b/apps/api/prisma/migrations/20260805123000_add_world_seed/migration.sql @@ -0,0 +1,22 @@ +CREATE TABLE "WorldSeed" ( + "id" TEXT NOT NULL, + "worldId" TEXT NOT NULL, + "premise" TEXT NOT NULL, + "genre" TEXT NOT NULL, + "tone" TEXT NOT NULL, + "mainConflict" TEXT NOT NULL, + "startingLocation" TEXT, + "firstCharacters" JSONB, + "firstFactions" JSONB, + "firstHistoricalEvent" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "WorldSeed_pkey" PRIMARY KEY ("id") +); + +CREATE UNIQUE INDEX "WorldSeed_worldId_key" ON "WorldSeed"("worldId"); +CREATE INDEX "WorldSeed_genre_idx" ON "WorldSeed"("genre"); +CREATE INDEX "WorldSeed_tone_idx" ON "WorldSeed"("tone"); + +ALTER TABLE "WorldSeed" ADD CONSTRAINT "WorldSeed_worldId_fkey" FOREIGN KEY ("worldId") REFERENCES "World"("id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index 46e369c..71e8c70 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -304,6 +304,7 @@ model World { founder User @relation("WorldFounder", fields: [founderId], references: [id], onDelete: Restrict) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt + seed WorldSeed? bibleVersions WorldBibleVersion[] loreEntries LoreEntry[] loreRelationships LoreRelationship[] @@ -317,6 +318,26 @@ model World { @@index([createdAt]) } +model WorldSeed { + id String @id @default(cuid()) + worldId String @unique + world World @relation(fields: [worldId], references: [id], onDelete: Restrict) + premise String + genre String + tone String + mainConflict String + startingLocation String? + firstCharacters Json? + firstFactions Json? + firstHistoricalEvent String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + /// Mutable off-chain world seed. Hive publication requires a future approved publication flow. + + @@index([genre]) + @@index([tone]) +} + model WorldMembership { id String @id @default(cuid()) worldId String diff --git a/apps/api/prisma/seed.ts b/apps/api/prisma/seed.ts index 2da8d6a..8311856 100644 --- a/apps/api/prisma/seed.ts +++ b/apps/api/prisma/seed.ts @@ -75,6 +75,31 @@ async function main() { userId: founder.id, }); + await prisma.worldSeed.upsert({ + where: { + worldId: world.id, + }, + update: { + genre: 'Fantasy', + mainConflict: 'Weather, memory, and craft compete to define what the islands remember.', + premise: + 'A fictional chain of floating islands where memory, weather, and craft are tightly intertwined.', + tone: 'Luminous mystery', + }, + create: { + worldId: world.id, + premise: + 'A fictional chain of floating islands where memory, weather, and craft are tightly intertwined.', + genre: 'Fantasy', + tone: 'Luminous mystery', + mainConflict: 'Weather, memory, and craft compete to define what the islands remember.', + startingLocation: 'Mirror Lighthouse', + firstCharacters: ['Ember Quill', 'Mira Vale'], + firstFactions: ['Cartographers Guild'], + firstHistoricalEvent: 'The first reflected storm appears before its clouds arrive.', + }, + }); + await prisma.worldBibleVersion.upsert({ where: { worldId_versionNumber: { diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index bc2bfbe..f77600d 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -7,6 +7,7 @@ import { createPrismaRateLimitStore } from './lib/auth-rate-limit-store.js'; import { prisma } from './lib/prisma.js'; import { registerAuthRoutes } from './routes/auth.js'; import { registerHealthRoute } from './routes/health.js'; +import { registerWorldRoutes } from './routes/worlds.js'; export async function buildApp() { const app = Fastify({ @@ -41,6 +42,7 @@ export async function buildApp() { await registerHealthRoute(app); await registerAuthRoutes(app); + await registerWorldRoutes(app); return app; } diff --git a/apps/api/src/lib/worlds.ts b/apps/api/src/lib/worlds.ts new file mode 100644 index 0000000..87f2f9d --- /dev/null +++ b/apps/api/src/lib/worlds.ts @@ -0,0 +1,505 @@ +import type { Prisma, PrismaClient } from '../generated/prisma/client.js'; +import { + LoreStatus, + ProposalStatus, + WorldAuditAction, + WorldRole, +} from '../generated/prisma/enums.js'; + +export type WorldSeedInput = { + premise: string; + genre: string; + tone: string; + mainConflict: string; + startingLocation?: string | undefined; + firstCharacters?: string[] | undefined; + firstFactions?: string[] | undefined; + firstHistoricalEvent?: string | undefined; +}; + +export type WorldBibleInput = { + content: Prisma.InputJsonValue; + changeSummary?: string | undefined; +}; + +export type CreateWorldInput = { + creatorId: string; + title: string; + description: string; + seed: WorldSeedInput; + bible: WorldBibleInput; +}; + +export type UpdateWorldInput = { + worldId: string; + title?: string | undefined; + description?: string | undefined; + seed?: Partial | undefined; + bible?: WorldBibleInput | undefined; +}; + +export type WorldDatabase = Pick< + PrismaClient, + | '$transaction' + | 'loreEntry' + | 'proposal' + | 'world' + | 'worldAuditLog' + | 'worldBibleVersion' + | 'worldMembership' + | 'worldSeed' +>; + +const worldInclude = { + bibleVersions: { + orderBy: { + versionNumber: 'desc', + }, + take: 1, + }, + founder: { + select: { + avatarUrl: true, + displayName: true, + hiveUsername: true, + id: true, + normalizedHiveUsername: true, + }, + }, + seed: true, +} as const; + +type WorldWithRelations = Prisma.WorldGetPayload<{ + include: typeof worldInclude; +}>; + +export function createSlug(title: string) { + const slug = title + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') + .slice(0, 80); + + return slug || 'world'; +} + +function normalizeSeedInput(seed: WorldSeedInput) { + return { + firstCharacters: seed.firstCharacters?.map((value) => value.trim()).filter(Boolean) ?? [], + firstFactions: seed.firstFactions?.map((value) => value.trim()).filter(Boolean) ?? [], + firstHistoricalEvent: seed.firstHistoricalEvent?.trim() || null, + genre: seed.genre.trim(), + mainConflict: seed.mainConflict.trim(), + premise: seed.premise.trim(), + startingLocation: seed.startingLocation?.trim() || null, + tone: seed.tone.trim(), + }; +} + +function serializeSeed(seed: NonNullable) { + return { + createdAt: seed.createdAt.toISOString(), + firstCharacters: Array.isArray(seed.firstCharacters) ? seed.firstCharacters : [], + firstFactions: Array.isArray(seed.firstFactions) ? seed.firstFactions : [], + firstHistoricalEvent: seed.firstHistoricalEvent, + genre: seed.genre, + id: seed.id, + mainConflict: seed.mainConflict, + premise: seed.premise, + startingLocation: seed.startingLocation, + tone: seed.tone, + updatedAt: seed.updatedAt.toISOString(), + }; +} + +function serializeBibleVersion(version: WorldWithRelations['bibleVersions'][number] | null) { + if (!version) { + return null; + } + + return { + changeSummary: version.changeSummary, + content: version.content, + createdAt: version.createdAt.toISOString(), + creatorId: version.creatorId, + hiveReferenceId: version.hiveReferenceId, + id: version.id, + publishedAt: version.publishedAt?.toISOString() ?? null, + updatedAt: version.updatedAt.toISOString(), + versionNumber: version.versionNumber, + }; +} + +export function serializeWorld(world: WorldWithRelations) { + return { + createdAt: world.createdAt.toISOString(), + currentBibleVersion: serializeBibleVersion(world.bibleVersions[0] ?? null), + description: world.description, + founder: world.founder, + founderId: world.founderId, + id: world.id, + seed: world.seed ? serializeSeed(world.seed) : null, + slug: world.slug, + title: world.title, + updatedAt: world.updatedAt.toISOString(), + }; +} + +export type SerializedWorld = ReturnType; + +async function generateUniqueWorldSlug(database: Pick, title: string) { + const baseSlug = createSlug(title); + let slug = baseSlug; + + for (let suffix = 2; suffix <= 50; suffix += 1) { + const existing = await database.world.findUnique({ + where: { + slug, + }, + select: { + id: true, + }, + }); + + if (!existing) { + return slug; + } + + slug = `${baseSlug}-${suffix}`; + } + + throw new Error('Unable to create a unique world slug.'); +} + +export async function createWorld(database: WorldDatabase, input: CreateWorldInput) { + const slug = await generateUniqueWorldSlug(database, input.title); + const seed = normalizeSeedInput(input.seed); + + const world = await database.$transaction(async (transaction) => { + const createdWorld = await transaction.world.create({ + data: { + description: input.description.trim(), + founderId: input.creatorId, + slug, + title: input.title.trim(), + }, + }); + + await transaction.worldSeed.create({ + data: { + firstCharacters: seed.firstCharacters, + firstFactions: seed.firstFactions, + firstHistoricalEvent: seed.firstHistoricalEvent, + genre: seed.genre, + mainConflict: seed.mainConflict, + premise: seed.premise, + startingLocation: seed.startingLocation, + tone: seed.tone, + worldId: createdWorld.id, + }, + }); + + await transaction.worldBibleVersion.create({ + data: { + changeSummary: input.bible.changeSummary?.trim() || 'Initial world bible.', + content: input.bible.content, + creatorId: input.creatorId, + versionNumber: 1, + worldId: createdWorld.id, + }, + }); + + const membership = await transaction.worldMembership.create({ + data: { + grantedById: input.creatorId, + role: WorldRole.FOUNDER, + userId: input.creatorId, + worldId: createdWorld.id, + }, + }); + + await transaction.worldAuditLog.create({ + data: { + action: WorldAuditAction.ROLE_ASSIGNED, + actorId: input.creatorId, + metadata: { + role: WorldRole.FOUNDER, + }, + targetId: membership.id, + targetType: 'WORLD_MEMBERSHIP', + worldId: createdWorld.id, + }, + }); + + return transaction.world.findUniqueOrThrow({ + include: worldInclude, + where: { + id: createdWorld.id, + }, + }); + }); + + return serializeWorld(world); +} + +export async function listWorlds( + database: WorldDatabase, + input: { + genre?: string | undefined; + page?: number | undefined; + pageSize?: number | undefined; + q?: string | undefined; + tone?: string | undefined; + }, +) { + const page = input.page ?? 1; + const pageSize = input.pageSize ?? 20; + const where: Prisma.WorldWhereInput = {}; + + if (input.q) { + where.OR = [ + { + title: { + contains: input.q, + mode: 'insensitive', + }, + }, + { + description: { + contains: input.q, + mode: 'insensitive', + }, + }, + ]; + } + + if (input.genre || input.tone) { + where.seed = { + ...(input.genre + ? { + genre: { + equals: input.genre, + mode: 'insensitive', + }, + } + : {}), + ...(input.tone + ? { + tone: { + equals: input.tone, + mode: 'insensitive', + }, + } + : {}), + }; + } + + const [worlds, total] = await Promise.all([ + database.world.findMany({ + include: worldInclude, + orderBy: [ + { + createdAt: 'desc', + }, + { + id: 'desc', + }, + ], + skip: (page - 1) * pageSize, + take: pageSize, + where, + }), + database.world.count({ + where, + }), + ]); + + return { + pagination: { + page, + pageSize, + total, + }, + worlds: worlds.map(serializeWorld), + }; +} + +export async function getWorld(database: WorldDatabase, worldId: string) { + const world = await database.world.findUnique({ + include: worldInclude, + where: { + id: worldId, + }, + }); + + return world ? serializeWorld(world) : null; +} + +export async function getWorldHub(database: WorldDatabase, worldId: string) { + const world = await getWorld(database, worldId); + + if (!world) { + return null; + } + + const [canonLoreCount, draftLoreCount, activeProposalCount, latestLoreEntries] = + await Promise.all([ + database.loreEntry.count({ + where: { + status: LoreStatus.PUBLISHED_CANON, + worldId, + }, + }), + database.loreEntry.count({ + where: { + status: LoreStatus.DRAFT, + worldId, + }, + }), + database.proposal.count({ + where: { + status: { + in: [ProposalStatus.SUBMITTED, ProposalStatus.VOTING], + }, + worldId, + }, + }), + database.loreEntry.findMany({ + orderBy: [ + { + updatedAt: 'desc', + }, + { + id: 'desc', + }, + ], + select: { + id: true, + loreType: true, + slug: true, + status: true, + title: true, + updatedAt: true, + }, + take: 6, + where: { + worldId, + }, + }), + ]); + + return { + latestLoreEntries: latestLoreEntries.map((entry) => ({ + ...entry, + updatedAt: entry.updatedAt.toISOString(), + })), + stats: { + activeProposalCount, + canonLoreCount, + draftLoreCount, + }, + world, + }; +} + +export async function updateWorld(database: WorldDatabase, input: UpdateWorldInput) { + const world = await database.$transaction(async (transaction) => { + const current = await transaction.world.findUnique({ + include: { + bibleVersions: { + orderBy: { + versionNumber: 'desc', + }, + take: 1, + }, + seed: true, + }, + where: { + id: input.worldId, + }, + }); + + if (!current) { + return null; + } + + if (input.title || input.description) { + await transaction.world.update({ + data: { + ...(input.description ? { description: input.description.trim() } : {}), + ...(input.title ? { title: input.title.trim() } : {}), + }, + where: { + id: input.worldId, + }, + }); + } + + if (input.seed) { + const currentSeed = current.seed; + + if (!currentSeed) { + throw new Error('World seed is missing.'); + } + + const normalizedSeed = normalizeSeedInput({ + firstCharacters: + input.seed.firstCharacters ?? + (Array.isArray(currentSeed.firstCharacters) + ? currentSeed.firstCharacters.filter( + (value): value is string => typeof value === 'string', + ) + : []), + firstFactions: + input.seed.firstFactions ?? + (Array.isArray(currentSeed.firstFactions) + ? currentSeed.firstFactions.filter( + (value): value is string => typeof value === 'string', + ) + : []), + firstHistoricalEvent: + input.seed.firstHistoricalEvent ?? currentSeed.firstHistoricalEvent ?? undefined, + genre: input.seed.genre ?? currentSeed.genre, + mainConflict: input.seed.mainConflict ?? currentSeed.mainConflict, + premise: input.seed.premise ?? currentSeed.premise, + startingLocation: input.seed.startingLocation ?? currentSeed.startingLocation ?? undefined, + tone: input.seed.tone ?? currentSeed.tone, + }); + + await transaction.worldSeed.update({ + data: normalizedSeed, + where: { + worldId: input.worldId, + }, + }); + } + + if (input.bible) { + const currentBible = current.bibleVersions[0]; + + if (!currentBible) { + throw new Error('World bible version is missing.'); + } + + await transaction.worldBibleVersion.update({ + data: { + changeSummary: input.bible.changeSummary?.trim() || currentBible.changeSummary, + content: input.bible.content, + }, + where: { + id: currentBible.id, + }, + }); + } + + return transaction.world.findUniqueOrThrow({ + include: worldInclude, + where: { + id: input.worldId, + }, + }); + }); + + return world ? serializeWorld(world) : null; +} diff --git a/apps/api/src/routes/worlds.test.ts b/apps/api/src/routes/worlds.test.ts new file mode 100644 index 0000000..d55b173 --- /dev/null +++ b/apps/api/src/routes/worlds.test.ts @@ -0,0 +1,555 @@ +import assert from 'node:assert/strict'; +import { describe, test } from 'node:test'; + +import Fastify from 'fastify'; + +import { env } from '../config/env.js'; +import { + LoreStatus, + PlatformRole, + ProposalStatus, + WorldAuditAction, + WorldRole, +} from '../generated/prisma/enums.js'; +import { signAccessToken } from '../lib/auth-crypto.js'; +import { registerWorldRoutes } from './worlds.js'; + +const founder = { + avatarUrl: null, + displayName: 'Ember Quill', + hiveUsername: 'emberquill.dev', + id: 'user-1', + normalizedHiveUsername: 'emberquill.dev', +}; + +const contributor = { + avatarUrl: null, + displayName: 'Mira Vale', + hiveUsername: 'mira-vale.dev', + id: 'user-2', + normalizedHiveUsername: 'mira-vale.dev', +}; + +function authHeader(userId = founder.id) { + const token = signAccessToken( + { + hiveUsername: founder.hiveUsername, + normalizedHiveUsername: founder.normalizedHiveUsername, + platformRole: PlatformRole.USER, + sid: 'session-1', + sub: userId, + }, + { + audience: env.AUTH_JWT_AUDIENCE, + issuer: env.AUTH_JWT_ISSUER, + secret: env.AUTH_JWT_SECRET, + ttlSeconds: 900, + }, + ); + + return { + authorization: `Bearer ${token}`, + }; +} + +type StoredWorld = { + id: string; + slug: string; + title: string; + description: string; + founderId: string; + founder: typeof founder; + createdAt: Date; + updatedAt: Date; +}; + +type StoredWorldSeed = { + id: string; + worldId: string; + premise: string; + genre: string; + tone: string; + mainConflict: string; + startingLocation: string | null; + firstCharacters: unknown; + firstFactions: unknown; + firstHistoricalEvent: string | null; + createdAt: Date; + updatedAt: Date; +}; + +type StoredBibleVersion = { + id: string; + worldId: string; + versionNumber: number; + creatorId: string; + content: unknown; + changeSummary: string | null; + publishedAt: Date | null; + hiveReferenceId: string | null; + createdAt: Date; + updatedAt: Date; +}; + +function createWorldRecord(input: { + id: string; + slug: string; + title: string; + description: string; + founderId?: string; +}): StoredWorld { + return { + createdAt: new Date('2026-08-05T00:00:00.000Z'), + description: input.description, + founder: input.founderId === contributor.id ? contributor : founder, + founderId: input.founderId ?? founder.id, + id: input.id, + slug: input.slug, + title: input.title, + updatedAt: new Date('2026-08-05T00:00:00.000Z'), + }; +} + +function createDatabase() { + const worlds: StoredWorld[] = []; + const seeds: StoredWorldSeed[] = []; + const bibleVersions: StoredBibleVersion[] = []; + const memberships: Array<{ + id: string; + worldId: string; + userId: string; + role: WorldRole; + revokedAt: Date | null; + }> = []; + const auditLogs: unknown[] = []; + const loreEntries: Array<{ + id: string; + worldId: string; + title: string; + slug: string; + loreType: string; + status: LoreStatus; + updatedAt: Date; + }> = []; + const proposals: Array<{ + id: string; + worldId: string; + status: ProposalStatus; + }> = []; + + function includeWorld(world: StoredWorld) { + return { + ...world, + bibleVersions: bibleVersions + .filter((version) => version.worldId === world.id) + .sort((left, right) => right.versionNumber - left.versionNumber) + .slice(0, 1), + founder: world.founder, + seed: seeds.find((seed) => seed.worldId === world.id) ?? null, + }; + } + + const database = { + async $transaction(this: unknown, callback: (transaction: never) => Promise) { + return callback(this as never); + }, + loreEntry: { + async count(args: { where: { worldId: string; status?: LoreStatus } }) { + return loreEntries.filter( + (entry) => + entry.worldId === args.where.worldId && + (!args.where.status || entry.status === args.where.status), + ).length; + }, + async findMany(args: { where: { worldId: string }; take: number }) { + return loreEntries + .filter((entry) => entry.worldId === args.where.worldId) + .slice(0, args.take); + }, + }, + proposal: { + async count(args: { where: { worldId: string; status: { in: ProposalStatus[] } } }) { + return proposals.filter( + (proposal) => + proposal.worldId === args.where.worldId && + args.where.status.in.includes(proposal.status), + ).length; + }, + }, + world: { + async count() { + return worlds.length; + }, + async create(args: { + data: { + description: string; + founderId: string; + slug: string; + title: string; + }; + }) { + const world = createWorldRecord({ + description: args.data.description, + founderId: args.data.founderId, + id: `world-${worlds.length + 1}`, + slug: args.data.slug, + title: args.data.title, + }); + worlds.push(world); + return world; + }, + async findMany(args: { take: number; skip: number }) { + return worlds.slice(args.skip, args.skip + args.take).map(includeWorld); + }, + async findUnique(args: { where: { id?: string; slug?: string } }) { + const world = worlds.find((candidate) => { + if (args.where.id) { + return candidate.id === args.where.id; + } + + return candidate.slug === args.where.slug; + }); + + return world ? includeWorld(world) : null; + }, + async findUniqueOrThrow(args: { where: { id: string } }) { + const world = worlds.find((candidate) => candidate.id === args.where.id); + + if (!world) { + throw new Error('Missing world.'); + } + + return includeWorld(world); + }, + async update(args: { + data: Partial>; + where: { id: string }; + }) { + const world = worlds.find((candidate) => candidate.id === args.where.id); + + if (!world) { + throw new Error('Missing world.'); + } + + Object.assign(world, args.data, { + updatedAt: new Date('2026-08-05T00:01:00.000Z'), + }); + + return world; + }, + }, + worldAuditLog: { + async create(args: { data: unknown }) { + auditLogs.push(args.data); + return args.data; + }, + }, + worldBibleVersion: { + async create(args: { + data: { + changeSummary: string; + content: unknown; + creatorId: string; + versionNumber: number; + worldId: string; + }; + }) { + const version = { + ...args.data, + createdAt: new Date('2026-08-05T00:00:00.000Z'), + hiveReferenceId: null, + id: `bible-${bibleVersions.length + 1}`, + publishedAt: null, + updatedAt: new Date('2026-08-05T00:00:00.000Z'), + }; + bibleVersions.push(version); + return version; + }, + async update(args: { + data: Partial>; + where: { id: string }; + }) { + const version = bibleVersions.find((candidate) => candidate.id === args.where.id); + + if (!version) { + throw new Error('Missing bible version.'); + } + + Object.assign(version, args.data, { + updatedAt: new Date('2026-08-05T00:01:00.000Z'), + }); + + return version; + }, + }, + worldMembership: { + async create(args: { + data: { + grantedById: string; + role: WorldRole; + userId: string; + worldId: string; + }; + }) { + const membership = { + id: `membership-${memberships.length + 1}`, + revokedAt: null, + ...args.data, + }; + memberships.push(membership); + return membership; + }, + async findUnique(args: { + where: { worldId_userId: { worldId: string; userId: string }; revokedAt?: null }; + }) { + return ( + memberships.find( + (membership) => + membership.worldId === args.where.worldId_userId.worldId && + membership.userId === args.where.worldId_userId.userId && + !membership.revokedAt, + ) ?? null + ); + }, + }, + worldSeed: { + async create(args: { + data: { + firstCharacters: string[]; + firstFactions: string[]; + firstHistoricalEvent: string | null; + genre: string; + mainConflict: string; + premise: string; + startingLocation: string | null; + tone: string; + worldId: string; + }; + }) { + const seed = { + ...args.data, + createdAt: new Date('2026-08-05T00:00:00.000Z'), + id: `seed-${seeds.length + 1}`, + updatedAt: new Date('2026-08-05T00:00:00.000Z'), + }; + seeds.push(seed); + return seed; + }, + async update(args: { data: Partial; where: { worldId: string } }) { + const seed = seeds.find((candidate) => candidate.worldId === args.where.worldId); + + if (!seed) { + throw new Error('Missing seed.'); + } + + Object.assign(seed, args.data, { + updatedAt: new Date('2026-08-05T00:01:00.000Z'), + }); + + return seed; + }, + }, + }; + + return { + auditLogs, + bibleVersions, + database, + loreEntries, + memberships, + proposals, + seeds, + worlds, + }; +} + +async function createApp(database: ReturnType['database']) { + const app = Fastify(); + await registerWorldRoutes(app, { + database: database as never, + }); + return app; +} + +const worldPayload = { + bible: { + changeSummary: 'Initial bible.', + content: { + rules: ['No false canon.'], + style: 'Mythic', + }, + }, + description: 'A realm of moonlit archives.', + seed: { + firstCharacters: ['Archivist Lume'], + firstFactions: ['Moon Scribes'], + firstHistoricalEvent: 'The first archive opens.', + genre: 'Fantasy', + mainConflict: 'Memory fades whenever the moons align.', + premise: 'A world where moonlight writes history before people can.', + startingLocation: 'The Glass Archive', + tone: 'Mystic wonder', + }, + title: 'Moon Archive', +}; + +describe('world routes', () => { + test('creates a world atomically with seed, initial bible, founder membership, and audit log', async () => { + const state = createDatabase(); + const app = await createApp(state.database); + const response = await app.inject({ + headers: authHeader(), + method: 'POST', + payload: worldPayload, + url: '/worlds', + }); + + assert.equal(response.statusCode, 201); + assert.equal(response.json().world.title, 'Moon Archive'); + assert.equal(response.json().world.seed.genre, 'Fantasy'); + assert.equal(response.json().world.currentBibleVersion.versionNumber, 1); + assert.equal(response.json().world.currentBibleVersion.publishedAt, null); + assert.equal(state.worlds.length, 1); + assert.equal(state.seeds.length, 1); + assert.equal(state.bibleVersions.length, 1); + assert.equal(state.memberships[0]?.role, WorldRole.FOUNDER); + assert.deepEqual(state.auditLogs[0], { + action: WorldAuditAction.ROLE_ASSIGNED, + actorId: founder.id, + metadata: { + role: WorldRole.FOUNDER, + }, + targetId: 'membership-1', + targetType: 'WORLD_MEMBERSHIP', + worldId: 'world-1', + }); + await app.close(); + }); + + test('requires authentication to create a world and ignores client founder role data', async () => { + const state = createDatabase(); + const app = await createApp(state.database); + const response = await app.inject({ + method: 'POST', + payload: { + ...worldPayload, + founderId: contributor.id, + role: WorldRole.CURATOR, + }, + url: '/worlds', + }); + + assert.equal(response.statusCode, 401); + assert.equal(state.worlds.length, 0); + await app.close(); + }); + + test('browses, retrieves, and opens a world hub response', async () => { + const state = createDatabase(); + const app = await createApp(state.database); + await app.inject({ + headers: authHeader(), + method: 'POST', + payload: worldPayload, + url: '/worlds', + }); + state.loreEntries.push({ + id: 'lore-1', + loreType: 'CHARACTER', + slug: 'archivist-lume', + status: LoreStatus.PUBLISHED_CANON, + title: 'Archivist Lume', + updatedAt: new Date('2026-08-05T00:02:00.000Z'), + worldId: 'world-1', + }); + state.proposals.push({ + id: 'proposal-1', + status: ProposalStatus.SUBMITTED, + worldId: 'world-1', + }); + + const listResponse = await app.inject({ + method: 'GET', + url: '/worlds?q=moon&page=1&pageSize=10', + }); + const getResponse = await app.inject({ + method: 'GET', + url: '/worlds/world-1', + }); + const hubResponse = await app.inject({ + method: 'GET', + url: '/worlds/world-1/hub', + }); + + assert.equal(listResponse.statusCode, 200); + assert.equal(listResponse.json().pagination.total, 1); + assert.equal(getResponse.statusCode, 200); + assert.equal(getResponse.json().world.seed.mainConflict, worldPayload.seed.mainConflict); + assert.equal(hubResponse.statusCode, 200); + assert.equal(hubResponse.json().stats.canonLoreCount, 1); + assert.equal(hubResponse.json().stats.activeProposalCount, 1); + assert.equal(hubResponse.json().latestLoreEntries[0].id, 'lore-1'); + await app.close(); + }); + + test('allows a founder to update mutable off-chain world seed and bible data', async () => { + const state = createDatabase(); + const app = await createApp(state.database); + await app.inject({ + headers: authHeader(), + method: 'POST', + payload: worldPayload, + url: '/worlds', + }); + + const response = await app.inject({ + headers: authHeader(), + method: 'PATCH', + payload: { + bible: { + changeSummary: 'Refined initial bible.', + content: { + rules: ['Keep the moon archive coherent.'], + }, + }, + seed: { + tone: 'Quiet awe', + }, + title: 'Moon Archive Revised', + }, + url: '/worlds/world-1', + }); + + assert.equal(response.statusCode, 200); + assert.equal(response.json().world.title, 'Moon Archive Revised'); + assert.equal(response.json().world.seed.tone, 'Quiet awe'); + assert.deepEqual(response.json().world.currentBibleVersion.content, { + rules: ['Keep the moon archive coherent.'], + }); + assert.equal(response.json().world.currentBibleVersion.publishedAt, null); + await app.close(); + }); + + test('rejects world updates without founder permissions', async () => { + const state = createDatabase(); + const app = await createApp(state.database); + await app.inject({ + headers: authHeader(), + method: 'POST', + payload: worldPayload, + url: '/worlds', + }); + + const response = await app.inject({ + headers: authHeader(contributor.id), + method: 'PATCH', + payload: { + title: 'Unauthorized', + }, + url: '/worlds/world-1', + }); + + assert.equal(response.statusCode, 403); + await app.close(); + }); +}); diff --git a/apps/api/src/routes/worlds.ts b/apps/api/src/routes/worlds.ts new file mode 100644 index 0000000..3456c05 --- /dev/null +++ b/apps/api/src/routes/worlds.ts @@ -0,0 +1,230 @@ +import type { FastifyInstance } from 'fastify'; +import { z } from 'zod'; + +import { env } from '../config/env.js'; +import { authenticateRequest, requireSession } from '../lib/auth-middleware.js'; +import { prisma } from '../lib/prisma.js'; +import { + createWorld, + getWorld, + getWorldHub, + listWorlds, + updateWorld, + type WorldDatabase, + type WorldSeedInput, +} from '../lib/worlds.js'; +import { WORLD_PERMISSIONS } from '../lib/world-permissions.js'; +import { authorizeWorldPermission } from '../lib/world-authorization.js'; +import type { WorldMembershipLookup } from '../lib/world-authorization.js'; + +const paramsSchema = z.object({ + worldId: z.string().min(1), +}); + +const stringListSchema = z.array(z.string().trim().min(1).max(120)).max(20); + +const worldSeedSchema = z.object({ + firstCharacters: stringListSchema.optional(), + firstFactions: stringListSchema.optional(), + firstHistoricalEvent: z.string().trim().min(1).max(1_000).optional(), + genre: z.string().trim().min(1).max(80), + mainConflict: z.string().trim().min(1).max(2_000), + premise: z.string().trim().min(1).max(3_000), + startingLocation: z.string().trim().min(1).max(500).optional(), + tone: z.string().trim().min(1).max(80), +}); + +const worldBibleSchema = z.object({ + changeSummary: z.string().trim().min(1).max(500).optional(), + content: z.unknown(), +}); + +const createWorldSchema = z.object({ + bible: worldBibleSchema, + description: z.string().trim().min(1).max(1_000), + seed: worldSeedSchema, + title: z.string().trim().min(1).max(160), +}); + +const updateWorldSchema = z + .object({ + bible: worldBibleSchema.optional(), + description: z.string().trim().min(1).max(1_000).optional(), + seed: worldSeedSchema.partial().optional(), + title: z.string().trim().min(1).max(160).optional(), + }) + .refine((value) => Object.keys(value).length > 0, { + message: 'At least one editable world field is required.', + }); + +const listWorldsSchema = z.object({ + genre: z.string().trim().min(1).max(80).optional(), + page: z.coerce.number().int().positive().default(1), + pageSize: z.coerce.number().int().positive().max(100).default(20), + q: z.string().trim().min(1).max(120).optional(), + tone: z.string().trim().min(1).max(80).optional(), +}); + +type RegisterWorldRoutesOptions = { + database?: WorldDatabase & WorldMembershipLookup; +}; + +function authOptions() { + return { + audience: env.AUTH_JWT_AUDIENCE, + issuer: env.AUTH_JWT_ISSUER, + jwtSecret: env.AUTH_JWT_SECRET, + }; +} + +export async function registerWorldRoutes( + app: FastifyInstance, + options: RegisterWorldRoutesOptions = {}, +) { + const database = options.database ?? prisma; + + app.post( + '/worlds', + { + preHandler: requireSession(authOptions()), + }, + async (request, reply) => { + const body = createWorldSchema.safeParse(request.body); + + if (!body.success) { + return reply.code(400).send({ + error: 'Invalid world payload.', + }); + } + + const authenticatedUser = authenticateRequest(request, authOptions()); + + if (!authenticatedUser) { + return reply.code(401).send({ + error: 'Authentication required.', + }); + } + + const world = await createWorld(database, { + ...body.data, + bible: { + ...body.data.bible, + content: body.data.bible.content as never, + }, + creatorId: authenticatedUser.id, + }); + + return reply.code(201).send({ + world, + }); + }, + ); + + app.get('/worlds', async (request, reply) => { + const query = listWorldsSchema.safeParse(request.query); + + if (!query.success) { + return reply.code(400).send({ + error: 'Invalid worlds query.', + }); + } + + return listWorlds(database, query.data); + }); + + app.get('/worlds/:worldId', async (request, reply) => { + const params = paramsSchema.safeParse(request.params); + + if (!params.success) { + return reply.code(400).send({ + error: 'Invalid world route.', + }); + } + + const world = await getWorld(database, params.data.worldId); + + if (!world) { + return reply.code(404).send({ + error: 'World not found.', + }); + } + + return { + world, + }; + }); + + app.get('/worlds/:worldId/hub', async (request, reply) => { + const params = paramsSchema.safeParse(request.params); + + if (!params.success) { + return reply.code(400).send({ + error: 'Invalid world route.', + }); + } + + const hub = await getWorldHub(database, params.data.worldId); + + if (!hub) { + return reply.code(404).send({ + error: 'World not found.', + }); + } + + return hub; + }); + + app.patch( + '/worlds/:worldId', + { + preHandler: requireSession(authOptions()), + }, + async (request, reply) => { + const params = paramsSchema.safeParse(request.params); + const body = updateWorldSchema.safeParse(request.body); + + if (!params.success || !body.success) { + return reply.code(400).send({ + error: 'Invalid world payload.', + }); + } + + const authorized = await authorizeWorldPermission( + request, + reply, + params.data.worldId, + WORLD_PERMISSIONS.EDIT_INITIAL_CANON, + database, + ); + + if (!authorized) { + return; + } + + const world = await updateWorld(database, { + ...(body.data.description ? { description: body.data.description } : {}), + ...(body.data.seed ? { seed: body.data.seed as Partial } : {}), + ...(body.data.title ? { title: body.data.title } : {}), + ...(body.data.bible + ? { + bible: { + ...body.data.bible, + content: body.data.bible.content as never, + }, + } + : {}), + worldId: params.data.worldId, + }); + + if (!world) { + return reply.code(404).send({ + error: 'World not found.', + }); + } + + return { + world, + }; + }, + ); +} From 1231993337efdbaccf4edc79e8e63f8fa9ca57d3 Mon Sep 17 00:00:00 2001 From: Perla Imad Date: Wed, 5 Aug 2026 19:14:13 +0300 Subject: [PATCH 05/22] Harden world hub and bible validation --- README.md | 2 +- apps/api/src/lib/worlds.ts | 79 ++++++++++++++---------------- apps/api/src/routes/worlds.test.ts | 44 ++++++++++++++++- apps/api/src/routes/worlds.ts | 41 ++++++++++++++-- 4 files changed, 117 insertions(+), 49 deletions(-) diff --git a/README.md b/README.md index 61a60ab..f51671b 100644 --- a/README.md +++ b/README.md @@ -324,7 +324,7 @@ Implemented world endpoints: - `POST /worlds`: requires authentication, creates a mutable off-chain World Seed and initial `WorldBibleVersion` in one transaction, and assigns the authenticated user an active `FOUNDER` membership. The route never accepts a client-supplied founder or role and does not publish to Hive. - `GET /worlds`: browses discoverable worlds with pagination and basic title/description, genre, and tone filters. - `GET /worlds/:worldId`: returns one world with its founder, seed, and current latest World Bible version. -- `GET /worlds/:worldId/hub`: returns the World Hub aggregate for the frontend, including world seed, current bible preview, simple lore/proposal stats, and latest lore entry summaries. +- `GET /worlds/:worldId/hub`: returns the World Hub aggregate for the frontend, including world seed, current bible preview, simple canon/proposal stats, and latest canon lore entry summaries. - `PATCH /worlds/:worldId`: requires `EDIT_INITIAL_CANON` world permission and updates mutable off-chain world title/description, seed, and latest initial bible content only. It does not create proposals, canonize content, or publish to Hive. --- diff --git a/apps/api/src/lib/worlds.ts b/apps/api/src/lib/worlds.ts index 87f2f9d..a6bfcc1 100644 --- a/apps/api/src/lib/worlds.ts +++ b/apps/api/src/lib/worlds.ts @@ -343,51 +343,45 @@ export async function getWorldHub(database: WorldDatabase, worldId: string) { return null; } - const [canonLoreCount, draftLoreCount, activeProposalCount, latestLoreEntries] = - await Promise.all([ - database.loreEntry.count({ - where: { - status: LoreStatus.PUBLISHED_CANON, - worldId, - }, - }), - database.loreEntry.count({ - where: { - status: LoreStatus.DRAFT, - worldId, - }, - }), - database.proposal.count({ - where: { - status: { - in: [ProposalStatus.SUBMITTED, ProposalStatus.VOTING], - }, - worldId, + const [canonLoreCount, activeProposalCount, latestLoreEntries] = await Promise.all([ + database.loreEntry.count({ + where: { + status: LoreStatus.PUBLISHED_CANON, + worldId, + }, + }), + database.proposal.count({ + where: { + status: { + in: [ProposalStatus.SUBMITTED, ProposalStatus.VOTING], }, - }), - database.loreEntry.findMany({ - orderBy: [ - { - updatedAt: 'desc', - }, - { - id: 'desc', - }, - ], - select: { - id: true, - loreType: true, - slug: true, - status: true, - title: true, - updatedAt: true, + worldId, + }, + }), + database.loreEntry.findMany({ + orderBy: [ + { + updatedAt: 'desc', }, - take: 6, - where: { - worldId, + { + id: 'desc', }, - }), - ]); + ], + select: { + id: true, + loreType: true, + slug: true, + status: true, + title: true, + updatedAt: true, + }, + take: 6, + where: { + status: LoreStatus.PUBLISHED_CANON, + worldId, + }, + }), + ]); return { latestLoreEntries: latestLoreEntries.map((entry) => ({ @@ -397,7 +391,6 @@ export async function getWorldHub(database: WorldDatabase, worldId: string) { stats: { activeProposalCount, canonLoreCount, - draftLoreCount, }, world, }; diff --git a/apps/api/src/routes/worlds.test.ts b/apps/api/src/routes/worlds.test.ts index d55b173..2419868 100644 --- a/apps/api/src/routes/worlds.test.ts +++ b/apps/api/src/routes/worlds.test.ts @@ -161,9 +161,13 @@ function createDatabase() { (!args.where.status || entry.status === args.where.status), ).length; }, - async findMany(args: { where: { worldId: string }; take: number }) { + async findMany(args: { where: { worldId: string; status?: LoreStatus }; take: number }) { return loreEntries - .filter((entry) => entry.worldId === args.where.worldId) + .filter( + (entry) => + entry.worldId === args.where.worldId && + (!('status' in args.where) || entry.status === args.where.status), + ) .slice(0, args.take); }, }, @@ -444,6 +448,26 @@ describe('world routes', () => { await app.close(); }); + test('rejects non-json world bible content', async () => { + const state = createDatabase(); + const app = await createApp(state.database); + const response = await app.inject({ + headers: authHeader(), + method: 'POST', + payload: { + ...worldPayload, + bible: { + changeSummary: 'Missing content should not pass as undefined.', + }, + }, + url: '/worlds', + }); + + assert.equal(response.statusCode, 400); + assert.equal(state.worlds.length, 0); + await app.close(); + }); + test('browses, retrieves, and opens a world hub response', async () => { const state = createDatabase(); const app = await createApp(state.database); @@ -462,6 +486,15 @@ describe('world routes', () => { updatedAt: new Date('2026-08-05T00:02:00.000Z'), worldId: 'world-1', }); + state.loreEntries.push({ + id: 'draft-lore', + loreType: 'FACTION', + slug: 'hidden-draft', + status: LoreStatus.DRAFT, + title: 'Hidden Draft', + updatedAt: new Date('2026-08-05T00:03:00.000Z'), + worldId: 'world-1', + }); state.proposals.push({ id: 'proposal-1', status: ProposalStatus.SUBMITTED, @@ -488,7 +521,14 @@ describe('world routes', () => { assert.equal(hubResponse.statusCode, 200); assert.equal(hubResponse.json().stats.canonLoreCount, 1); assert.equal(hubResponse.json().stats.activeProposalCount, 1); + assert.equal('draftLoreCount' in hubResponse.json().stats, false); assert.equal(hubResponse.json().latestLoreEntries[0].id, 'lore-1'); + assert.equal( + hubResponse + .json() + .latestLoreEntries.some((entry: { id: string }) => entry.id === 'draft-lore'), + false, + ); await app.close(); }); diff --git a/apps/api/src/routes/worlds.ts b/apps/api/src/routes/worlds.ts index 3456c05..85dae37 100644 --- a/apps/api/src/routes/worlds.ts +++ b/apps/api/src/routes/worlds.ts @@ -2,6 +2,7 @@ import type { FastifyInstance } from 'fastify'; import { z } from 'zod'; import { env } from '../config/env.js'; +import type { Prisma } from '../generated/prisma/client.js'; import { authenticateRequest, requireSession } from '../lib/auth-middleware.js'; import { prisma } from '../lib/prisma.js'; import { @@ -23,6 +24,40 @@ const paramsSchema = z.object({ const stringListSchema = z.array(z.string().trim().min(1).max(120)).max(20); +function isJsonValue(value: unknown): value is Prisma.InputJsonValue { + if (value === null) { + return true; + } + + if (typeof value === 'string' || typeof value === 'boolean') { + return true; + } + + if (typeof value === 'number') { + return Number.isFinite(value); + } + + if (Array.isArray(value)) { + return value.every(isJsonValue); + } + + if (typeof value === 'object') { + const prototype = Object.getPrototypeOf(value); + + if (prototype !== Object.prototype && prototype !== null) { + return false; + } + + return Object.values(value).every(isJsonValue); + } + + return false; +} + +const jsonValueSchema = z.custom(isJsonValue, { + message: 'World bible content must be valid JSON.', +}); + const worldSeedSchema = z.object({ firstCharacters: stringListSchema.optional(), firstFactions: stringListSchema.optional(), @@ -36,7 +71,7 @@ const worldSeedSchema = z.object({ const worldBibleSchema = z.object({ changeSummary: z.string().trim().min(1).max(500).optional(), - content: z.unknown(), + content: jsonValueSchema, }); const createWorldSchema = z.object({ @@ -109,7 +144,7 @@ export async function registerWorldRoutes( ...body.data, bible: { ...body.data.bible, - content: body.data.bible.content as never, + content: body.data.bible.content, }, creatorId: authenticatedUser.id, }); @@ -209,7 +244,7 @@ export async function registerWorldRoutes( ? { bible: { ...body.data.bible, - content: body.data.bible.content as never, + content: body.data.bible.content, }, } : {}), From 8d3518ad6acedf6b6fe55ffa9babd6a7f09d5b81 Mon Sep 17 00:00:00 2001 From: Perla Imad Date: Wed, 5 Aug 2026 21:00:12 +0300 Subject: [PATCH 06/22] Add structured contribution draft workflow --- README.md | 9 + .../migration.sql | 70 ++ apps/api/prisma/schema.prisma | 121 ++- apps/api/prisma/seed.ts | 83 ++ apps/api/src/app.ts | 2 + apps/api/src/lib/contributions.ts | 755 ++++++++++++++ apps/api/src/routes/contributions.test.ts | 942 ++++++++++++++++++ apps/api/src/routes/contributions.ts | 430 ++++++++ 8 files changed, 2376 insertions(+), 36 deletions(-) create mode 100644 apps/api/prisma/migrations/20260805170000_add_contribution_drafts/migration.sql create mode 100644 apps/api/src/lib/contributions.ts create mode 100644 apps/api/src/routes/contributions.test.ts create mode 100644 apps/api/src/routes/contributions.ts diff --git a/README.md b/README.md index f51671b..d556e96 100644 --- a/README.md +++ b/README.md @@ -327,6 +327,15 @@ Implemented world endpoints: - `GET /worlds/:worldId/hub`: returns the World Hub aggregate for the frontend, including world seed, current bible preview, simple canon/proposal stats, and latest canon lore entry summaries. - `PATCH /worlds/:worldId`: requires `EDIT_INITIAL_CANON` world permission and updates mutable off-chain world title/description, seed, and latest initial bible content only. It does not create proposals, canonize content, or publish to Hive. +Implemented contribution endpoints: + +- `POST /worlds/:worldId/contributions`: requires authentication and `CREATE_LORE_DRAFT` world permission, creates an author-owned structured contribution draft for lore or story content, and optionally links it to a lore entry in the same world. +- `GET /worlds/:worldId/contributions`: requires authentication and lists only the authenticated writer's contributions in the world with page/pageSize pagination and optional status/kind filters. +- `GET /worlds/:worldId/contributions/:contributionId`: returns an authenticated writer's own contribution draft or submitted contribution without exposing other writers' private drafts. +- `PATCH /worlds/:worldId/contributions/:contributionId`: requires `EDIT_OWN_DRAFT`, updates only mutable draft fields, and rejects submitted contributions. +- `DELETE /worlds/:worldId/contributions/:contributionId`: requires `EDIT_OWN_DRAFT`, deletes only draft contributions, and never deletes submitted proposals. +- `POST /worlds/:worldId/contributions/:contributionId/submit`: requires `SUBMIT_PROPOSAL`, atomically locks the draft, creates exactly one submitted proposal with an immutable `proposedContent` snapshot, and does not publish to Hive. + --- # Reputation & Rewards diff --git a/apps/api/prisma/migrations/20260805170000_add_contribution_drafts/migration.sql b/apps/api/prisma/migrations/20260805170000_add_contribution_drafts/migration.sql new file mode 100644 index 0000000..2f95fad --- /dev/null +++ b/apps/api/prisma/migrations/20260805170000_add_contribution_drafts/migration.sql @@ -0,0 +1,70 @@ +-- CreateEnum +CREATE TYPE "ContributionKind" AS ENUM ('LORE', 'STORY'); + +-- CreateEnum +CREATE TYPE "ContributionStatus" AS ENUM ('DRAFT', 'SUBMITTED'); + +-- AlterEnum +ALTER TYPE "ProposalType" ADD VALUE 'ADD_STORY'; + +-- AlterEnum +ALTER TYPE "WorldAuditAction" ADD VALUE 'CONTRIBUTION_CREATED'; +ALTER TYPE "WorldAuditAction" ADD VALUE 'CONTRIBUTION_UPDATED'; +ALTER TYPE "WorldAuditAction" ADD VALUE 'CONTRIBUTION_DELETED'; +ALTER TYPE "WorldAuditAction" ADD VALUE 'CONTRIBUTION_SUBMITTED'; +ALTER TYPE "WorldAuditAction" ADD VALUE 'PROPOSAL_CREATED'; + +-- AlterTable +ALTER TABLE "Proposal" ADD COLUMN "contributionKind" "ContributionKind"; + +-- CreateTable +CREATE TABLE "ContributionDraft" ( + "id" TEXT NOT NULL, + "worldId" TEXT NOT NULL, + "authorId" TEXT NOT NULL, + "targetLoreEntryId" TEXT, + "kind" "ContributionKind" NOT NULL, + "title" TEXT NOT NULL, + "summary" TEXT, + "content" JSONB NOT NULL, + "status" "ContributionStatus" NOT NULL DEFAULT 'DRAFT', + "proposalId" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + "submittedAt" TIMESTAMP(3), + + CONSTRAINT "ContributionDraft_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "ContributionDraft_proposalId_key" ON "ContributionDraft"("proposalId"); + +-- CreateIndex +CREATE INDEX "ContributionDraft_worldId_idx" ON "ContributionDraft"("worldId"); + +-- CreateIndex +CREATE INDEX "ContributionDraft_authorId_idx" ON "ContributionDraft"("authorId"); + +-- CreateIndex +CREATE INDEX "ContributionDraft_worldId_status_idx" ON "ContributionDraft"("worldId", "status"); + +-- CreateIndex +CREATE INDEX "ContributionDraft_worldId_authorId_status_idx" ON "ContributionDraft"("worldId", "authorId", "status"); + +-- CreateIndex +CREATE INDEX "ContributionDraft_targetLoreEntryId_idx" ON "ContributionDraft"("targetLoreEntryId"); + +-- CreateIndex +CREATE INDEX "Proposal_worldId_contributionKind_idx" ON "Proposal"("worldId", "contributionKind"); + +-- AddForeignKey +ALTER TABLE "ContributionDraft" ADD CONSTRAINT "ContributionDraft_worldId_fkey" FOREIGN KEY ("worldId") REFERENCES "World"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "ContributionDraft" ADD CONSTRAINT "ContributionDraft_authorId_fkey" FOREIGN KEY ("authorId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "ContributionDraft" ADD CONSTRAINT "ContributionDraft_targetLoreEntryId_fkey" FOREIGN KEY ("targetLoreEntryId") REFERENCES "LoreEntry"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "ContributionDraft" ADD CONSTRAINT "ContributionDraft_proposalId_fkey" FOREIGN KEY ("proposalId") REFERENCES "Proposal"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index 71e8c70..7e33a9b 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -29,10 +29,21 @@ enum LoreStatus { enum ProposalType { ADD_LORE + ADD_STORY UPDATE_LORE UPDATE_WORLD_BIBLE } +enum ContributionKind { + LORE + STORY +} + +enum ContributionStatus { + DRAFT + SUBMITTED +} + enum ProposalStatus { DRAFT SUBMITTED @@ -129,6 +140,11 @@ enum WorldAuditAction { MODERATION_ACTION CANON_STATUS_EXECUTED AI_WARNING_RESOLVED + CONTRIBUTION_CREATED + CONTRIBUTION_UPDATED + CONTRIBUTION_DELETED + CONTRIBUTION_SUBMITTED + PROPOSAL_CREATED } enum AuthProvider { @@ -168,6 +184,7 @@ model User { bibleVersions WorldBibleVersion[] @relation("WorldBibleVersionCreator") loreEntries LoreEntry[] @relation("LoreEntryAuthor") proposals Proposal[] @relation("ProposalAuthor") + contributionDrafts ContributionDraft[] @relation("ContributionDraftAuthor") votes AppVote[] @relation("AppVoteVoter") moderationReports ModerationReport[] @relation("ModerationReporter") moderationReviews ModerationReport[] @relation("ModerationReviewer") @@ -296,23 +313,24 @@ model RcDelegation { } model World { - id String @id @default(cuid()) - slug String @unique - title String - description String - founderId String - founder User @relation("WorldFounder", fields: [founderId], references: [id], onDelete: Restrict) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - seed WorldSeed? - bibleVersions WorldBibleVersion[] - loreEntries LoreEntry[] - loreRelationships LoreRelationship[] - proposals Proposal[] - moderationReports ModerationReport[] - searchIndexes SearchIndex[] - memberships WorldMembership[] - auditLogs WorldAuditLog[] + id String @id @default(cuid()) + slug String @unique + title String + description String + founderId String + founder User @relation("WorldFounder", fields: [founderId], references: [id], onDelete: Restrict) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + seed WorldSeed? + bibleVersions WorldBibleVersion[] + loreEntries LoreEntry[] + loreRelationships LoreRelationship[] + proposals Proposal[] + contributionDrafts ContributionDraft[] + moderationReports ModerationReport[] + searchIndexes SearchIndex[] + memberships WorldMembership[] + auditLogs WorldAuditLog[] @@index([founderId]) @@index([createdAt]) @@ -401,26 +419,27 @@ model WorldBibleVersion { } model LoreEntry { - id String @id @default(cuid()) - worldId String - world World @relation(fields: [worldId], references: [id], onDelete: Restrict) - authorId String - author User @relation("LoreEntryAuthor", fields: [authorId], references: [id], onDelete: Restrict) - title String - slug String - loreType LoreType - content Json - status LoreStatus @default(DRAFT) - publishedAt DateTime? - hiveReferenceId String? @unique + id String @id @default(cuid()) + worldId String + world World @relation(fields: [worldId], references: [id], onDelete: Restrict) + authorId String + author User @relation("LoreEntryAuthor", fields: [authorId], references: [id], onDelete: Restrict) + title String + slug String + loreType LoreType + content Json + status LoreStatus @default(DRAFT) + publishedAt DateTime? + hiveReferenceId String? @unique /// Published canon must be confirmed through an indexed HiveReference, not only this mutable row. - hiveReference HiveReference? @relation(fields: [hiveReferenceId], references: [id], onDelete: SetNull) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - outgoingRelations LoreRelationship[] @relation("LoreRelationshipSource") - incomingRelations LoreRelationship[] @relation("LoreRelationshipTarget") - targetProposals Proposal[] @relation("ProposalTargetLoreEntry") - resultingProposals Proposal[] @relation("ProposalResultingLoreEntry") + hiveReference HiveReference? @relation(fields: [hiveReferenceId], references: [id], onDelete: SetNull) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + outgoingRelations LoreRelationship[] @relation("LoreRelationshipSource") + incomingRelations LoreRelationship[] @relation("LoreRelationshipTarget") + targetProposals Proposal[] @relation("ProposalTargetLoreEntry") + targetContributionDrafts ContributionDraft[] @relation("ContributionDraftTargetLoreEntry") + resultingProposals Proposal[] @relation("ProposalResultingLoreEntry") @@unique([worldId, slug]) @@index([worldId, status]) @@ -455,6 +474,7 @@ model Proposal { authorId String author User @relation("ProposalAuthor", fields: [authorId], references: [id], onDelete: Restrict) proposalType ProposalType + contributionKind ContributionKind? status ProposalStatus @default(DRAFT) title String summary String @@ -472,12 +492,41 @@ model Proposal { publishedAt DateTime? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt + contributionDraft ContributionDraft? votes AppVote[] aiReports AIReport[] moderationReports ModerationReport[] @@index([worldId, status]) @@index([authorId]) + @@index([worldId, contributionKind]) + @@index([targetLoreEntryId]) +} + +model ContributionDraft { + id String @id @default(cuid()) + worldId String + world World @relation(fields: [worldId], references: [id], onDelete: Cascade) + authorId String + author User @relation("ContributionDraftAuthor", fields: [authorId], references: [id], onDelete: Cascade) + targetLoreEntryId String? + targetLoreEntry LoreEntry? @relation("ContributionDraftTargetLoreEntry", fields: [targetLoreEntryId], references: [id], onDelete: SetNull) + kind ContributionKind + title String + summary String? + content Json + status ContributionStatus @default(DRAFT) + proposalId String? @unique + /// Submitted drafts are locked; Proposal.proposedContent stores the immutable reviewer snapshot. + proposal Proposal? @relation(fields: [proposalId], references: [id], onDelete: SetNull) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + submittedAt DateTime? + + @@index([worldId]) + @@index([authorId]) + @@index([worldId, status]) + @@index([worldId, authorId, status]) @@index([targetLoreEntryId]) } diff --git a/apps/api/prisma/seed.ts b/apps/api/prisma/seed.ts index 8311856..d999c72 100644 --- a/apps/api/prisma/seed.ts +++ b/apps/api/prisma/seed.ts @@ -3,6 +3,7 @@ import 'dotenv/config'; import { PrismaPg } from '@prisma/adapter-pg'; import { PrismaClient } from '../src/generated/prisma/client.js'; +import { WorldRole } from '../src/generated/prisma/enums.js'; import { ensureActiveFounderMembership } from '../src/lib/founder-memberships.js'; const databaseUrl = process.env.DATABASE_URL; @@ -75,6 +76,26 @@ async function main() { userId: founder.id, }); + await prisma.worldMembership.upsert({ + where: { + worldId_userId: { + worldId: world.id, + userId: contributor.id, + }, + }, + update: { + role: WorldRole.CONTRIBUTOR, + grantedById: founder.id, + revokedAt: null, + }, + create: { + worldId: world.id, + userId: contributor.id, + role: WorldRole.CONTRIBUTOR, + grantedById: founder.id, + }, + }); + await prisma.worldSeed.upsert({ where: { worldId: world.id, @@ -270,6 +291,68 @@ async function main() { }, }); + await prisma.contributionDraft.upsert({ + where: { + id: 'dev-contribution-glass-archipelago-001', + }, + update: { + kind: 'LORE', + title: 'Draft: Lighthouse Keeper Oath', + summary: 'Development-only structured contribution draft.', + content: { + type: 'doc', + content: [ + { + type: 'heading', + attrs: { level: 1 }, + content: [{ type: 'text', text: 'Lighthouse Keeper Oath' }], + }, + { + type: 'paragraph', + content: [ + { + type: 'text', + text: 'Keepers swear to report reflected storms before dawn bells sound.', + }, + ], + }, + ], + }, + status: 'DRAFT', + proposalId: null, + submittedAt: null, + targetLoreEntryId: lighthouse.id, + }, + create: { + id: 'dev-contribution-glass-archipelago-001', + worldId: world.id, + authorId: contributor.id, + kind: 'LORE', + title: 'Draft: Lighthouse Keeper Oath', + summary: 'Development-only structured contribution draft.', + content: { + type: 'doc', + content: [ + { + type: 'heading', + attrs: { level: 1 }, + content: [{ type: 'text', text: 'Lighthouse Keeper Oath' }], + }, + { + type: 'paragraph', + content: [ + { + type: 'text', + text: 'Keepers swear to report reflected storms before dawn bells sound.', + }, + ], + }, + ], + }, + targetLoreEntryId: lighthouse.id, + }, + }); + await prisma.aIReport.upsert({ where: { id: 'dev-ai-report-glass-archipelago-001' }, update: { diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index f77600d..4745945 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -6,6 +6,7 @@ import { env } from './config/env.js'; import { createPrismaRateLimitStore } from './lib/auth-rate-limit-store.js'; import { prisma } from './lib/prisma.js'; import { registerAuthRoutes } from './routes/auth.js'; +import { registerContributionRoutes } from './routes/contributions.js'; import { registerHealthRoute } from './routes/health.js'; import { registerWorldRoutes } from './routes/worlds.js'; @@ -43,6 +44,7 @@ export async function buildApp() { await registerHealthRoute(app); await registerAuthRoutes(app); await registerWorldRoutes(app); + await registerContributionRoutes(app); return app; } diff --git a/apps/api/src/lib/contributions.ts b/apps/api/src/lib/contributions.ts new file mode 100644 index 0000000..675109b --- /dev/null +++ b/apps/api/src/lib/contributions.ts @@ -0,0 +1,755 @@ +import type { Prisma, PrismaClient } from '../generated/prisma/client.js'; +import { + ContributionKind, + ContributionStatus, + ProposalStatus, + ProposalType, + WorldAuditAction, +} from '../generated/prisma/enums.js'; + +const STRUCTURED_DOCUMENT_MAX_BYTES = 100 * 1024; + +export class ContributionError extends Error { + constructor( + public readonly statusCode: number, + message: string, + ) { + super(message); + } +} + +export type ContributionDatabase = Pick< + PrismaClient, + | '$transaction' + | 'contributionDraft' + | 'loreEntry' + | 'proposal' + | 'world' + | 'worldAuditLog' + | 'worldMembership' +>; + +export type StructuredDocument = Prisma.InputJsonObject; + +export type CreateContributionInput = { + worldId: string; + authorId: string; + kind: ContributionKind; + title: string; + summary?: string | undefined; + targetLoreEntryId?: string | undefined; + content: unknown; +}; + +export type UpdateContributionInput = { + worldId: string; + contributionId: string; + authorId: string; + kind?: ContributionKind | undefined; + title?: string | undefined; + summary?: string | null | undefined; + targetLoreEntryId?: string | null | undefined; + content?: unknown; +}; + +export type ListContributionInput = { + worldId: string; + authorId: string; + status?: ContributionStatus | undefined; + kind?: ContributionKind | undefined; + page?: number | undefined; + pageSize?: number | undefined; +}; + +const contributionInclude = { + proposal: true, + targetLoreEntry: { + select: { + id: true, + loreType: true, + slug: true, + title: true, + worldId: true, + }, + }, +} as const; + +type ContributionWithRelations = Prisma.ContributionDraftGetPayload<{ + include: typeof contributionInclude; +}>; + +type ProposalRecord = Prisma.ProposalGetPayload>; + +function serializeDate(value: Date | null) { + return value?.toISOString() ?? null; +} + +export function serializeProposal(proposal: ProposalRecord | null) { + if (!proposal) { + return null; + } + + return { + approvedAt: serializeDate(proposal.approvedAt), + authorId: proposal.authorId, + contributionKind: proposal.contributionKind, + createdAt: proposal.createdAt.toISOString(), + id: proposal.id, + proposedContent: proposal.proposedContent, + proposalType: proposal.proposalType, + publishedAt: serializeDate(proposal.publishedAt), + rejectedAt: serializeDate(proposal.rejectedAt), + resultingBibleVersionId: proposal.resultingBibleVersionId, + resultingLoreEntryId: proposal.resultingLoreEntryId, + status: proposal.status, + submittedAt: serializeDate(proposal.submittedAt), + summary: proposal.summary, + targetLoreEntryId: proposal.targetLoreEntryId, + title: proposal.title, + updatedAt: proposal.updatedAt.toISOString(), + votingStartedAt: serializeDate(proposal.votingStartedAt), + worldId: proposal.worldId, + }; +} + +export function serializeContribution(contribution: ContributionWithRelations) { + return { + authorId: contribution.authorId, + content: contribution.content, + createdAt: contribution.createdAt.toISOString(), + id: contribution.id, + kind: contribution.kind, + proposal: serializeProposal(contribution.proposal), + proposalId: contribution.proposalId, + status: contribution.status, + submittedAt: serializeDate(contribution.submittedAt), + summary: contribution.summary, + targetLoreEntry: contribution.targetLoreEntry, + targetLoreEntryId: contribution.targetLoreEntryId, + title: contribution.title, + updatedAt: contribution.updatedAt.toISOString(), + worldId: contribution.worldId, + }; +} + +function isPlainObject(value: unknown): value is Record { + return ( + typeof value === 'object' && + value !== null && + !Array.isArray(value) && + (Object.getPrototypeOf(value) === Object.prototype || Object.getPrototypeOf(value) === null) + ); +} + +function assertJsonByteLimit(value: unknown) { + const serialized = JSON.stringify(value); + + if (!serialized || Buffer.byteLength(serialized, 'utf8') > STRUCTURED_DOCUMENT_MAX_BYTES) { + throw new ContributionError(400, 'Structured content exceeds the 100 KB limit.'); + } +} + +export function validateStructuredDocument(value: unknown): StructuredDocument { + assertJsonByteLimit(value); + + if (!isPlainObject(value)) { + throw new ContributionError(400, 'Structured content must be a JSON object.'); + } + + if (value.type !== 'doc') { + throw new ContributionError(400, 'Structured content root type must be "doc".'); + } + + if ('content' in value && !Array.isArray(value.content)) { + throw new ContributionError(400, 'Structured content root content must be an array.'); + } + + return value as StructuredDocument; +} + +export function hasMeaningfulText(value: unknown): boolean { + if (Array.isArray(value)) { + return value.some(hasMeaningfulText); + } + + if (!isPlainObject(value)) { + return false; + } + + if (typeof value.text === 'string' && value.text.trim().length > 0) { + return true; + } + + return Object.values(value).some(hasMeaningfulText); +} + +function normalizeTitle(title: string) { + const normalized = title.trim(); + + if (!normalized) { + throw new ContributionError(400, 'Contribution title is required.'); + } + + if (normalized.length > 200) { + throw new ContributionError(400, 'Contribution title must be at most 200 characters.'); + } + + return normalized; +} + +function normalizeSummary(summary: string | null | undefined) { + const normalized = summary?.trim() || null; + + if (normalized && normalized.length > 1_000) { + throw new ContributionError(400, 'Contribution summary must be at most 1000 characters.'); + } + + return normalized; +} + +async function assertWorldExists(database: Pick, worldId: string) { + const world = await database.world.findUnique({ + select: { + id: true, + }, + where: { + id: worldId, + }, + }); + + if (!world) { + throw new ContributionError(404, 'World not found.'); + } +} + +async function assertTargetLoreEntry( + database: Pick, + worldId: string, + targetLoreEntryId: string | null | undefined, +) { + if (!targetLoreEntryId) { + return null; + } + + const target = await database.loreEntry.findUnique({ + select: { + id: true, + worldId: true, + }, + where: { + id: targetLoreEntryId, + }, + }); + + if (!target) { + throw new ContributionError(404, 'Target lore entry not found.'); + } + + if (target.worldId !== worldId) { + throw new ContributionError(400, 'Target lore entry must belong to the same world.'); + } + + return target; +} + +function proposalTypeForContribution(kind: ContributionKind, hasTargetLoreEntry: boolean) { + if (kind === ContributionKind.STORY) { + return ProposalType.ADD_STORY; + } + + return hasTargetLoreEntry ? ProposalType.UPDATE_LORE : ProposalType.ADD_LORE; +} + +async function createContributionAuditLog( + database: Pick, + input: { + action: (typeof WorldAuditAction)[keyof typeof WorldAuditAction]; + actorId: string; + worldId: string; + targetId: string; + targetType: string; + metadata?: Prisma.InputJsonObject | undefined; + }, +) { + await database.worldAuditLog.create({ + data: { + action: input.action, + actorId: input.actorId, + metadata: input.metadata ?? {}, + targetId: input.targetId, + targetType: input.targetType, + worldId: input.worldId, + }, + }); +} + +export async function createContribution( + database: ContributionDatabase, + input: CreateContributionInput, +) { + const title = normalizeTitle(input.title); + const summary = normalizeSummary(input.summary); + const content = validateStructuredDocument(input.content); + + await assertWorldExists(database, input.worldId); + await assertTargetLoreEntry(database, input.worldId, input.targetLoreEntryId); + + const contribution = await database.$transaction(async (transaction) => { + const created = await transaction.contributionDraft.create({ + data: { + authorId: input.authorId, + content, + kind: input.kind, + summary, + targetLoreEntryId: input.targetLoreEntryId || null, + title, + worldId: input.worldId, + }, + include: contributionInclude, + }); + + await createContributionAuditLog(transaction, { + action: WorldAuditAction.CONTRIBUTION_CREATED, + actorId: input.authorId, + metadata: { + kind: input.kind, + targetLoreEntryId: input.targetLoreEntryId ?? null, + }, + targetId: created.id, + targetType: 'CONTRIBUTION_DRAFT', + worldId: input.worldId, + }); + + return created; + }); + + return serializeContribution(contribution); +} + +export async function listOwnedContributions( + database: ContributionDatabase, + input: ListContributionInput, +) { + const page = input.page ?? 1; + const pageSize = input.pageSize ?? 20; + const where: Prisma.ContributionDraftWhereInput = { + authorId: input.authorId, + ...(input.kind ? { kind: input.kind } : {}), + ...(input.status ? { status: input.status } : {}), + worldId: input.worldId, + }; + + const [contributions, total] = await Promise.all([ + database.contributionDraft.findMany({ + include: contributionInclude, + orderBy: [ + { + updatedAt: 'desc', + }, + { + id: 'desc', + }, + ], + skip: (page - 1) * pageSize, + take: pageSize, + where, + }), + database.contributionDraft.count({ + where, + }), + ]); + + return { + contributions: contributions.map(serializeContribution), + pagination: { + page, + pageSize, + total, + }, + }; +} + +async function resolveMissingOrLockedContribution( + database: Pick, + input: { + worldId: string; + contributionId: string; + authorId: string; + lockedMessage: string; + }, +) { + const current = await database.contributionDraft.findFirst({ + select: { + status: true, + }, + where: { + authorId: input.authorId, + id: input.contributionId, + worldId: input.worldId, + }, + }); + + if (!current) { + throw new ContributionError(404, 'Contribution not found.'); + } + + if (current.status !== ContributionStatus.DRAFT) { + throw new ContributionError(409, input.lockedMessage); + } + + throw new ContributionError(409, 'Concurrent contribution update conflict.'); +} + +async function findOwnedContributionOrThrow( + database: Pick, + input: { + worldId: string; + contributionId: string; + authorId: string; + }, +) { + const contribution = await database.contributionDraft.findFirst({ + include: contributionInclude, + where: { + authorId: input.authorId, + id: input.contributionId, + worldId: input.worldId, + }, + }); + + if (!contribution) { + throw new ContributionError(404, 'Contribution not found.'); + } + + return contribution; +} + +export async function getOwnedContribution( + database: ContributionDatabase, + input: { + worldId: string; + contributionId: string; + authorId: string; + }, +) { + const contribution = await database.contributionDraft.findFirst({ + include: contributionInclude, + where: { + authorId: input.authorId, + id: input.contributionId, + worldId: input.worldId, + }, + }); + + return contribution ? serializeContribution(contribution) : null; +} + +export async function updateContribution( + database: ContributionDatabase, + input: UpdateContributionInput, +) { + await findOwnedContributionOrThrow(database, input); + + const data: Prisma.ContributionDraftUpdateManyMutationInput = {}; + const changedFields: string[] = []; + let targetLoreEntryId: string | null | undefined; + + if (input.kind) { + data.kind = input.kind; + changedFields.push('kind'); + } + + if (input.title !== undefined) { + data.title = normalizeTitle(input.title); + changedFields.push('title'); + } + + if (input.summary !== undefined) { + data.summary = normalizeSummary(input.summary); + changedFields.push('summary'); + } + + if (input.content !== undefined) { + data.content = validateStructuredDocument(input.content); + changedFields.push('content'); + } + + if (input.targetLoreEntryId !== undefined) { + await assertTargetLoreEntry(database, input.worldId, input.targetLoreEntryId); + targetLoreEntryId = input.targetLoreEntryId; + changedFields.push('targetLoreEntryId'); + } + + if (Object.keys(data).length === 0) { + data.updatedAt = new Date(); + } + + const contribution = await database.$transaction(async (transaction) => { + const updated = await transaction.contributionDraft.updateMany({ + data, + where: { + authorId: input.authorId, + id: input.contributionId, + status: ContributionStatus.DRAFT, + worldId: input.worldId, + }, + }); + + if (updated.count !== 1) { + await resolveMissingOrLockedContribution(transaction, { + ...input, + lockedMessage: 'Submitted contributions cannot be edited.', + }); + } + + if (targetLoreEntryId !== undefined) { + await transaction.contributionDraft.update({ + data: { + targetLoreEntry: + targetLoreEntryId === null + ? { + disconnect: true, + } + : { + connect: { + id: targetLoreEntryId, + }, + }, + }, + where: { + id: input.contributionId, + }, + }); + } + + const current = await transaction.contributionDraft.findFirst({ + include: contributionInclude, + where: { + authorId: input.authorId, + id: input.contributionId, + worldId: input.worldId, + }, + }); + + if (!current) { + throw new ContributionError(404, 'Contribution not found.'); + } + + await createContributionAuditLog(transaction, { + action: WorldAuditAction.CONTRIBUTION_UPDATED, + actorId: input.authorId, + metadata: { + changedFields: changedFields.sort(), + }, + targetId: current.id, + targetType: 'CONTRIBUTION_DRAFT', + worldId: input.worldId, + }); + + return current; + }); + + return serializeContribution(contribution); +} + +export async function deleteContribution( + database: ContributionDatabase, + input: { + worldId: string; + contributionId: string; + authorId: string; + }, +) { + await database.$transaction(async (transaction) => { + const existing = await transaction.contributionDraft.findFirst({ + where: { + authorId: input.authorId, + id: input.contributionId, + worldId: input.worldId, + }, + }); + + if (!existing) { + throw new ContributionError(404, 'Contribution not found.'); + } + + if (existing.status !== ContributionStatus.DRAFT) { + throw new ContributionError(409, 'Submitted contributions cannot be deleted.'); + } + + const deleted = await transaction.contributionDraft.deleteMany({ + where: { + authorId: input.authorId, + id: input.contributionId, + status: ContributionStatus.DRAFT, + worldId: input.worldId, + }, + }); + + if (deleted.count !== 1) { + await resolveMissingOrLockedContribution(transaction, { + ...input, + lockedMessage: 'Submitted contributions cannot be deleted.', + }); + } + + await createContributionAuditLog(transaction, { + action: WorldAuditAction.CONTRIBUTION_DELETED, + actorId: input.authorId, + metadata: { + kind: existing.kind, + targetLoreEntryId: existing.targetLoreEntryId, + }, + targetId: existing.id, + targetType: 'CONTRIBUTION_DRAFT', + worldId: input.worldId, + }); + }); +} + +export async function submitContribution( + database: ContributionDatabase, + input: { + worldId: string; + contributionId: string; + authorId: string; + }, +) { + const result = await database.$transaction(async (transaction) => { + const existing = await transaction.contributionDraft.findFirst({ + include: contributionInclude, + where: { + authorId: input.authorId, + id: input.contributionId, + worldId: input.worldId, + }, + }); + + if (!existing) { + throw new ContributionError(404, 'Contribution not found.'); + } + + if (existing.status !== ContributionStatus.DRAFT) { + if (existing.proposal) { + return { + alreadySubmitted: true, + contribution: existing, + proposal: existing.proposal, + }; + } + + throw new ContributionError(409, 'Contribution already submitted.'); + } + + normalizeTitle(existing.title); + const structuredContent = validateStructuredDocument(existing.content); + + if (!hasMeaningfulText(existing.content)) { + throw new ContributionError(400, 'Contribution content must include meaningful text.'); + } + + await assertWorldExists(transaction, input.worldId); + await assertTargetLoreEntry(transaction, input.worldId, existing.targetLoreEntryId); + + const submittedAt = new Date(); + const transition = await transaction.contributionDraft.updateMany({ + data: { + status: ContributionStatus.SUBMITTED, + submittedAt, + }, + where: { + authorId: input.authorId, + id: input.contributionId, + status: ContributionStatus.DRAFT, + worldId: input.worldId, + }, + }); + + if (transition.count !== 1) { + const submitted = await transaction.contributionDraft.findFirst({ + include: contributionInclude, + where: { + authorId: input.authorId, + id: input.contributionId, + worldId: input.worldId, + }, + }); + + if (submitted?.proposal) { + return { + alreadySubmitted: true, + contribution: submitted, + proposal: submitted.proposal, + }; + } + + throw new ContributionError(409, 'Concurrent submission conflict.'); + } + + // Proposal.proposedContent is the immutable reviewer snapshot; submitted + // drafts are locked so draft endpoints cannot mutate active voting content. + const proposal = await transaction.proposal.create({ + data: { + authorId: input.authorId, + contributionKind: existing.kind, + proposedContent: structuredContent, + proposalType: proposalTypeForContribution( + existing.kind, + Boolean(existing.targetLoreEntryId), + ), + status: ProposalStatus.SUBMITTED, + submittedAt, + summary: existing.summary ?? '', + targetLoreEntryId: existing.targetLoreEntryId, + title: existing.title, + worldId: input.worldId, + }, + }); + + const contribution = await transaction.contributionDraft.update({ + data: { + proposalId: proposal.id, + }, + include: contributionInclude, + where: { + id: existing.id, + }, + }); + + await createContributionAuditLog(transaction, { + action: WorldAuditAction.PROPOSAL_CREATED, + actorId: input.authorId, + metadata: { + contributionId: contribution.id, + contributionKind: contribution.kind, + proposalType: proposal.proposalType, + }, + targetId: proposal.id, + targetType: 'PROPOSAL', + worldId: input.worldId, + }); + + await createContributionAuditLog(transaction, { + action: WorldAuditAction.CONTRIBUTION_SUBMITTED, + actorId: input.authorId, + metadata: { + proposalId: proposal.id, + }, + targetId: contribution.id, + targetType: 'CONTRIBUTION_DRAFT', + worldId: input.worldId, + }); + + return { + alreadySubmitted: false, + contribution, + proposal, + }; + }); + + return { + alreadySubmitted: result.alreadySubmitted, + contribution: serializeContribution(result.contribution), + proposal: serializeProposal(result.proposal), + }; +} diff --git a/apps/api/src/routes/contributions.test.ts b/apps/api/src/routes/contributions.test.ts new file mode 100644 index 0000000..7d0e459 --- /dev/null +++ b/apps/api/src/routes/contributions.test.ts @@ -0,0 +1,942 @@ +import assert from 'node:assert/strict'; +import { describe, test } from 'node:test'; + +import Fastify from 'fastify'; + +import { env } from '../config/env.js'; +import { + ContributionKind, + ContributionStatus, + PlatformRole, + ProposalStatus, + ProposalType, + WorldAuditAction, + WorldRole, +} from '../generated/prisma/enums.js'; +import { signAccessToken } from '../lib/auth-crypto.js'; +import { + createContribution, + deleteContribution, + submitContribution, + updateContribution, +} from '../lib/contributions.js'; +import { registerContributionRoutes } from './contributions.js'; + +const author = { + hiveUsername: 'mira-vale.dev', + id: 'user-author', + normalizedHiveUsername: 'mira-vale.dev', +}; + +const otherUser = { + hiveUsername: 'emberquill.dev', + id: 'user-other', + normalizedHiveUsername: 'emberquill.dev', +}; + +function authHeader(user = author) { + const token = signAccessToken( + { + hiveUsername: user.hiveUsername, + normalizedHiveUsername: user.normalizedHiveUsername, + platformRole: PlatformRole.USER, + sid: `session-${user.id}`, + sub: user.id, + }, + { + audience: env.AUTH_JWT_AUDIENCE, + issuer: env.AUTH_JWT_ISSUER, + secret: env.AUTH_JWT_SECRET, + ttlSeconds: 900, + }, + ); + + return { + authorization: `Bearer ${token}`, + }; +} + +const structuredDoc = { + type: 'doc', + content: [ + { + type: 'paragraph', + content: [{ type: 'text', text: 'The northern gate fell during the third winter.' }], + }, + ], +}; + +const emptyDoc = { + type: 'doc', + content: [ + { + type: 'paragraph', + content: [{ type: 'text', text: ' ' }], + }, + ], +}; + +type StoredContribution = { + id: string; + worldId: string; + authorId: string; + targetLoreEntryId: string | null; + kind: ContributionKind; + title: string; + summary: string | null; + content: unknown; + status: ContributionStatus; + proposalId: string | null; + createdAt: Date; + updatedAt: Date; + submittedAt: Date | null; +}; + +type StoredProposal = { + id: string; + worldId: string; + authorId: string; + proposalType: ProposalType; + contributionKind: ContributionKind; + status: ProposalStatus; + title: string; + summary: string; + proposedContent: unknown; + targetLoreEntryId: string | null; + resultingLoreEntryId: string | null; + resultingBibleVersionId: string | null; + submittedAt: Date | null; + votingStartedAt: Date | null; + approvedAt: Date | null; + rejectedAt: Date | null; + publishedAt: Date | null; + createdAt: Date; + updatedAt: Date; +}; + +function now() { + return new Date('2026-08-05T12:00:00.000Z'); +} + +function createContributionRecord(input: Partial = {}): StoredContribution { + return { + authorId: author.id, + content: structuredDoc, + createdAt: now(), + id: `contribution-${Math.random().toString(36).slice(2)}`, + kind: ContributionKind.LORE, + proposalId: null, + status: ContributionStatus.DRAFT, + submittedAt: null, + summary: 'A short summary.', + targetLoreEntryId: 'lore-1', + title: 'The Fall of the Northern Gate', + updatedAt: now(), + worldId: 'world-1', + ...input, + }; +} + +function createDatabase() { + const worlds = [{ id: 'world-1' }, { id: 'world-2' }]; + const loreEntries = [ + { + id: 'lore-1', + loreType: 'LOCATION', + slug: 'north-gate', + title: 'North Gate', + worldId: 'world-1', + }, + { + id: 'lore-2', + loreType: 'EVENT', + slug: 'other-world-event', + title: 'Other Event', + worldId: 'world-2', + }, + ]; + const memberships = [ + { + id: 'membership-author', + revokedAt: null, + role: WorldRole.CONTRIBUTOR, + userId: author.id, + worldId: 'world-1', + }, + { + id: 'membership-other', + revokedAt: null, + role: WorldRole.READER, + userId: otherUser.id, + worldId: 'world-1', + }, + ]; + const contributions: StoredContribution[] = []; + const proposals: StoredProposal[] = []; + const auditLogs: unknown[] = []; + let failNextProposalCreate = false; + let failNextAuditCreate = false; + let submitBeforeNextDeleteMany = false; + let submitBeforeNextUpdateMany = false; + let transactionQueue = Promise.resolve(); + const externallySubmittedContributionIds = new Set(); + + function includeContribution(contribution: StoredContribution) { + return { + ...contribution, + proposal: contribution.proposalId + ? (proposals.find((proposal) => proposal.id === contribution.proposalId) ?? null) + : null, + targetLoreEntry: contribution.targetLoreEntryId + ? (loreEntries.find((entry) => entry.id === contribution.targetLoreEntryId) ?? null) + : null, + }; + } + + const database: Record = {}; + + Object.assign(database, { + async $transaction(callback: (transaction: unknown) => Promise) { + const previousTransaction = transactionQueue; + let releaseTransaction!: () => void; + transactionQueue = new Promise((resolve) => { + releaseTransaction = resolve; + }); + + await previousTransaction; + + const contributionSnapshot = contributions.map((contribution) => ({ ...contribution })); + const proposalSnapshot = proposals.map((proposal) => ({ ...proposal })); + const auditSnapshot = [...auditLogs]; + + try { + return await callback(database); + } catch (error) { + contributions.splice(0, contributions.length, ...contributionSnapshot); + proposals.splice(0, proposals.length, ...proposalSnapshot); + auditLogs.splice(0, auditLogs.length, ...auditSnapshot); + + for (const contributionId of externallySubmittedContributionIds) { + const contribution = contributions.find((candidate) => candidate.id === contributionId); + + if (contribution) { + contribution.status = ContributionStatus.SUBMITTED; + contribution.submittedAt = now(); + } + } + + externallySubmittedContributionIds.clear(); + throw error; + } finally { + releaseTransaction(); + } + }, + contributionDraft: { + async create(args: { data: Partial; include: unknown }) { + const contribution = createContributionRecord({ + id: `contribution-${contributions.length + 1}`, + proposalId: null, + status: ContributionStatus.DRAFT, + submittedAt: null, + ...args.data, + }); + contributions.push(contribution); + return includeContribution(contribution); + }, + async delete(args: { where: { id: string } }) { + const index = contributions.findIndex((contribution) => contribution.id === args.where.id); + const [deleted] = contributions.splice(index, 1); + return deleted; + }, + async deleteMany(args: { where: Partial }) { + if (submitBeforeNextDeleteMany) { + const contribution = contributions.find( + (candidate) => + candidate.id === args.where.id && candidate.authorId === args.where.authorId, + ); + + if (contribution) { + contribution.status = ContributionStatus.SUBMITTED; + contribution.submittedAt = now(); + externallySubmittedContributionIds.add(contribution.id); + } + + submitBeforeNextDeleteMany = false; + } + + const before = contributions.length; + + for (let index = contributions.length - 1; index >= 0; index -= 1) { + const contribution = contributions[index]!; + + if ( + Object.entries(args.where).every( + ([key, value]) => contribution[key as keyof StoredContribution] === value, + ) + ) { + contributions.splice(index, 1); + } + } + + return { count: before - contributions.length }; + }, + async findFirst(args: { where: Partial; include?: unknown }) { + const contribution = + contributions.find((candidate) => + Object.entries(args.where).every(([key, value]) => { + if (value && typeof value === 'object' && 'lt' in value) { + return String(candidate[key as keyof StoredContribution]) < String(value.lt); + } + + return candidate[key as keyof StoredContribution] === value; + }), + ) ?? null; + + return contribution ? includeContribution(contribution) : null; + }, + async findMany(args: { + where: Partial & { id?: { lt: string } }; + skip?: number; + take: number; + }) { + return contributions + .filter((candidate) => + Object.entries(args.where).every(([key, value]) => { + if (key === 'id' && value && typeof value === 'object' && 'lt' in value) { + return candidate.id < String(value.lt); + } + + return candidate[key as keyof StoredContribution] === value; + }), + ) + .sort((left, right) => { + const updatedAtOrder = right.updatedAt.getTime() - left.updatedAt.getTime(); + + if (updatedAtOrder !== 0) { + return updatedAtOrder; + } + + return right.id.localeCompare(left.id); + }) + .slice(args.skip ?? 0, (args.skip ?? 0) + args.take) + .map(includeContribution); + }, + async count(args: { where: Partial }) { + return contributions.filter((candidate) => + Object.entries(args.where).every( + ([key, value]) => candidate[key as keyof StoredContribution] === value, + ), + ).length; + }, + async update(args: { + data: Partial; + where: { id: string }; + include?: unknown; + }) { + const contribution = contributions.find((candidate) => candidate.id === args.where.id); + + if (!contribution) { + throw new Error('Contribution missing.'); + } + + if ('targetLoreEntry' in args.data) { + const relation = args.data.targetLoreEntry as + { connect: { id: string } } | { disconnect: true }; + contribution.targetLoreEntryId = 'disconnect' in relation ? null : relation.connect.id; + delete args.data.targetLoreEntry; + } + + Object.assign(contribution, args.data, { + updatedAt: now(), + }); + + return includeContribution(contribution); + }, + async updateMany(args: { + data: Partial; + where: Partial; + }) { + if (submitBeforeNextUpdateMany) { + const contribution = contributions.find( + (candidate) => + candidate.id === args.where.id && candidate.authorId === args.where.authorId, + ); + + if (contribution) { + contribution.status = ContributionStatus.SUBMITTED; + contribution.submittedAt = now(); + externallySubmittedContributionIds.add(contribution.id); + } + + submitBeforeNextUpdateMany = false; + } + + let count = 0; + + for (const contribution of contributions) { + if ( + Object.entries(args.where).every( + ([key, value]) => contribution[key as keyof StoredContribution] === value, + ) + ) { + Object.assign(contribution, args.data, { + updatedAt: now(), + }); + count += 1; + } + } + + return { count }; + }, + }, + loreEntry: { + async findUnique(args: { where: { id: string } }) { + return loreEntries.find((entry) => entry.id === args.where.id) ?? null; + }, + }, + proposal: { + async create(args: { data: Omit }) { + if (failNextProposalCreate) { + failNextProposalCreate = false; + throw new Error('Injected proposal create failure.'); + } + + const proposal: StoredProposal = { + ...args.data, + approvedAt: args.data.approvedAt ?? null, + createdAt: now(), + id: `proposal-${proposals.length + 1}`, + publishedAt: args.data.publishedAt ?? null, + rejectedAt: args.data.rejectedAt ?? null, + resultingBibleVersionId: args.data.resultingBibleVersionId ?? null, + resultingLoreEntryId: args.data.resultingLoreEntryId ?? null, + updatedAt: now(), + votingStartedAt: args.data.votingStartedAt ?? null, + }; + proposals.push(proposal); + return proposal; + }, + }, + world: { + async findUnique(args: { where: { id: string } }) { + return worlds.find((world) => world.id === args.where.id) ?? null; + }, + }, + worldAuditLog: { + async create(args: { data: unknown }) { + if (failNextAuditCreate) { + failNextAuditCreate = false; + throw new Error('Injected audit failure.'); + } + + auditLogs.push(args.data); + return args.data; + }, + }, + worldMembership: { + async findUnique(args: { + where: { worldId_userId: { worldId: string; userId: string }; revokedAt: null }; + }) { + return ( + memberships.find( + (membership) => + membership.worldId === args.where.worldId_userId.worldId && + membership.userId === args.where.worldId_userId.userId && + !membership.revokedAt, + ) ?? null + ); + }, + }, + }); + + return { + auditLogs, + contributions, + database, + failNextProposalCreate() { + failNextProposalCreate = true; + }, + failNextAuditCreate() { + failNextAuditCreate = true; + }, + loreEntries, + memberships, + proposals, + submitBeforeNextDeleteMany() { + submitBeforeNextDeleteMany = true; + }, + submitBeforeNextUpdateMany() { + submitBeforeNextUpdateMany = true; + }, + }; +} + +async function createApp(database: ReturnType['database']) { + const app = Fastify(); + await registerContributionRoutes(app, { + database: database as never, + }); + return app; +} + +describe('contribution routes', () => { + test('authenticated contributor can create lore and story drafts with session-derived author', async () => { + const state = createDatabase(); + const app = await createApp(state.database); + + const loreResponse = await app.inject({ + headers: authHeader(), + method: 'POST', + payload: { + content: structuredDoc, + kind: ContributionKind.LORE, + targetLoreEntryId: 'lore-1', + title: ' The Fall of the Northern Gate ', + }, + url: '/worlds/world-1/contributions', + }); + const storyResponse = await app.inject({ + headers: authHeader(), + method: 'POST', + payload: { + content: structuredDoc, + kind: ContributionKind.STORY, + title: 'A Winter Road', + }, + url: '/worlds/world-1/contributions', + }); + const protectedFieldResponse = await app.inject({ + headers: authHeader(), + method: 'POST', + payload: { + authorId: otherUser.id, + content: structuredDoc, + kind: ContributionKind.STORY, + proposalId: 'proposal-injected', + title: 'A Winter Road', + }, + url: '/worlds/world-1/contributions', + }); + + assert.equal(loreResponse.statusCode, 201); + assert.equal(loreResponse.json().contribution.authorId, author.id); + assert.equal(loreResponse.json().contribution.title, 'The Fall of the Northern Gate'); + assert.equal(storyResponse.statusCode, 201); + assert.equal(storyResponse.json().contribution.kind, ContributionKind.STORY); + assert.equal(protectedFieldResponse.statusCode, 400); + assert.equal(state.contributions.length, 2); + await app.close(); + }); + + test('rejects unauthenticated users, users without world permission, invalid docs, oversized docs, missing content, missing worlds, and mismatched targets', async () => { + const state = createDatabase(); + const app = await createApp(state.database); + const oversizedDoc = { + type: 'doc', + content: [ + { + type: 'paragraph', + content: [{ type: 'text', text: 'x'.repeat(101 * 1024) }], + }, + ], + }; + + const unauthenticated = await app.inject({ + method: 'POST', + payload: { content: structuredDoc, kind: ContributionKind.LORE, title: 'Gate' }, + url: '/worlds/world-1/contributions', + }); + const forbidden = await app.inject({ + headers: authHeader(otherUser), + method: 'POST', + payload: { content: structuredDoc, kind: ContributionKind.LORE, title: 'Gate' }, + url: '/worlds/world-1/contributions', + }); + const invalidDoc = await app.inject({ + headers: authHeader(), + method: 'POST', + payload: { + content: { type: 'html', content: '

Nope

' }, + kind: ContributionKind.LORE, + title: 'Gate', + }, + url: '/worlds/world-1/contributions', + }); + const oversized = await app.inject({ + headers: authHeader(), + method: 'POST', + payload: { + content: oversizedDoc, + kind: ContributionKind.LORE, + title: 'Gate', + }, + url: '/worlds/world-1/contributions', + }); + const missingContent = await app.inject({ + headers: authHeader(), + method: 'POST', + payload: { + kind: ContributionKind.LORE, + title: 'Gate', + }, + url: '/worlds/world-1/contributions', + }); + const missingWorld = await app.inject({ + headers: authHeader(), + method: 'POST', + payload: { + content: structuredDoc, + kind: ContributionKind.LORE, + title: 'Gate', + }, + url: '/worlds/world-missing/contributions', + }); + const mismatchedTarget = await app.inject({ + headers: authHeader(), + method: 'POST', + payload: { + content: structuredDoc, + kind: ContributionKind.LORE, + targetLoreEntryId: 'lore-2', + title: 'Gate', + }, + url: '/worlds/world-1/contributions', + }); + + assert.equal(unauthenticated.statusCode, 401); + assert.equal(forbidden.statusCode, 403); + assert.equal(invalidDoc.statusCode, 400); + assert.equal(oversized.statusCode, 400); + assert.equal(missingContent.statusCode, 400); + assert.equal(missingWorld.statusCode, 404); + assert.equal(mismatchedTarget.statusCode, 400); + await app.close(); + }); + + test('author can list and read their draft while unauthorized routes do not reveal private data', async () => { + const state = createDatabase(); + state.memberships.find((membership) => membership.userId === otherUser.id)!.role = + WorldRole.CONTRIBUTOR; + state.memberships.push({ + id: 'membership-author-world-2', + revokedAt: null, + role: WorldRole.CONTRIBUTOR, + userId: author.id, + worldId: 'world-2', + }); + state.contributions.push(createContributionRecord({ id: 'draft-1' })); + state.contributions.push(createContributionRecord({ authorId: otherUser.id, id: 'draft-2' })); + const app = await createApp(state.database); + + const listResponse = await app.inject({ + headers: authHeader(), + method: 'GET', + url: '/worlds/world-1/contributions?status=DRAFT&kind=LORE&page=1&pageSize=1', + }); + const readResponse = await app.inject({ + headers: authHeader(), + method: 'GET', + url: '/worlds/world-1/contributions/draft-1', + }); + const otherReadResponse = await app.inject({ + headers: authHeader(otherUser), + method: 'GET', + url: '/worlds/world-1/contributions/draft-1', + }); + const wrongWorldResponse = await app.inject({ + headers: authHeader(), + method: 'GET', + url: '/worlds/world-2/contributions/draft-1', + }); + + assert.equal(listResponse.statusCode, 200); + assert.deepEqual(listResponse.json().pagination, { + page: 1, + pageSize: 1, + total: 1, + }); + assert.deepEqual( + listResponse.json().contributions.map((contribution: { id: string }) => contribution.id), + ['draft-1'], + ); + assert.equal(readResponse.statusCode, 200); + assert.equal(otherReadResponse.statusCode, 404); + assert.equal(wrongWorldResponse.statusCode, 404); + await app.close(); + }); + + test('author can update and delete draft, but protected fields and submitted rows are rejected', async () => { + const state = createDatabase(); + state.contributions.push(createContributionRecord({ id: 'draft-1' })); + state.contributions.push( + createContributionRecord({ + id: 'submitted-1', + proposalId: 'proposal-1', + status: ContributionStatus.SUBMITTED, + submittedAt: now(), + }), + ); + const app = await createApp(state.database); + + const updateResponse = await app.inject({ + headers: authHeader(), + method: 'PATCH', + payload: { + content: structuredDoc, + summary: 'Updated.', + targetLoreEntryId: null, + title: 'Updated Gate', + }, + url: '/worlds/world-1/contributions/draft-1', + }); + const protectedFieldResponse = await app.inject({ + headers: authHeader(), + method: 'PATCH', + payload: { + authorId: otherUser.id, + title: 'No', + }, + url: '/worlds/world-1/contributions/draft-1', + }); + const submittedUpdateResponse = await app.inject({ + headers: authHeader(), + method: 'PATCH', + payload: { title: 'No' }, + url: '/worlds/world-1/contributions/submitted-1', + }); + const invalidContentResponse = await app.inject({ + headers: authHeader(), + method: 'PATCH', + payload: { content: { type: 'doc', content: {} } }, + url: '/worlds/world-1/contributions/draft-1', + }); + const deleteResponse = await app.inject({ + headers: authHeader(), + method: 'DELETE', + url: '/worlds/world-1/contributions/draft-1', + }); + const submittedDeleteResponse = await app.inject({ + headers: authHeader(), + method: 'DELETE', + url: '/worlds/world-1/contributions/submitted-1', + }); + + assert.equal(updateResponse.statusCode, 200); + assert.equal(updateResponse.json().contribution.title, 'Updated Gate'); + assert.equal(updateResponse.json().contribution.targetLoreEntryId, null); + assert.equal(protectedFieldResponse.statusCode, 400); + assert.equal(submittedUpdateResponse.statusCode, 409); + assert.equal(invalidContentResponse.statusCode, 400); + assert.equal(deleteResponse.statusCode, 204); + assert.equal(submittedDeleteResponse.statusCode, 409); + await app.close(); + }); + + test('update and delete reject drafts that are submitted by a concurrent operation', async () => { + const updateState = createDatabase(); + updateState.contributions.push(createContributionRecord({ id: 'draft-update-race' })); + updateState.submitBeforeNextUpdateMany(); + + await assert.rejects( + updateContribution(updateState.database as never, { + authorId: author.id, + contributionId: 'draft-update-race', + title: 'Should Not Land', + worldId: 'world-1', + }), + (error: unknown) => + error instanceof Error && error.message === 'Submitted contributions cannot be edited.', + ); + + assert.equal(updateState.contributions[0]?.title, 'The Fall of the Northern Gate'); + assert.equal(updateState.contributions[0]?.status, ContributionStatus.SUBMITTED); + + const deleteState = createDatabase(); + deleteState.contributions.push(createContributionRecord({ id: 'draft-delete-race' })); + deleteState.submitBeforeNextDeleteMany(); + + await assert.rejects( + deleteContribution(deleteState.database as never, { + authorId: author.id, + contributionId: 'draft-delete-race', + worldId: 'world-1', + }), + (error: unknown) => + error instanceof Error && error.message === 'Submitted contributions cannot be deleted.', + ); + + assert.equal(deleteState.contributions.length, 1); + assert.equal(deleteState.contributions[0]?.status, ContributionStatus.SUBMITTED); + }); + + test('another user cannot update or delete an author private draft', async () => { + const state = createDatabase(); + state.memberships.find((membership) => membership.userId === otherUser.id)!.role = + WorldRole.CONTRIBUTOR; + state.contributions.push(createContributionRecord({ id: 'draft-1' })); + const app = await createApp(state.database); + + const updateResponse = await app.inject({ + headers: authHeader(otherUser), + method: 'PATCH', + payload: { title: 'No' }, + url: '/worlds/world-1/contributions/draft-1', + }); + const deleteResponse = await app.inject({ + headers: authHeader(otherUser), + method: 'DELETE', + url: '/worlds/world-1/contributions/draft-1', + }); + + assert.equal(updateResponse.statusCode, 404); + assert.equal(deleteResponse.statusCode, 404); + await app.close(); + }); + + test('valid draft submission creates exactly one proposal and locks immutable proposed content', async () => { + const state = createDatabase(); + state.contributions.push(createContributionRecord({ id: 'draft-1' })); + const app = await createApp(state.database); + + const submitResponse = await app.inject({ + headers: authHeader(), + method: 'POST', + url: '/worlds/world-1/contributions/draft-1/submit', + }); + const duplicateResponse = await app.inject({ + headers: authHeader(), + method: 'POST', + url: '/worlds/world-1/contributions/draft-1/submit', + }); + + assert.equal(submitResponse.statusCode, 201); + assert.equal(duplicateResponse.statusCode, 200); + assert.equal(state.proposals.length, 1); + assert.equal(state.contributions[0]?.status, ContributionStatus.SUBMITTED); + assert.equal(state.contributions[0]?.proposalId, 'proposal-1'); + assert.equal(submitResponse.json().proposal.authorId, author.id); + assert.equal(submitResponse.json().proposal.status, ProposalStatus.SUBMITTED); + assert.equal(submitResponse.json().proposal.proposalType, ProposalType.UPDATE_LORE); + assert.deepEqual(submitResponse.json().proposal.proposedContent, structuredDoc); + assert.equal( + state.auditLogs.some( + (entry) => + (entry as { action?: WorldAuditAction }).action === + WorldAuditAction.CONTRIBUTION_SUBMITTED, + ), + true, + ); + await app.close(); + }); + + test('create rollback leaves no contribution when audit logging fails', async () => { + const state = createDatabase(); + state.failNextAuditCreate(); + + await assert.rejects( + createContribution(state.database as never, { + authorId: author.id, + content: structuredDoc, + kind: ContributionKind.LORE, + title: 'Rollback Draft', + worldId: 'world-1', + }), + ); + + assert.equal(state.contributions.length, 0); + assert.equal(state.auditLogs.length, 0); + }); + + test('story draft submission creates an ADD_STORY proposal', async () => { + const state = createDatabase(); + state.contributions.push( + createContributionRecord({ + id: 'story-1', + kind: ContributionKind.STORY, + targetLoreEntryId: null, + }), + ); + const app = await createApp(state.database); + + const response = await app.inject({ + headers: authHeader(), + method: 'POST', + url: '/worlds/world-1/contributions/story-1/submit', + }); + + assert.equal(response.statusCode, 201); + assert.equal(response.json().proposal.proposalType, ProposalType.ADD_STORY); + assert.equal(response.json().proposal.contributionKind, ContributionKind.STORY); + await app.close(); + }); + + test('submission rejects empty content, missing submit permission, and mismatched target lore', async () => { + const state = createDatabase(); + state.contributions.push(createContributionRecord({ content: emptyDoc, id: 'empty-1' })); + state.contributions.push( + createContributionRecord({ id: 'mismatch-1', targetLoreEntryId: 'lore-2' }), + ); + const app = await createApp(state.database); + + const emptyResponse = await app.inject({ + headers: authHeader(), + method: 'POST', + url: '/worlds/world-1/contributions/empty-1/submit', + }); + const forbiddenResponse = await app.inject({ + headers: authHeader(otherUser), + method: 'POST', + url: '/worlds/world-1/contributions/empty-1/submit', + }); + const mismatchResponse = await app.inject({ + headers: authHeader(), + method: 'POST', + url: '/worlds/world-1/contributions/mismatch-1/submit', + }); + + assert.equal(emptyResponse.statusCode, 400); + assert.equal(forbiddenResponse.statusCode, 403); + assert.equal(mismatchResponse.statusCode, 400); + assert.equal(state.proposals.length, 0); + await app.close(); + }); + + test('concurrent submissions and rollback do not create duplicate or partial proposal state', async () => { + const concurrentState = createDatabase(); + concurrentState.contributions.push(createContributionRecord({ id: 'draft-1' })); + const [first, second] = await Promise.allSettled([ + submitContribution(concurrentState.database as never, { + authorId: author.id, + contributionId: 'draft-1', + worldId: 'world-1', + }), + submitContribution(concurrentState.database as never, { + authorId: author.id, + contributionId: 'draft-1', + worldId: 'world-1', + }), + ]); + + assert.equal(concurrentState.proposals.length, 1); + assert.equal( + [first, second].filter((result) => result.status === 'fulfilled').length >= 1, + true, + ); + + const rollbackState = createDatabase(); + rollbackState.contributions.push(createContributionRecord({ id: 'draft-rollback' })); + rollbackState.failNextProposalCreate(); + + await assert.rejects( + submitContribution(rollbackState.database as never, { + authorId: author.id, + contributionId: 'draft-rollback', + worldId: 'world-1', + }), + ); + + assert.equal(rollbackState.proposals.length, 0); + assert.equal(rollbackState.contributions[0]?.status, ContributionStatus.DRAFT); + assert.equal(rollbackState.contributions[0]?.proposalId, null); + }); +}); diff --git a/apps/api/src/routes/contributions.ts b/apps/api/src/routes/contributions.ts new file mode 100644 index 0000000..8229b6c --- /dev/null +++ b/apps/api/src/routes/contributions.ts @@ -0,0 +1,430 @@ +import type { FastifyInstance, FastifyReply } from 'fastify'; +import { z } from 'zod'; + +import { env } from '../config/env.js'; +import { ContributionKind, ContributionStatus } from '../generated/prisma/enums.js'; +import { requireSession } from '../lib/auth-middleware.js'; +import { + ContributionError, + createContribution, + deleteContribution, + getOwnedContribution, + listOwnedContributions, + submitContribution, + updateContribution, + type ContributionDatabase, +} from '../lib/contributions.js'; +import { prisma } from '../lib/prisma.js'; +import { authorizeWorldPermission } from '../lib/world-authorization.js'; +import { WORLD_PERMISSIONS } from '../lib/world-permissions.js'; +import type { WorldMembershipLookup } from '../lib/world-authorization.js'; + +const routeParamsSchema = z.object({ + contributionId: z.string().min(1).optional(), + worldId: z.string().min(1), +}); + +const contributionKindSchema = z.enum([ContributionKind.LORE, ContributionKind.STORY]); +const contributionStatusSchema = z.enum([ContributionStatus.DRAFT, ContributionStatus.SUBMITTED]); + +const createContributionBodySchema = z + .object({ + content: z.unknown(), + kind: contributionKindSchema, + summary: z.string().trim().max(1_000).optional(), + targetLoreEntryId: z.string().trim().min(1).optional(), + title: z.string().trim().min(1).max(200), + }) + .strict(); + +const updateContributionBodySchema = z + .object({ + content: z.unknown().optional(), + kind: contributionKindSchema.optional(), + summary: z.string().trim().max(1_000).nullable().optional(), + targetLoreEntryId: z.string().trim().min(1).nullable().optional(), + title: z.string().trim().min(1).max(200).optional(), + }) + .strict() + .refine((value) => Object.keys(value).length > 0, { + message: 'At least one editable contribution field is required.', + }); + +const listContributionsQuerySchema = z.object({ + kind: contributionKindSchema.optional(), + page: z.coerce.number().int().positive().default(1), + pageSize: z.coerce.number().int().positive().max(100).default(20), + status: contributionStatusSchema.optional(), +}); + +type RegisterContributionRoutesOptions = { + database?: ContributionDatabase & WorldMembershipLookup; +}; + +function authOptions() { + return { + audience: env.AUTH_JWT_AUDIENCE, + issuer: env.AUTH_JWT_ISSUER, + jwtSecret: env.AUTH_JWT_SECRET, + }; +} + +function handleContributionError(error: unknown, reply: FastifyReply) { + if (error instanceof ContributionError) { + return reply.code(error.statusCode).send({ + error: error.message, + }); + } + + throw error; +} + +async function ensureWorldExists( + database: Pick, + worldId: string, + reply: FastifyReply, +) { + const world = await database.world.findUnique({ + select: { + id: true, + }, + where: { + id: worldId, + }, + }); + + if (!world) { + await reply.code(404).send({ + error: 'World not found.', + }); + + return false; + } + + return true; +} + +export async function registerContributionRoutes( + app: FastifyInstance, + options: RegisterContributionRoutesOptions = {}, +) { + const database = options.database ?? prisma; + + app.post( + '/worlds/:worldId/contributions', + { + preHandler: requireSession(authOptions()), + }, + async (request, reply) => { + const params = routeParamsSchema.safeParse(request.params); + const body = createContributionBodySchema.safeParse(request.body); + + if (!params.success || !body.success) { + return reply.code(400).send({ + error: 'Invalid contribution payload.', + }); + } + + const worldExists = await ensureWorldExists(database, params.data.worldId, reply); + + if (!worldExists) { + return; + } + + const authorized = await authorizeWorldPermission( + request, + reply, + params.data.worldId, + WORLD_PERMISSIONS.CREATE_LORE_DRAFT, + database, + ); + + if (!authorized) { + return; + } + + if (!request.user) { + return reply.code(401).send({ + error: 'Authentication required.', + }); + } + + try { + const contribution = await createContribution(database, { + ...body.data, + authorId: request.user.id, + worldId: params.data.worldId, + }); + + return reply.code(201).send({ + contribution, + }); + } catch (error) { + return handleContributionError(error, reply); + } + }, + ); + + app.get( + '/worlds/:worldId/contributions', + { + preHandler: requireSession(authOptions()), + }, + async (request, reply) => { + const params = routeParamsSchema.safeParse(request.params); + const query = listContributionsQuerySchema.safeParse(request.query); + + if (!params.success || !query.success) { + return reply.code(400).send({ + error: 'Invalid contributions query.', + }); + } + + const worldExists = await ensureWorldExists(database, params.data.worldId, reply); + + if (!worldExists) { + return; + } + + const authorized = await authorizeWorldPermission( + request, + reply, + params.data.worldId, + WORLD_PERMISSIONS.CREATE_LORE_DRAFT, + database, + ); + + if (!authorized) { + return; + } + + if (!request.user) { + return reply.code(401).send({ + error: 'Authentication required.', + }); + } + + return listOwnedContributions(database, { + ...query.data, + authorId: request.user.id, + worldId: params.data.worldId, + }); + }, + ); + + app.get( + '/worlds/:worldId/contributions/:contributionId', + { + preHandler: requireSession(authOptions()), + }, + async (request, reply) => { + const params = routeParamsSchema.required().safeParse(request.params); + + if (!params.success) { + return reply.code(400).send({ + error: 'Invalid contribution route.', + }); + } + + const worldExists = await ensureWorldExists(database, params.data.worldId, reply); + + if (!worldExists) { + return; + } + + const authorized = await authorizeWorldPermission( + request, + reply, + params.data.worldId, + WORLD_PERMISSIONS.CREATE_LORE_DRAFT, + database, + ); + + if (!authorized) { + return; + } + + if (!request.user) { + return reply.code(401).send({ + error: 'Authentication required.', + }); + } + + const contribution = await getOwnedContribution(database, { + authorId: request.user.id, + contributionId: params.data.contributionId, + worldId: params.data.worldId, + }); + + if (!contribution) { + return reply.code(404).send({ + error: 'Contribution not found.', + }); + } + + return { + contribution, + }; + }, + ); + + app.patch( + '/worlds/:worldId/contributions/:contributionId', + { + preHandler: requireSession(authOptions()), + }, + async (request, reply) => { + const params = routeParamsSchema.required().safeParse(request.params); + const body = updateContributionBodySchema.safeParse(request.body); + + if (!params.success || !body.success) { + return reply.code(400).send({ + error: 'Invalid contribution payload.', + }); + } + + const worldExists = await ensureWorldExists(database, params.data.worldId, reply); + + if (!worldExists) { + return; + } + + const authorized = await authorizeWorldPermission( + request, + reply, + params.data.worldId, + WORLD_PERMISSIONS.EDIT_OWN_DRAFT, + database, + ); + + if (!authorized) { + return; + } + + if (!request.user) { + return reply.code(401).send({ + error: 'Authentication required.', + }); + } + + try { + const contribution = await updateContribution(database, { + ...body.data, + authorId: request.user.id, + contributionId: params.data.contributionId, + worldId: params.data.worldId, + }); + + return { + contribution, + }; + } catch (error) { + return handleContributionError(error, reply); + } + }, + ); + + app.delete( + '/worlds/:worldId/contributions/:contributionId', + { + preHandler: requireSession(authOptions()), + }, + async (request, reply) => { + const params = routeParamsSchema.required().safeParse(request.params); + + if (!params.success) { + return reply.code(400).send({ + error: 'Invalid contribution route.', + }); + } + + const worldExists = await ensureWorldExists(database, params.data.worldId, reply); + + if (!worldExists) { + return; + } + + const authorized = await authorizeWorldPermission( + request, + reply, + params.data.worldId, + WORLD_PERMISSIONS.EDIT_OWN_DRAFT, + database, + ); + + if (!authorized) { + return; + } + + if (!request.user) { + return reply.code(401).send({ + error: 'Authentication required.', + }); + } + + try { + await deleteContribution(database, { + authorId: request.user.id, + contributionId: params.data.contributionId, + worldId: params.data.worldId, + }); + + return reply.code(204).send(); + } catch (error) { + return handleContributionError(error, reply); + } + }, + ); + + app.post( + '/worlds/:worldId/contributions/:contributionId/submit', + { + preHandler: requireSession(authOptions()), + }, + async (request, reply) => { + const params = routeParamsSchema.required().safeParse(request.params); + + if (!params.success) { + return reply.code(400).send({ + error: 'Invalid contribution route.', + }); + } + + const worldExists = await ensureWorldExists(database, params.data.worldId, reply); + + if (!worldExists) { + return; + } + + const authorized = await authorizeWorldPermission( + request, + reply, + params.data.worldId, + WORLD_PERMISSIONS.SUBMIT_PROPOSAL, + database, + ); + + if (!authorized) { + return; + } + + if (!request.user) { + return reply.code(401).send({ + error: 'Authentication required.', + }); + } + + try { + const result = await submitContribution(database, { + authorId: request.user.id, + contributionId: params.data.contributionId, + worldId: params.data.worldId, + }); + + return reply.code(result.alreadySubmitted ? 200 : 201).send(result); + } catch (error) { + return handleContributionError(error, reply); + } + }, + ); +} From a0ab1850f3b18c0319d7b693be4efe0753127e7a Mon Sep 17 00:00:00 2001 From: Kareem Naous <62202089+KareemN1936@users.noreply.github.com> Date: Thu, 6 Aug 2026 01:06:36 +0300 Subject: [PATCH 07/22] feat(worlds): connect frontend to world seed backend --- apps/web/src/app/page.tsx | 6 + apps/web/src/app/worlds/[worldId]/page.tsx | 332 +++++++++++++++++- apps/web/src/app/worlds/new/page.tsx | 369 ++++++++++++++++++++- apps/web/src/app/worlds/page.tsx | 284 +++++++++++++++- apps/web/src/lib/api/session.ts | 27 ++ apps/web/src/lib/api/worlds.ts | 127 +++++++ apps/web/src/lib/worlds/constants.ts | 27 ++ 7 files changed, 1166 insertions(+), 6 deletions(-) create mode 100644 apps/web/src/lib/api/session.ts create mode 100644 apps/web/src/lib/api/worlds.ts create mode 100644 apps/web/src/lib/worlds/constants.ts diff --git a/apps/web/src/app/page.tsx b/apps/web/src/app/page.tsx index af50030..6d9ef90 100644 --- a/apps/web/src/app/page.tsx +++ b/apps/web/src/app/page.tsx @@ -18,6 +18,7 @@ import { type SafeUser, } from '@/lib/api/auth'; import { ApiError } from '@/lib/api/errors'; +import { clearStoredAccessToken, storeAccessToken } from '@/lib/api/session'; import { env } from '@/lib/env'; type HiveKeychainResponse = { @@ -98,6 +99,7 @@ export default function Home() { return; } + storeAccessToken(session.accessToken); setUser(session.user); }) .catch(() => { @@ -125,6 +127,7 @@ export default function Home() { ...(publicKey ? { publicKey } : {}), }); + storeAccessToken(session.accessToken); setUser(session.user); setChallenge(null); setManualSignature(''); @@ -186,9 +189,11 @@ export default function Home() { try { const session = await refreshAuthSession(); + storeAccessToken(session.accessToken); setUser(session.user); await getMe(session.accessToken); } catch (nextError) { + clearStoredAccessToken(); setUser(null); setError(getErrorMessage(nextError)); } finally { @@ -202,6 +207,7 @@ export default function Home() { try { await logoutAuthSession(); + clearStoredAccessToken(); setUser(null); } catch (nextError) { setError(getErrorMessage(nextError)); diff --git a/apps/web/src/app/worlds/[worldId]/page.tsx b/apps/web/src/app/worlds/[worldId]/page.tsx index 64454ea..74faa93 100644 --- a/apps/web/src/app/worlds/[worldId]/page.tsx +++ b/apps/web/src/app/worlds/[worldId]/page.tsx @@ -1,11 +1,339 @@ -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 { Button } from '@/components/ui/button'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; +import { ApiError } from '@/lib/api/errors'; +import { getWorldHub, type WorldHub } from '@/lib/api/worlds'; +import { loreCategories } from '@/lib/worlds/constants'; type WorldPageProps = { params: Promise<{ worldId: string }>; }; +function getBibleRules(content: unknown) { + if (!content || typeof content !== 'object' || Array.isArray(content)) { + return []; + } + + const rules = (content as { rules?: unknown }).rules; + + if (Array.isArray(rules)) { + return rules.filter( + (rule): rule is string => typeof rule === 'string' && rule.trim().length > 0, + ); + } + + const guidance = (content as { guidance?: unknown }).guidance; + + return typeof guidance === 'string' && guidance.trim() ? [guidance.trim()] : []; +} + +async function loadWorldHub(worldId: string) { + try { + return await getWorldHub(worldId); + } catch (error) { + if (error instanceof ApiError && error.status === 404) { + notFound(); + } + + throw error; + } +} + +function WorldHubContent({ hub }: { hub: WorldHub }) { + const { latestLoreEntries, stats, world } = hub; + const seed = world.seed; + const bibleRules = getBibleRules(world.currentBibleVersion?.content); + + return ( +
+
+
+
+ {seed?.genre ?? 'World'} + {stats.canonLoreCount} canon entries + + {stats.activeProposalCount} active proposals + +
+

+ {world.title} +

+

{world.description}

+
+
+
Tone
+
{seed?.tone ?? 'Not set'}
+
+
+
Founder
+
@{world.founder.hiveUsername}
+
+
+
Main conflict
+
{seed?.mainConflict ?? 'Not set'}
+
+
+
+ + + Next actions + Read, vote, or contribute inside this world. + + + + Create Lore Entry + + + Write Story Contribution + +
+ + +
+
+
+
+ +
+ + + World Seed + Foundation lore accepted when the world was created. + + +
+
+
Premise
+
{seed?.premise ?? 'Not set'}
+
+
+
Starting location
+
{seed?.startingLocation ?? 'Not set'}
+
+
+
+ First historical event +
+
{seed?.firstHistoricalEvent ?? 'Not set'}
+
+
+
Bible version
+
+ {world.currentBibleVersion + ? `Version ${world.currentBibleVersion.versionNumber}` + : 'Not set'} +
+
+
+
First characters
+
+ {(seed?.firstCharacters.length ? seed.firstCharacters : ['None yet']).map( + (character) => ( + {character} + ), + )} +
+
+
+
First factions
+
+ {(seed?.firstFactions.length ? seed.firstFactions : ['None yet']).map( + (faction) => ( + {faction} + ), + )} +
+
+
+
+
+ + + + Canon status + Quick health read before users contribute. + + +
+
+
Canon entries
+
{stats.canonLoreCount}
+
+

+ Published canon indexed for this world. +

+
+
+
+
Proposal queue
+
{stats.activeProposalCount}
+
+

+ Submitted or voting proposals currently active. +

+
+
+
+
+ +
+ + + World Codex preview + Contributor-facing rules that ground AI checks. + + + {bibleRules.length ? ( +
    + {bibleRules.map((rule) => ( +
  • +
  • + ))} +
+ ) : ( +

+ This world bible does not expose readable rules yet. +

+ )} +
+
+ + + + Ask about this world + + AI guide panel placeholder for summaries, contradictions, and contribution guidance. + + + + + + +
+ +
+

+ Canon entries +

+ + + {loreCategories.map((category) => ( + + {category.label} + + ))} + + {loreCategories.map((category) => ( + + + +
+
+

{category.label}

+

+ Entity CRUD is the next M2 card; this hub is ready to receive those entries. +

+
+ + Add {category.label} + +
+
+
+
+ ))} +
+
+ +
+
+
+

Latest canon

+

+ Published entries returned by the world hub endpoint. +

+
+
+ {latestLoreEntries.length ? ( + latestLoreEntries.map((entry) => ( + + +
+ {entry.loreType.replaceAll('_', ' ')} + {entry.status.replaceAll('_', ' ')} +
+ {entry.title} +
+ +

+ Updated {new Date(entry.updatedAt).toLocaleDateString()}. +

+ + Open entry + +
+
+ )) + ) : ( + + No canon entries yet + + Create the first structured lore contribution to start filling this hub. + + + )} +
+
+ +
+
+

Pending proposals

+

+ Contributions that need review or votes. +

+
+ + +

+ {stats.activeProposalCount + ? `${stats.activeProposalCount} active proposals are available through the backend summary.` + : 'No active proposals. This is a good moment to add structured lore.'} +

+
+
+
+
+
+ ); +} + export default async function WorldPage({ params }: WorldPageProps) { const { worldId } = await params; + const hub = await loadWorldHub(worldId); - return ; + return ; } diff --git a/apps/web/src/app/worlds/new/page.tsx b/apps/web/src/app/worlds/new/page.tsx index d8cb39b..b5cebf9 100644 --- a/apps/web/src/app/worlds/new/page.tsx +++ b/apps/web/src/app/worlds/new/page.tsx @@ -1,5 +1,370 @@ -import { PlaceholderPage } from '@/components/layout/placeholder-page'; +'use client'; + +import Link from 'next/link'; +import { useRouter } from 'next/navigation'; +import { type FormEvent, useMemo, useState } from 'react'; + +import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Input } from '@/components/ui/input'; +import { Select } from '@/components/ui/select'; +import { Textarea } from '@/components/ui/textarea'; +import { ApiError } from '@/lib/api/errors'; +import { getStoredAccessToken } from '@/lib/api/session'; +import { createWorld } from '@/lib/api/worlds'; + +const starterCharacters = ['Founder heir', 'Rival witness', 'Guide or chronicler']; +const starterFactions = ['Founder circle', 'Opposing faction', 'Neutral civic group']; + +function linesToList(value: string) { + return value + .split('\n') + .map((line) => line.trim()) + .filter(Boolean); +} + +function getErrorMessage(error: unknown) { + if (error instanceof ApiError) { + return error.body?.error ?? 'Unable to create world.'; + } + + if (error instanceof Error) { + return error.message; + } + + return 'Unable to create world.'; +} export default function NewWorldPage() { - return ; + const router = useRouter(); + const [description, setDescription] = useState(''); + const [error, setError] = useState(null); + const [firstCharacters, setFirstCharacters] = useState(''); + const [firstFactions, setFirstFactions] = useState(''); + const [firstHistoricalEvent, setFirstHistoricalEvent] = useState(''); + const [genre, setGenre] = useState('Political Fantasy'); + const [isSubmitting, setIsSubmitting] = useState(false); + const [mainConflict, setMainConflict] = useState(''); + const [premise, setPremise] = useState(''); + const [startingLocation, setStartingLocation] = useState(''); + const [title, setTitle] = useState(''); + const [tone, setTone] = useState(''); + const [worldRules, setWorldRules] = useState(''); + + const canSubmit = useMemo( + () => + title.trim() && + description.trim() && + premise.trim() && + genre.trim() && + tone.trim() && + mainConflict.trim() && + worldRules.trim() && + !isSubmitting, + [description, genre, isSubmitting, mainConflict, premise, title, tone, worldRules], + ); + + async function handleSubmit(event: FormEvent) { + event.preventDefault(); + setError(null); + + const accessToken = getStoredAccessToken(); + + if (!accessToken) { + setError('Please sign in before creating a world.'); + return; + } + + setIsSubmitting(true); + + try { + const response = await createWorld( + { + bible: { + changeSummary: 'Initial world bible.', + content: { + guidance: worldRules.trim(), + rules: linesToList(worldRules), + seed: { + firstCharacters: linesToList(firstCharacters), + firstFactions: linesToList(firstFactions), + firstHistoricalEvent: firstHistoricalEvent.trim() || null, + mainConflict: mainConflict.trim(), + premise: premise.trim(), + startingLocation: startingLocation.trim() || null, + }, + }, + }, + description: description.trim(), + seed: { + firstCharacters: linesToList(firstCharacters), + firstFactions: linesToList(firstFactions), + ...(firstHistoricalEvent.trim() + ? { firstHistoricalEvent: firstHistoricalEvent.trim() } + : {}), + genre: genre.trim(), + mainConflict: mainConflict.trim(), + premise: premise.trim(), + ...(startingLocation.trim() ? { startingLocation: startingLocation.trim() } : {}), + tone: tone.trim(), + }, + title: title.trim(), + }, + accessToken, + ); + + router.push(`/worlds/${response.world.id}`); + } catch (nextError) { + setError(getErrorMessage(nextError)); + } finally { + setIsSubmitting(false); + } + } + + return ( +
+
+
+

+ Founder setup +

+

+ Create a world seed and bible. +

+

+ Give contributors enough structure to understand the world, write inside its rules, and + start building canon through the community loop. +

+
+ + + Starter canon + + The founder creates the first layer, then canon decisions move through votes and AI + checks. + + + +
+ + {error ? ( + + World was not created + {error} + + ) : null} + +
+
+ + + World Seed + + Structured fields for the world identity, first conflict, and initial lore anchors. + + + +
+ + + + +