Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
129 changes: 129 additions & 0 deletions apps/web/src/app/api/prophecies/[chapterId]/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
import { NextResponse } from 'next/server'
import { prisma } from '@voidborne/database'

/**
* GET /api/prophecies/[chapterId]
* Get all prophecies for a specific chapter, with mint status.
*/
export async function GET(
_request: Request,
{ params }: { params: { chapterId: string } }
) {
try {
const { chapterId } = params

const chapter = await prisma.chapter.findUnique({
where: { id: chapterId },
select: {
id: true,
chapterNumber: true,
title: true,
storyId: true,
story: { select: { id: true, title: true } },
prophecies: {
orderBy: { createdAt: 'asc' },
include: {
_count: { select: { mints: true } },
},
},
},
})

if (!chapter) {
return NextResponse.json({ error: 'Chapter not found' }, { status: 404 })
}

const shaped = chapter.prophecies.map((p) => ({
id: p.id,
chapterId: p.chapterId,
teaser: p.teaser,
contentHash: p.contentHash,
pendingURI: p.pendingURI,
fulfilledURI: p.fulfilledURI,
echoedURI: p.echoedURI,
unfulfilledURI: p.unfulfilledURI,
status: p.status,
revealed: p.revealed,
revealedAt: p.revealedAt,
// Only expose text after reveal
text: p.revealed ? p.text : null,
artTheme: p.artTheme,
mintedCount: p._count.mints,
maxSupply: p.maxSupply,
spotsRemaining: p.maxSupply - p._count.mints,
createdAt: p.createdAt,
fulfilledAt: p.fulfilledAt,
}))

const summary = {
total: shaped.length,
totalMinted: shaped.reduce((s, p) => s + p.mintedCount, 0),
fulfilled: shaped.filter((p) => p.status === 'FULFILLED').length,
echoed: shaped.filter((p) => p.status === 'ECHOED').length,
unfulfilled: shaped.filter((p) => p.status === 'UNFULFILLED').length,
pending: shaped.filter((p) => p.status === 'PENDING').length,
}

return NextResponse.json({
chapter: {
id: chapter.id,
chapterNumber: chapter.chapterNumber,
title: chapter.title,
storyId: chapter.storyId,
story: chapter.story,
},
prophecies: shaped,
summary,
})
} catch (err) {
console.error('[GET /api/prophecies/[chapterId]]', err)
return NextResponse.json({ error: 'Failed to fetch chapter prophecies' }, { status: 500 })
}
}

/**
* PATCH /api/prophecies/[chapterId]
* Fulfill chapter prophecies after resolution (oracle only).
* Body: { outcomes: [{ prophecyId, status, metadataURI, explanation }], chapterSummary }
*/
export async function PATCH(
request: Request,
{ params }: { params: { chapterId: string } }
) {
try {
const body = await request.json()
const { outcomes, chapterSummary } = body

if (!Array.isArray(outcomes) || outcomes.length === 0) {
return NextResponse.json({ error: 'outcomes[] required' }, { status: 400 })
}

// Update all prophecies in one transaction
const updated = await prisma.$transaction(
outcomes.map((o: {
prophecyId: string
status: 'FULFILLED' | 'ECHOED' | 'UNFULFILLED'
metadataURI?: string
}) =>
prisma.prophecy.update({
where: { id: o.prophecyId },
data: {
status: o.status,
fulfilledAt: new Date(),
...(o.status === 'FULFILLED' ? { fulfilledURI: o.metadataURI } : {}),
...(o.status === 'ECHOED' ? { echoedURI: o.metadataURI } : {}),
...(o.status === 'UNFULFILLED' ? { unfulfilledURI: o.metadataURI } : {}),
},
})
)
)

return NextResponse.json({
updated: updated.length,
chapterSummary,
})
} catch (err) {
console.error('[PATCH /api/prophecies/[chapterId]]', err)
return NextResponse.json({ error: 'Failed to fulfill prophecies' }, { status: 500 })
}
}
85 changes: 85 additions & 0 deletions apps/web/src/app/api/prophecies/leaderboard/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import { NextResponse } from 'next/server'
import { prisma } from '@voidborne/database'

/**
* GET /api/prophecies/leaderboard
* Oracle leaderboard — top collectors ranked by fulfilled prophecy count.
* ?limit=20
*/
export async function GET(request: Request) {
try {
const { searchParams } = new URL(request.url)
const limit = Math.min(parseInt(searchParams.get('limit') ?? '20', 10), 100)

// Aggregate mints by user with prophecy status breakdown
const rawData = await prisma.prophecyMint.groupBy({
by: ['userId', 'walletAddress'],
_count: { id: true },
_sum: { forgePaid: true },
})

if (rawData.length === 0) {
return NextResponse.json({ leaderboard: [], updatedAt: new Date() })
}

// Enrich with per-status counts
const enriched = await Promise.all(
rawData.map(async (row) => {
const [fulfilled, echoed, unfulfilled, pending] = await Promise.all([
prisma.prophecyMint.count({
where: { userId: row.userId, prophecy: { status: 'FULFILLED' } },
}),
prisma.prophecyMint.count({
where: { userId: row.userId, prophecy: { status: 'ECHOED' } },
}),
prisma.prophecyMint.count({
where: { userId: row.userId, prophecy: { status: 'UNFULFILLED' } },
}),
prisma.prophecyMint.count({
where: { userId: row.userId, prophecy: { status: 'PENDING' } },
}),
])

const total = row._count.id
const user = await prisma.user.findUnique({
where: { id: row.userId },
select: { username: true },
})

return {
userId: row.userId,
walletAddress: row.walletAddress,
displayName: user?.username ?? null,
total,
fulfilled,
echoed,
unfulfilled,
pending,
fulfillmentRate: total > 0 ? Math.round((fulfilled / total) * 100) : 0,
totalForgePaid: Number(row._sum.forgePaid ?? 0),
estimatedPortfolioValue: fulfilled * 50 + echoed * 15 + unfulfilled * 5,
rank: computeOracleRank(fulfilled),
}
})
)

// Sort by fulfilled count desc, then total mints desc
const sorted = enriched
.sort((a, b) => b.fulfilled - a.fulfilled || b.total - a.total)
.slice(0, limit)
.map((entry, index) => ({ ...entry, position: index + 1 }))

return NextResponse.json({ leaderboard: sorted, updatedAt: new Date() })
} catch (err) {
console.error('[GET /api/prophecies/leaderboard]', err)
return NextResponse.json({ error: 'Failed to fetch leaderboard' }, { status: 500 })
}
}

function computeOracleRank(fulfilled: number): string {
if (fulfilled >= 50) return 'VOID_EYE'
if (fulfilled >= 25) return 'PROPHET'
if (fulfilled >= 10) return 'ORACLE'
if (fulfilled >= 3) return 'SEER'
return 'NOVICE'
}
Loading