diff --git a/bun.lock b/bun.lock
index 3219264..29c4b75 100644
--- a/bun.lock
+++ b/bun.lock
@@ -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=="],
diff --git a/package.json b/package.json
index 2b60d78..86921d5 100644
--- a/package.json
+++ b/package.json
@@ -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": {
diff --git a/scripts/refresh-history-by-date.ts b/scripts/refresh-history-by-date.ts
new file mode 100644
index 0000000..a15c920
--- /dev/null
+++ b/scripts/refresh-history-by-date.ts
@@ -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)
+ })
+}
\ No newline at end of file
diff --git a/src/app/(home)/admin/releases/page.tsx b/src/app/(home)/admin/releases/page.tsx
new file mode 100644
index 0000000..c1c5c86
--- /dev/null
+++ b/src/app/(home)/admin/releases/page.tsx
@@ -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 (
+
+ )
+ }
+
+ await api.releases.getReleases.prefetch()
+
+ return (
+
+
+
+
+
+
+
+ )
+}
diff --git a/src/app/(home)/admin/releases/releases-client.tsx b/src/app/(home)/admin/releases/releases-client.tsx
new file mode 100644
index 0000000..7484741
--- /dev/null
+++ b/src/app/(home)/admin/releases/releases-client.tsx
@@ -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(['latest'])
+ const [lovelyVersions, setLovelyVersions] = useState(['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 (
+
+
Releases
+
+
+
+
+ Name
+ Version
+ Description
+ URL
+ Steamodded Version
+ Lovely Injector Version
+ Actions
+
+
+
+ {releases.map((release) => (
+
+ {release.name}
+ {release.version}
+
+
+ {release.description}
+
+
+
+
+ {release.url}
+
+
+ {release.smods_version || 'latest'}
+ {release.lovely_version || 'latest'}
+
+
+
+
+
+ ))}
+
+
+
+
+
+
+ {/* Edit Release Dialog */}
+
+
+ {/* Delete Confirmation Dialog */}
+
+
+
+ Are you sure?
+
+ This action cannot be undone. This will permanently delete the
+ release
+ {selectedRelease && "{selectedRelease.name}"}.
+
+
+
+ Cancel
+
+ selectedRelease &&
+ deleteRelease.mutate({ id: selectedRelease.id })
+ }
+ >
+ Delete
+
+
+
+
+
+ )
+}
diff --git a/src/app/api/refresh-history-by-date/route.ts b/src/app/api/refresh-history-by-date/route.ts
new file mode 100644
index 0000000..612221d
--- /dev/null
+++ b/src/app/api/refresh-history-by-date/route.ts
@@ -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 })
+ }
+}
\ No newline at end of file
diff --git a/src/app/api/releases/route.ts b/src/app/api/releases/route.ts
new file mode 100644
index 0000000..a69d24d
--- /dev/null
+++ b/src/app/api/releases/route.ts
@@ -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)
+}
diff --git a/src/server/api/root.ts b/src/server/api/root.ts
index aa5f044..e21cf2e 100644
--- a/src/server/api/root.ts
+++ b/src/server/api/root.ts
@@ -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
diff --git a/src/server/api/routers/history.ts b/src/server/api/routers/history.ts
index 58d4255..4116ad1 100644
--- a/src/server/api/routers/history.ts
+++ b/src/server/api/routers/history.ts
@@ -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 = {}
+
+ 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()
+
+ 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') {
diff --git a/src/server/api/routers/releases.ts b/src/server/api/routers/releases.ts
new file mode 100644
index 0000000..75aa9c0
--- /dev/null
+++ b/src/server/api/routers/releases.ts
@@ -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 }
+ }),
+})
diff --git a/src/server/api/trpc.ts b/src/server/api/trpc.ts
index 4974a32..920f978 100644
--- a/src/server/api/trpc.ts
+++ b/src/server/api/trpc.ts
@@ -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 },
+ },
+ })
+ })
diff --git a/src/server/db/schema.ts b/src/server/db/schema.ts
index cc047bb..2ee5845 100644
--- a/src/server/db/schema.ts
+++ b/src/server/db/schema.ts
@@ -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()),
+})