making funds work better and some refactor
Build Images and Deploy / Update-PROD-Stack (push) Successful in 1m21s
Build Images and Deploy / Update-PROD-Stack (push) Successful in 1m21s
This commit is contained in:
@@ -20,6 +20,7 @@ model User {
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
isFund Boolean @default(false)
|
||||
isHidden Boolean @default(false) // hidden from leaderboards and public listings
|
||||
|
||||
positions Position[]
|
||||
trades Trade[]
|
||||
|
||||
@@ -20,6 +20,7 @@ export default async function AdminOverviewPage() {
|
||||
},
|
||||
}),
|
||||
prisma.user.findMany({
|
||||
where: { isFund: false, isHidden: false },
|
||||
orderBy: { balance: 'desc' },
|
||||
take: 10,
|
||||
select: { id: true, username: true, balance: true, isAdmin: true },
|
||||
|
||||
@@ -10,6 +10,7 @@ interface UserData {
|
||||
balance: number
|
||||
researchPoints: number
|
||||
isAdmin: boolean
|
||||
isHidden: boolean
|
||||
}
|
||||
|
||||
export function AdminUserActions({ user }: { user: UserData }) {
|
||||
@@ -17,6 +18,7 @@ export function AdminUserActions({ user }: { user: UserData }) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [balance, setBalance] = useState(String(user.balance))
|
||||
const [points, setPoints] = useState(String(user.researchPoints))
|
||||
const [hidden, setHidden] = useState(user.isHidden)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [resetUrl, setResetUrl] = useState<string | null>(null)
|
||||
const [lotteryReset, setLotteryReset] = useState(false)
|
||||
@@ -31,6 +33,7 @@ export function AdminUserActions({ user }: { user: UserData }) {
|
||||
body: JSON.stringify({
|
||||
balance: parseFloat(balance),
|
||||
researchPoints: parseInt(points, 10),
|
||||
isHidden: hidden,
|
||||
}),
|
||||
})
|
||||
const data = await res.json()
|
||||
@@ -113,6 +116,23 @@ export function AdminUserActions({ user }: { user: UserData }) {
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<label className="text-sm text-slate-400">Hidden from leaderboards</label>
|
||||
<p className="text-xs text-slate-500">Excludes this user from public rankings</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setHidden((h) => !h)}
|
||||
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
|
||||
hidden ? 'bg-slate-600' : 'bg-indigo-600'
|
||||
}`}
|
||||
>
|
||||
<span className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
|
||||
hidden ? 'translate-x-1' : 'translate-x-6'
|
||||
}`} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
|
||||
@@ -30,6 +30,7 @@ export default async function AdminUsersPage({ searchParams }: Props) {
|
||||
balance: true,
|
||||
researchPoints: true,
|
||||
isAdmin: true,
|
||||
isHidden: true,
|
||||
createdAt: true,
|
||||
_count: { select: { trades: true, positions: true } },
|
||||
},
|
||||
@@ -73,7 +74,7 @@ export default async function AdminUsersPage({ searchParams }: Props) {
|
||||
</thead>
|
||||
<tbody className="divide-y divide-surface-border">
|
||||
{users.map((user) => (
|
||||
<tr key={user.id} className="hover:bg-surface-hover transition-colors">
|
||||
<tr key={user.id} className={`hover:bg-surface-hover transition-colors ${user.isHidden ? 'opacity-50' : ''}`}>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<a href={`/profile/${user.username}`} className="hover:text-indigo-300">
|
||||
@@ -84,6 +85,11 @@ export default async function AdminUsersPage({ searchParams }: Props) {
|
||||
admin
|
||||
</span>
|
||||
)}
|
||||
{user.isHidden && (
|
||||
<span className="text-xs bg-slate-500/20 text-slate-400 px-1.5 rounded">
|
||||
hidden
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3 font-medium">{formatCurrency(user.balance)}</td>
|
||||
|
||||
@@ -8,6 +8,7 @@ const schema = z.object({
|
||||
balance: z.number().min(0).optional(),
|
||||
researchPoints: z.number().int().min(0).optional(),
|
||||
isAdmin: z.boolean().optional(),
|
||||
isHidden: z.boolean().optional(),
|
||||
resetLotteryAt: z.boolean().optional(),
|
||||
})
|
||||
|
||||
@@ -31,7 +32,7 @@ export async function PATCH(req: NextRequest, { params }: { params: { userId: st
|
||||
...rest,
|
||||
...(resetLotteryAt ? { lastLotteryAt: null } : {}),
|
||||
},
|
||||
select: { id: true, username: true, balance: true, researchPoints: true, isAdmin: true },
|
||||
select: { id: true, username: true, balance: true, researchPoints: true, isAdmin: true, isHidden: true },
|
||||
})
|
||||
|
||||
return NextResponse.json(updated)
|
||||
|
||||
+229
-96
@@ -2,14 +2,15 @@ import { prisma } from '@/lib/prisma'
|
||||
import { getServerSession } from 'next-auth'
|
||||
import { authOptions } from '@/lib/auth'
|
||||
import { formatCurrency } from '@/lib/utils'
|
||||
import { calcFundNav } from '@/lib/pricing'
|
||||
import Link from 'next/link'
|
||||
import { Trophy, TrendingUp, TrendingDown } from 'lucide-react'
|
||||
import { Trophy, TrendingUp, TrendingDown, Building2, Users } from 'lucide-react'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
async function getLeaderboard() {
|
||||
// Fetch all users with their open positions (to calculate net worth)
|
||||
const users = await prisma.user.findMany({
|
||||
where: { isFund: false, isHidden: false },
|
||||
select: {
|
||||
id: true,
|
||||
username: true,
|
||||
@@ -52,10 +53,66 @@ async function getLeaderboard() {
|
||||
.slice(0, 50)
|
||||
}
|
||||
|
||||
export default async function LeaderboardPage() {
|
||||
const [session, players] = await Promise.all([
|
||||
async function getFundLeaderboard() {
|
||||
const funds = await prisma.hedgeFund.findMany({
|
||||
include: {
|
||||
user: {
|
||||
select: {
|
||||
balance: true,
|
||||
positions: {
|
||||
where: { shares: { gt: 0 } },
|
||||
select: {
|
||||
positionType: true,
|
||||
shares: true,
|
||||
avgBuyPrice: true,
|
||||
hashtag: { select: { currentPrice: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
managers: { select: { userId: true } },
|
||||
_count: { select: { investments: true } },
|
||||
},
|
||||
orderBy: { createdAt: 'asc' },
|
||||
})
|
||||
|
||||
return funds
|
||||
.map((f) => {
|
||||
const portfolioValue = f.user.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 totalValue = f.user.balance + portfolioValue
|
||||
const nav = calcFundNav(totalValue, f.sharesOutstanding)
|
||||
return {
|
||||
id: f.id,
|
||||
name: f.name,
|
||||
slug: f.slug,
|
||||
cash: f.user.balance,
|
||||
portfolioValue,
|
||||
totalValue,
|
||||
nav,
|
||||
sharesOutstanding: f.sharesOutstanding,
|
||||
managerCount: f.managers.length,
|
||||
investorCount: f._count.investments,
|
||||
}
|
||||
})
|
||||
.sort((a, b) => b.totalValue - a.totalValue)
|
||||
}
|
||||
|
||||
export default async function LeaderboardPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: { tab?: string }
|
||||
}) {
|
||||
const tab = searchParams.tab === 'funds' ? 'funds' : 'players'
|
||||
|
||||
const [session, players, funds] = await Promise.all([
|
||||
getServerSession(authOptions),
|
||||
getLeaderboard(),
|
||||
getFundLeaderboard(),
|
||||
])
|
||||
|
||||
const myRank = session ? players.findIndex((p) => p.id === session.user.id) + 1 : 0
|
||||
@@ -66,105 +123,181 @@ export default async function LeaderboardPage() {
|
||||
<Trophy className="h-7 w-7 text-amber-400" />
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Leaderboard</h1>
|
||||
<p className="text-sm text-slate-400">Top 50 players by net worth (cash + open positions)</p>
|
||||
<p className="text-sm text-slate-400">Ranked by net worth (cash + open positions)</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{session && myRank > 0 && (
|
||||
<div className="bg-indigo-500/10 border border-indigo-500/30 rounded-xl px-4 py-3 text-sm">
|
||||
You are ranked <span className="font-bold text-indigo-300">#{myRank}</span> with a net worth of{' '}
|
||||
<span className="font-bold text-indigo-300">
|
||||
{formatCurrency(players[myRank - 1].netWorth)}
|
||||
</span>
|
||||
</div>
|
||||
{/* Tabs */}
|
||||
<div className="flex gap-1 bg-surface-card border border-surface-border rounded-xl p-1 w-fit">
|
||||
<Link
|
||||
href="/leaderboard?tab=players"
|
||||
className={`flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
|
||||
tab === 'players' ? 'bg-indigo-600 text-white' : 'text-slate-400 hover:text-slate-200'
|
||||
}`}
|
||||
>
|
||||
<Users className="h-4 w-4" />
|
||||
Players
|
||||
</Link>
|
||||
<Link
|
||||
href="/leaderboard?tab=funds"
|
||||
className={`flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
|
||||
tab === 'funds' ? 'bg-indigo-600 text-white' : 'text-slate-400 hover:text-slate-200'
|
||||
}`}
|
||||
>
|
||||
<Building2 className="h-4 w-4" />
|
||||
Hedge Funds
|
||||
{funds.length > 0 && (
|
||||
<span className="text-xs bg-white/10 px-1.5 py-0.5 rounded-full">{funds.length}</span>
|
||||
)}
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{tab === 'players' && (
|
||||
<>
|
||||
{session && myRank > 0 && (
|
||||
<div className="bg-indigo-500/10 border border-indigo-500/30 rounded-xl px-4 py-3 text-sm">
|
||||
You are ranked <span className="font-bold text-indigo-300">#{myRank}</span> with a net worth of{' '}
|
||||
<span className="font-bold text-indigo-300">
|
||||
{formatCurrency(players[myRank - 1].netWorth)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="bg-surface-card border border-surface-border rounded-xl overflow-hidden">
|
||||
<div className="grid grid-cols-[2.5rem_1fr_repeat(3,_8rem)] gap-2 px-4 py-2 text-xs text-slate-500 border-b border-surface-border">
|
||||
<span>#</span>
|
||||
<span>Player</span>
|
||||
<span className="text-right">Net worth</span>
|
||||
<span className="text-right hidden sm:block">Cash</span>
|
||||
<span className="text-right hidden sm:block">Trades</span>
|
||||
</div>
|
||||
|
||||
{players.length === 0 && (
|
||||
<p className="text-center py-12 text-slate-500">No players yet.</p>
|
||||
)}
|
||||
|
||||
<div className="divide-y divide-surface-border">
|
||||
{players.map((player, i) => {
|
||||
const rank = i + 1
|
||||
const isMe = session?.user.id === player.id
|
||||
const rankIcon = rank === 1 ? '🥇' : rank === 2 ? '🥈' : rank === 3 ? '🥉' : null
|
||||
|
||||
return (
|
||||
<div
|
||||
key={player.id}
|
||||
className={`grid grid-cols-[2.5rem_1fr_repeat(3,_8rem)] gap-2 items-center px-4 py-3 text-sm ${
|
||||
isMe ? 'bg-indigo-500/5' : 'hover:bg-surface-hover'
|
||||
} transition-colors`}
|
||||
>
|
||||
<span className={`font-bold ${rank <= 3 ? 'text-amber-400' : 'text-slate-500'}`}>
|
||||
{rankIcon ?? `#${rank}`}
|
||||
</span>
|
||||
<Link
|
||||
href={`/profile/${player.username}`}
|
||||
className={`font-medium hover:text-indigo-300 transition-colors ${isMe ? 'text-indigo-300' : ''}`}
|
||||
>
|
||||
{player.displayUsername ?? player.username}
|
||||
{isMe && <span className="ml-2 text-xs text-slate-500">(you)</span>}
|
||||
</Link>
|
||||
<span className="text-right font-bold">{formatCurrency(player.netWorth)}</span>
|
||||
<span className="text-right text-slate-400 hidden sm:block">
|
||||
{formatCurrency(player.balance)}
|
||||
</span>
|
||||
<span className="text-right text-slate-400 hidden sm:block">
|
||||
{player.tradeCount}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Unrealized P&L podium */}
|
||||
{players.slice(0, 3).some((p) => p.unrealizedPnl !== 0) && (
|
||||
<section>
|
||||
<h2 className="text-sm font-semibold text-slate-400 mb-3 flex items-center gap-2">
|
||||
<TrendingUp className="h-4 w-4" />
|
||||
Top unrealized gainers (from open positions)
|
||||
</h2>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
{players
|
||||
.slice()
|
||||
.sort((a, b) => b.unrealizedPnl - a.unrealizedPnl)
|
||||
.slice(0, 3)
|
||||
.map((p) => (
|
||||
<div key={p.id} className="bg-surface-card border border-surface-border rounded-xl p-3 text-center">
|
||||
<Link href={`/profile/${p.username}`} className="font-medium text-sm hover:text-indigo-300">
|
||||
{p.displayUsername ?? p.username}
|
||||
</Link>
|
||||
<p className={`text-sm font-bold mt-1 ${p.unrealizedPnl >= 0 ? 'text-emerald-400' : 'text-red-400'}`}>
|
||||
{p.unrealizedPnl >= 0 ? (
|
||||
<span className="flex items-center justify-center gap-1">
|
||||
<TrendingUp className="h-3 w-3" />
|
||||
+{formatCurrency(p.unrealizedPnl)}
|
||||
</span>
|
||||
) : (
|
||||
<span className="flex items-center justify-center gap-1">
|
||||
<TrendingDown className="h-3 w-3" />
|
||||
{formatCurrency(p.unrealizedPnl)}
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="bg-surface-card border border-surface-border rounded-xl overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="grid grid-cols-[2.5rem_1fr_repeat(3,_8rem)] gap-2 px-4 py-2 text-xs text-slate-500 border-b border-surface-border">
|
||||
<span>#</span>
|
||||
<span>Player</span>
|
||||
<span className="text-right">Net worth</span>
|
||||
<span className="text-right hidden sm:block">Cash</span>
|
||||
<span className="text-right hidden sm:block">Trades</span>
|
||||
</div>
|
||||
|
||||
{players.length === 0 && (
|
||||
<p className="text-center py-12 text-slate-500">No players yet.</p>
|
||||
)}
|
||||
|
||||
<div className="divide-y divide-surface-border">
|
||||
{players.map((player, i) => {
|
||||
const rank = i + 1
|
||||
const isMe = session?.user.id === player.id
|
||||
const rankIcon =
|
||||
rank === 1 ? '🥇' : rank === 2 ? '🥈' : rank === 3 ? '🥉' : null
|
||||
|
||||
return (
|
||||
<div
|
||||
key={player.id}
|
||||
className={`grid grid-cols-[2.5rem_1fr_repeat(3,_8rem)] gap-2 items-center px-4 py-3 text-sm ${
|
||||
isMe ? 'bg-indigo-500/5' : 'hover:bg-surface-hover'
|
||||
} transition-colors`}
|
||||
>
|
||||
<span className={`font-bold ${rank <= 3 ? 'text-amber-400' : 'text-slate-500'}`}>
|
||||
{rankIcon ?? `#${rank}`}
|
||||
</span>
|
||||
<Link
|
||||
href={`/profile/${player.username}`}
|
||||
className={`font-medium hover:text-indigo-300 transition-colors ${isMe ? 'text-indigo-300' : ''}`}
|
||||
>
|
||||
{player.displayUsername ?? player.username}
|
||||
{isMe && <span className="ml-2 text-xs text-slate-500">(you)</span>}
|
||||
</Link>
|
||||
<span className="text-right font-bold">{formatCurrency(player.netWorth)}</span>
|
||||
<span className="text-right text-slate-400 hidden sm:block">
|
||||
{formatCurrency(player.balance)}
|
||||
</span>
|
||||
<span className="text-right text-slate-400 hidden sm:block">
|
||||
{player.tradeCount}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Unrealized P&L podium */}
|
||||
{players.slice(0, 3).some((p) => p.unrealizedPnl !== 0) && (
|
||||
<section>
|
||||
<h2 className="text-sm font-semibold text-slate-400 mb-3 flex items-center gap-2">
|
||||
<TrendingUp className="h-4 w-4" />
|
||||
Top unrealized gainers (from open positions)
|
||||
</h2>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
{players
|
||||
.slice()
|
||||
.sort((a, b) => b.unrealizedPnl - a.unrealizedPnl)
|
||||
.slice(0, 3)
|
||||
.map((p) => (
|
||||
<div key={p.id} className="bg-surface-card border border-surface-border rounded-xl p-3 text-center">
|
||||
<Link href={`/profile/${p.username}`} className="font-medium text-sm hover:text-indigo-300">
|
||||
{p.displayUsername ?? p.username}
|
||||
</Link>
|
||||
<p className={`text-sm font-bold mt-1 ${p.unrealizedPnl >= 0 ? 'text-emerald-400' : 'text-red-400'}`}>
|
||||
{p.unrealizedPnl >= 0 ? (
|
||||
<span className="flex items-center justify-center gap-1">
|
||||
<TrendingUp className="h-3 w-3" />
|
||||
+{formatCurrency(p.unrealizedPnl)}
|
||||
</span>
|
||||
) : (
|
||||
<span className="flex items-center justify-center gap-1">
|
||||
<TrendingDown className="h-3 w-3" />
|
||||
{formatCurrency(p.unrealizedPnl)}
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
{tab === 'funds' && (
|
||||
<div className="bg-surface-card border border-surface-border rounded-xl overflow-hidden">
|
||||
<div className="grid grid-cols-[2.5rem_1fr_repeat(3,_7rem)] gap-2 px-4 py-2 text-xs text-slate-500 border-b border-surface-border">
|
||||
<span>#</span>
|
||||
<span>Fund</span>
|
||||
<span className="text-right">Total value</span>
|
||||
<span className="text-right hidden sm:block">NAV/share</span>
|
||||
<span className="text-right hidden sm:block">Investors</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{funds.length === 0 && (
|
||||
<p className="text-center py-12 text-slate-500">No funds yet.</p>
|
||||
)}
|
||||
|
||||
<div className="divide-y divide-surface-border">
|
||||
{funds.map((fund, i) => {
|
||||
const rank = i + 1
|
||||
const rankIcon = rank === 1 ? '🥇' : rank === 2 ? '🥈' : rank === 3 ? '🥉' : null
|
||||
|
||||
return (
|
||||
<div
|
||||
key={fund.id}
|
||||
className="grid grid-cols-[2.5rem_1fr_repeat(3,_7rem)] gap-2 items-center px-4 py-3 text-sm hover:bg-surface-hover transition-colors"
|
||||
>
|
||||
<span className={`font-bold ${rank <= 3 ? 'text-amber-400' : 'text-slate-500'}`}>
|
||||
{rankIcon ?? `#${rank}`}
|
||||
</span>
|
||||
<Link
|
||||
href={`/fund/${fund.slug}`}
|
||||
className="font-medium hover:text-indigo-300 transition-colors flex items-center gap-2"
|
||||
>
|
||||
<Building2 className="h-3.5 w-3.5 text-indigo-400 shrink-0" />
|
||||
{fund.name}
|
||||
</Link>
|
||||
<span className="text-right font-bold">{formatCurrency(fund.totalValue)}</span>
|
||||
<span className="text-right text-slate-400 hidden sm:block">
|
||||
{formatCurrency(fund.nav)}
|
||||
</span>
|
||||
<span className="text-right text-slate-400 hidden sm:block">
|
||||
{fund.investorCount}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
+1
-22
@@ -230,11 +230,7 @@ const schedulerWorker = new Worker(
|
||||
)
|
||||
}
|
||||
|
||||
const [waitingAfter, activeAfter] = await Promise.all([
|
||||
priceUpdateQueue.getWaitingCount(),
|
||||
priceUpdateQueue.getActiveCount(),
|
||||
])
|
||||
console.log(`[scheduler] queued ${toQueue.length} price-update jobs (${hashtags.length - toQueue.length} already waiting) — queue now: waiting=${waitingAfter} active=${activeAfter}`)
|
||||
console.log(`[scheduler] queued ${toQueue.length} price-update jobs (${hashtags.length - toQueue.length} already in-flight)`)
|
||||
},
|
||||
{ connection: redisOpts() },
|
||||
)
|
||||
@@ -251,27 +247,10 @@ for (const [name, worker] of [['price', priceWorker], ['maintenance', maintenanc
|
||||
})
|
||||
}
|
||||
|
||||
// Diagnostic: log when price jobs complete or stall
|
||||
priceWorker.on('active', (job) => {
|
||||
console.log(`[price-worker] active (picked up) job ${job.id} (${job.data?.tag})`)
|
||||
})
|
||||
priceWorker.on('completed', (job) => {
|
||||
console.log(`[price-worker] completed job ${job.id} (${job.data?.tag})`)
|
||||
})
|
||||
priceWorker.on('stalled', (jobId) => {
|
||||
console.warn(`[price-worker] stalled job ${jobId} — lock expired, will retry`)
|
||||
})
|
||||
|
||||
// Log queue depths every 30 s so we can see if jobs are piling up or vanishing
|
||||
setInterval(async () => {
|
||||
const [waiting, active, failed] = await Promise.all([
|
||||
priceUpdateQueue.getWaitingCount(),
|
||||
priceUpdateQueue.getActiveCount(),
|
||||
priceUpdateQueue.getFailedCount(),
|
||||
])
|
||||
console.log(`[price-queue] waiting=${waiting} active=${active} failed=${failed}`)
|
||||
}, 30_000)
|
||||
|
||||
// ── Repeatable jobs ───────────────────────────────────────────────────────────
|
||||
|
||||
async function setupRepeatableJobs() {
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user