Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 03ee361f29 | |||
| 1ce1511954 |
@@ -246,6 +246,35 @@ Accessible only to users with `isAdmin = true`.
|
||||
- Live view of all three BullMQ queues
|
||||
- Shows waiting, active, delayed, completed, and failed counts
|
||||
- Lists active, waiting, and recent failed jobs with payload and failure reason
|
||||
- **Retry failed** button per queue to requeue all failed jobs
|
||||
|
||||
### Hedge Funds (`/admin/funds`)
|
||||
- Create a named hedge fund with an initial starting balance
|
||||
- Add or remove managers (by username) per fund
|
||||
- Delete a fund (removes all its positions and trade history)
|
||||
|
||||
---
|
||||
|
||||
## Hedge Funds
|
||||
|
||||
Multiple players can collaborate via a **Hedge Fund** — a shared pool of capital with its own balance, positions, and trade history.
|
||||
|
||||
### How it works
|
||||
1. An admin creates a fund at `/admin/funds` (providing a name and initial balance).
|
||||
2. The admin adds one or more players as **managers** of the fund.
|
||||
3. A manager visits `/fund/[slug]` to see the fund's portfolio.
|
||||
4. From there they can click any held position (or browse all stocks) — the link includes `?fund=[slug]`, putting the hashtag trade page in **Fund Mode**.
|
||||
5. In Fund Mode a banner confirms which fund the trade will be on behalf of. All buys and sells deduct from / credit to the fund's balance, not the manager's.
|
||||
6. The manager can return to their `/profile` at any time to trade under their own account.
|
||||
|
||||
### Fund page (`/fund/[slug]`)
|
||||
- Public — anyone can view the fund name, balance, positions, managers, and trade history.
|
||||
- Managers see a management panel with quick links to trade each held position in Fund Mode.
|
||||
|
||||
### Key rules
|
||||
- A fund account cannot sign in directly — it is a shadow account controlled via the manager interface.
|
||||
- A user can manage multiple funds simultaneously.
|
||||
- Funds do not earn research points or play the daily lottery.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -19,9 +19,37 @@ model User {
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
isFund Boolean @default(false)
|
||||
|
||||
positions Position[]
|
||||
trades Trade[]
|
||||
passwordResets PasswordReset[]
|
||||
managedFunds FundManager[]
|
||||
fund HedgeFund?
|
||||
}
|
||||
|
||||
model HedgeFund {
|
||||
id String @id @default(cuid())
|
||||
name String @unique
|
||||
slug String @unique // lowercase, URL-safe
|
||||
userId String @unique // shadow User account that holds positions/trades/balance
|
||||
user User @relation(fields: [userId], references: [id])
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
managers FundManager[]
|
||||
|
||||
@@index([slug])
|
||||
}
|
||||
|
||||
model FundManager {
|
||||
id String @id @default(cuid())
|
||||
fundId String
|
||||
fund HedgeFund @relation(fields: [fundId], references: [id], onDelete: Cascade)
|
||||
userId String
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
addedAt DateTime @default(now())
|
||||
|
||||
@@unique([fundId, userId])
|
||||
}
|
||||
|
||||
model PasswordReset {
|
||||
|
||||
@@ -5,6 +5,7 @@ services:
|
||||
command: sh -c "npx prisma db push --accept-data-loss && npm start"
|
||||
ports:
|
||||
- "12131:3000"
|
||||
- "12555:5555"
|
||||
environment:
|
||||
DATABASE_URL: "${DATABASE_URL}"
|
||||
NEXTAUTH_SECRET: "${NEXTAUTH_SECRET}"
|
||||
|
||||
@@ -0,0 +1,230 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { formatCurrency } from '@/lib/utils'
|
||||
import { Building2, Plus, Trash2, UserPlus, UserMinus } from 'lucide-react'
|
||||
|
||||
interface Manager {
|
||||
id: string
|
||||
userId: string
|
||||
user: { id: string; username: string; displayUsername: string | null }
|
||||
}
|
||||
|
||||
interface Fund {
|
||||
id: string
|
||||
name: string
|
||||
slug: string
|
||||
user: { balance: number }
|
||||
managers: Manager[]
|
||||
}
|
||||
|
||||
export default function AdminFundActions({ funds: initialFunds }: { funds: Fund[] }) {
|
||||
const router = useRouter()
|
||||
const [funds, setFunds] = useState<Fund[]>(initialFunds)
|
||||
const [creating, setCreating] = useState(false)
|
||||
const [newName, setNewName] = useState('')
|
||||
const [newBalance, setNewBalance] = useState('10000')
|
||||
const [addUsername, setAddUsername] = useState<Record<string, string>>({})
|
||||
const [expandedId, setExpandedId] = useState<string | null>(null)
|
||||
const [error, setError] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
async function createFund() {
|
||||
if (!newName.trim()) return
|
||||
setLoading(true)
|
||||
setError('')
|
||||
const res = await fetch('/api/admin/funds', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name: newName.trim(), initialBalance: parseFloat(newBalance) || 10000 }),
|
||||
})
|
||||
const data = await res.json()
|
||||
setLoading(false)
|
||||
if (!res.ok) { setError(data.error ?? 'Failed'); return }
|
||||
setFunds([...funds, data])
|
||||
setNewName('')
|
||||
setNewBalance('10000')
|
||||
setCreating(false)
|
||||
router.refresh()
|
||||
}
|
||||
|
||||
async function addManager(fundId: string) {
|
||||
const username = (addUsername[fundId] ?? '').trim()
|
||||
if (!username) return
|
||||
setLoading(true)
|
||||
setError('')
|
||||
const res = await fetch(`/api/admin/funds/${fundId}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ addManagerUsername: username }),
|
||||
})
|
||||
const data = await res.json()
|
||||
setLoading(false)
|
||||
if (!res.ok) { setError(data.error ?? 'Failed'); return }
|
||||
setFunds(funds.map((f) => f.id === fundId ? { ...f, managers: data.managers } : f))
|
||||
setAddUsername({ ...addUsername, [fundId]: '' })
|
||||
}
|
||||
|
||||
async function removeManager(fundId: string, userId: string) {
|
||||
setLoading(true)
|
||||
const res = await fetch(`/api/admin/funds/${fundId}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ removeManagerUserId: userId }),
|
||||
})
|
||||
const data = await res.json()
|
||||
setLoading(false)
|
||||
if (!res.ok) { setError(data.error ?? 'Failed'); return }
|
||||
setFunds(funds.map((f) => f.id === fundId ? { ...f, managers: data.managers } : f))
|
||||
}
|
||||
|
||||
async function deleteFund(fundId: string) {
|
||||
if (!confirm('Delete this fund? This will remove all its positions and trade history.')) return
|
||||
setLoading(true)
|
||||
await fetch(`/api/admin/funds/${fundId}`, { method: 'DELETE' })
|
||||
setLoading(false)
|
||||
setFunds(funds.filter((f) => f.id !== fundId))
|
||||
router.refresh()
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{error && (
|
||||
<p className="text-red-400 text-sm bg-red-400/10 border border-red-400/20 rounded-lg px-3 py-2">{error}</p>
|
||||
)}
|
||||
|
||||
{/* Create form */}
|
||||
{creating ? (
|
||||
<div className="bg-surface-card border border-surface-border rounded-xl p-4 space-y-3">
|
||||
<h3 className="font-medium text-sm">New Hedge Fund</h3>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="text-xs text-slate-500 block mb-1">Fund Name</label>
|
||||
<input
|
||||
value={newName}
|
||||
onChange={(e) => setNewName(e.target.value)}
|
||||
placeholder="TechAlpha Capital"
|
||||
className="w-full bg-surface border border-surface-border rounded-lg px-3 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-slate-500 block mb-1">Initial Balance</label>
|
||||
<input
|
||||
type="number"
|
||||
value={newBalance}
|
||||
onChange={(e) => setNewBalance(e.target.value)}
|
||||
className="w-full bg-surface border border-surface-border rounded-lg px-3 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={createFund}
|
||||
disabled={loading || !newName.trim()}
|
||||
className="px-3 py-1.5 bg-indigo-600 hover:bg-indigo-500 text-white text-xs rounded-lg disabled:opacity-50 transition-colors"
|
||||
>
|
||||
Create Fund
|
||||
</button>
|
||||
<button onClick={() => setCreating(false)} className="px-3 py-1.5 text-xs text-slate-400 hover:text-slate-100">
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => setCreating(true)}
|
||||
className="flex items-center gap-2 text-sm px-3 py-1.5 bg-indigo-600/20 border border-indigo-500/30 hover:bg-indigo-600/30 text-indigo-300 rounded-lg transition-colors"
|
||||
>
|
||||
<Plus className="h-4 w-4" /> New Fund
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Funds list */}
|
||||
{funds.length === 0 && !creating && (
|
||||
<p className="text-slate-500 text-sm">No hedge funds yet.</p>
|
||||
)}
|
||||
|
||||
{funds.map((fund) => (
|
||||
<div key={fund.id} className="bg-surface-card border border-surface-border rounded-xl overflow-hidden">
|
||||
<div
|
||||
className="flex items-center justify-between px-4 py-3 cursor-pointer hover:bg-surface/50 transition-colors"
|
||||
onClick={() => setExpandedId(expandedId === fund.id ? null : fund.id)}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<Building2 className="h-4 w-4 text-indigo-400" />
|
||||
<div>
|
||||
<p className="font-medium text-sm">{fund.name}</p>
|
||||
<p className="text-xs text-slate-500">/fund/{fund.slug} · {formatCurrency(fund.user.balance)}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-xs text-slate-500">
|
||||
<span>{fund.managers.length} manager{fund.managers.length !== 1 ? 's' : ''}</span>
|
||||
<a
|
||||
href={`/fund/${fund.slug}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="text-indigo-400 hover:text-indigo-300"
|
||||
>
|
||||
View →
|
||||
</a>
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); deleteFund(fund.id) }}
|
||||
disabled={loading}
|
||||
className="text-red-500 hover:text-red-400 disabled:opacity-50"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{expandedId === fund.id && (
|
||||
<div className="border-t border-surface-border px-4 py-3 space-y-3">
|
||||
{/* Current managers */}
|
||||
<div>
|
||||
<p className="text-xs text-slate-500 mb-2 font-medium">Managers</p>
|
||||
{fund.managers.length === 0 ? (
|
||||
<p className="text-xs text-slate-600">None yet.</p>
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{fund.managers.map((m) => (
|
||||
<div key={m.id} className="flex items-center gap-1.5 text-xs bg-surface border border-surface-border rounded-full px-2.5 py-1">
|
||||
<span>{m.user.displayUsername ?? m.user.username}</span>
|
||||
<button
|
||||
onClick={() => removeManager(fund.id, m.userId)}
|
||||
disabled={loading}
|
||||
className="text-slate-500 hover:text-red-400 disabled:opacity-50"
|
||||
>
|
||||
<UserMinus className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Add manager */}
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
value={addUsername[fund.id] ?? ''}
|
||||
onChange={(e) => setAddUsername({ ...addUsername, [fund.id]: e.target.value })}
|
||||
placeholder="username"
|
||||
onKeyDown={(e) => e.key === 'Enter' && addManager(fund.id)}
|
||||
className="flex-1 bg-surface border border-surface-border rounded-lg px-3 py-1.5 text-xs focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
||||
/>
|
||||
<button
|
||||
onClick={() => addManager(fund.id)}
|
||||
disabled={loading || !(addUsername[fund.id] ?? '').trim()}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 bg-emerald-600/20 border border-emerald-500/30 hover:bg-emerald-600/30 text-emerald-300 text-xs rounded-lg disabled:opacity-50 transition-colors"
|
||||
>
|
||||
<UserPlus className="h-3 w-3" /> Add Manager
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import AdminFundActions from './AdminFundActions'
|
||||
|
||||
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' },
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-lg font-semibold">Hedge Funds</h2>
|
||||
<AdminFundActions funds={funds} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -20,6 +20,7 @@ export default async function AdminLayout({ children }: { children: React.ReactN
|
||||
{ href: '/admin', label: 'Overview' },
|
||||
{ href: '/admin/users', label: 'Users' },
|
||||
{ href: '/admin/stocks', label: 'Stocks' },
|
||||
{ href: '/admin/funds', label: 'Funds' },
|
||||
{ href: '/admin/queue', label: 'Queue' },
|
||||
].map((link) => (
|
||||
<a
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
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 patchSchema = z.object({
|
||||
addManagerUsername: z.string().optional(),
|
||||
removeManagerUserId: z.string().optional(),
|
||||
balance: z.number().min(0).optional(),
|
||||
})
|
||||
|
||||
/**
|
||||
* PATCH /api/admin/funds/[fundId]
|
||||
* Add/remove manager, or adjust fund balance.
|
||||
*/
|
||||
export async function PATCH(
|
||||
req: NextRequest,
|
||||
{ params }: { params: { fundId: string } }
|
||||
) {
|
||||
const session = await getServerSession(authOptions)
|
||||
if (!session?.user?.isAdmin) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||
}
|
||||
|
||||
const fund = await prisma.hedgeFund.findUnique({
|
||||
where: { id: params.fundId },
|
||||
include: { user: true },
|
||||
})
|
||||
if (!fund) return NextResponse.json({ error: 'Fund not found.' }, { status: 404 })
|
||||
|
||||
const body = await req.json().catch(() => null)
|
||||
const parsed = patchSchema.safeParse(body)
|
||||
if (!parsed.success) return NextResponse.json({ error: 'Invalid request.' }, { status: 400 })
|
||||
|
||||
const { addManagerUsername, removeManagerUserId, balance } = parsed.data
|
||||
|
||||
if (addManagerUsername) {
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { username: addManagerUsername.toLowerCase() },
|
||||
select: { id: true, isFund: true },
|
||||
})
|
||||
if (!user) return NextResponse.json({ error: 'User not found.' }, { status: 404 })
|
||||
if (user.isFund) return NextResponse.json({ error: 'Cannot add a fund as a manager.' }, { status: 400 })
|
||||
|
||||
await prisma.fundManager.upsert({
|
||||
where: { fundId_userId: { fundId: fund.id, userId: user.id } },
|
||||
create: { fundId: fund.id, userId: user.id },
|
||||
update: {},
|
||||
})
|
||||
}
|
||||
|
||||
if (removeManagerUserId) {
|
||||
await prisma.fundManager.deleteMany({
|
||||
where: { fundId: fund.id, userId: removeManagerUserId },
|
||||
})
|
||||
}
|
||||
|
||||
if (typeof balance === 'number') {
|
||||
await prisma.user.update({ where: { id: fund.userId }, data: { balance } })
|
||||
}
|
||||
|
||||
const updated = await prisma.hedgeFund.findUnique({
|
||||
where: { id: fund.id },
|
||||
include: {
|
||||
user: { select: { balance: true } },
|
||||
managers: {
|
||||
include: { user: { select: { id: true, username: true, displayUsername: true } } },
|
||||
orderBy: { addedAt: 'asc' },
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
return NextResponse.json(updated)
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /api/admin/funds/[fundId]
|
||||
* Deletes the fund and its shadow user (cascades positions/trades).
|
||||
*/
|
||||
export async function DELETE(
|
||||
req: NextRequest,
|
||||
{ params }: { params: { fundId: string } }
|
||||
) {
|
||||
const session = await getServerSession(authOptions)
|
||||
if (!session?.user?.isAdmin) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||
}
|
||||
|
||||
const fund = await prisma.hedgeFund.findUnique({ where: { id: params.fundId } })
|
||||
if (!fund) return NextResponse.json({ error: 'Fund not found.' }, { status: 404 })
|
||||
|
||||
// Delete shadow user cascades positions, trades, and the fund record
|
||||
await prisma.user.delete({ where: { id: fund.userId } })
|
||||
|
||||
return NextResponse.json({ ok: true })
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
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'
|
||||
|
||||
const createSchema = z.object({
|
||||
name: z.string().min(1).max(60),
|
||||
initialBalance: z.number().min(0).default(10_000),
|
||||
})
|
||||
|
||||
function toSlug(name: string) {
|
||||
return name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '')
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/admin/funds
|
||||
* Body: { name: string, initialBalance?: number }
|
||||
* Creates a HedgeFund with a shadow User account.
|
||||
*/
|
||||
export async function POST(req: NextRequest) {
|
||||
const session = await getServerSession(authOptions)
|
||||
if (!session?.user?.isAdmin) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||
}
|
||||
|
||||
const body = await req.json().catch(() => null)
|
||||
const parsed = createSchema.safeParse(body)
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json({ error: 'Invalid request.' }, { status: 400 })
|
||||
}
|
||||
|
||||
const { name, initialBalance } = parsed.data
|
||||
const slug = toSlug(name)
|
||||
|
||||
if (!slug) {
|
||||
return NextResponse.json({ error: 'Fund name produces an empty slug.' }, { status: 400 })
|
||||
}
|
||||
|
||||
// Check for existing fund
|
||||
const existing = await prisma.hedgeFund.findFirst({
|
||||
where: { OR: [{ name }, { slug }] },
|
||||
})
|
||||
if (existing) {
|
||||
return NextResponse.json({ error: 'A fund with that name already exists.' }, { status: 409 })
|
||||
}
|
||||
|
||||
// Shadow username must also be unique
|
||||
const shadowUsername = `fund:${slug}`
|
||||
const existingUser = await prisma.user.findUnique({ where: { username: shadowUsername } })
|
||||
if (existingUser) {
|
||||
return NextResponse.json({ error: 'Username conflict — try a different name.' }, { status: 409 })
|
||||
}
|
||||
|
||||
// Create shadow user + fund in a transaction
|
||||
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), // random, non-loginable
|
||||
balance: initialBalance,
|
||||
isFund: true,
|
||||
},
|
||||
})
|
||||
|
||||
return tx.hedgeFund.create({
|
||||
data: { name, slug, userId: shadowUser.id },
|
||||
include: { user: { select: { balance: true } }, managers: true },
|
||||
})
|
||||
})
|
||||
|
||||
return NextResponse.json(fund, { status: 201 })
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/admin/funds
|
||||
* Returns all funds with their managers.
|
||||
*/
|
||||
export async function GET(req: NextRequest) {
|
||||
const session = await getServerSession(authOptions)
|
||||
if (!session?.user?.isAdmin) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||
}
|
||||
|
||||
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' },
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
return NextResponse.json(funds)
|
||||
}
|
||||
@@ -9,6 +9,7 @@ const tradeSchema = z.object({
|
||||
hashtagId: z.string().min(1),
|
||||
type: z.enum(['BUY_LONG', 'SELL_LONG', 'BUY_SHORT', 'SELL_SHORT']),
|
||||
shares: z.number().positive().max(1_000_000),
|
||||
fundId: z.string().optional(), // if set: trade on behalf of this fund
|
||||
})
|
||||
|
||||
/**
|
||||
@@ -24,12 +25,24 @@ export async function POST(req: NextRequest) {
|
||||
return NextResponse.json({ error: 'Invalid request.' }, { status: 400 })
|
||||
}
|
||||
|
||||
const { hashtagId, type, shares } = parsed.data
|
||||
const { hashtagId, type, shares, fundId } = parsed.data
|
||||
|
||||
// Fetch hashtag and user together
|
||||
// Fetch hashtag and determine which user account to trade as
|
||||
let actorId = session.user.id
|
||||
if (fundId) {
|
||||
const membership = await prisma.fundManager.findUnique({
|
||||
where: { fundId_userId: { fundId, userId: session.user.id } },
|
||||
include: { fund: { select: { userId: true } } },
|
||||
})
|
||||
if (!membership) {
|
||||
return NextResponse.json({ error: 'You are not a manager of this fund.' }, { status: 403 })
|
||||
}
|
||||
actorId = membership.fund.userId
|
||||
}
|
||||
// Fetch hashtag and actor account together
|
||||
const [hashtag, user] = await Promise.all([
|
||||
prisma.hashtag.findUnique({ where: { id: hashtagId, isActive: true } }),
|
||||
prisma.user.findUnique({ where: { id: session.user.id } }),
|
||||
prisma.user.findUnique({ where: { id: actorId } }),
|
||||
])
|
||||
|
||||
if (!hashtag) return NextResponse.json({ error: 'Hashtag not found or inactive.' }, { status: 404 })
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { getServerSession } from 'next-auth'
|
||||
import { authOptions } from '@/lib/auth'
|
||||
import { notFound } from 'next/navigation'
|
||||
import { formatCurrency, formatPnl, pnlColor } from '@/lib/utils'
|
||||
import { formatDistanceToNow } from 'date-fns'
|
||||
import Link from 'next/link'
|
||||
import { Building2, TrendingUp, TrendingDown } from 'lucide-react'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
export default async function FundPage({ params }: { params: { slug: string } }) {
|
||||
const session = await getServerSession(authOptions)
|
||||
const slug = decodeURIComponent(params.slug).toLowerCase()
|
||||
|
||||
const fund = await prisma.hedgeFund.findUnique({
|
||||
where: { slug },
|
||||
include: {
|
||||
user: {
|
||||
select: {
|
||||
balance: true,
|
||||
positions: {
|
||||
where: { shares: { gt: 0 } },
|
||||
include: { hashtag: { select: { tag: true, displayTag: true, currentPrice: true, isActive: true } } },
|
||||
},
|
||||
trades: {
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 30,
|
||||
include: { hashtag: { select: { tag: true, displayTag: true } } },
|
||||
},
|
||||
},
|
||||
},
|
||||
managers: {
|
||||
include: { user: { select: { id: true, username: true, displayUsername: true } } },
|
||||
orderBy: { addedAt: 'asc' },
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
if (!fund) notFound()
|
||||
|
||||
const isManager = session
|
||||
? fund.managers.some((m) => m.userId === session.user.id)
|
||||
: false
|
||||
|
||||
const positions = fund.user.positions
|
||||
const portfolioValue = positions.reduce((sum, p) => {
|
||||
const val = p.positionType === 'LONG'
|
||||
? p.shares * p.hashtag.currentPrice
|
||||
: p.avgBuyPrice * p.shares - (p.hashtag.currentPrice - p.avgBuyPrice) * p.shares
|
||||
return sum + val
|
||||
}, 0)
|
||||
const unrealizedPnl = positions.reduce((sum, p) => {
|
||||
const pnl = p.positionType === 'LONG'
|
||||
? (p.hashtag.currentPrice - p.avgBuyPrice) * p.shares
|
||||
: (p.avgBuyPrice - p.hashtag.currentPrice) * p.shares
|
||||
return sum + pnl
|
||||
}, 0)
|
||||
|
||||
const totalValue = fund.user.balance + portfolioValue
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto space-y-8">
|
||||
{/* Header */}
|
||||
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4">
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<Building2 className="h-6 w-6 text-indigo-400" />
|
||||
<h1 className="text-3xl font-bold">{fund.name}</h1>
|
||||
</div>
|
||||
<p className="text-slate-500 text-sm">Hedge Fund</p>
|
||||
{isManager && (
|
||||
<span className="inline-block mt-2 text-xs px-2 py-0.5 rounded bg-indigo-500/20 text-indigo-300 border border-indigo-500/30">
|
||||
You are a manager of this fund
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-3xl font-bold">{formatCurrency(totalValue)}</p>
|
||||
<p className="text-sm text-slate-400">total fund value</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
|
||||
{[
|
||||
{ label: 'Cash', value: formatCurrency(fund.user.balance) },
|
||||
{ label: 'Invested', value: formatCurrency(portfolioValue) },
|
||||
{ label: 'Unrealized P&L', value: formatPnl(unrealizedPnl), colorClass: pnlColor(unrealizedPnl) },
|
||||
{ label: 'Managers', value: String(fund.managers.length) },
|
||||
].map(({ label, value, colorClass }) => (
|
||||
<div key={label} className="bg-surface-card border border-surface-border rounded-xl p-4">
|
||||
<p className="text-xs text-slate-500 mb-1">{label}</p>
|
||||
<p className={`text-lg font-semibold ${colorClass ?? ''}`}>{value}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Manager trading panel */}
|
||||
{isManager && (
|
||||
<div className="bg-indigo-500/5 border border-indigo-500/20 rounded-xl p-5">
|
||||
<h2 className="font-semibold mb-1 flex items-center gap-2">
|
||||
<Building2 className="h-4 w-4 text-indigo-400" />
|
||||
Trade on behalf of this fund
|
||||
</h2>
|
||||
<p className="text-sm text-slate-400 mb-4">
|
||||
Search for a hashtag below and trade using the fund's balance.
|
||||
All positions and profit belong to the fund.
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
{positions.map((p) => (
|
||||
<Link
|
||||
key={p.id}
|
||||
href={`/hashtag/${p.hashtag.tag}?fund=${fund.slug}`}
|
||||
className="text-xs bg-surface border border-surface-border hover:border-indigo-500/50 text-slate-300 hover:text-indigo-300 px-3 py-1.5 rounded-full transition-colors"
|
||||
>
|
||||
#{p.hashtag.displayTag}
|
||||
</Link>
|
||||
))}
|
||||
<Link
|
||||
href={`/stocks?fund=${fund.slug}`}
|
||||
className="text-xs bg-indigo-600/20 border border-indigo-500/30 hover:bg-indigo-600/30 text-indigo-300 px-3 py-1.5 rounded-full transition-colors"
|
||||
>
|
||||
Browse all stocks →
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Open positions */}
|
||||
{positions.length > 0 && (
|
||||
<div className="bg-surface-card border border-surface-border rounded-xl overflow-hidden">
|
||||
<h2 className="text-sm font-medium text-slate-400 px-4 py-3 border-b border-surface-border">
|
||||
Open Positions
|
||||
</h2>
|
||||
<div className="divide-y divide-surface-border">
|
||||
{positions.map((p) => {
|
||||
const pnl = p.positionType === 'LONG'
|
||||
? (p.hashtag.currentPrice - p.avgBuyPrice) * p.shares
|
||||
: (p.avgBuyPrice - p.hashtag.currentPrice) * p.shares
|
||||
return (
|
||||
<div key={p.id} className="flex items-center justify-between px-4 py-3 text-sm">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link
|
||||
href={isManager ? `/hashtag/${p.hashtag.tag}?fund=${fund.slug}` : `/hashtag/${p.hashtag.tag}`}
|
||||
className="font-medium text-indigo-300 hover:text-indigo-200"
|
||||
>
|
||||
#{p.hashtag.displayTag}
|
||||
</Link>
|
||||
<span className={`text-xs px-1.5 py-0.5 rounded ${p.positionType === 'LONG' ? 'bg-emerald-500/15 text-emerald-400' : 'bg-red-500/15 text-red-400'}`}>
|
||||
{p.positionType}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="font-medium">{formatCurrency(p.shares * p.hashtag.currentPrice)}</p>
|
||||
<p className={`text-xs ${pnlColor(pnl)}`}>{formatPnl(pnl)}</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Managers */}
|
||||
<div className="bg-surface-card border border-surface-border rounded-xl overflow-hidden">
|
||||
<h2 className="text-sm font-medium text-slate-400 px-4 py-3 border-b border-surface-border">
|
||||
Managers
|
||||
</h2>
|
||||
{fund.managers.length === 0 ? (
|
||||
<p className="text-slate-500 text-sm px-4 py-3">No managers assigned yet.</p>
|
||||
) : (
|
||||
<div className="divide-y divide-surface-border">
|
||||
{fund.managers.map((m) => (
|
||||
<div key={m.id} className="flex items-center justify-between px-4 py-3 text-sm">
|
||||
<Link href={`/profile/${m.user.username}`} className="text-slate-300 hover:text-white">
|
||||
{m.user.displayUsername ?? m.user.username}
|
||||
</Link>
|
||||
<span className="text-xs text-slate-500">
|
||||
since {formatDistanceToNow(new Date(m.addedAt), { addSuffix: true })}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Recent trades */}
|
||||
{fund.user.trades.length > 0 && (
|
||||
<div className="bg-surface-card border border-surface-border rounded-xl overflow-hidden">
|
||||
<h2 className="text-sm font-medium text-slate-400 px-4 py-3 border-b border-surface-border">
|
||||
Recent Trades
|
||||
</h2>
|
||||
<div className="divide-y divide-surface-border">
|
||||
{fund.user.trades.map((t) => (
|
||||
<div key={t.id} className="flex items-center justify-between px-4 py-2.5 text-sm">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className={`text-xs font-medium px-2 py-0.5 rounded ${t.type.startsWith('BUY') ? 'bg-emerald-500/15 text-emerald-400' : 'bg-red-500/15 text-red-400'}`}>
|
||||
{t.type.replace('_', ' ')}
|
||||
</span>
|
||||
{t.hashtag && (
|
||||
<Link href={`/hashtag/${t.hashtag.tag}`} className="text-slate-300 hover:text-white">
|
||||
#{t.hashtag.displayTag}
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-right text-xs text-slate-400">
|
||||
<p>{formatCurrency(t.total)}</p>
|
||||
<p>{formatDistanceToNow(new Date(t.createdAt), { addSuffix: true })}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -9,11 +9,13 @@ interface Props {
|
||||
balance: number
|
||||
longPosition: { shares: number; avgBuyPrice: number } | null
|
||||
shortPosition: { shares: number; avgBuyPrice: number } | null
|
||||
fundId?: string
|
||||
fundName?: string
|
||||
}
|
||||
|
||||
type Tab = 'BUY_LONG' | 'SELL_LONG' | 'BUY_SHORT' | 'SELL_SHORT'
|
||||
|
||||
export function TradePanel({ hashtag, balance, longPosition, shortPosition }: Props) {
|
||||
export function TradePanel({ hashtag, balance, longPosition, shortPosition, fundId, fundName }: Props) {
|
||||
const router = useRouter()
|
||||
const [tab, setTab] = useState<Tab>('BUY_LONG')
|
||||
const [shares, setShares] = useState('')
|
||||
@@ -38,7 +40,7 @@ export function TradePanel({ hashtag, balance, longPosition, shortPosition }: Pr
|
||||
const res = await fetch('/api/trade', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ hashtagId: hashtag.id, type: tab, shares: sharesNum }),
|
||||
body: JSON.stringify({ hashtagId: hashtag.id, type: tab, shares: sharesNum, ...(fundId ? { fundId } : {}) }),
|
||||
})
|
||||
|
||||
const data = await res.json()
|
||||
@@ -54,6 +56,13 @@ export function TradePanel({ hashtag, balance, longPosition, shortPosition }: Pr
|
||||
|
||||
return (
|
||||
<div className="bg-surface-card border border-surface-border rounded-xl p-6 space-y-5">
|
||||
{fundName && (
|
||||
<div className="flex items-center gap-2 text-xs bg-indigo-500/10 border border-indigo-500/30 rounded-lg px-3 py-2 text-indigo-300">
|
||||
<span className="text-lg">🏦</span>
|
||||
Trading as <span className="font-semibold">{fundName}</span>
|
||||
<span className="text-indigo-500 ml-auto">Fund mode</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="font-semibold">Trade #{hashtag.displayTag}</h2>
|
||||
<span className="text-sm text-slate-400">
|
||||
|
||||
@@ -14,11 +14,13 @@ export const dynamic = 'force-dynamic'
|
||||
|
||||
interface Props {
|
||||
params: { tag: string }
|
||||
searchParams: { fund?: string }
|
||||
}
|
||||
|
||||
export default async function HashtagPage({ params }: Props) {
|
||||
export default async function HashtagPage({ params, searchParams }: Props) {
|
||||
const session = await getServerSession(authOptions)
|
||||
const tag = decodeURIComponent(params.tag).toLowerCase().replace(/^#+/, '')
|
||||
const fundSlug = searchParams.fund
|
||||
|
||||
const [hashtag, userBalance, userPosition] = await Promise.all([
|
||||
prisma.hashtag.findUnique({
|
||||
@@ -51,6 +53,29 @@ export default async function HashtagPage({ params }: Props) {
|
||||
: [],
|
||||
])
|
||||
|
||||
// Resolve fund context if ?fund= is provided
|
||||
let fundContext: { id: string; name: string; balance: number; positions: { hashtagId: string; positionType: string; shares: number; avgBuyPrice: number }[] } | null = null
|
||||
if (fundSlug && session) {
|
||||
const fund = await prisma.hedgeFund.findUnique({
|
||||
where: { slug: fundSlug },
|
||||
include: {
|
||||
user: { select: { balance: true } },
|
||||
managers: { where: { userId: session.user.id }, select: { userId: true } },
|
||||
},
|
||||
})
|
||||
if (fund && fund.managers.length > 0) {
|
||||
const fundPositions = await prisma.position.findMany({
|
||||
where: { userId: fund.userId },
|
||||
})
|
||||
fundContext = {
|
||||
id: fund.id,
|
||||
name: fund.name,
|
||||
balance: fund.user.balance,
|
||||
positions: fundPositions,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Unknown hashtag — show research panel
|
||||
if (!hashtag || !hashtag.isActive) {
|
||||
return (
|
||||
@@ -87,6 +112,15 @@ export default async function HashtagPage({ params }: Props) {
|
||||
const longPosition = positions.find((p) => p.positionType === 'LONG')
|
||||
const shortPosition = positions.find((p) => p.positionType === 'SHORT')
|
||||
|
||||
// When trading as a fund, show the fund's positions instead
|
||||
const activeLong = fundContext
|
||||
? fundContext.positions.find((p) => p.hashtagId === hashtag.id && p.positionType === 'LONG' && p.shares > 0) ?? null
|
||||
: longPosition ?? null
|
||||
const activeShort = fundContext
|
||||
? fundContext.positions.find((p) => p.hashtagId === hashtag.id && p.positionType === 'SHORT' && p.shares > 0) ?? null
|
||||
: shortPosition ?? null
|
||||
const activeBalance = fundContext ? fundContext.balance : (userBalance?.balance ?? 0)
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
{/* Header */}
|
||||
@@ -125,17 +159,11 @@ export default async function HashtagPage({ params }: Props) {
|
||||
displayTag: hashtag.displayTag,
|
||||
currentPrice: hashtag.currentPrice,
|
||||
}}
|
||||
balance={userBalance?.balance ?? 0}
|
||||
longPosition={
|
||||
longPosition
|
||||
? { shares: longPosition.shares, avgBuyPrice: longPosition.avgBuyPrice }
|
||||
: null
|
||||
}
|
||||
shortPosition={
|
||||
shortPosition
|
||||
? { shares: shortPosition.shares, avgBuyPrice: shortPosition.avgBuyPrice }
|
||||
: null
|
||||
}
|
||||
balance={activeBalance}
|
||||
longPosition={activeLong ? { shares: activeLong.shares, avgBuyPrice: activeLong.avgBuyPrice } : null}
|
||||
shortPosition={activeShort ? { shares: activeShort.shares, avgBuyPrice: activeShort.avgBuyPrice } : null}
|
||||
fundId={fundContext?.id}
|
||||
fundName={fundContext?.name}
|
||||
/>
|
||||
) : (
|
||||
<div className="bg-surface-card border border-surface-border rounded-xl p-6 text-center">
|
||||
|
||||
+33
-20
@@ -5,8 +5,7 @@ import { Loader2, Dices, CheckCircle2 } from 'lucide-react'
|
||||
import { formatCurrency } from '@/lib/utils'
|
||||
import { useSession } from 'next-auth/react'
|
||||
import Link from 'next/link'
|
||||
|
||||
const GRID_SIZE = 25
|
||||
import { GRID_SIZE, PRIZE_MAP } from '@/lib/lottery'
|
||||
|
||||
type PickResult = {
|
||||
box: number
|
||||
@@ -86,11 +85,24 @@ export default function LotteryPage() {
|
||||
)
|
||||
}
|
||||
|
||||
const prizeLabels: Record<number, string> = {
|
||||
200: '🏆 $200',
|
||||
50: '🥈 $50',
|
||||
10: '🎁 $10',
|
||||
}
|
||||
const PRIZE_EMOJIS = ['🏆', '🥈', '🎁', '✨', '⭐']
|
||||
const uniquePrizes = [...new Set(Object.values(PRIZE_MAP))].filter(v => v > 0).sort((a, b) => b - a)
|
||||
const prizeCounts = uniquePrizes.reduce<Record<number, number>>((acc, amt) => {
|
||||
acc[amt] = Object.values(PRIZE_MAP).filter(v => v === amt).length
|
||||
return acc
|
||||
}, {})
|
||||
const prizeBoxCount = Object.values(PRIZE_MAP).filter(v => v > 0).length
|
||||
const emptyBoxCount = GRID_SIZE - prizeBoxCount
|
||||
const TIER_COLORS = [
|
||||
{ bg: 'bg-amber-500/10', border: 'border-amber-500/20', text: 'text-amber-400' },
|
||||
{ bg: 'bg-slate-500/10', border: 'border-slate-500/20', text: 'text-slate-300' },
|
||||
{ bg: 'bg-emerald-500/10', border: 'border-emerald-500/20', text: 'text-emerald-400' },
|
||||
{ bg: 'bg-indigo-500/10', border: 'border-indigo-500/20', text: 'text-indigo-400' },
|
||||
{ bg: 'bg-pink-500/10', border: 'border-pink-500/20', text: 'text-pink-400' },
|
||||
]
|
||||
const prizeLabels: Record<number, string> = Object.fromEntries(
|
||||
uniquePrizes.map((amt, i) => [amt, `${PRIZE_EMOJIS[i] ?? '🎁'} ${formatCurrency(amt)}`])
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="max-w-xl mx-auto space-y-8">
|
||||
@@ -107,21 +119,22 @@ export default function LotteryPage() {
|
||||
{/* Prize table */}
|
||||
<div className="bg-surface-card border border-surface-border rounded-xl p-4">
|
||||
<p className="text-xs text-slate-500 mb-3 font-medium uppercase tracking-wider">Prize pool</p>
|
||||
<div className="grid grid-cols-3 gap-2 text-sm text-center">
|
||||
<div className="bg-amber-500/10 border border-amber-500/20 rounded-lg p-2">
|
||||
<p className="font-bold text-amber-400">$200</p>
|
||||
<p className="text-xs text-slate-500">×1 box</p>
|
||||
<div
|
||||
className="grid gap-2 text-sm text-center"
|
||||
style={{ gridTemplateColumns: `repeat(${uniquePrizes.length}, 1fr)` }}
|
||||
>
|
||||
{uniquePrizes.map((amt, i) => {
|
||||
const color = TIER_COLORS[i] ?? TIER_COLORS[TIER_COLORS.length - 1]
|
||||
const count = prizeCounts[amt]
|
||||
return (
|
||||
<div key={amt} className={`${color.bg} border ${color.border} rounded-lg p-2`}>
|
||||
<p className={`font-bold ${color.text}`}>{formatCurrency(amt)}</p>
|
||||
<p className="text-xs text-slate-500">×{count} box{count !== 1 ? 'es' : ''}</p>
|
||||
</div>
|
||||
<div className="bg-slate-500/10 border border-slate-500/20 rounded-lg p-2">
|
||||
<p className="font-bold text-slate-300">$50</p>
|
||||
<p className="text-xs text-slate-500">×2 boxes</p>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<div className="bg-emerald-500/10 border border-emerald-500/20 rounded-lg p-2">
|
||||
<p className="font-bold text-emerald-400">$10</p>
|
||||
<p className="text-xs text-slate-500">×3 boxes</p>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-slate-600 mt-2 text-center">Remaining 19 boxes: $0</p>
|
||||
<p className="text-xs text-slate-600 mt-2 text-center">All remaining {emptyBoxCount} boxes: $0</p>
|
||||
</div>
|
||||
|
||||
{/* Result banner */}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { getServerSession } from 'next-auth'
|
||||
import { authOptions } from '@/lib/auth'
|
||||
import { notFound } from 'next/navigation'
|
||||
import { formatCurrency, formatNumber, pnlColor, formatPnl } from '@/lib/utils'
|
||||
import { getBalanceTier } from '@/lib/pricing'
|
||||
import Link from 'next/link'
|
||||
import { TrendingUp, TrendingDown, Coins } from 'lucide-react'
|
||||
import ChangePasswordForm from './ChangePasswordForm'
|
||||
@@ -74,9 +75,28 @@ export default async function ProfilePage({ params }: Props) {
|
||||
<p className="text-slate-500 text-sm">@{user.username}</p>
|
||||
)}
|
||||
{isOwn && (
|
||||
<p className="text-slate-400 text-sm mt-1">
|
||||
<div className="mt-1 text-sm text-slate-400 space-y-0.5">
|
||||
{(() => {
|
||||
const tier = getBalanceTier(user.balance)
|
||||
return (
|
||||
<>
|
||||
<p>
|
||||
<span className="text-slate-300 font-medium">Tier {tier.level}</span>
|
||||
<span className="text-slate-600 mx-1.5">·</span>
|
||||
{tier.pointsPerDay} research pt{tier.pointsPerDay !== 1 ? 's' : ''}/day
|
||||
{tier.nextThreshold && (
|
||||
<span className="text-slate-600 text-xs ml-1.5">
|
||||
(next tier at {formatCurrency(tier.nextThreshold)})
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
<p>
|
||||
{user.researchPoints} research point{user.researchPoints !== 1 ? 's' : ''} available
|
||||
</p>
|
||||
</>
|
||||
)
|
||||
})()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-right">
|
||||
|
||||
@@ -25,6 +25,20 @@ export function dailyResearchPoints(balance: number): number {
|
||||
return 1
|
||||
}
|
||||
|
||||
export interface BalanceTier {
|
||||
level: number
|
||||
pointsPerDay: number
|
||||
nextThreshold: number | null
|
||||
}
|
||||
|
||||
/** Returns the tier info for a given balance. */
|
||||
export function getBalanceTier(balance: number): BalanceTier {
|
||||
if (balance >= 1_000_000) return { level: 4, pointsPerDay: 5, nextThreshold: null }
|
||||
if (balance >= 100_000) return { level: 3, pointsPerDay: 3, nextThreshold: 1_000_000 }
|
||||
if (balance >= 10_000) return { level: 2, pointsPerDay: 2, nextThreshold: 100_000 }
|
||||
return { level: 1, pointsPerDay: 1, nextThreshold: 10_000 }
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate the cost/proceeds and realized P&L for a trade.
|
||||
*
|
||||
|
||||
+6
-2
@@ -170,14 +170,18 @@ const maintenanceWorker = new Worker(
|
||||
async (job) => {
|
||||
console.log(`[maintenance] running daily maintenance (job ${job.id})`)
|
||||
|
||||
const users = await prisma.user.findMany({ select: { id: true, balance: true } })
|
||||
const MAX_RESEARCH_POINTS = 10
|
||||
const users = await prisma.user.findMany({ select: { id: true, balance: true, researchPoints: true } })
|
||||
for (const user of users) {
|
||||
const points = dailyResearchPoints(user.balance)
|
||||
const newTotal = Math.min(user.researchPoints + points, MAX_RESEARCH_POINTS)
|
||||
if (newTotal !== user.researchPoints) {
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: { researchPoints: { increment: points } },
|
||||
data: { researchPoints: newTotal },
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`[maintenance] awarded research points to ${users.length} users`)
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user