No pending fund applications.
+ )
+ }
+
+ return (
+
-
Hedge Funds
-
+
+
+
+ Fund Applications
+ {applications.length > 0 && (
+
+ {applications.length}
+
+ )}
+
+
+
+
+
)
}
diff --git a/src/app/api/admin/fund-applications/[applicationId]/route.ts b/src/app/api/admin/fund-applications/[applicationId]/route.ts
new file mode 100644
index 0000000..4593f07
--- /dev/null
+++ b/src/app/api/admin/fund-applications/[applicationId]/route.ts
@@ -0,0 +1,100 @@
+import { NextRequest, NextResponse } from 'next/server'
+import { getServerSession } from 'next-auth'
+import { authOptions } from '@/lib/auth'
+import { prisma } from '@/lib/prisma'
+import { z } from 'zod'
+import bcrypt from 'bcryptjs'
+
+function toSlug(name: string) {
+ return name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '')
+}
+
+const approveSchema = z.object({
+ action: z.literal('approve'),
+ startingBalance: z.number().min(0).default(0),
+})
+
+const denySchema = z.object({
+ action: z.literal('deny'),
+})
+
+/**
+ * POST /api/admin/fund-applications/[applicationId]
+ * action: 'approve' — creates the fund, adds applicant as manager, deletes the application
+ * action: 'deny' — deletes the application
+ */
+export async function POST(
+ req: NextRequest,
+ { params }: { params: { applicationId: string } }
+) {
+ const session = await getServerSession(authOptions)
+ if (!session?.user?.isAdmin) {
+ return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
+ }
+
+ const application = await prisma.fundApplication.findUnique({
+ where: { id: params.applicationId },
+ include: { user: { select: { id: true, username: true } } },
+ })
+ if (!application) {
+ return NextResponse.json({ error: 'Application not found' }, { status: 404 })
+ }
+
+ const body = await req.json()
+
+ if (body.action === 'deny') {
+ await prisma.fundApplication.delete({ where: { id: params.applicationId } })
+ return NextResponse.json({ ok: true })
+ }
+
+ const parsed = approveSchema.safeParse(body)
+ if (!parsed.success) {
+ return NextResponse.json({ error: parsed.error.errors[0]?.message ?? 'Invalid input' }, { status: 400 })
+ }
+
+ const { startingBalance } = parsed.data
+ const name = application.fundName
+ const slug = toSlug(name)
+ const shadowUsername = `fund:${slug}`
+
+ // Check for conflicts
+ const [existingFund, existingSlug, existingUser] = await Promise.all([
+ prisma.hedgeFund.findFirst({ where: { name: { equals: name, mode: 'insensitive' } } }),
+ prisma.hedgeFund.findUnique({ where: { slug } }),
+ prisma.user.findUnique({ where: { username: shadowUsername } }),
+ ])
+
+ if (existingFund) return NextResponse.json({ error: 'A fund with that name already exists.' }, { status: 409 })
+ if (existingSlug) return NextResponse.json({ error: 'A fund with that slug already exists.' }, { status: 409 })
+ if (existingUser) return NextResponse.json({ error: 'Shadow user conflict.' }, { status: 409 })
+
+ const fund = await prisma.$transaction(async (tx) => {
+ const shadowUser = await tx.user.create({
+ data: {
+ username: shadowUsername,
+ displayUsername: name,
+ passwordHash: await bcrypt.hash(crypto.randomUUID(), 10),
+ balance: startingBalance,
+ isFund: true,
+ },
+ })
+
+ const newFund = await tx.hedgeFund.create({
+ data: { name, slug, userId: shadowUser.id, sharesOutstanding: 0 },
+ include: {
+ user: { select: { balance: true } },
+ managers: { include: { user: { select: { id: true, username: true, displayUsername: true } } } },
+ },
+ })
+
+ await tx.fundManager.create({
+ data: { fundId: newFund.id, userId: application.userId },
+ })
+
+ await tx.fundApplication.delete({ where: { id: application.id } })
+
+ return { ...newFund, managers: [{ id: 'new', userId: application.userId, user: application.user }] }
+ })
+
+ return NextResponse.json(fund, { status: 201 })
+}
diff --git a/src/app/api/fund-applications/route.ts b/src/app/api/fund-applications/route.ts
new file mode 100644
index 0000000..62aad6c
--- /dev/null
+++ b/src/app/api/fund-applications/route.ts
@@ -0,0 +1,64 @@
+import { NextRequest, NextResponse } from 'next/server'
+import { getServerSession } from 'next-auth'
+import { authOptions } from '@/lib/auth'
+import { prisma } from '@/lib/prisma'
+import { z } from 'zod'
+
+const submitSchema = z.object({
+ fundName: z.string().min(1).max(60),
+ reason: z.string().min(10).max(1000),
+})
+
+/**
+ * GET /api/fund-applications
+ * Returns the current user's pending application, or null.
+ */
+export async function GET() {
+ const session = await getServerSession(authOptions)
+ if (!session) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
+
+ const application = await prisma.fundApplication.findUnique({
+ where: { userId: session.user.id },
+ })
+
+ return NextResponse.json(application)
+}
+
+/**
+ * POST /api/fund-applications
+ * Submit a fund application. One per user at a time.
+ */
+export async function POST(req: NextRequest) {
+ const session = await getServerSession(authOptions)
+ if (!session) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
+
+ const body = await req.json()
+ const parsed = submitSchema.safeParse(body)
+ if (!parsed.success) {
+ return NextResponse.json({ error: parsed.error.errors[0]?.message ?? 'Invalid input' }, { status: 400 })
+ }
+
+ const { fundName, reason } = parsed.data
+
+ try {
+ const application = await prisma.fundApplication.create({
+ data: { userId: session.user.id, fundName: fundName.trim(), reason: reason.trim() },
+ })
+ return NextResponse.json(application, { status: 201 })
+ } catch {
+ // Unique constraint violation — already has a pending application
+ return NextResponse.json({ error: 'You already have a pending application.' }, { status: 409 })
+ }
+}
+
+/**
+ * DELETE /api/fund-applications
+ * Withdraw the current user's pending application.
+ */
+export async function DELETE() {
+ const session = await getServerSession(authOptions)
+ if (!session) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
+
+ await prisma.fundApplication.deleteMany({ where: { userId: session.user.id } })
+ return NextResponse.json({ ok: true })
+}
diff --git a/src/app/fund/apply/FundApplicationClient.tsx b/src/app/fund/apply/FundApplicationClient.tsx
new file mode 100644
index 0000000..75ab8d3
--- /dev/null
+++ b/src/app/fund/apply/FundApplicationClient.tsx
@@ -0,0 +1,147 @@
+'use client'
+
+import { useState } from 'react'
+import { useRouter } from 'next/navigation'
+import Link from 'next/link'
+import { Building2, Clock, CheckCircle } from 'lucide-react'
+
+interface Props {
+ existing: { fundName: string; reason: string; createdAt: string } | null
+ managedFund: { name: string; slug: string } | null
+}
+
+export default function FundApplicationClient({ existing, managedFund }: Props) {
+ const router = useRouter()
+ const [fundName, setFundName] = useState('')
+ const [reason, setReason] = useState('')
+ const [error, setError] = useState('')
+ const [loading, setLoading] = useState(false)
+ const [withdrawing, setWithdrawing] = useState(false)
+
+ // Already a fund manager
+ if (managedFund) {
+ return (
+
+
+
+
You already manage a fund
+
+
+ You are a manager of{' '}
+
+ {managedFund.name}
+
+ .
+
+
+ )
+ }
+
+ // Pending application
+ if (existing) {
+ async function withdraw() {
+ setWithdrawing(true)
+ await fetch('/api/fund-applications', { method: 'DELETE' })
+ setWithdrawing(false)
+ router.refresh()
+ }
+
+ return (
+
+
+
+
Application pending review
+
+
+
+
Fund Name
+
{existing.fundName}
+
+
+
Reason
+
{existing.reason}
+
+
+ Submitted {new Date(existing.createdAt).toLocaleDateString()}
+
+
+
+
+ )
+ }
+
+ // Submit form
+ async function handleSubmit(e: React.FormEvent) {
+ e.preventDefault()
+ if (!fundName.trim() || !reason.trim()) return
+ setLoading(true)
+ setError('')
+ const res = await fetch('/api/fund-applications', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ fundName: fundName.trim(), reason: reason.trim() }),
+ })
+ const data = await res.json()
+ setLoading(false)
+ if (!res.ok) { setError(data.error ?? 'Failed to submit'); return }
+ router.refresh()
+ }
+
+ return (
+
+ )
+}
diff --git a/src/app/fund/apply/page.tsx b/src/app/fund/apply/page.tsx
new file mode 100644
index 0000000..b52c8c7
--- /dev/null
+++ b/src/app/fund/apply/page.tsx
@@ -0,0 +1,35 @@
+import { getServerSession } from 'next-auth'
+import { authOptions } from '@/lib/auth'
+import { redirect } from 'next/navigation'
+import { prisma } from '@/lib/prisma'
+import FundApplicationClient from './FundApplicationClient'
+
+export const dynamic = 'force-dynamic'
+
+export default async function FundApplyPage() {
+ const session = await getServerSession(authOptions)
+ if (!session) redirect('/auth/signin?callbackUrl=/fund/apply')
+
+ const [application, managedFund] = await Promise.all([
+ prisma.fundApplication.findUnique({ where: { userId: session.user.id } }),
+ prisma.fundManager.findFirst({
+ where: { userId: session.user.id },
+ include: { fund: { select: { name: true, slug: true } } },
+ }),
+ ])
+
+ return (
+
+
+
Apply for a Hedge Fund
+
+ Propose a new fund. Admins will review your application and approve or deny it.
+
+
+
+
+ )
+}