diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 6ca2939..0855e7a 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -22,13 +22,14 @@ model User { isFund Boolean @default(false) isHidden Boolean @default(false) // hidden from leaderboards and public listings - positions Position[] - trades Trade[] - passwordResets PasswordReset[] - managedFunds FundManager[] - fund HedgeFund? - fundInvestments FundInvestment[] + positions Position[] + trades Trade[] + passwordResets PasswordReset[] + managedFunds FundManager[] + fund HedgeFund? + fundInvestments FundInvestment[] portfolioHistory UserPortfolioHistory[] + fundApplication FundApplication? } model HedgeFund { @@ -213,3 +214,12 @@ enum TradeType { FUND_INVEST // invested cash into a hedge fund FUND_REDEEM // redeemed shares from a hedge fund } + +model FundApplication { + id String @id @default(cuid()) + userId String @unique + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + fundName String + reason String + createdAt DateTime @default(now()) +} diff --git a/src/app/about/page.tsx b/src/app/about/page.tsx index d3cb93b..24595a3 100644 --- a/src/app/about/page.tsx +++ b/src/app/about/page.tsx @@ -149,6 +149,12 @@ export default function AboutPage() { Fund shares are stored to 6 decimal places. Fund accounts cannot sign in directly and do not earn research points or play the lottery.

+

+ Want to run your own fund?{' '} + + Apply here → + +

diff --git a/src/app/admin/funds/AdminFundApplications.tsx b/src/app/admin/funds/AdminFundApplications.tsx new file mode 100644 index 0000000..3ba8947 --- /dev/null +++ b/src/app/admin/funds/AdminFundApplications.tsx @@ -0,0 +1,159 @@ +'use client' + +import { useState } from 'react' +import { useRouter } from 'next/navigation' +import { FileText, Check, X, ChevronDown, ChevronUp } from 'lucide-react' + +interface Applicant { + id: string + username: string + displayUsername: string | null +} + +interface Application { + id: string + fundName: string + reason: string + createdAt: string + user: Applicant +} + +export default function AdminFundApplications({ applications: initial }: { applications: Application[] }) { + const router = useRouter() + const [applications, setApplications] = useState(initial) + const [expanded, setExpanded] = useState(null) + const [approveId, setApproveId] = useState(null) + const [startingBalance, setStartingBalance] = useState('0') + const [error, setError] = useState('') + const [loading, setLoading] = useState(false) + + async function approve(app: Application) { + setLoading(true) + setError('') + const res = await fetch(`/api/admin/fund-applications/${app.id}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ action: 'approve', startingBalance: parseFloat(startingBalance) || 0 }), + }) + const data = await res.json() + setLoading(false) + if (!res.ok) { setError(data.error ?? 'Failed'); return } + setApplications(applications.filter((a) => a.id !== app.id)) + setApproveId(null) + router.refresh() + } + + async function deny(id: string) { + if (!confirm('Deny this application? The applicant will not be notified.')) return + setLoading(true) + const res = await fetch(`/api/admin/fund-applications/${id}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ action: 'deny' }), + }) + setLoading(false) + if (res.ok) { + setApplications(applications.filter((a) => a.id !== id)) + router.refresh() + } + } + + if (applications.length === 0) { + return ( +

No pending fund applications.

+ ) + } + + return ( +
+ {error && ( +

{error}

+ )} + {applications.map((app) => ( +
+
+
+ +
+

{app.fundName}

+

+ by{' '} + {app.user.displayUsername ?? app.user.username} + {' '}· {new Date(app.createdAt).toLocaleDateString()} +

+
+
+ +
+ + + +
+
+ + {expanded === app.id && ( +
+ {app.reason} +
+ )} + + {approveId === app.id && ( +
+

+ Approve {app.fundName} for{' '} + {app.user.displayUsername ?? app.user.username} +

+
+
+ + setStartingBalance(e.target.value)} + className="w-32 bg-surface border border-surface-border rounded-lg px-3 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-green-500" + /> +
+
+ + +
+
+
+ )} +
+ ))} +
+ ) +} diff --git a/src/app/admin/funds/page.tsx b/src/app/admin/funds/page.tsx index bb15cb7..8646da7 100644 --- a/src/app/admin/funds/page.tsx +++ b/src/app/admin/funds/page.tsx @@ -1,24 +1,50 @@ import { prisma } from '@/lib/prisma' import AdminFundActions from './AdminFundActions' +import AdminFundApplications from './AdminFundApplications' export const dynamic = 'force-dynamic' export default async function AdminFundsPage() { - const funds = await prisma.hedgeFund.findMany({ - orderBy: { createdAt: 'asc' }, - include: { - user: { select: { balance: true } }, - managers: { - include: { user: { select: { id: true, username: true, displayUsername: true } } }, - orderBy: { addedAt: 'asc' }, + const [funds, applications] = await Promise.all([ + prisma.hedgeFund.findMany({ + orderBy: { createdAt: 'asc' }, + include: { + user: { select: { balance: true } }, + managers: { + include: { user: { select: { id: true, username: true, displayUsername: true } } }, + orderBy: { addedAt: 'asc' }, + }, }, - }, - }) + }), + prisma.fundApplication.findMany({ + orderBy: { createdAt: 'asc' }, + include: { user: { select: { id: true, username: true, displayUsername: true } } }, + }), + ]) + + const serialisedApplications = applications.map((a) => ({ + ...a, + createdAt: a.createdAt.toISOString(), + })) return ( -
-

Hedge Funds

- +
+
+

+ Fund Applications + {applications.length > 0 && ( + + {applications.length} + + )} +

+ +
+ +
+

Hedge Funds

+ +
) } 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 ( +
+
+ + New Fund Application +
+ + {error && ( +

{error}

+ )} + +
+ + setFundName(e.target.value)} + maxLength={60} + placeholder="TechAlpha Capital" + required + className="w-full bg-surface border border-surface-border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500" + /> +
+ +
+ +