Merge branch 'refs/heads/feature/admin/releases'

# Conflicts:
#	src/server/api/routers/history.ts
This commit is contained in:
2025-06-11 18:48:05 +02:00
12 changed files with 714 additions and 3 deletions

View File

@@ -79,6 +79,7 @@
"usehooks-ts": "^3.1.1",
"uuid": "^11.1.0",
"vaul": "^1.1.2",
"zlib": "^1.0.5",
"zod": "^3.24.2",
},
"devDependencies": {
@@ -1137,6 +1138,8 @@
"which": ["which@4.0.0", "", { "dependencies": { "isexe": "^3.1.1" }, "bin": { "node-which": "bin/which.js" } }, "sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg=="],
"zlib": ["zlib@1.0.5", "", {}, "sha512-40fpE2II+Cd3k8HWTWONfeKE2jL+P42iWJ1zzps5W51qcTsOUKM5Q5m2PFb0CLxlmFAaUuUdJGc3OfZy947v0w=="],
"zod": ["zod@3.24.2", "", {}, "sha512-lY7CDW43ECgW9u1TcT3IoXHflywfVqDYze4waEz812jR/bZ8FHDsl7pFQoSZTz5N+2NqRXs8GBwnAwo3ZNxqhQ=="],
"zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="],

View File

@@ -94,6 +94,7 @@
"usehooks-ts": "^3.1.1",
"uuid": "^11.1.0",
"vaul": "^1.1.2",
"zlib": "^1.0.5",
"zod": "^3.24.2"
},
"devDependencies": {

View File

@@ -0,0 +1,50 @@
import { syncHistoryByDateRange } from '@/server/api/routers/history'
async function refreshHistoryByDate(startDate?: string, endDate?: string) {
try {
console.log('Refreshing history by date range...')
if (startDate) {
console.log(`Start date: ${startDate}`)
}
if (endDate) {
console.log(`End date: ${endDate}`)
}
await syncHistoryByDateRange(startDate, endDate)
console.log('History refresh completed successfully')
} catch (err) {
console.error('History refresh failed:', err)
throw err
}
}
// Parse command line arguments
function parseArgs() {
const args = process.argv.slice(2)
let startDate: string | undefined
let endDate: string | undefined
for (let i = 0; i < args.length; i++) {
if (args[i] === '--start-date' && i + 1 < args.length) {
startDate = args[i + 1]
i++
} else if (args[i] === '--end-date' && i + 1 < args.length) {
endDate = args[i + 1]
i++
}
}
return { startDate, endDate }
}
// Run if called directly
if (require.main === module) {
const { startDate, endDate } = parseArgs()
refreshHistoryByDate(startDate, endDate)
.then(() => process.exit(0))
.catch((err) => {
console.error('Refresh failed:', err)
process.exit(1)
})
}

View File

@@ -0,0 +1,35 @@
import { ReleasesClient } from '@/app/(home)/admin/releases/releases-client'
import { auth } from '@/server/auth'
import { HydrateClient, api } from '@/trpc/server'
import { Suspense } from 'react'
export default async function ReleasesPage() {
const session = await auth()
const isAdmin = session?.user.role === 'admin'
console.log(session)
if (!isAdmin) {
return (
<div className={'container mx-auto pt-8'}>
<div className={'prose'}>
<h1>Forbidden</h1>
</div>
</div>
)
}
await api.releases.getReleases.prefetch()
return (
<Suspense>
<HydrateClient>
<div
className={
'mx-auto flex w-[calc(100%-1rem)] max-w-fd-container flex-col gap-4 pt-16'
}
>
<ReleasesClient />
</div>
</HydrateClient>
</Suspense>
)
}

View File

@@ -0,0 +1,419 @@
'use client'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import { Textarea } from '@/components/ui/textarea'
import { api } from '@/trpc/react'
import { Pencil, Trash2 } from 'lucide-react'
import { useEffect, useState } from 'react'
import { useForm } from 'react-hook-form'
import { toast } from 'sonner'
export function ReleasesClient() {
const utils = api.useUtils()
const [releases] = api.releases.getReleases.useSuspenseQuery()
const addRelease = api.releases.addRelease.useMutation({
onSuccess: () => {
utils.releases.getReleases.invalidate()
toast.success('Release added successfully')
form.reset()
},
onError: (error) => {
toast.error(`Error adding release: ${error.message}`)
},
})
const updateRelease = api.releases.updateRelease.useMutation({
onSuccess: () => {
utils.releases.getReleases.invalidate()
toast.success('Release updated successfully')
setEditDialogOpen(false)
},
onError: (error) => {
toast.error(`Error updating release: ${error.message}`)
},
})
const deleteRelease = api.releases.deleteRelease.useMutation({
onSuccess: () => {
utils.releases.getReleases.invalidate()
toast.success('Release deleted successfully')
setDeleteDialogOpen(false)
},
onError: (error) => {
toast.error(`Error deleting release: ${error.message}`)
},
})
const [smodsVersions, setSmodsVersions] = useState<string[]>(['latest'])
const [lovelyVersions, setLovelyVersions] = useState<string[]>(['latest'])
const [editDialogOpen, setEditDialogOpen] = useState(false)
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false)
const [selectedRelease, setSelectedRelease] = useState<
(typeof releases)[0] | null
>(null)
const SMODS_RELEASES_URL =
'https://api.github.com/repos/Steamodded/smods/releases'
const LOVELY_RELEASES_BASE_URL =
'https://github.com/ethangreen-dev/lovely-injector/releases'
useEffect(() => {
// Fetch Steamodded versions
fetch(SMODS_RELEASES_URL)
.then((response) => response.json())
.then((data) => {
const versions = data.map((release: any) => release.tag_name)
setSmodsVersions(['latest', ...versions])
})
.catch((error) => {
console.error('Error fetching Steamodded versions:', error)
})
// Fetch lovely injector versions
// Since we don't have a direct API for lovely injector, we'll use GitHub API
fetch(
'https://api.github.com/repos/ethangreen-dev/lovely-injector/releases'
)
.then((response) => response.json())
.then((data) => {
const versions = data.map((release: any) => release.tag_name)
setLovelyVersions(['latest', ...versions])
})
.catch((error) => {
console.error('Error fetching lovely injector versions:', error)
})
}, [])
const form = useForm({
defaultValues: {
name: '',
version: '',
description: '',
url: '',
smods_version: 'latest',
lovely_version: 'latest',
},
})
const editForm = useForm({
defaultValues: {
id: 0,
name: '',
version: '',
description: '',
url: '',
smods_version: 'latest',
lovely_version: 'latest',
},
})
const handleEditRelease = (release: (typeof releases)[0]) => {
setSelectedRelease(release)
editForm.reset({
id: release.id,
name: release.name,
version: release.version,
description: release.description || '',
url: release.url,
smods_version: release.smods_version || 'latest',
lovely_version: release.lovely_version || 'latest',
})
setEditDialogOpen(true)
}
const handleDeleteRelease = (release: (typeof releases)[0]) => {
setSelectedRelease(release)
setDeleteDialogOpen(true)
}
return (
<div className='space-y-8'>
<h1 className='font-bold text-3xl'>Releases</h1>
<div className='overflow-hidden rounded-md border shadow-sm'>
<Table className='w-full table-auto'>
<TableHeader>
<TableRow className='bg-muted/50'>
<TableHead>Name</TableHead>
<TableHead>Version</TableHead>
<TableHead>Description</TableHead>
<TableHead>URL</TableHead>
<TableHead>Steamodded Version</TableHead>
<TableHead>Lovely Injector Version</TableHead>
<TableHead className='text-right'>Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{releases.map((release) => (
<TableRow key={release.id} className='hover:bg-muted/50'>
<TableCell className='font-medium'>{release.name}</TableCell>
<TableCell>{release.version}</TableCell>
<TableCell className='max-w-xs'>
<div className='truncate' title={release.description || ''}>
{release.description}
</div>
</TableCell>
<TableCell className='max-w-xs'>
<a
href={release.url}
target='_blank'
rel='noopener noreferrer'
className='text-blue-500 hover:underline'
title={release.url}
>
<div className='truncate'>{release.url}</div>
</a>
</TableCell>
<TableCell>{release.smods_version || 'latest'}</TableCell>
<TableCell>{release.lovely_version || 'latest'}</TableCell>
<TableCell className='space-x-2 text-right'>
<Button
variant='outline'
size='sm'
onClick={() => handleEditRelease(release)}
className='inline-flex items-center'
>
<Pencil className='mr-1 h-4 w-4' />
Edit
</Button>
<Button
variant='destructive'
size='sm'
onClick={() => handleDeleteRelease(release)}
className='inline-flex items-center text-white'
>
<Trash2 className='mr-1 h-4 w-4' />
Delete
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
<div className='mt-8 overflow-hidden rounded-md border bg-card shadow-sm'>
<div className='p-6'>
<h2 className='mb-4 font-semibold text-2xl'>Add New Release</h2>
<form
className='space-y-4'
onSubmit={form.handleSubmit((values) => addRelease.mutate(values))}
>
<div className='grid grid-cols-1 gap-2'>
<Label htmlFor='name'>Title</Label>
<Input id='name' {...form.register('name')} />
</div>
<div className='grid grid-cols-1 gap-2'>
<Label htmlFor='version'>Version</Label>
<Input id='version' {...form.register('version')} />
</div>
<div className='grid grid-cols-1 gap-2'>
<Label htmlFor='description'>Description</Label>
<Textarea id='description' {...form.register('description')} />
</div>
<div className='grid grid-cols-1 gap-2'>
<Label htmlFor='url'>URL</Label>
<Input id='url' {...form.register('url')} />
</div>
<div className='grid grid-cols-2 gap-4'>
<div className='grid grid-cols-1 gap-2'>
<Label htmlFor='smods_version'>Steamodded Version</Label>
<Select
defaultValue='latest'
onValueChange={(value) =>
form.setValue('smods_version', value)
}
>
<SelectTrigger id='smods_version'>
<SelectValue placeholder='Select Steamodded version' />
</SelectTrigger>
<SelectContent>
{smodsVersions.map((version) => (
<SelectItem key={version} value={version}>
{version}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className='grid grid-cols-1 gap-2'>
<Label htmlFor='lovely_version'>Lovely Injector Version</Label>
<Select
defaultValue='latest'
onValueChange={(value) =>
form.setValue('lovely_version', value)
}
>
<SelectTrigger id='lovely_version'>
<SelectValue placeholder='Select Lovely Injector version' />
</SelectTrigger>
<SelectContent>
{lovelyVersions.map((version) => (
<SelectItem key={version} value={version}>
{version}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<Button type='submit' className='w-full'>
Add new release
</Button>
</form>
</div>
</div>
{/* Edit Release Dialog */}
<Dialog open={editDialogOpen} onOpenChange={setEditDialogOpen}>
<DialogContent className='sm:max-w-[600px]'>
<DialogHeader>
<DialogTitle>Edit Release</DialogTitle>
<DialogDescription>
Make changes to the release. Click save when you're done.
</DialogDescription>
</DialogHeader>
<form
onSubmit={editForm.handleSubmit((values) =>
updateRelease.mutate(values)
)}
>
<div className='grid gap-4 py-4'>
<div className='grid grid-cols-1 gap-2'>
<Label htmlFor='edit-name'>Title</Label>
<Input id='edit-name' {...editForm.register('name')} />
</div>
<div className='grid grid-cols-1 gap-2'>
<Label htmlFor='edit-version'>Version</Label>
<Input id='edit-version' {...editForm.register('version')} />
</div>
<div className='grid grid-cols-1 gap-2'>
<Label htmlFor='edit-description'>Description</Label>
<Textarea
id='edit-description'
{...editForm.register('description')}
/>
</div>
<div className='grid grid-cols-1 gap-2'>
<Label htmlFor='edit-url'>URL</Label>
<Input id='edit-url' {...editForm.register('url')} />
</div>
<div className='grid grid-cols-2 gap-4'>
<div className='grid grid-cols-1 gap-2'>
<Label htmlFor='edit-smods_version'>Steamodded Version</Label>
<Select
value={editForm.watch('smods_version')}
onValueChange={(value) =>
editForm.setValue('smods_version', value)
}
>
<SelectTrigger id='edit-smods_version'>
<SelectValue placeholder='Select Steamodded version' />
</SelectTrigger>
<SelectContent>
{smodsVersions.map((version) => (
<SelectItem key={version} value={version}>
{version}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className='grid grid-cols-1 gap-2'>
<Label htmlFor='edit-lovely_version'>
Lovely Injector Version
</Label>
<Select
value={editForm.watch('lovely_version')}
onValueChange={(value) =>
editForm.setValue('lovely_version', value)
}
>
<SelectTrigger id='edit-lovely_version'>
<SelectValue placeholder='Select Lovely Injector version' />
</SelectTrigger>
<SelectContent>
{lovelyVersions.map((version) => (
<SelectItem key={version} value={version}>
{version}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
</div>
<DialogFooter>
<Button
type='button'
variant='outline'
onClick={() => setEditDialogOpen(false)}
>
Cancel
</Button>
<Button type='submit'>Save changes</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
{/* Delete Confirmation Dialog */}
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Are you sure?</AlertDialogTitle>
<AlertDialogDescription>
This action cannot be undone. This will permanently delete the
release
{selectedRelease && <strong> "{selectedRelease.name}"</strong>}.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
className='bg-red-600 text-white hover:bg-red-700'
onClick={() =>
selectedRelease &&
deleteRelease.mutate({ id: selectedRelease.id })
}
>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
)
}

View File

@@ -0,0 +1,41 @@
import { env } from '@/env'
import { syncHistoryByDateRange } from '@/server/api/routers/history'
import { headers } from 'next/headers'
const SECURE_TOKEN = env.CRON_SECRET
export async function POST(request: Request) {
const headersList = await headers()
const authToken = headersList.get('authorization')?.replace('Bearer ', '')
if (authToken !== SECURE_TOKEN) {
return new Response('unauthorized', { status: 401 })
}
try {
// Parse request body to get date range parameters
const body = await request.json().catch(() => ({}))
const startDate = body.start_date
const endDate = body.end_date
try {
console.log('refreshing history by date range...')
if (startDate) {
console.log(`Start date: ${startDate}`)
}
if (endDate) {
console.log(`End date: ${endDate}`)
}
await syncHistoryByDateRange(startDate, endDate)
} catch (err) {
console.error('history refresh by date range failed:', err)
return new Response('internal error', { status: 500 })
}
return Response.json({ success: true })
} catch (err) {
console.error('refresh failed:', err)
return new Response('internal error', { status: 500 })
}
}

View File

@@ -0,0 +1,8 @@
import { db } from '@/server/db'
import { releases } from '@/server/db/schema'
export async function GET() {
const res = await db.select().from(releases)
return Response.json(res)
}

View File

@@ -2,6 +2,7 @@ import { discord_router } from '@/server/api/routers/discord'
import { history_router } from '@/server/api/routers/history'
import { leaderboard_router } from '@/server/api/routers/leaderboard'
import { playerStateRouter } from '@/server/api/routers/player-state'
import { releasesRouter } from '@/server/api/routers/releases'
import { createCallerFactory, createTRPCRouter } from '@/server/api/trpc'
/**
@@ -14,6 +15,7 @@ export const appRouter = createTRPCRouter({
discord: discord_router,
leaderboard: leaderboard_router,
playerState: playerStateRouter,
releases: releasesRouter,
})
// export type definition of API

View File

@@ -91,9 +91,6 @@ export const history_router = createTRPCRouter({
groupBy,
}))
}),
sync: publicProcedure.mutation(async () => {
return syncHistory()
}),
user_games: publicProcedure
.input(
z.object({
@@ -107,6 +104,19 @@ export const history_router = createTRPCRouter({
.where(eq(player_games.playerId, input.user_id))
.orderBy(desc(player_games.gameNum))
}),
sync: publicProcedure.mutation(async () => {
return syncHistory()
}),
syncByDateRange: publicProcedure
.input(
z.object({
start_date: z.string().optional(),
end_date: z.string().optional(),
})
)
.mutation(async ({ input }) => {
return syncHistoryByDateRange(input.start_date, input.end_date)
}),
})
export async function syncHistory() {
@@ -164,6 +174,36 @@ export async function syncHistory() {
return data
}
export async function syncHistoryByDateRange(
start_date?: string,
end_date?: string
) {
const searchParams: Record<string, string> = {}
if (start_date) {
searchParams.start_date = start_date
}
if (end_date) {
searchParams.end_date = end_date
}
const data = await ky
.get('https://api.neatqueue.com/api/history/1226193436521267223', {
searchParams,
timeout: 60000,
})
.json<any>()
const chunkedData = chunk(data.data, 100)
for (const chunk of chunkedData) {
await insertGameHistory(chunk).catch((e) => {
console.error(e)
})
}
return data
}
function processGameEntry(gameId: number, game_num: number, entry: any) {
const parsedEntry = typeof entry === 'string' ? JSON.parse(entry) : entry
if (parsedEntry.game === '1v1-attrition') {

View File

@@ -0,0 +1,83 @@
import {
adminProcedure,
createTRPCRouter,
publicProcedure,
} from '@/server/api/trpc'
import { db } from '@/server/db'
import { releases } from '@/server/db/schema'
import { z } from 'zod'
import { eq } from 'drizzle-orm'
export const releasesRouter = createTRPCRouter({
getReleases: publicProcedure.query(async () => {
const res = await db.select().from(releases)
return res
}),
addRelease: adminProcedure
.input(
z.object({
version: z.string(),
url: z.string(),
name: z.string(),
description: z.string(),
smods_version: z.string().default('latest'),
lovely_version: z.string().default('latest'),
})
)
.mutation(async ({ input }) => {
const res = await db
.insert(releases)
.values({
version: input.version,
url: input.url,
name: input.name,
description: input.description,
smods_version: input.smods_version,
lovely_version: input.lovely_version,
})
.returning()
return res[0]
}),
updateRelease: adminProcedure
.input(
z.object({
id: z.number(),
version: z.string(),
url: z.string(),
name: z.string(),
description: z.string(),
smods_version: z.string().default('latest'),
lovely_version: z.string().default('latest'),
})
)
.mutation(async ({ input }) => {
const res = await db
.update(releases)
.set({
version: input.version,
url: input.url,
name: input.name,
description: input.description,
smods_version: input.smods_version,
lovely_version: input.lovely_version,
})
.where(eq(releases.id, input.id))
.returning()
return res[0]
}),
deleteRelease: adminProcedure
.input(
z.object({
id: z.number(),
})
)
.mutation(async ({ input }) => {
await db
.delete(releases)
.where(eq(releases.id, input.id))
return { success: true }
}),
})

View File

@@ -131,3 +131,17 @@ export const protectedProcedure = t.procedure
},
})
})
export const adminProcedure = t.procedure
.use(timingMiddleware)
.use(({ ctx, next }) => {
if (!ctx.session?.user || ctx.session.user.role !== 'admin') {
throw new TRPCError({ code: 'FORBIDDEN' })
}
return next({
ctx: {
// infers the `session` as non-nullable
session: { ...ctx.session, user: ctx.session.user },
},
})
})

View File

@@ -124,3 +124,18 @@ export const verificationTokens = pgTable(
}),
(t) => [primaryKey({ columns: [t.identifier, t.token] })]
)
export const releases = pgTable('mod_release', {
id: integer('id').primaryKey().generatedByDefaultAsIdentity(),
name: text('name').notNull(),
description: text('description'),
version: text('version').notNull(),
url: text('url').notNull(),
smods_version: text('smods_version').default('latest'),
lovely_version: text('lovely_version').default('latest'),
createdAt: timestamp('created_at').notNull().defaultNow(),
updatedAt: timestamp('updated_at')
.notNull()
.defaultNow()
.$onUpdate(() => new Date()),
})