refactor: convert to bun workspaces monorepo
- Move web app into apps/web/ - Create packages/shared/ with shared types - Create plugins/decky-vault/ scaffold - Root package.json manages workspaces only
This commit is contained in:
@@ -0,0 +1,137 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest"
|
||||
import { db } from "@/lib/db/index"
|
||||
import { checkAndAutoPin } from "@/lib/api/auto-pin"
|
||||
|
||||
// Mock drizzle-orm to avoid ESM named-export resolution errors between test files
|
||||
vi.mock("drizzle-orm", () => ({
|
||||
eq: vi.fn((col, val) => ({ col, val })),
|
||||
}))
|
||||
vi.mock("drizzle-orm/pg-core", () => ({
|
||||
pgTable: vi.fn((name, columns, indexes) => ({ name, columns, indexes })),
|
||||
pgEnum: vi.fn((name, values) => ({ name, values })),
|
||||
text: vi.fn((name) => name),
|
||||
integer: vi.fn((name) => name),
|
||||
real: vi.fn((name) => name),
|
||||
boolean: vi.fn((name) => name),
|
||||
timestamp: vi.fn((name) => name),
|
||||
jsonb: vi.fn((name) => name),
|
||||
index: vi.fn((name) => ({ on: vi.fn() })),
|
||||
}))
|
||||
|
||||
// Mock db schema — only the symbols the module under test references
|
||||
vi.mock("@/lib/db/schema", () => ({
|
||||
performanceEntries: {
|
||||
id: "id",
|
||||
isPinned: "is_pinned",
|
||||
isRemoved: "is_removed",
|
||||
upvotes: "upvotes",
|
||||
downvotes: "downvotes",
|
||||
},
|
||||
}))
|
||||
|
||||
// Mock the database
|
||||
vi.mock("@/lib/db/index", () => ({
|
||||
db: {
|
||||
select: vi.fn(),
|
||||
update: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
function setupMockEntry(overrides: {
|
||||
id?: string
|
||||
isPinned?: boolean
|
||||
isRemoved?: boolean
|
||||
upvotes?: number
|
||||
downvotes?: number
|
||||
}) {
|
||||
const entry = {
|
||||
id: overrides.id ?? "test-entry-1",
|
||||
isPinned: overrides.isPinned ?? false,
|
||||
isRemoved: overrides.isRemoved ?? false,
|
||||
upvotes: overrides.upvotes ?? 0,
|
||||
downvotes: overrides.downvotes ?? 0,
|
||||
}
|
||||
|
||||
// Mock select chain: .select(...).from(...).where(...).limit(...)
|
||||
const limitMock = vi.fn().mockResolvedValue([entry])
|
||||
const whereMock = vi.fn().mockReturnValue({ limit: limitMock })
|
||||
const fromMock = vi.fn().mockReturnValue({ where: whereMock })
|
||||
const selectMock = vi.fn().mockReturnValue({ from: fromMock })
|
||||
;(db.select as any).mockReturnValue({ from: fromMock })
|
||||
|
||||
// Mock update chain: .update(...).set(...).where(...)
|
||||
const updateWhereMock = vi.fn().mockResolvedValue(undefined)
|
||||
const updateSetMock = vi.fn().mockReturnValue({ where: updateWhereMock })
|
||||
const updateMock = vi.fn().mockReturnValue({ set: updateSetMock })
|
||||
;(db.update as any).mockImplementation(updateMock)
|
||||
|
||||
return entry
|
||||
}
|
||||
|
||||
describe("checkAndAutoPin", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it("pins an entry with 8 upvotes and 2 downvotes (80% approval, 10 total)", async () => {
|
||||
setupMockEntry({ upvotes: 8, downvotes: 2 })
|
||||
|
||||
const result = await checkAndAutoPin("test-entry-1")
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
|
||||
it("does NOT pin an entry with 7 upvotes and 1 downvote (< 8 absolute upvotes)", async () => {
|
||||
setupMockEntry({ upvotes: 7, downvotes: 1 })
|
||||
|
||||
const result = await checkAndAutoPin("test-entry-1")
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
|
||||
it("does NOT pin an entry with 8 upvotes and 0 downvotes (< 10 total votes)", async () => {
|
||||
setupMockEntry({ upvotes: 8, downvotes: 0 })
|
||||
|
||||
const result = await checkAndAutoPin("test-entry-1")
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
|
||||
it("does NOT pin an entry with 6 upvotes and 4 downvotes (60% approval, below 80%)", async () => {
|
||||
setupMockEntry({ upvotes: 6, downvotes: 4 })
|
||||
|
||||
const result = await checkAndAutoPin("test-entry-1")
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
|
||||
it("does NOT pin an already-pinned entry", async () => {
|
||||
setupMockEntry({ isPinned: true, upvotes: 20, downvotes: 0 })
|
||||
|
||||
const result = await checkAndAutoPin("test-entry-1")
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
|
||||
it("does NOT pin a removed entry", async () => {
|
||||
setupMockEntry({ isRemoved: true, upvotes: 20, downvotes: 0 })
|
||||
|
||||
const result = await checkAndAutoPin("test-entry-1")
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
|
||||
it("pins an entry with 20 upvotes and 5 downvotes (80% approval exactly)", async () => {
|
||||
setupMockEntry({ upvotes: 20, downvotes: 5 })
|
||||
|
||||
const result = await checkAndAutoPin("test-entry-1")
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
|
||||
it("does NOT pin when entry is not found", async () => {
|
||||
// Mock select returning empty array
|
||||
const limitMock = vi.fn().mockResolvedValue([])
|
||||
const whereMock = vi.fn().mockReturnValue({ limit: limitMock })
|
||||
const fromMock = vi.fn().mockReturnValue({ where: whereMock })
|
||||
const selectMock = vi.fn().mockReturnValue({ from: fromMock })
|
||||
;(db.select as any).mockReturnValue({ from: fromMock })
|
||||
;(db.update as any) = vi.fn()
|
||||
|
||||
const result = await checkAndAutoPin("nonexistent")
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,64 @@
|
||||
import { describe, it, expect } from "vitest"
|
||||
import { loginEmailSchema } from "@/lib/auth/validation"
|
||||
|
||||
describe("check-email validation", () => {
|
||||
it("accepts valid email", () => {
|
||||
const result = loginEmailSchema.safeParse({ email: "user@example.com" })
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it("rejects missing email", () => {
|
||||
const result = loginEmailSchema.safeParse({})
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it("rejects invalid email format", () => {
|
||||
const result = loginEmailSchema.safeParse({ email: "not-email" })
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it("rejects empty string email", () => {
|
||||
const result = loginEmailSchema.safeParse({ email: "" })
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("check-email response contract", () => {
|
||||
it("returns { exists: boolean } on success", () => {
|
||||
const successShape = { exists: true }
|
||||
const failureShape = { exists: false }
|
||||
expect(successShape).toHaveProperty("exists")
|
||||
expect(failureShape).toHaveProperty("exists")
|
||||
expect(typeof successShape.exists).toBe("boolean")
|
||||
expect(typeof failureShape.exists).toBe("boolean")
|
||||
})
|
||||
|
||||
it("returns { error: string } on validation failure", () => {
|
||||
const errorShape = { error: "Invalid request body" }
|
||||
expect(errorShape).toHaveProperty("error")
|
||||
expect(typeof errorShape.error).toBe("string")
|
||||
})
|
||||
})
|
||||
|
||||
describe("check-email does NOT block deckyvault.xyz for login", () => {
|
||||
it("accepts a @deckyvault.xyz email through loginEmailSchema", () => {
|
||||
const result = loginEmailSchema.safeParse({
|
||||
email: "admin@deckyvault.xyz",
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it("accepts a @DECKYVAULT.XYZ email (uppercase) through loginEmailSchema", () => {
|
||||
const result = loginEmailSchema.safeParse({
|
||||
email: "admin@DECKYVAULT.XYZ",
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it("the response contract still matches { exists: boolean }", () => {
|
||||
const successShape = { exists: true }
|
||||
const failureShape = { exists: false }
|
||||
expect(typeof successShape.exists).toBe("boolean")
|
||||
expect(typeof failureShape.exists).toBe("boolean")
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,117 @@
|
||||
import { describe, it, expect } from "vitest"
|
||||
import { Elysia } from "elysia"
|
||||
import { isDeckyVaultEmail, DOMAIN_BLOCK_ERROR } from "@/lib/auth/domain-block"
|
||||
|
||||
/**
|
||||
* The domain-block onBeforeHandle handler – mirrors the logic
|
||||
* wired into the auth group in lib/api/app.ts. Tested in isolation
|
||||
* here so we don't need to stand up the full app / DB.
|
||||
*
|
||||
* The handler is typed loosely to avoid Elysia's complex context type
|
||||
* in test environments — the actual type safety is verified against
|
||||
* the real app.ts implementation at integration test time.
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const domainBlockOnBeforeHandle = async (context: any) => {
|
||||
const { request, set } = context as {
|
||||
request: Request
|
||||
set: { status: number }
|
||||
}
|
||||
const url = new URL(request.url)
|
||||
const isSignUp =
|
||||
url.pathname === "/api/auth/sign-up/email" &&
|
||||
request.method === "POST"
|
||||
|
||||
if (!isSignUp) return
|
||||
|
||||
if (process.env.NODE_ENV !== "development") {
|
||||
try {
|
||||
const cloned = request.clone()
|
||||
const body = await cloned.json()
|
||||
if (isDeckyVaultEmail(body.email)) {
|
||||
set.status = 400
|
||||
return { error: DOMAIN_BLOCK_ERROR }
|
||||
}
|
||||
} catch {
|
||||
// Malformed body — let Better Auth reject it downstream
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe("domain block middleware", () => {
|
||||
it("blocks POST /api/auth/sign-up/email with @deckyvault.xyz email", async () => {
|
||||
const app = new Elysia()
|
||||
.onBeforeHandle(domainBlockOnBeforeHandle)
|
||||
.post("/api/auth/sign-up/email", () => ({
|
||||
success: "should not reach this",
|
||||
}))
|
||||
|
||||
const res = await app.handle(
|
||||
new Request("http://localhost:3000/api/auth/sign-up/email", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
email: "bad@deckyvault.xyz",
|
||||
password: "abcdefghij",
|
||||
name: "Test",
|
||||
}),
|
||||
}),
|
||||
)
|
||||
|
||||
expect(res.status).toBe(400)
|
||||
const body = await res.json()
|
||||
expect(body.error).toBe(DOMAIN_BLOCK_ERROR)
|
||||
})
|
||||
|
||||
it("allows POST /api/auth/sign-up/email with non-deckyvault email", async () => {
|
||||
const app = new Elysia()
|
||||
.onBeforeHandle(domainBlockOnBeforeHandle)
|
||||
.post("/api/auth/sign-up/email", () => ({
|
||||
success: true,
|
||||
}))
|
||||
|
||||
const res = await app.handle(
|
||||
new Request("http://localhost:3000/api/auth/sign-up/email", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
email: "good@gmail.com",
|
||||
password: "abcdefghij",
|
||||
name: "Test",
|
||||
}),
|
||||
}),
|
||||
)
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
})
|
||||
|
||||
it("ignores non-sign-up routes (GET /api/auth/something)", async () => {
|
||||
const app = new Elysia()
|
||||
.onBeforeHandle(domainBlockOnBeforeHandle)
|
||||
.get("/api/auth/something", () => ({ ok: true }))
|
||||
|
||||
const res = await app.handle(
|
||||
new Request("http://localhost:3000/api/auth/something"),
|
||||
)
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
const body = await res.json()
|
||||
expect(body.ok).toBe(true)
|
||||
})
|
||||
|
||||
it("handles malformed JSON body gracefully (passes through)", async () => {
|
||||
const app = new Elysia()
|
||||
.onBeforeHandle(domainBlockOnBeforeHandle)
|
||||
.post("/api/auth/sign-up/email", () => ({ success: true }))
|
||||
|
||||
const res = await app.handle(
|
||||
new Request("http://localhost:3000/api/auth/sign-up/email", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: "not-valid-json",
|
||||
}),
|
||||
)
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,23 @@
|
||||
import { describe, it, expect } from "vitest"
|
||||
|
||||
describe("API version", () => {
|
||||
it("matches package.json version", () => {
|
||||
const pkg = require("../../../package.json")
|
||||
const expectedVersion = pkg.version
|
||||
expect(expectedVersion).toBe("2026.2.2")
|
||||
})
|
||||
})
|
||||
|
||||
describe("set-password endpoint security", () => {
|
||||
it("only accepts JSON body for newPassword (not URL-encoded or form-data)", () => {
|
||||
const allowedContentTypes = ["application/json"]
|
||||
const forbiddenContentTypes = [
|
||||
"application/x-www-form-urlencoded",
|
||||
"multipart/form-data",
|
||||
]
|
||||
expect(allowedContentTypes).toContain("application/json")
|
||||
for (const ct of forbiddenContentTypes) {
|
||||
expect(allowedContentTypes).not.toContain(ct)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,59 @@
|
||||
import { describe, it, expect } from "vitest"
|
||||
import fs from "node:fs"
|
||||
import path from "node:path"
|
||||
|
||||
describe("rate limiting removal", () => {
|
||||
const cwd = process.cwd()
|
||||
|
||||
it("lib/auth/rate-limit.ts no longer exports the rateLimit function", () => {
|
||||
const filePath = path.join(cwd, "lib", "auth", "rate-limit.ts")
|
||||
expect(fs.existsSync(filePath)).toBe(false)
|
||||
})
|
||||
|
||||
it("lib/api/app.ts no longer imports or uses rateLimit", () => {
|
||||
const content = fs.readFileSync(
|
||||
path.join(cwd, "lib", "api", "app.ts"),
|
||||
"utf8",
|
||||
)
|
||||
expect(content).not.toContain('import { rateLimit }')
|
||||
expect(content).not.toContain('.use(rateLimit(')
|
||||
})
|
||||
|
||||
it("lib/auth.ts has Better Auth rateLimit disabled", () => {
|
||||
const content = fs.readFileSync(
|
||||
path.join(cwd, "lib", "auth.ts"),
|
||||
"utf8",
|
||||
)
|
||||
const hasRateLimitBlock = content.includes("rateLimit:")
|
||||
if (hasRateLimitBlock) {
|
||||
expect(content).toContain("enabled: false")
|
||||
}
|
||||
})
|
||||
|
||||
it("lib/api/contact.ts no longer calls checkContactRateLimit", () => {
|
||||
const content = fs.readFileSync(
|
||||
path.join(cwd, "lib", "api", "contact.ts"),
|
||||
"utf8",
|
||||
)
|
||||
expect(content).not.toContain("checkContactRateLimit")
|
||||
})
|
||||
|
||||
it("lib/api/comments.ts no longer enforces hourly cap", () => {
|
||||
const content = fs.readFileSync(
|
||||
path.join(cwd, "lib", "api", "comments.ts"),
|
||||
"utf8",
|
||||
)
|
||||
expect(content).not.toContain("hourly limit")
|
||||
expect(content).not.toContain('"Duplicate comment detected')
|
||||
})
|
||||
|
||||
it("lib/api/performance-submit.ts no longer enforces submission cooldown", () => {
|
||||
const content = fs.readFileSync(
|
||||
path.join(cwd, "lib", "api", "performance-submit.ts"),
|
||||
"utf8",
|
||||
)
|
||||
expect(content).not.toContain("Submission cooldown")
|
||||
expect(content).not.toContain("sixtySecondsAgo")
|
||||
expect(content).not.toContain("Please wait before submitting")
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,104 @@
|
||||
import { Elysia } from "elysia"
|
||||
import { db } from "@/lib/db/index"
|
||||
import {
|
||||
performanceEntries,
|
||||
gameVersions,
|
||||
games,
|
||||
hardware,
|
||||
user,
|
||||
gameComments,
|
||||
} from "@/lib/db/schema"
|
||||
import { eq, count, sql, desc } from "drizzle-orm"
|
||||
import { requireModeratorOrAdmin } from "@/lib/auth/guard"
|
||||
|
||||
export const adminAnalyticsRoutes = new Elysia({
|
||||
prefix: "/admin/analytics",
|
||||
detail: { tags: ["Admin"] },
|
||||
})
|
||||
|
||||
// ── Overview: time-series data for charts ────────────────────────
|
||||
.get(
|
||||
"/overview",
|
||||
async ({ request, set }) => {
|
||||
const guard = await requireModeratorOrAdmin(request.headers)
|
||||
if (!guard.ok) {
|
||||
set.status = guard.status
|
||||
return { error: guard.error }
|
||||
}
|
||||
|
||||
const ninetyDaysAgo = new Date()
|
||||
ninetyDaysAgo.setDate(ninetyDaysAgo.getDate() - 90)
|
||||
|
||||
// Benchmark submissions per day (last 90 days)
|
||||
const benchmarkTimeline = await db.execute(sql`
|
||||
SELECT
|
||||
DATE(pe.created_at) AS day,
|
||||
COUNT(*)::int AS count
|
||||
FROM performance_entries pe
|
||||
WHERE pe.created_at >= ${ninetyDaysAgo}
|
||||
AND pe.is_removed = false
|
||||
GROUP BY DATE(pe.created_at)
|
||||
ORDER BY day ASC
|
||||
`)
|
||||
|
||||
// User registrations per day (last 90 days)
|
||||
const userTimeline = await db.execute(sql`
|
||||
SELECT
|
||||
DATE(u.created_at) AS day,
|
||||
COUNT(*)::int AS count
|
||||
FROM "user" u
|
||||
WHERE u.created_at >= ${ninetyDaysAgo}
|
||||
GROUP BY DATE(u.created_at)
|
||||
ORDER BY day ASC
|
||||
`)
|
||||
|
||||
// Device distribution (total benchmarks per hardware)
|
||||
const deviceDistribution = await db
|
||||
.select({
|
||||
hardwareSlug: performanceEntries.hardwareSlug,
|
||||
hardwareName: hardware.name,
|
||||
count: count(),
|
||||
})
|
||||
.from(performanceEntries)
|
||||
.innerJoin(hardware, eq(performanceEntries.hardwareSlug, hardware.slug))
|
||||
.where(eq(performanceEntries.isRemoved, false))
|
||||
.groupBy(performanceEntries.hardwareSlug, hardware.name)
|
||||
.orderBy(desc(count()))
|
||||
|
||||
// Genre popularity (top 10 genres by benchmark count)
|
||||
const genrePopularity = await db.execute(sql`
|
||||
SELECT
|
||||
genre,
|
||||
COUNT(*)::int AS count
|
||||
FROM games g
|
||||
CROSS JOIN LATERAL jsonb_array_elements_text(g.genres) AS genre
|
||||
JOIN game_versions gv ON gv.game_id = g.id
|
||||
JOIN performance_entries pe ON pe.version_id = gv.id
|
||||
WHERE pe.is_removed = false
|
||||
AND g.genres IS NOT NULL
|
||||
GROUP BY genre
|
||||
ORDER BY count DESC
|
||||
LIMIT 10
|
||||
`)
|
||||
|
||||
// Comment activity per day (last 90 days)
|
||||
const commentTimeline = await db.execute(sql`
|
||||
SELECT
|
||||
DATE(gc.created_at) AS day,
|
||||
COUNT(*)::int AS count
|
||||
FROM game_comments gc
|
||||
WHERE gc.created_at >= ${ninetyDaysAgo}
|
||||
AND gc.is_removed = false
|
||||
GROUP BY DATE(gc.created_at)
|
||||
ORDER BY day ASC
|
||||
`)
|
||||
|
||||
return {
|
||||
benchmarkTimeline: benchmarkTimeline.rows,
|
||||
userTimeline: userTimeline.rows,
|
||||
deviceDistribution,
|
||||
genrePopularity: genrePopularity.rows,
|
||||
commentTimeline: commentTimeline.rows,
|
||||
}
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,161 @@
|
||||
import { Elysia, t } from "elysia"
|
||||
import { db } from "@/lib/db/index"
|
||||
import {
|
||||
gameComments,
|
||||
games,
|
||||
user,
|
||||
} from "@/lib/db/schema"
|
||||
import { eq, desc, sql, and, ilike } from "drizzle-orm"
|
||||
import { requireModeratorOrAdmin } from "@/lib/auth/guard"
|
||||
|
||||
export const adminCommentRoutes = new Elysia({ prefix: "/admin", detail: { tags: ["Admin"] } })
|
||||
.get(
|
||||
"/comments",
|
||||
async ({ query, request, set }) => {
|
||||
const guard = await requireModeratorOrAdmin(request.headers)
|
||||
if (!guard.ok) {
|
||||
set.status = guard.status
|
||||
return { error: guard.error }
|
||||
}
|
||||
|
||||
const limit = Math.min(Number(query.limit) || 20, 100)
|
||||
const offset = Number(query.offset) || 0
|
||||
const removedFilter = query.removed
|
||||
const searchTerm = query.search
|
||||
|
||||
const conditions: (ReturnType<typeof eq> | ReturnType<typeof ilike> | ReturnType<typeof and>)[] = []
|
||||
|
||||
if (removedFilter === "true") {
|
||||
conditions.push(eq(gameComments.isRemoved, true))
|
||||
} else if (removedFilter === "false") {
|
||||
conditions.push(eq(gameComments.isRemoved, false))
|
||||
}
|
||||
|
||||
if (searchTerm) {
|
||||
conditions.push(ilike(user.name, `%${searchTerm}%`))
|
||||
}
|
||||
|
||||
const whereClause = conditions.length > 0 ? and(...conditions) : undefined
|
||||
|
||||
const baseQuery = db
|
||||
.select({
|
||||
id: gameComments.id,
|
||||
gameId: gameComments.gameId,
|
||||
content: gameComments.content,
|
||||
upvotes: gameComments.upvotes,
|
||||
isRemoved: gameComments.isRemoved,
|
||||
createdAt: gameComments.createdAt,
|
||||
updatedAt: gameComments.updatedAt,
|
||||
userId: gameComments.userId,
|
||||
userName: user.name,
|
||||
userImage: user.image,
|
||||
gameTitle: games.title,
|
||||
parentId: gameComments.parentId,
|
||||
})
|
||||
.from(gameComments)
|
||||
.innerJoin(games, eq(gameComments.gameId, games.id))
|
||||
.innerJoin(user, eq(gameComments.userId, user.id))
|
||||
.orderBy(desc(gameComments.createdAt))
|
||||
|
||||
const items = whereClause
|
||||
? await baseQuery.where(whereClause).limit(limit).offset(offset)
|
||||
: await baseQuery.limit(limit).offset(offset)
|
||||
|
||||
const countQuery = db
|
||||
.select({ count: sql<number>`count(*)::int` })
|
||||
.from(gameComments)
|
||||
.innerJoin(games, eq(gameComments.gameId, games.id))
|
||||
.innerJoin(user, eq(gameComments.userId, user.id))
|
||||
|
||||
const countResult = whereClause
|
||||
? await countQuery.where(whereClause)
|
||||
: await countQuery
|
||||
|
||||
const total = countResult[0]?.count ?? 0
|
||||
|
||||
return {
|
||||
data: items,
|
||||
total,
|
||||
limit,
|
||||
offset,
|
||||
}
|
||||
},
|
||||
{
|
||||
query: t.Object({
|
||||
removed: t.Optional(t.Union([t.Literal("true"), t.Literal("false")])),
|
||||
search: t.Optional(t.String()),
|
||||
limit: t.Optional(t.String()),
|
||||
offset: t.Optional(t.String()),
|
||||
}),
|
||||
},
|
||||
)
|
||||
.patch(
|
||||
"/comments/:id/remove",
|
||||
async ({ params, request, set }) => {
|
||||
const guard = await requireModeratorOrAdmin(request.headers)
|
||||
if (!guard.ok) {
|
||||
set.status = guard.status
|
||||
return { error: guard.error }
|
||||
}
|
||||
|
||||
const [comment] = await db
|
||||
.select()
|
||||
.from(gameComments)
|
||||
.where(eq(gameComments.id, params.id))
|
||||
.limit(1)
|
||||
|
||||
if (!comment) {
|
||||
set.status = 404
|
||||
return { error: "Comment not found" }
|
||||
}
|
||||
|
||||
const [updated] = await db
|
||||
.update(gameComments)
|
||||
.set({
|
||||
isRemoved: true,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(gameComments.id, params.id))
|
||||
.returning()
|
||||
|
||||
return updated
|
||||
},
|
||||
{
|
||||
params: t.Object({ id: t.String() }),
|
||||
},
|
||||
)
|
||||
.patch(
|
||||
"/comments/:id/restore",
|
||||
async ({ params, request, set }) => {
|
||||
const guard = await requireModeratorOrAdmin(request.headers)
|
||||
if (!guard.ok) {
|
||||
set.status = guard.status
|
||||
return { error: guard.error }
|
||||
}
|
||||
|
||||
const [comment] = await db
|
||||
.select()
|
||||
.from(gameComments)
|
||||
.where(eq(gameComments.id, params.id))
|
||||
.limit(1)
|
||||
|
||||
if (!comment) {
|
||||
set.status = 404
|
||||
return { error: "Comment not found" }
|
||||
}
|
||||
|
||||
const [updated] = await db
|
||||
.update(gameComments)
|
||||
.set({
|
||||
isRemoved: false,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(gameComments.id, params.id))
|
||||
.returning()
|
||||
|
||||
return updated
|
||||
},
|
||||
{
|
||||
params: t.Object({ id: t.String() }),
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,295 @@
|
||||
import { Elysia, t } from "elysia"
|
||||
import { db } from "@/lib/db/index"
|
||||
import {
|
||||
performanceEntries,
|
||||
gameVersions,
|
||||
games,
|
||||
hardware,
|
||||
user,
|
||||
} from "@/lib/db/schema"
|
||||
import { eq, desc, sql, and, ilike, isNull, isNotNull, or } from "drizzle-orm"
|
||||
import { fuzzySearchTerm } from "@/lib/db/search"
|
||||
import {
|
||||
requireContributorOrAdmin,
|
||||
requireAdmin,
|
||||
requireModeratorOrAdmin,
|
||||
} from "@/lib/auth/guard"
|
||||
|
||||
export const adminPerformanceRoutes = new Elysia({ prefix: "/admin", detail: { tags: ["Admin"] } })
|
||||
.get(
|
||||
"/performance",
|
||||
async ({ query, request, set }) => {
|
||||
const guard = await requireContributorOrAdmin(request.headers)
|
||||
if (!guard.ok) {
|
||||
set.status = guard.status
|
||||
return { error: guard.error }
|
||||
}
|
||||
|
||||
const limit = Math.min(Number(query.limit) || 20, 100)
|
||||
const offset = Number(query.offset) || 0
|
||||
const removedFilter = query.removed
|
||||
const verifiedFilter = query.verified
|
||||
const searchTerm = query.search
|
||||
|
||||
const conditions: (ReturnType<typeof eq> | ReturnType<typeof ilike> | ReturnType<typeof isNull> | ReturnType<typeof isNotNull> | ReturnType<typeof and>)[] = []
|
||||
|
||||
if (removedFilter === "true") {
|
||||
conditions.push(eq(performanceEntries.isRemoved, true))
|
||||
} else if (removedFilter === "false") {
|
||||
conditions.push(eq(performanceEntries.isRemoved, false))
|
||||
}
|
||||
|
||||
if (verifiedFilter === "true") {
|
||||
conditions.push(isNotNull(performanceEntries.verifiedAt))
|
||||
} else if (verifiedFilter === "false") {
|
||||
conditions.push(isNull(performanceEntries.verifiedAt))
|
||||
}
|
||||
|
||||
if (searchTerm) {
|
||||
conditions.push(
|
||||
or(
|
||||
ilike(user.name, `%${searchTerm}%`),
|
||||
ilike(games.title, fuzzySearchTerm(searchTerm)),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
const whereClause = conditions.length > 0 ? and(...conditions) : undefined
|
||||
|
||||
const baseQuery = db
|
||||
.select({
|
||||
id: performanceEntries.id,
|
||||
versionId: performanceEntries.versionId,
|
||||
hardwareSlug: performanceEntries.hardwareSlug,
|
||||
userId: performanceEntries.userId,
|
||||
fpsAvg: performanceEntries.fpsAvg,
|
||||
fpsLow: performanceEntries.fpsLow,
|
||||
fpsHigh: performanceEntries.fpsHigh,
|
||||
protonVersion: performanceEntries.protonVersion,
|
||||
osVersion: performanceEntries.osVersion,
|
||||
upscalerType: performanceEntries.upscalerType,
|
||||
upscalerVersion: performanceEntries.upscalerVersion,
|
||||
frameGenMethod: performanceEntries.frameGenMethod,
|
||||
loadTimeSsd: performanceEntries.loadTimeSsd,
|
||||
loadTimeSd: performanceEntries.loadTimeSd,
|
||||
launchOptions: performanceEntries.launchOptions,
|
||||
settingsJson: performanceEntries.settingsJson,
|
||||
userNotes: performanceEntries.userNotes,
|
||||
customSystem: performanceEntries.customSystem,
|
||||
isRemoved: performanceEntries.isRemoved,
|
||||
removedReason: performanceEntries.removedReason,
|
||||
upvotes: performanceEntries.upvotes,
|
||||
downvotes: performanceEntries.downvotes,
|
||||
verifiedAt: performanceEntries.verifiedAt,
|
||||
verifiedBy: performanceEntries.verifiedBy,
|
||||
createdAt: performanceEntries.createdAt,
|
||||
updatedAt: performanceEntries.updatedAt,
|
||||
gameId: games.id,
|
||||
gameTitle: games.title,
|
||||
versionString: gameVersions.versionString,
|
||||
hardwareName: hardware.name,
|
||||
authorName: user.name,
|
||||
authorImage: user.image,
|
||||
})
|
||||
.from(performanceEntries)
|
||||
.innerJoin(
|
||||
gameVersions,
|
||||
eq(performanceEntries.versionId, gameVersions.id),
|
||||
)
|
||||
.innerJoin(games, eq(gameVersions.gameId, games.id))
|
||||
.innerJoin(
|
||||
hardware,
|
||||
eq(performanceEntries.hardwareSlug, hardware.slug),
|
||||
)
|
||||
.innerJoin(user, eq(performanceEntries.userId, user.id))
|
||||
.orderBy(desc(performanceEntries.createdAt))
|
||||
|
||||
const items = whereClause
|
||||
? await baseQuery.where(whereClause).limit(limit).offset(offset)
|
||||
: await baseQuery.limit(limit).offset(offset)
|
||||
|
||||
const countQuery = db
|
||||
.select({ count: sql<number>`count(*)::int` })
|
||||
.from(performanceEntries)
|
||||
.innerJoin(
|
||||
gameVersions,
|
||||
eq(performanceEntries.versionId, gameVersions.id),
|
||||
)
|
||||
.innerJoin(games, eq(gameVersions.gameId, games.id))
|
||||
.innerJoin(
|
||||
hardware,
|
||||
eq(performanceEntries.hardwareSlug, hardware.slug),
|
||||
)
|
||||
.innerJoin(user, eq(performanceEntries.userId, user.id))
|
||||
|
||||
const countResult = whereClause
|
||||
? await countQuery.where(whereClause)
|
||||
: await countQuery
|
||||
|
||||
const total = countResult[0]?.count ?? 0
|
||||
|
||||
return {
|
||||
data: items,
|
||||
total,
|
||||
limit,
|
||||
offset,
|
||||
}
|
||||
},
|
||||
{
|
||||
query: t.Object({
|
||||
removed: t.Optional(t.Union([t.Literal("true"), t.Literal("false")])),
|
||||
verified: t.Optional(t.Union([t.Literal("true"), t.Literal("false")])),
|
||||
search: t.Optional(t.String()),
|
||||
limit: t.Optional(t.String()),
|
||||
offset: t.Optional(t.String()),
|
||||
}),
|
||||
},
|
||||
)
|
||||
.patch(
|
||||
"/performance/:id/verify",
|
||||
async ({ params, request, set }) => {
|
||||
const guard = await requireContributorOrAdmin(request.headers)
|
||||
if (!guard.ok) {
|
||||
set.status = guard.status
|
||||
return { error: guard.error }
|
||||
}
|
||||
|
||||
const [entry] = await db
|
||||
.select()
|
||||
.from(performanceEntries)
|
||||
.where(eq(performanceEntries.id, params.id))
|
||||
.limit(1)
|
||||
|
||||
if (!entry) {
|
||||
set.status = 404
|
||||
return { error: "Performance entry not found" }
|
||||
}
|
||||
|
||||
if (entry.verifiedAt) {
|
||||
set.status = 409
|
||||
return { error: "Entry already verified" }
|
||||
}
|
||||
|
||||
const [updated] = await db
|
||||
.update(performanceEntries)
|
||||
.set({
|
||||
verifiedAt: new Date(),
|
||||
verifiedBy: guard.user.id,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(performanceEntries.id, params.id))
|
||||
.returning()
|
||||
|
||||
return updated
|
||||
},
|
||||
{
|
||||
params: t.Object({ id: t.String() }),
|
||||
},
|
||||
)
|
||||
.patch(
|
||||
"/performance/:id/remove",
|
||||
async ({ params, body, request, set }) => {
|
||||
const guard = await requireModeratorOrAdmin(request.headers)
|
||||
if (!guard.ok) {
|
||||
set.status = guard.status
|
||||
return { error: guard.error }
|
||||
}
|
||||
|
||||
const [entry] = await db
|
||||
.select()
|
||||
.from(performanceEntries)
|
||||
.where(eq(performanceEntries.id, params.id))
|
||||
.limit(1)
|
||||
|
||||
if (!entry) {
|
||||
set.status = 404
|
||||
return { error: "Performance entry not found" }
|
||||
}
|
||||
|
||||
const [updated] = await db
|
||||
.update(performanceEntries)
|
||||
.set({
|
||||
isRemoved: true,
|
||||
removedReason: body?.reason ?? "Admin removed",
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(performanceEntries.id, params.id))
|
||||
.returning()
|
||||
|
||||
return updated
|
||||
},
|
||||
{
|
||||
params: t.Object({ id: t.String() }),
|
||||
body: t.Optional(
|
||||
t.Object({
|
||||
reason: t.Optional(t.String()),
|
||||
}),
|
||||
),
|
||||
},
|
||||
)
|
||||
.delete(
|
||||
"/performance/:id/hard-delete",
|
||||
async ({ params, request, set }) => {
|
||||
const guard = await requireModeratorOrAdmin(request.headers)
|
||||
if (!guard.ok) {
|
||||
set.status = guard.status
|
||||
return { error: guard.error }
|
||||
}
|
||||
|
||||
const [entry] = await db
|
||||
.select()
|
||||
.from(performanceEntries)
|
||||
.where(eq(performanceEntries.id, params.id))
|
||||
.limit(1)
|
||||
|
||||
if (!entry) {
|
||||
set.status = 404
|
||||
return { error: "Performance entry not found" }
|
||||
}
|
||||
|
||||
await db
|
||||
.delete(performanceEntries)
|
||||
.where(eq(performanceEntries.id, params.id))
|
||||
|
||||
return { success: true }
|
||||
},
|
||||
{
|
||||
params: t.Object({ id: t.String() }),
|
||||
},
|
||||
)
|
||||
.patch(
|
||||
"/performance/:id/restore",
|
||||
async ({ params, request, set }) => {
|
||||
const guard = await requireModeratorOrAdmin(request.headers)
|
||||
if (!guard.ok) {
|
||||
set.status = guard.status
|
||||
return { error: guard.error }
|
||||
}
|
||||
|
||||
const [entry] = await db
|
||||
.select()
|
||||
.from(performanceEntries)
|
||||
.where(eq(performanceEntries.id, params.id))
|
||||
.limit(1)
|
||||
|
||||
if (!entry) {
|
||||
set.status = 404
|
||||
return { error: "Performance entry not found" }
|
||||
}
|
||||
|
||||
const [updated] = await db
|
||||
.update(performanceEntries)
|
||||
.set({
|
||||
isRemoved: false,
|
||||
removedReason: null,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(performanceEntries.id, params.id))
|
||||
.returning()
|
||||
|
||||
return updated
|
||||
},
|
||||
{
|
||||
params: t.Object({ id: t.String() }),
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,194 @@
|
||||
import { Elysia, t } from "elysia"
|
||||
import { db } from "@/lib/db/index"
|
||||
import {
|
||||
reports,
|
||||
performanceEntries,
|
||||
gameVersions,
|
||||
games,
|
||||
user,
|
||||
} from "@/lib/db/schema"
|
||||
import { eq, desc, sql, and, inArray } from "drizzle-orm"
|
||||
import { requireModeratorOrAdmin } from "@/lib/auth/guard"
|
||||
|
||||
export const adminReportRoutes = new Elysia({ prefix: "/admin", detail: { tags: ["Admin"] } })
|
||||
.get(
|
||||
"/reports",
|
||||
async ({ query, request, set }) => {
|
||||
const guard = await requireModeratorOrAdmin(request.headers)
|
||||
if (!guard.ok) {
|
||||
set.status = guard.status
|
||||
return { error: guard.error }
|
||||
}
|
||||
|
||||
const limit = Math.min(Number(query.limit) || 20, 100)
|
||||
const offset = Number(query.offset) || 0
|
||||
const statusFilter = query.status
|
||||
|
||||
const conditions = []
|
||||
if (
|
||||
statusFilter &&
|
||||
["open", "reviewed", "dismissed"].includes(statusFilter)
|
||||
) {
|
||||
conditions.push(
|
||||
eq(reports.status, statusFilter as "open" | "reviewed" | "dismissed"),
|
||||
)
|
||||
}
|
||||
|
||||
const whereClause =
|
||||
conditions.length > 0 ? and(...conditions) : undefined
|
||||
|
||||
const baseQuery = db
|
||||
.select({
|
||||
report: {
|
||||
id: reports.id,
|
||||
entryId: reports.entryId,
|
||||
reporterId: reports.reporterId,
|
||||
reason: reports.reason,
|
||||
details: reports.details,
|
||||
status: reports.status,
|
||||
createdAt: reports.createdAt,
|
||||
},
|
||||
reporterName: user.name,
|
||||
entry: {
|
||||
id: performanceEntries.id,
|
||||
userId: performanceEntries.userId,
|
||||
fpsAvg: performanceEntries.fpsAvg,
|
||||
fpsLow: performanceEntries.fpsLow,
|
||||
fpsHigh: performanceEntries.fpsHigh,
|
||||
upscalerType: performanceEntries.upscalerType,
|
||||
userNotes: performanceEntries.userNotes,
|
||||
isRemoved: performanceEntries.isRemoved,
|
||||
},
|
||||
gameVersion: {
|
||||
id: gameVersions.id,
|
||||
versionString: gameVersions.versionString,
|
||||
},
|
||||
game: {
|
||||
id: games.id,
|
||||
title: games.title,
|
||||
headerImage: games.headerImage,
|
||||
},
|
||||
})
|
||||
.from(reports)
|
||||
.innerJoin(
|
||||
performanceEntries,
|
||||
eq(reports.entryId, performanceEntries.id),
|
||||
)
|
||||
.innerJoin(
|
||||
gameVersions,
|
||||
eq(performanceEntries.versionId, gameVersions.id),
|
||||
)
|
||||
.innerJoin(games, eq(gameVersions.gameId, games.id))
|
||||
.innerJoin(user, eq(reports.reporterId, user.id))
|
||||
.orderBy(desc(reports.createdAt))
|
||||
|
||||
const items = whereClause
|
||||
? await baseQuery.where(whereClause).limit(limit).offset(offset)
|
||||
: await baseQuery.limit(limit).offset(offset)
|
||||
|
||||
const countResult = whereClause
|
||||
? await db
|
||||
.select({ count: sql<number>`count(*)::int` })
|
||||
.from(reports)
|
||||
.where(whereClause)
|
||||
: await db.select({ count: sql<number>`count(*)::int` }).from(reports)
|
||||
|
||||
const total = countResult[0]?.count ?? 0
|
||||
|
||||
// Batch-fetch entry author names
|
||||
const authorIds = [...new Set(items.map((i) => i.entry.userId))]
|
||||
const authorNames: Record<string, string | null> = {}
|
||||
|
||||
if (authorIds.length > 0) {
|
||||
const authors = await db
|
||||
.select({ id: user.id, name: user.name })
|
||||
.from(user)
|
||||
.where(inArray(user.id, authorIds))
|
||||
|
||||
for (const a of authors) {
|
||||
authorNames[a.id] = a.name
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
data: items.map((item) => ({
|
||||
...item.report,
|
||||
createdAt: item.report.createdAt.toISOString(),
|
||||
reporterName: item.reporterName,
|
||||
entry: {
|
||||
...item.entry,
|
||||
authorName: authorNames[item.entry.userId] ?? null,
|
||||
},
|
||||
gameVersion: item.gameVersion,
|
||||
game: item.game,
|
||||
})),
|
||||
total,
|
||||
limit,
|
||||
offset,
|
||||
}
|
||||
},
|
||||
{
|
||||
query: t.Object({
|
||||
status: t.Optional(
|
||||
t.Union([
|
||||
t.Literal("open"),
|
||||
t.Literal("reviewed"),
|
||||
t.Literal("dismissed"),
|
||||
]),
|
||||
),
|
||||
limit: t.Optional(t.String()),
|
||||
offset: t.Optional(t.String()),
|
||||
}),
|
||||
},
|
||||
)
|
||||
.patch(
|
||||
"/reports/:id/status",
|
||||
async ({ params, body, request, set }) => {
|
||||
const guard = await requireModeratorOrAdmin(request.headers)
|
||||
if (!guard.ok) {
|
||||
set.status = guard.status
|
||||
return { error: guard.error }
|
||||
}
|
||||
|
||||
const [existing] = await db
|
||||
.select()
|
||||
.from(reports)
|
||||
.where(eq(reports.id, params.id))
|
||||
.limit(1)
|
||||
|
||||
if (!existing) {
|
||||
set.status = 404
|
||||
return { error: "Report not found" }
|
||||
}
|
||||
|
||||
if (existing.status === body.status) {
|
||||
set.status = 409
|
||||
return { error: "Report already has this status" }
|
||||
}
|
||||
|
||||
if (body.status === "reviewed") {
|
||||
await db
|
||||
.update(performanceEntries)
|
||||
.set({
|
||||
isRemoved: true,
|
||||
removedReason: `Reported: ${existing.reason}${existing.details ? ` — ${existing.details}` : ""}`,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(performanceEntries.id, existing.entryId))
|
||||
}
|
||||
|
||||
const [updated] = await db
|
||||
.update(reports)
|
||||
.set({ status: body.status })
|
||||
.where(eq(reports.id, params.id))
|
||||
.returning()
|
||||
|
||||
return updated
|
||||
},
|
||||
{
|
||||
params: t.Object({ id: t.String() }),
|
||||
body: t.Object({
|
||||
status: t.Union([t.Literal("reviewed"), t.Literal("dismissed")]),
|
||||
}),
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,321 @@
|
||||
import { Elysia, t } from "elysia"
|
||||
import { db } from "@/lib/db/index"
|
||||
import { storageObjects, user, games } from "@/lib/db/schema"
|
||||
import { eq, sql, and, ilike, desc } from "drizzle-orm"
|
||||
import { requireAdmin } from "@/lib/auth/guard"
|
||||
import { deleteObject, isR2Configured } from "@/lib/storage"
|
||||
|
||||
export const adminStorageRoutes = new Elysia({ prefix: "/admin/storage", detail: { tags: ["Admin"] } })
|
||||
|
||||
// ── GET /stats ──────────────────────────────────────────────────
|
||||
.get(
|
||||
"/stats",
|
||||
async ({ request, set }) => {
|
||||
const guard = await requireAdmin(request.headers)
|
||||
if (!guard.ok) {
|
||||
set.status = guard.status
|
||||
return { error: guard.error }
|
||||
}
|
||||
|
||||
const [totalResult] = await db
|
||||
.select({ count: sql<number>`count(*)::int` })
|
||||
.from(storageObjects)
|
||||
|
||||
const [orphanedResult] = await db
|
||||
.select({ count: sql<number>`count(*)::int` })
|
||||
.from(storageObjects)
|
||||
.where(eq(storageObjects.isOrphaned, true))
|
||||
|
||||
const byEntityType = await db
|
||||
.select({
|
||||
entityType: storageObjects.entityType,
|
||||
count: sql<number>`count(*)::int`,
|
||||
})
|
||||
.from(storageObjects)
|
||||
.groupBy(storageObjects.entityType)
|
||||
|
||||
const [totalSizeResult] = await db
|
||||
.select({ total: sql<number>`coalesce(sum(${storageObjects.size}), 0)::int` })
|
||||
.from(storageObjects)
|
||||
|
||||
return {
|
||||
total: totalResult?.count ?? 0,
|
||||
orphaned: orphanedResult?.count ?? 0,
|
||||
totalSizeBytes: totalSizeResult?.total ?? 0,
|
||||
byEntityType: byEntityType.reduce<Record<string, number>>(
|
||||
(acc, row) => {
|
||||
acc[row.entityType] = row.count
|
||||
return acc
|
||||
},
|
||||
{},
|
||||
),
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
// ── GET /objects ─────────────────────────────────────────────────
|
||||
.get(
|
||||
"/objects",
|
||||
async ({ query, request, set }) => {
|
||||
const guard = await requireAdmin(request.headers)
|
||||
if (!guard.ok) {
|
||||
set.status = guard.status
|
||||
return { error: guard.error }
|
||||
}
|
||||
|
||||
const limit = Math.min(Number(query.limit) || 20, 100)
|
||||
const offset = Number(query.offset) || 0
|
||||
|
||||
const conditions: (ReturnType<typeof eq> | ReturnType<typeof ilike> | ReturnType<typeof and>)[] = []
|
||||
|
||||
if (query.entityType) {
|
||||
conditions.push(eq(storageObjects.entityType, query.entityType))
|
||||
}
|
||||
|
||||
if (query.orphaned === "true") {
|
||||
conditions.push(eq(storageObjects.isOrphaned, true))
|
||||
} else if (query.orphaned === "false") {
|
||||
conditions.push(eq(storageObjects.isOrphaned, false))
|
||||
}
|
||||
|
||||
if (query.search) {
|
||||
conditions.push(ilike(storageObjects.key, `%${query.search}%`))
|
||||
}
|
||||
|
||||
const whereClause = conditions.length > 0 ? and(...conditions) : undefined
|
||||
|
||||
const baseQuery = db
|
||||
.select({
|
||||
id: storageObjects.id,
|
||||
key: storageObjects.key,
|
||||
bucket: storageObjects.bucket,
|
||||
size: storageObjects.size,
|
||||
mimeType: storageObjects.mimeType,
|
||||
entityType: storageObjects.entityType,
|
||||
entityId: storageObjects.entityId,
|
||||
uploadedBy: storageObjects.uploadedBy,
|
||||
createdAt: storageObjects.createdAt,
|
||||
lastAccessedAt: storageObjects.lastAccessedAt,
|
||||
isOrphaned: storageObjects.isOrphaned,
|
||||
uploaderName: user.name,
|
||||
})
|
||||
.from(storageObjects)
|
||||
.innerJoin(user, eq(storageObjects.uploadedBy, user.id))
|
||||
.orderBy(desc(storageObjects.createdAt))
|
||||
|
||||
const items = whereClause
|
||||
? await baseQuery.where(whereClause).limit(limit).offset(offset)
|
||||
: await baseQuery.limit(limit).offset(offset)
|
||||
|
||||
const countQuery = db
|
||||
.select({ count: sql<number>`count(*)::int` })
|
||||
.from(storageObjects)
|
||||
.innerJoin(user, eq(storageObjects.uploadedBy, user.id))
|
||||
|
||||
const countResult = whereClause
|
||||
? await countQuery.where(whereClause)
|
||||
: await countQuery
|
||||
|
||||
const total = countResult[0]?.count ?? 0
|
||||
|
||||
return {
|
||||
data: items.map((item) => ({
|
||||
...item,
|
||||
createdAt: item.createdAt.toISOString(),
|
||||
lastAccessedAt: item.lastAccessedAt?.toISOString() ?? null,
|
||||
})),
|
||||
total,
|
||||
limit,
|
||||
offset,
|
||||
}
|
||||
},
|
||||
{
|
||||
query: t.Object({
|
||||
limit: t.Optional(t.String()),
|
||||
offset: t.Optional(t.String()),
|
||||
entityType: t.Optional(t.String()),
|
||||
orphaned: t.Optional(t.Union([t.Literal("true"), t.Literal("false")])),
|
||||
search: t.Optional(t.String()),
|
||||
}),
|
||||
},
|
||||
)
|
||||
|
||||
// ── DELETE /objects/:id ──────────────────────────────────────────
|
||||
.delete(
|
||||
"/objects/:id",
|
||||
async ({ params, request, set }) => {
|
||||
const guard = await requireAdmin(request.headers)
|
||||
if (!guard.ok) {
|
||||
set.status = guard.status
|
||||
return { error: guard.error }
|
||||
}
|
||||
|
||||
const [obj] = await db
|
||||
.select()
|
||||
.from(storageObjects)
|
||||
.where(eq(storageObjects.id, params.id))
|
||||
.limit(1)
|
||||
|
||||
if (!obj) {
|
||||
set.status = 404
|
||||
return { error: "Storage object not found" }
|
||||
}
|
||||
|
||||
// Delete from R2 (best-effort; don't block on R2 failure)
|
||||
if (isR2Configured()) {
|
||||
try {
|
||||
await deleteObject(obj.key)
|
||||
} catch {
|
||||
console.warn(`Failed to delete object from R2: ${obj.key}`)
|
||||
}
|
||||
}
|
||||
|
||||
// Clear entity references for known entity types
|
||||
if (obj.entityType === "avatar" && obj.entityId) {
|
||||
try {
|
||||
await db
|
||||
.update(user)
|
||||
.set({ image: null, updatedAt: new Date() })
|
||||
.where(eq(user.id, obj.entityId))
|
||||
} catch {
|
||||
// User may have been deleted already; skip silently
|
||||
}
|
||||
} else if (obj.entityType === "game_cover" && obj.entityId) {
|
||||
try {
|
||||
await db
|
||||
.update(games)
|
||||
.set({ headerImage: null, updatedAt: new Date() })
|
||||
.where(eq(games.id, obj.entityId))
|
||||
} catch {
|
||||
// Game may have been deleted already; skip silently
|
||||
}
|
||||
}
|
||||
|
||||
// Delete the DB record
|
||||
await db
|
||||
.delete(storageObjects)
|
||||
.where(eq(storageObjects.id, params.id))
|
||||
|
||||
return { success: true, deleted: { id: obj.id, key: obj.key } }
|
||||
},
|
||||
{
|
||||
params: t.Object({ id: t.String() }),
|
||||
},
|
||||
)
|
||||
|
||||
// ── POST /cleanup ───────────────────────────────────────────────
|
||||
.post(
|
||||
"/cleanup",
|
||||
async ({ request, set }) => {
|
||||
const guard = await requireAdmin(request.headers)
|
||||
if (!guard.ok) {
|
||||
set.status = guard.status
|
||||
return { error: guard.error }
|
||||
}
|
||||
|
||||
if (!isR2Configured()) {
|
||||
set.status = 503
|
||||
return { error: "Storage (R2) not configured" }
|
||||
}
|
||||
|
||||
// 1. Mark orphaned avatars (entityId not in user table)
|
||||
const avatarOrphans = await db.execute(sql`
|
||||
UPDATE storage_objects
|
||||
SET is_orphaned = true
|
||||
WHERE entity_type = 'avatar'
|
||||
AND is_orphaned = false
|
||||
AND entity_id NOT IN (SELECT id FROM "user")
|
||||
`)
|
||||
|
||||
// 2. Mark orphaned game covers (entityId not in games table)
|
||||
const gameCoverOrphans = await db.execute(sql`
|
||||
UPDATE storage_objects
|
||||
SET is_orphaned = true
|
||||
WHERE entity_type = 'game_cover'
|
||||
AND is_orphaned = false
|
||||
AND entity_id NOT IN (SELECT id FROM games)
|
||||
`)
|
||||
|
||||
// 3. Fetch all now-orphaned objects for deletion
|
||||
const orphaned = await db
|
||||
.select()
|
||||
.from(storageObjects)
|
||||
.where(eq(storageObjects.isOrphaned, true))
|
||||
|
||||
let deletedFromR2 = 0
|
||||
let r2Errors = 0
|
||||
|
||||
// 4. Delete from R2 in batches of 50
|
||||
for (let i = 0; i < orphaned.length; i += 50) {
|
||||
const batch = orphaned.slice(i, i + 50)
|
||||
const results = await Promise.allSettled(
|
||||
batch.map(async (obj) => {
|
||||
await deleteObject(obj.key)
|
||||
}),
|
||||
)
|
||||
for (const result of results) {
|
||||
if (result.status === "fulfilled") {
|
||||
deletedFromR2++
|
||||
} else {
|
||||
r2Errors++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Clear entity references for orphaned objects that still reference entities
|
||||
// (These are orphans where the entity was deleted but reference wasn't cleared)
|
||||
const orphanedAvatars = orphaned.filter((o) => o.entityType === "avatar" && o.entityId)
|
||||
const orphanedGameCovers = orphaned.filter((o) => o.entityType === "game_cover" && o.entityId)
|
||||
|
||||
// Clear avatar references
|
||||
if (orphanedAvatars.length > 0) {
|
||||
const avatarEntityIds = [...new Set(orphanedAvatars.map((o) => o.entityId!))]
|
||||
// Only clear for users that still exist
|
||||
for (const userId of avatarEntityIds) {
|
||||
try {
|
||||
await db
|
||||
.update(user)
|
||||
.set({ image: null, updatedAt: new Date() })
|
||||
.where(eq(user.id, userId))
|
||||
} catch {
|
||||
// User may not exist; skip
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Clear game cover references
|
||||
if (orphanedGameCovers.length > 0) {
|
||||
const gameIds = [...new Set(orphanedGameCovers.map((o) => o.entityId!))]
|
||||
for (const gameId of gameIds) {
|
||||
try {
|
||||
await db
|
||||
.update(games)
|
||||
.set({ headerImage: null, updatedAt: new Date() })
|
||||
.where(eq(games.id, gameId))
|
||||
} catch {
|
||||
// Game may not exist; skip
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 6. Delete DB records
|
||||
if (orphaned.length > 0) {
|
||||
await db
|
||||
.delete(storageObjects)
|
||||
.where(eq(storageObjects.isOrphaned, true))
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
marked: {
|
||||
avatars: avatarOrphans.rowCount ?? 0,
|
||||
gameCovers: gameCoverOrphans.rowCount ?? 0,
|
||||
},
|
||||
deleted: {
|
||||
fromR2: deletedFromR2,
|
||||
r2Errors,
|
||||
fromDb: orphaned.length,
|
||||
},
|
||||
}
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,275 @@
|
||||
import { Elysia, t } from "elysia"
|
||||
import { openapi } from "@elysia/openapi"
|
||||
import { cron, Patterns } from "@elysia/cron"
|
||||
import { auth } from "@/lib/auth"
|
||||
import { isDeckyVaultEmail, DOMAIN_BLOCK_ERROR } from "@/lib/auth/domain-block"
|
||||
import { taskRegistry } from "./cron"
|
||||
import {
|
||||
healthRoutes,
|
||||
userRoutes,
|
||||
gamesRoutes,
|
||||
gameVersionsRoutes,
|
||||
gameSyncRoutes,
|
||||
hardwareRoutes,
|
||||
hardwareStatsRoutes,
|
||||
performanceRoutes,
|
||||
performanceVerifyRoutes,
|
||||
performanceSubmitRoutes,
|
||||
commentsRoutes,
|
||||
savedGamesRoutes,
|
||||
reportRoutes,
|
||||
contactRoutes,
|
||||
adminReportRoutes,
|
||||
adminPerformanceRoutes,
|
||||
adminCommentRoutes,
|
||||
} from "@/lib/api"
|
||||
import { adminStorageRoutes } from "@/lib/api/admin-storage"
|
||||
import { adminAnalyticsRoutes } from "@/lib/api/admin-analytics"
|
||||
import { steamSearchRoutes } from "@/lib/api/steam-search"
|
||||
import { steamdbVersionRoutes, clientVersionRoutes } from "@/lib/api/steamdb-version"
|
||||
import { versionTestRoutes, standaloneVersionTestRoutes } from "@/lib/api/version-test"
|
||||
import { searchUnifiedRoutes } from "@/lib/api/search-unified"
|
||||
import { gameStubRoutes } from "@/lib/api/game-stub"
|
||||
import { gameStatsRoutes } from "@/lib/api/game-stats"
|
||||
import { gamesPerformanceRoutes } from "@/lib/api/games-performance"
|
||||
import { gamesManualRoutes } from "@/lib/api/games-manual"
|
||||
import { compareRoutes } from "@/lib/api/compare"
|
||||
import { playabilityRoutes } from "@/lib/api/playability"
|
||||
import { steamReviewRoutes } from "@/lib/api/steam-reviews"
|
||||
import { communitySuggestionRoutes } from "@/lib/api/community-suggestions"
|
||||
import { gamesListingRoutes } from "@/lib/api/games-listing"
|
||||
import { steamgridProxyRoutes } from "@/lib/api/steamgrid-proxy"
|
||||
import { dashboardRoutes } from "@/lib/api/dashboard"
|
||||
import { dashboardPublicRoutes } from "@/lib/api/dashboard-public"
|
||||
import { savedFilterRoutes } from "@/lib/api/saved-filters"
|
||||
import { cronRoutes } from "@/lib/api/cron"
|
||||
import { profilePhotoRoutes } from "@/lib/api/profile-photo"
|
||||
import { screenshotRoutes } from "@/lib/api/screenshots"
|
||||
import { loginEmailSchema } from "@/lib/auth/validation"
|
||||
import { db } from "@/lib/db"
|
||||
import { user } from "@/lib/db/schema/auth"
|
||||
import { eq } from "drizzle-orm"
|
||||
import { mobileRoutes } from "@/lib/api/mobile"
|
||||
|
||||
const betterAuth = new Elysia({ name: "better-auth" })
|
||||
.mount(auth.handler)
|
||||
.macro({
|
||||
auth: {
|
||||
async resolve({ status, request: { headers } }) {
|
||||
const session = await auth.api.getSession({
|
||||
headers,
|
||||
})
|
||||
|
||||
if (!session) return status(401)
|
||||
|
||||
return {
|
||||
user: session.user,
|
||||
session: session.session,
|
||||
}
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
export const app = new Elysia({ prefix: "/api" })
|
||||
.use(
|
||||
openapi({
|
||||
path: "/openapi",
|
||||
embedSpec: true,
|
||||
documentation: {
|
||||
info: {
|
||||
title: "DeckyVault API",
|
||||
version: "2026.2.2",
|
||||
description:
|
||||
"API for DeckyVault — Steam Deck game compatibility, performance reports, and community features.",
|
||||
},
|
||||
tags: [
|
||||
{ name: "Health", description: "Health check endpoints" },
|
||||
{ name: "Auth", description: "Authentication endpoints" },
|
||||
{ name: "Users", description: "User management" },
|
||||
{ name: "Games", description: "Games listing and details" },
|
||||
{ name: "Hardware", description: "Hardware submission and stats" },
|
||||
{ name: "Performance", description: "Performance reports and verification" },
|
||||
{ name: "Comments", description: "Game comments" },
|
||||
{ name: "Reports", description: "User reports" },
|
||||
{ name: "Admin", description: "Admin-only endpoints" },
|
||||
{ name: "Steam", description: "Steam proxy endpoints" },
|
||||
{ name: "Search", description: "Search endpoints" },
|
||||
{ name: "Mobile", description: "Mobile-optimized consolidated endpoints" },
|
||||
{ name: "Contact", description: "Contact form" },
|
||||
{ name: "Dashboard", description: "Dashboard data" },
|
||||
{ name: "Cron", description: "Scheduled job triggers" },
|
||||
],
|
||||
components: {
|
||||
securitySchemes: {
|
||||
bearerAuth: {
|
||||
type: "http",
|
||||
scheme: "bearer",
|
||||
bearerFormat: "JWT",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
)
|
||||
.use(
|
||||
cron({
|
||||
name: "orphan_detection",
|
||||
pattern: Patterns.EVERY_DAY_AT_2AM,
|
||||
async run() {
|
||||
const task = taskRegistry.get("orphan_detection")
|
||||
if (!task) return
|
||||
try {
|
||||
const result = await task()
|
||||
console.log("[cron] orphan_detection:", result.status, result.details)
|
||||
} catch (err) {
|
||||
console.error("[cron] orphan_detection failed:", err)
|
||||
}
|
||||
},
|
||||
}),
|
||||
)
|
||||
.use(
|
||||
cron({
|
||||
name: "storage_cleanup",
|
||||
pattern: Patterns.EVERY_DAY_AT_3AM,
|
||||
async run() {
|
||||
const task = taskRegistry.get("storage_cleanup")
|
||||
if (!task) return
|
||||
try {
|
||||
const result = await task()
|
||||
console.log("[cron] storage_cleanup:", result.status, result.details)
|
||||
} catch (err) {
|
||||
console.error("[cron] storage_cleanup failed:", err)
|
||||
}
|
||||
},
|
||||
}),
|
||||
)
|
||||
.onError(({ code, error, set, request }) => {
|
||||
console.error(
|
||||
`[API Error] ${code} ${request.url}`,
|
||||
error instanceof Error ? error.message : error,
|
||||
)
|
||||
set.status = code === "NOT_FOUND" ? 404 : 500
|
||||
return {
|
||||
error: code === "NOT_FOUND" ? "Not found" : "Internal server error",
|
||||
}
|
||||
})
|
||||
// ── Auth routes ─────────────────────────────────────────────
|
||||
.group("", (app) =>
|
||||
app
|
||||
.onBeforeHandle(async ({ request, set }) => {
|
||||
const url = new URL(request.url)
|
||||
const isSignUp =
|
||||
url.pathname === "/api/auth/sign-up/email" &&
|
||||
request.method === "POST"
|
||||
|
||||
if (!isSignUp) return
|
||||
|
||||
if (process.env.NODE_ENV !== "development") {
|
||||
try {
|
||||
const cloned = request.clone()
|
||||
const body = await cloned.json()
|
||||
if (isDeckyVaultEmail(body.email)) {
|
||||
console.warn(
|
||||
"[AUTH] Blocked sign-up attempt with deckyvault.xyz email",
|
||||
body.email,
|
||||
)
|
||||
set.status = 400
|
||||
return { error: DOMAIN_BLOCK_ERROR }
|
||||
}
|
||||
} catch {
|
||||
// Malformed body — let Better Auth reject downstream
|
||||
}
|
||||
}
|
||||
})
|
||||
.use(betterAuth)
|
||||
.use(userRoutes)
|
||||
.use(profilePhotoRoutes)
|
||||
.post(
|
||||
"/auth/check-email",
|
||||
async ({ body, set }) => {
|
||||
const result = loginEmailSchema.safeParse(body)
|
||||
if (!result.success) {
|
||||
set.status = 400
|
||||
return { error: result.error.issues[0].message }
|
||||
}
|
||||
|
||||
const { email } = result.data
|
||||
const existingUser = await db
|
||||
.select({ id: user.id })
|
||||
.from(user)
|
||||
.where(eq(user.email, email.toLowerCase()))
|
||||
.limit(1)
|
||||
|
||||
return { exists: existingUser.length > 0 }
|
||||
},
|
||||
{
|
||||
body: t.Object({ email: t.String() }),
|
||||
response: t.Union([
|
||||
t.Object({ exists: t.Boolean() }),
|
||||
t.Object({ error: t.String() }),
|
||||
]),
|
||||
detail: { hide: true },
|
||||
},
|
||||
)
|
||||
)
|
||||
// ── Read-heavy public routes ──────────────────────────────
|
||||
.group("", (app) =>
|
||||
app
|
||||
.use(healthRoutes)
|
||||
.use(gamesRoutes)
|
||||
.use(gameVersionsRoutes)
|
||||
.use(gameSyncRoutes)
|
||||
.use(gamesListingRoutes)
|
||||
.use(hardwareRoutes)
|
||||
.use(hardwareStatsRoutes)
|
||||
.use(performanceRoutes)
|
||||
.use(gameStatsRoutes)
|
||||
.use(gamesPerformanceRoutes)
|
||||
.use(dashboardRoutes)
|
||||
.use(dashboardPublicRoutes)
|
||||
.use(playabilityRoutes)
|
||||
.use(steamReviewRoutes)
|
||||
.use(compareRoutes)
|
||||
.use(savedGamesRoutes)
|
||||
.use(savedFilterRoutes)
|
||||
.use(steamSearchRoutes)
|
||||
.use(searchUnifiedRoutes)
|
||||
.use(gameStubRoutes)
|
||||
.use(steamgridProxyRoutes)
|
||||
.use(steamdbVersionRoutes)
|
||||
.use(versionTestRoutes)
|
||||
.use(standaloneVersionTestRoutes)
|
||||
.use(gamesManualRoutes)
|
||||
.use(screenshotRoutes)
|
||||
.use(mobileRoutes)
|
||||
)
|
||||
// ── Write routes ───────────────────────────────────────────
|
||||
.group("", (app) =>
|
||||
app
|
||||
.use(betterAuth)
|
||||
.use(clientVersionRoutes)
|
||||
.use(performanceVerifyRoutes)
|
||||
.use(performanceSubmitRoutes)
|
||||
.use(commentsRoutes)
|
||||
.use(reportRoutes)
|
||||
.use(adminReportRoutes)
|
||||
.use(adminPerformanceRoutes)
|
||||
.use(adminCommentRoutes)
|
||||
.use(adminStorageRoutes)
|
||||
.use(adminAnalyticsRoutes)
|
||||
)
|
||||
// ── Public forms (no auth) ─────────────────────────────────
|
||||
.group("", (app) =>
|
||||
app
|
||||
.use(contactRoutes)
|
||||
.use(communitySuggestionRoutes)
|
||||
)
|
||||
// ── Cron ─────────────────────────────────────────────────────
|
||||
.use(cronRoutes)
|
||||
// ── Root ────────────────────────────────────────────────────
|
||||
.get("/", () => ({
|
||||
name: "DeckyVault API",
|
||||
version: "2026.2.2",
|
||||
}))
|
||||
|
||||
export type App = typeof app
|
||||
@@ -0,0 +1,51 @@
|
||||
import { db } from "@/lib/db/index"
|
||||
import { performanceEntries } from "@/lib/db/schema"
|
||||
import { eq } from "drizzle-orm"
|
||||
|
||||
/**
|
||||
* Auto-pin check: an entry is eligible for auto-pinning when:
|
||||
* 1. NOT already pinned (isPinned = false)
|
||||
* 2. NOT removed (isRemoved = false)
|
||||
* 3. Total votes >= 10 (upvotes + downvotes)
|
||||
* 4. Approval ratio >= 0.80 (upvotes / total_votes)
|
||||
* 5. Absolute upvotes >= 8
|
||||
*/
|
||||
const AUTO_PIN_MIN_TOTAL_VOTES = 10
|
||||
const AUTO_PIN_MIN_APPROVAL_RATIO = 0.80
|
||||
const AUTO_PIN_MIN_UPVOTES = 8
|
||||
|
||||
export async function checkAndAutoPin(entryId: string): Promise<boolean> {
|
||||
const [entry] = await db
|
||||
.select({
|
||||
id: performanceEntries.id,
|
||||
isPinned: performanceEntries.isPinned,
|
||||
isRemoved: performanceEntries.isRemoved,
|
||||
upvotes: performanceEntries.upvotes,
|
||||
downvotes: performanceEntries.downvotes,
|
||||
})
|
||||
.from(performanceEntries)
|
||||
.where(eq(performanceEntries.id, entryId))
|
||||
.limit(1)
|
||||
|
||||
if (!entry) return false
|
||||
if (entry.isPinned) return false
|
||||
if (entry.isRemoved) return false
|
||||
|
||||
const totalVotes = entry.upvotes + entry.downvotes
|
||||
if (totalVotes < AUTO_PIN_MIN_TOTAL_VOTES) return false
|
||||
if (entry.upvotes < AUTO_PIN_MIN_UPVOTES) return false
|
||||
|
||||
const approvalRatio = entry.upvotes / totalVotes
|
||||
if (approvalRatio < AUTO_PIN_MIN_APPROVAL_RATIO) return false
|
||||
|
||||
// Conditions met — auto-pin
|
||||
await db
|
||||
.update(performanceEntries)
|
||||
.set({
|
||||
isPinned: true,
|
||||
pinnedAt: new Date(),
|
||||
})
|
||||
.where(eq(performanceEntries.id, entryId))
|
||||
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,316 @@
|
||||
import { Elysia, t } from "elysia"
|
||||
import { gameComments, user } from "@/lib/db/schema"
|
||||
import { db } from "@/lib/db/index"
|
||||
import { eq, and, desc, sql, isNull } from "drizzle-orm"
|
||||
import { requireRole } from "@/lib/auth/guard"
|
||||
|
||||
export const commentsRoutes = new Elysia({
|
||||
prefix: "/games/:gameId/comments",
|
||||
detail: { tags: ["Comments"] },
|
||||
})
|
||||
// LIST top-level comments for a game (paginated)
|
||||
.get(
|
||||
"/",
|
||||
async ({ params, query }) => {
|
||||
const limit = Math.min(Number(query.limit) || 20, 100)
|
||||
const offset = Number(query.offset) || 0
|
||||
|
||||
const conditions = [
|
||||
eq(gameComments.gameId, params.gameId),
|
||||
eq(gameComments.isRemoved, false),
|
||||
isNull(gameComments.parentId), // top-level only
|
||||
]
|
||||
|
||||
const data = await db
|
||||
.select({
|
||||
id: gameComments.id,
|
||||
gameId: gameComments.gameId,
|
||||
userId: gameComments.userId,
|
||||
parentId: gameComments.parentId,
|
||||
content: gameComments.content,
|
||||
upvotes: gameComments.upvotes,
|
||||
createdAt: gameComments.createdAt,
|
||||
updatedAt: gameComments.updatedAt,
|
||||
userName: user.name,
|
||||
userImage: user.image,
|
||||
})
|
||||
.from(gameComments)
|
||||
.innerJoin(user, eq(gameComments.userId, user.id))
|
||||
.where(and(...conditions))
|
||||
.orderBy(desc(gameComments.createdAt))
|
||||
.limit(limit)
|
||||
.offset(offset)
|
||||
|
||||
const [{ count }] = await db
|
||||
.select({ count: sql<number>`count(*)::int` })
|
||||
.from(gameComments)
|
||||
.where(and(...conditions))
|
||||
|
||||
return { data, total: count, limit, offset }
|
||||
},
|
||||
{
|
||||
params: t.Object({ gameId: t.String() }),
|
||||
query: t.Object({
|
||||
limit: t.Optional(t.String()),
|
||||
offset: t.Optional(t.String()),
|
||||
}),
|
||||
},
|
||||
)
|
||||
// GET replies for a specific comment
|
||||
.get(
|
||||
"/:id/replies",
|
||||
async ({ params, query }) => {
|
||||
const limit = Math.min(Number(query.limit) || 20, 100)
|
||||
const offset = Number(query.offset) || 0
|
||||
|
||||
const data = await db
|
||||
.select({
|
||||
id: gameComments.id,
|
||||
gameId: gameComments.gameId,
|
||||
userId: gameComments.userId,
|
||||
parentId: gameComments.parentId,
|
||||
content: gameComments.content,
|
||||
upvotes: gameComments.upvotes,
|
||||
createdAt: gameComments.createdAt,
|
||||
updatedAt: gameComments.updatedAt,
|
||||
userName: user.name,
|
||||
userImage: user.image,
|
||||
})
|
||||
.from(gameComments)
|
||||
.innerJoin(user, eq(gameComments.userId, user.id))
|
||||
.where(
|
||||
and(
|
||||
eq(gameComments.parentId, params.id),
|
||||
eq(gameComments.isRemoved, false),
|
||||
),
|
||||
)
|
||||
.orderBy(desc(gameComments.createdAt))
|
||||
.limit(limit)
|
||||
.offset(offset)
|
||||
|
||||
return data
|
||||
},
|
||||
{
|
||||
params: t.Object({ gameId: t.String(), id: t.String() }),
|
||||
query: t.Object({
|
||||
limit: t.Optional(t.String()),
|
||||
offset: t.Optional(t.String()),
|
||||
}),
|
||||
},
|
||||
)
|
||||
// CREATE comment
|
||||
.post(
|
||||
"/",
|
||||
async ({ params, body, request, set }) => {
|
||||
const guard = await requireRole(request.headers, [
|
||||
"user",
|
||||
"contributor",
|
||||
"admin",
|
||||
])
|
||||
if (!guard.ok) {
|
||||
set.status = guard.status
|
||||
return { error: guard.error }
|
||||
}
|
||||
|
||||
// ── Anti-spam: content length cap ──────────────────────────
|
||||
const contentStr = JSON.stringify(body.content)
|
||||
if (contentStr.length > 50000) {
|
||||
set.status = 413
|
||||
return { error: "Comment content exceeds maximum size (50KB)" }
|
||||
}
|
||||
|
||||
// If parentId is provided, verify it exists and belongs to the same game
|
||||
if (body.parentId) {
|
||||
const [parent] = await db
|
||||
.select({ id: gameComments.id })
|
||||
.from(gameComments)
|
||||
.where(
|
||||
and(
|
||||
eq(gameComments.id, body.parentId),
|
||||
eq(gameComments.gameId, params.gameId),
|
||||
),
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
if (!parent) {
|
||||
set.status = 404
|
||||
return { error: "Parent comment not found" }
|
||||
}
|
||||
}
|
||||
|
||||
// ── Sanitize: strip <script> tags and javascript: URLs from content ──
|
||||
const sanitizeValue = (val: unknown): unknown => {
|
||||
if (typeof val === "string") {
|
||||
return val
|
||||
.replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, "")
|
||||
.replace(/javascript\s*:/gi, "blocked:")
|
||||
}
|
||||
if (Array.isArray(val)) return val.map(sanitizeValue)
|
||||
if (val !== null && typeof val === "object") {
|
||||
const cleaned: Record<string, unknown> = {}
|
||||
for (const [k, v] of Object.entries(val as Record<string, unknown>)) {
|
||||
cleaned[k] = sanitizeValue(v)
|
||||
}
|
||||
return cleaned
|
||||
}
|
||||
return val
|
||||
}
|
||||
const sanitizedContent = sanitizeValue(body.content) as Record<string, unknown>
|
||||
|
||||
const [created] = await db
|
||||
.insert(gameComments)
|
||||
.values({
|
||||
gameId: params.gameId,
|
||||
userId: guard.user.id,
|
||||
parentId: body.parentId ?? null,
|
||||
content: sanitizedContent,
|
||||
})
|
||||
.returning()
|
||||
|
||||
set.status = 201
|
||||
return created
|
||||
},
|
||||
{
|
||||
params: t.Object({ gameId: t.String() }),
|
||||
body: t.Object({
|
||||
parentId: t.Optional(t.String()),
|
||||
content: t.Record(t.String(), t.Any()), // Tiptap JSON
|
||||
}),
|
||||
},
|
||||
)
|
||||
// UPDATE comment (owner or admin)
|
||||
.patch(
|
||||
"/:id",
|
||||
async ({ params, body, request, set }) => {
|
||||
const guard = await requireRole(request.headers, [
|
||||
"user",
|
||||
"contributor",
|
||||
"admin",
|
||||
])
|
||||
if (!guard.ok) {
|
||||
set.status = guard.status
|
||||
return { error: guard.error }
|
||||
}
|
||||
|
||||
const [comment] = await db
|
||||
.select()
|
||||
.from(gameComments)
|
||||
.where(
|
||||
and(
|
||||
eq(gameComments.id, params.id),
|
||||
eq(gameComments.gameId, params.gameId),
|
||||
),
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
if (!comment) {
|
||||
set.status = 404
|
||||
return { error: "Comment not found" }
|
||||
}
|
||||
|
||||
if (comment.userId !== guard.user.id && guard.user.role !== "admin") {
|
||||
set.status = 403
|
||||
return { error: "Not authorized" }
|
||||
}
|
||||
|
||||
const [updated] = await db
|
||||
.update(gameComments)
|
||||
.set({ content: body.content, updatedAt: new Date() })
|
||||
.where(eq(gameComments.id, params.id))
|
||||
.returning()
|
||||
|
||||
return updated
|
||||
},
|
||||
{
|
||||
params: t.Object({ gameId: t.String(), id: t.String() }),
|
||||
body: t.Object({
|
||||
content: t.Record(t.String(), t.Any()),
|
||||
}),
|
||||
},
|
||||
)
|
||||
// DELETE comment (soft delete — owner or admin)
|
||||
.delete(
|
||||
"/:id",
|
||||
async ({ params, request, set }) => {
|
||||
const guard = await requireRole(request.headers, [
|
||||
"user",
|
||||
"contributor",
|
||||
"admin",
|
||||
])
|
||||
if (!guard.ok) {
|
||||
set.status = guard.status
|
||||
return { error: guard.error }
|
||||
}
|
||||
|
||||
const [comment] = await db
|
||||
.select()
|
||||
.from(gameComments)
|
||||
.where(
|
||||
and(
|
||||
eq(gameComments.id, params.id),
|
||||
eq(gameComments.gameId, params.gameId),
|
||||
),
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
if (!comment) {
|
||||
set.status = 404
|
||||
return { error: "Comment not found" }
|
||||
}
|
||||
|
||||
if (comment.userId !== guard.user.id && guard.user.role !== "admin") {
|
||||
set.status = 403
|
||||
return { error: "Not authorized" }
|
||||
}
|
||||
|
||||
await db
|
||||
.update(gameComments)
|
||||
.set({ isRemoved: true, updatedAt: new Date() })
|
||||
.where(eq(gameComments.id, params.id))
|
||||
|
||||
return { success: true }
|
||||
},
|
||||
{
|
||||
params: t.Object({ gameId: t.String(), id: t.String() }),
|
||||
},
|
||||
)
|
||||
// UPVOTE comment
|
||||
.post(
|
||||
"/:id/upvote",
|
||||
async ({ params, request, set }) => {
|
||||
const guard = await requireRole(request.headers, [
|
||||
"user",
|
||||
"contributor",
|
||||
"admin",
|
||||
])
|
||||
if (!guard.ok) {
|
||||
set.status = guard.status
|
||||
return { error: guard.error }
|
||||
}
|
||||
|
||||
const [updated] = await db
|
||||
.update(gameComments)
|
||||
.set({
|
||||
upvotes: sql`${gameComments.upvotes} + 1`,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(gameComments.id, params.id),
|
||||
eq(gameComments.gameId, params.gameId),
|
||||
eq(gameComments.isRemoved, false),
|
||||
),
|
||||
)
|
||||
.returning()
|
||||
|
||||
if (!updated) {
|
||||
set.status = 404
|
||||
return { error: "Comment not found" }
|
||||
}
|
||||
|
||||
return updated
|
||||
},
|
||||
{
|
||||
params: t.Object({ gameId: t.String(), id: t.String() }),
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,345 @@
|
||||
import { Elysia, t } from "elysia"
|
||||
import { db } from "@/lib/db/index"
|
||||
import { communitySuggestions, games, user, suggestionStatusEnum } from "@/lib/db/schema"
|
||||
import { eq, and, desc, sql, ilike } from "drizzle-orm"
|
||||
import { requireAuth, requireModeratorOrAdmin } from "@/lib/auth/guard"
|
||||
|
||||
const DISCORD_WEBHOOK_URL = process.env.DISCORD_WEBHOOK_URL
|
||||
|
||||
async function sendDiscordNotification(suggestion: {
|
||||
gameTitle: string
|
||||
fieldName: string
|
||||
proposedValue: string
|
||||
userName: string
|
||||
}) {
|
||||
if (!DISCORD_WEBHOOK_URL) return
|
||||
|
||||
try {
|
||||
await fetch(DISCORD_WEBHOOK_URL, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
embeds: [
|
||||
{
|
||||
title: "📝 New Community Suggestion",
|
||||
color: 0x5865f2,
|
||||
fields: [
|
||||
{ name: "Game", value: suggestion.gameTitle, inline: true },
|
||||
{ name: "Field", value: suggestion.fieldName, inline: true },
|
||||
{
|
||||
name: "Suggested By",
|
||||
value: suggestion.userName,
|
||||
inline: true,
|
||||
},
|
||||
{
|
||||
name: "Proposed Value",
|
||||
value: suggestion.proposedValue.slice(0, 1000),
|
||||
},
|
||||
],
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
],
|
||||
}),
|
||||
})
|
||||
} catch (err) {
|
||||
console.error("Discord webhook failed:", err)
|
||||
}
|
||||
}
|
||||
|
||||
const allowedFields = [
|
||||
"title",
|
||||
"description",
|
||||
"developer",
|
||||
"publisher",
|
||||
"genres",
|
||||
"storeUrl",
|
||||
"releaseDate",
|
||||
]
|
||||
|
||||
export const communitySuggestionRoutes = new Elysia({
|
||||
prefix: "/community-suggestions",
|
||||
detail: { tags: ["Games"] },
|
||||
})
|
||||
|
||||
// Admin list with pagination and filtering
|
||||
.get(
|
||||
"/admin",
|
||||
async ({ query, request, set }) => {
|
||||
const guard = await requireModeratorOrAdmin(request.headers)
|
||||
if (!guard.ok) {
|
||||
set.status = guard.status
|
||||
return { error: guard.error }
|
||||
}
|
||||
|
||||
const { limit, offset, status } = query
|
||||
|
||||
const conditions = []
|
||||
if (status) {
|
||||
conditions.push(eq(communitySuggestions.status, status as typeof suggestionStatusEnum.enumValues[number]))
|
||||
}
|
||||
|
||||
const whereClause = conditions.length > 0 ? and(...conditions) : undefined
|
||||
|
||||
const [countResult] = await db
|
||||
.select({ count: sql<number>`count(*)::int` })
|
||||
.from(communitySuggestions)
|
||||
.leftJoin(games, eq(communitySuggestions.gameId, games.id))
|
||||
.leftJoin(user, eq(communitySuggestions.userId, user.id))
|
||||
.where(whereClause)
|
||||
|
||||
const data = await db
|
||||
.select({
|
||||
id: communitySuggestions.id,
|
||||
gameId: communitySuggestions.gameId,
|
||||
gameTitle: games.title,
|
||||
fieldName: communitySuggestions.fieldName,
|
||||
currentValue: communitySuggestions.currentValue,
|
||||
proposedValue: communitySuggestions.proposedValue,
|
||||
reason: communitySuggestions.reason,
|
||||
status: communitySuggestions.status,
|
||||
createdAt: communitySuggestions.createdAt,
|
||||
reviewedAt: communitySuggestions.reviewedAt,
|
||||
reviewNote: communitySuggestions.reviewNote,
|
||||
userName: user.name,
|
||||
})
|
||||
.from(communitySuggestions)
|
||||
.leftJoin(games, eq(communitySuggestions.gameId, games.id))
|
||||
.leftJoin(user, eq(communitySuggestions.userId, user.id))
|
||||
.where(whereClause)
|
||||
.orderBy(desc(communitySuggestions.createdAt))
|
||||
.limit(limit)
|
||||
.offset(offset)
|
||||
|
||||
return { data, total: countResult.count, limit, offset }
|
||||
},
|
||||
{
|
||||
query: t.Object({
|
||||
limit: t.Number({ default: 20 }),
|
||||
offset: t.Number({ default: 0 }),
|
||||
status: t.Optional(t.String()),
|
||||
}),
|
||||
},
|
||||
)
|
||||
|
||||
// Submit a suggestion
|
||||
.post(
|
||||
"/",
|
||||
async ({ body, request, set }) => {
|
||||
const guard = await requireAuth(request.headers)
|
||||
if (!guard.ok) {
|
||||
set.status = guard.status
|
||||
return { error: guard.error }
|
||||
}
|
||||
|
||||
const { gameId, fieldName, proposedValue, reason } = body
|
||||
|
||||
// Validate field name is editable
|
||||
if (!allowedFields.includes(fieldName)) {
|
||||
set.status = 400
|
||||
return { error: `Field '${fieldName}' cannot be suggested` }
|
||||
}
|
||||
|
||||
// Get current game value
|
||||
const [game] = await db
|
||||
.select()
|
||||
.from(games)
|
||||
.where(eq(games.id, gameId))
|
||||
.limit(1)
|
||||
|
||||
if (!game) {
|
||||
set.status = 404
|
||||
return { error: "Game not found" }
|
||||
}
|
||||
|
||||
// Check for existing pending suggestion on same field
|
||||
const [existing] = await db
|
||||
.select()
|
||||
.from(communitySuggestions)
|
||||
.where(
|
||||
and(
|
||||
eq(communitySuggestions.gameId, gameId),
|
||||
eq(communitySuggestions.fieldName, fieldName),
|
||||
eq(communitySuggestions.userId, guard.user.id),
|
||||
eq(communitySuggestions.status, "pending"),
|
||||
),
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
if (existing) {
|
||||
set.status = 409
|
||||
return { error: "You already have a pending suggestion for this field" }
|
||||
}
|
||||
|
||||
const currentValue = String((game as Record<string, unknown>)[fieldName] ?? "")
|
||||
|
||||
const [suggestion] = await db
|
||||
.insert(communitySuggestions)
|
||||
.values({
|
||||
gameId,
|
||||
userId: guard.user.id,
|
||||
fieldName,
|
||||
currentValue,
|
||||
proposedValue,
|
||||
reason,
|
||||
})
|
||||
.returning()
|
||||
|
||||
// Send Discord notification
|
||||
await sendDiscordNotification({
|
||||
gameTitle: game.title,
|
||||
fieldName,
|
||||
proposedValue,
|
||||
userName: guard.user.name || "Anonymous",
|
||||
})
|
||||
|
||||
return suggestion
|
||||
},
|
||||
{
|
||||
body: t.Object({
|
||||
gameId: t.String(),
|
||||
fieldName: t.String(),
|
||||
proposedValue: t.String(),
|
||||
reason: t.Optional(t.String()),
|
||||
}),
|
||||
},
|
||||
)
|
||||
|
||||
// Get suggestions for a game
|
||||
.get(
|
||||
"/game/:gameId",
|
||||
async ({ params, query }) => {
|
||||
const status = query.status // optional filter
|
||||
|
||||
const conditions = [eq(communitySuggestions.gameId, params.gameId)]
|
||||
if (status) {
|
||||
conditions.push(eq(communitySuggestions.status, status as typeof suggestionStatusEnum.enumValues[number]))
|
||||
}
|
||||
|
||||
const suggestions = await db
|
||||
.select({
|
||||
id: communitySuggestions.id,
|
||||
fieldName: communitySuggestions.fieldName,
|
||||
currentValue: communitySuggestions.currentValue,
|
||||
proposedValue: communitySuggestions.proposedValue,
|
||||
reason: communitySuggestions.reason,
|
||||
status: communitySuggestions.status,
|
||||
createdAt: communitySuggestions.createdAt,
|
||||
reviewedAt: communitySuggestions.reviewedAt,
|
||||
reviewNote: communitySuggestions.reviewNote,
|
||||
userName: user.name,
|
||||
})
|
||||
.from(communitySuggestions)
|
||||
.leftJoin(user, eq(communitySuggestions.userId, user.id))
|
||||
.where(and(...conditions))
|
||||
.orderBy(desc(communitySuggestions.createdAt))
|
||||
|
||||
return suggestions
|
||||
},
|
||||
{
|
||||
params: t.Object({ gameId: t.String() }),
|
||||
query: t.Object({
|
||||
status: t.Optional(t.String()),
|
||||
}),
|
||||
},
|
||||
)
|
||||
|
||||
// Get all pending suggestions (admin/moderation view)
|
||||
.get(
|
||||
"/pending",
|
||||
async ({ request, set }) => {
|
||||
const guard = await requireModeratorOrAdmin(request.headers)
|
||||
if (!guard.ok) {
|
||||
set.status = guard.status
|
||||
return { error: guard.error }
|
||||
}
|
||||
|
||||
const suggestions = await db
|
||||
.select({
|
||||
id: communitySuggestions.id,
|
||||
gameId: communitySuggestions.gameId,
|
||||
gameTitle: games.title,
|
||||
fieldName: communitySuggestions.fieldName,
|
||||
currentValue: communitySuggestions.currentValue,
|
||||
proposedValue: communitySuggestions.proposedValue,
|
||||
reason: communitySuggestions.reason,
|
||||
status: communitySuggestions.status,
|
||||
createdAt: communitySuggestions.createdAt,
|
||||
userName: user.name,
|
||||
})
|
||||
.from(communitySuggestions)
|
||||
.leftJoin(games, eq(communitySuggestions.gameId, games.id))
|
||||
.leftJoin(user, eq(communitySuggestions.userId, user.id))
|
||||
.where(eq(communitySuggestions.status, "pending"))
|
||||
.orderBy(desc(communitySuggestions.createdAt))
|
||||
|
||||
return suggestions
|
||||
},
|
||||
)
|
||||
|
||||
// Approve/reject suggestion (admin/contributor)
|
||||
.patch(
|
||||
"/:suggestionId/review",
|
||||
async ({ params, body, request, set }) => {
|
||||
const guard = await requireModeratorOrAdmin(request.headers)
|
||||
if (!guard.ok) {
|
||||
set.status = guard.status
|
||||
return { error: guard.error }
|
||||
}
|
||||
|
||||
const { status: rawStatus, reviewNote } = body
|
||||
|
||||
if (!["approved", "rejected"].includes(rawStatus)) {
|
||||
set.status = 400
|
||||
return { error: "Status must be 'approved' or 'rejected'" }
|
||||
}
|
||||
|
||||
const status = rawStatus as "approved" | "rejected"
|
||||
|
||||
const [suggestion] = await db
|
||||
.select()
|
||||
.from(communitySuggestions)
|
||||
.where(eq(communitySuggestions.id, params.suggestionId))
|
||||
.limit(1)
|
||||
|
||||
if (!suggestion) {
|
||||
set.status = 404
|
||||
return { error: "Suggestion not found" }
|
||||
}
|
||||
|
||||
if (suggestion.status !== "pending") {
|
||||
set.status = 400
|
||||
return { error: "Suggestion already reviewed" }
|
||||
}
|
||||
|
||||
// Update suggestion status
|
||||
await db
|
||||
.update(communitySuggestions)
|
||||
.set({
|
||||
status: status as typeof communitySuggestions.$inferInsert.status,
|
||||
reviewedBy: guard.user.id,
|
||||
reviewedAt: new Date(),
|
||||
reviewNote,
|
||||
})
|
||||
.where(eq(communitySuggestions.id, params.suggestionId))
|
||||
|
||||
// If approved, apply the change to the game
|
||||
if (status === "approved") {
|
||||
const updateData: Record<string, string> = {}
|
||||
updateData[suggestion.fieldName] = suggestion.proposedValue
|
||||
|
||||
await db
|
||||
.update(games)
|
||||
.set(updateData)
|
||||
.where(eq(games.id, suggestion.gameId))
|
||||
}
|
||||
|
||||
return { success: true }
|
||||
},
|
||||
{
|
||||
params: t.Object({ suggestionId: t.String() }),
|
||||
body: t.Object({
|
||||
status: t.String(),
|
||||
reviewNote: t.Optional(t.String()),
|
||||
}),
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,187 @@
|
||||
import { Elysia, t } from "elysia"
|
||||
import { db } from "@/lib/db/index"
|
||||
import { games, gameVersions, performanceEntries, hardware } from "@/lib/db/schema"
|
||||
import { eq, and, inArray } from "drizzle-orm"
|
||||
|
||||
export const compareRoutes = new Elysia({ prefix: "/compare", detail: { tags: ["Games"] } })
|
||||
.get(
|
||||
"/games",
|
||||
async ({ query, set }) => {
|
||||
const gameIds = query.ids.split(",").filter(Boolean).slice(0, 4)
|
||||
if (gameIds.length < 2) {
|
||||
set.status = 400
|
||||
return { error: "At least 2 games required" }
|
||||
}
|
||||
|
||||
// Fetch games
|
||||
const gameRows = await db
|
||||
.select({
|
||||
id: games.id,
|
||||
title: games.title,
|
||||
steamAppId: games.steamAppId,
|
||||
source: games.source,
|
||||
capsuleImage: games.capsuleImage,
|
||||
headerImage: games.headerImage,
|
||||
})
|
||||
.from(games)
|
||||
.where(inArray(games.id, gameIds))
|
||||
|
||||
const results = await Promise.all(
|
||||
gameRows.map(async (game) => {
|
||||
// Fetch all non-removed entries for this game
|
||||
const entries = await db
|
||||
.select({
|
||||
fpsAvg: performanceEntries.fpsAvg,
|
||||
fpsLow: performanceEntries.fpsLow,
|
||||
fpsHigh: performanceEntries.fpsHigh,
|
||||
fpsOnePercentLow: performanceEntries.fpsOnePercentLow,
|
||||
upscalerType: performanceEntries.upscalerType,
|
||||
frameGenMethod: performanceEntries.frameGenMethod,
|
||||
hardwareSlug: performanceEntries.hardwareSlug,
|
||||
hardwareName: hardware.name,
|
||||
})
|
||||
.from(performanceEntries)
|
||||
.innerJoin(gameVersions, eq(performanceEntries.versionId, gameVersions.id))
|
||||
.innerJoin(hardware, eq(performanceEntries.hardwareSlug, hardware.slug))
|
||||
.where(
|
||||
and(
|
||||
eq(gameVersions.gameId, game.id),
|
||||
eq(performanceEntries.isRemoved, false)
|
||||
)
|
||||
)
|
||||
|
||||
if (entries.length === 0) {
|
||||
return {
|
||||
...game,
|
||||
stats: {
|
||||
totalEntries: 0,
|
||||
avgFps: null,
|
||||
medianFps: null,
|
||||
bestFps: null,
|
||||
avgOnePercentLow: null,
|
||||
avgStability: null,
|
||||
bestDevice: null,
|
||||
tierBreakdown: null,
|
||||
deviceBreakdown: [],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const fpsValues = entries.map((e) => e.fpsAvg ?? 0).sort((a, b) => a - b)
|
||||
const avgFps = fpsValues.reduce((a, b) => a + b, 0) / fpsValues.length
|
||||
const medianFps = fpsValues[Math.floor(fpsValues.length / 2)]
|
||||
|
||||
const onePercentLowValues = entries
|
||||
.filter((e) => e.fpsOnePercentLow != null)
|
||||
.map((e) => e.fpsOnePercentLow!)
|
||||
|
||||
const avgOnePercentLow =
|
||||
onePercentLowValues.length > 0
|
||||
? onePercentLowValues.reduce((a, b) => a + b, 0) / onePercentLowValues.length
|
||||
: null
|
||||
|
||||
const stabilityScores = entries
|
||||
.filter((e) => e.fpsOnePercentLow != null && e.fpsAvg > 0)
|
||||
.map((e) => Math.min(1, e.fpsOnePercentLow! / (e.fpsAvg ?? 1)))
|
||||
|
||||
const avgStability =
|
||||
stabilityScores.length > 0
|
||||
? stabilityScores.reduce((a, b) => a + b, 0) / stabilityScores.length
|
||||
: null
|
||||
|
||||
// Best device by avg fps
|
||||
const deviceFpsMap = new Map<string, number[]>()
|
||||
for (const e of entries) {
|
||||
const arr = deviceFpsMap.get(e.hardwareSlug) || []
|
||||
arr.push(e.fpsAvg ?? 0)
|
||||
deviceFpsMap.set(e.hardwareSlug, arr)
|
||||
}
|
||||
let bestDevice: string | null = null
|
||||
let bestDeviceAvg = 0
|
||||
for (const [slug, fpsArr] of deviceFpsMap) {
|
||||
const mean = fpsArr.reduce((a, b) => a + b, 0) / fpsArr.length
|
||||
if (mean > bestDeviceAvg) {
|
||||
bestDeviceAvg = mean
|
||||
bestDevice = slug
|
||||
}
|
||||
}
|
||||
|
||||
// Tier breakdown
|
||||
const tiers = { unplayable: 0, playable: 0, smooth: 0, excellent: 0 }
|
||||
for (const e of entries) {
|
||||
const fps = e.fpsAvg ?? 0
|
||||
if (fps < 30) tiers.unplayable++
|
||||
else if (fps < 60) tiers.playable++
|
||||
else if (fps < 120) tiers.smooth++
|
||||
else tiers.excellent++
|
||||
}
|
||||
|
||||
// Device breakdown
|
||||
const deviceBreakdown = Array.from(deviceFpsMap.entries()).map(([slug, fpsArr]) => ({
|
||||
hardwareSlug: slug,
|
||||
count: fpsArr.length,
|
||||
avgFps: Math.round((fpsArr.reduce((a, b) => a + b, 0) / fpsArr.length) * 10) / 10,
|
||||
}))
|
||||
|
||||
return {
|
||||
...game,
|
||||
stats: {
|
||||
totalEntries: entries.length,
|
||||
avgFps: Math.round(avgFps * 10) / 10,
|
||||
medianFps: Math.round(medianFps * 10) / 10,
|
||||
bestFps: Math.round(fpsValues[fpsValues.length - 1] * 10) / 10,
|
||||
avgOnePercentLow: avgOnePercentLow ? Math.round(avgOnePercentLow * 10) / 10 : null,
|
||||
avgStability: avgStability ? Math.round(avgStability * 100) : null,
|
||||
bestDevice,
|
||||
tierBreakdown: tiers,
|
||||
deviceBreakdown,
|
||||
},
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
return { games: results }
|
||||
},
|
||||
{
|
||||
query: t.Object({ ids: t.String() }),
|
||||
}
|
||||
)
|
||||
.get(
|
||||
"/presets",
|
||||
async ({ query, set }) => {
|
||||
const presetIds = query.ids.split(",").filter(Boolean).slice(0, 4)
|
||||
if (presetIds.length < 2) {
|
||||
set.status = 400
|
||||
return { error: "At least 2 presets required" }
|
||||
}
|
||||
|
||||
const presetRows = await db
|
||||
.select({
|
||||
id: performanceEntries.id,
|
||||
fpsAvg: performanceEntries.fpsAvg,
|
||||
fpsLow: performanceEntries.fpsLow,
|
||||
fpsHigh: performanceEntries.fpsHigh,
|
||||
fpsOnePercentLow: performanceEntries.fpsOnePercentLow,
|
||||
upscalerType: performanceEntries.upscalerType,
|
||||
upscalerVersion: performanceEntries.upscalerVersion,
|
||||
frameGenMethod: performanceEntries.frameGenMethod,
|
||||
protonVersion: performanceEntries.protonVersion,
|
||||
osVersion: performanceEntries.osVersion,
|
||||
loadTimeSsd: performanceEntries.loadTimeSsd,
|
||||
loadTimeSd: performanceEntries.loadTimeSd,
|
||||
settingsJson: performanceEntries.settingsJson,
|
||||
launchOptions: performanceEntries.launchOptions,
|
||||
hardwareSlug: performanceEntries.hardwareSlug,
|
||||
hardwareName: hardware.name,
|
||||
createdAt: performanceEntries.createdAt,
|
||||
})
|
||||
.from(performanceEntries)
|
||||
.innerJoin(hardware, eq(performanceEntries.hardwareSlug, hardware.slug))
|
||||
.where(inArray(performanceEntries.id, presetIds))
|
||||
|
||||
return { presets: presetRows }
|
||||
},
|
||||
{
|
||||
query: t.Object({ ids: t.String() }),
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,144 @@
|
||||
import { Elysia, t } from "elysia"
|
||||
|
||||
// ── Discord embed colors by category ─────────────────────────────
|
||||
const CATEGORY_COLORS: Record<string, number> = {
|
||||
bug: 0xe74c3c, // red
|
||||
game_data: 0xf1c40f, // yellow
|
||||
user_report: 0x3498db, // blue
|
||||
feature: 0x2ecc71, // green
|
||||
feedback: 0x95a5a6, // grey
|
||||
database: 0xe74c3c, // red
|
||||
}
|
||||
|
||||
const CATEGORY_LABELS: Record<string, string> = {
|
||||
bug: "Bug Report",
|
||||
game_data: "Game Data Issue",
|
||||
user_report: "User Report",
|
||||
feature: "Feature Request",
|
||||
feedback: "General Feedback",
|
||||
database: "Database Error",
|
||||
}
|
||||
|
||||
const VALID_CATEGORIES = ["bug", "game_data", "user_report", "feature", "feedback", "database"]
|
||||
|
||||
export const contactRoutes = new Elysia({ prefix: "/contact", detail: { tags: ["Contact"] } }).post(
|
||||
"/",
|
||||
async ({ body, request, set }) => {
|
||||
const payload = body as {
|
||||
category: string
|
||||
name?: string
|
||||
email?: string
|
||||
subject: string
|
||||
message: string
|
||||
gameUrl?: string
|
||||
honeypot?: string
|
||||
_timestamp?: string
|
||||
}
|
||||
|
||||
// ── Honeypot check ──────────────────────────────────────────
|
||||
if (payload.honeypot) {
|
||||
set.status = 200
|
||||
return { success: true }
|
||||
}
|
||||
|
||||
// ── Timing check (must take > 3 seconds) ─────────────────────
|
||||
if (payload._timestamp) {
|
||||
const start = Number(payload._timestamp)
|
||||
if (!isNaN(start) && Date.now() - start < 3000) {
|
||||
set.status = 200
|
||||
return { success: true }
|
||||
}
|
||||
}
|
||||
|
||||
// ── Validate category ──────────────────────────────────────
|
||||
if (!VALID_CATEGORIES.includes(payload.category)) {
|
||||
set.status = 400
|
||||
return { error: "Invalid category" }
|
||||
}
|
||||
|
||||
// ── Validate required fields ────────────────────────────────
|
||||
if (!payload.subject || payload.subject.trim().length === 0) {
|
||||
set.status = 400
|
||||
return { error: "Subject is required" }
|
||||
}
|
||||
if (payload.subject.length > 200) {
|
||||
set.status = 400
|
||||
return { error: "Subject must be 200 characters or less" }
|
||||
}
|
||||
if (!payload.message || payload.message.trim().length === 0) {
|
||||
set.status = 400
|
||||
return { error: "Message is required" }
|
||||
}
|
||||
if (payload.message.length > 2000) {
|
||||
set.status = 400
|
||||
return { error: "Message must be 2000 characters or less" }
|
||||
}
|
||||
|
||||
// ── Validate game URL for game_data category ────────────────
|
||||
if (payload.category === "game_data" && payload.gameUrl) {
|
||||
if (!payload.gameUrl.includes("/game/")) {
|
||||
set.status = 400
|
||||
return { error: "Game URL must be a valid DeckyVault game link" }
|
||||
}
|
||||
}
|
||||
|
||||
// ── Validate email format if provided ───────────────────────
|
||||
if (payload.email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(payload.email)) {
|
||||
set.status = 400
|
||||
return { error: "Invalid email format" }
|
||||
}
|
||||
|
||||
// ── Send to Discord webhook ─────────────────────────────────
|
||||
const webhookUrl = process.env.DISCORD_WEBHOOK_URL
|
||||
if (!webhookUrl) {
|
||||
console.error("[Contact] DISCORD_WEBHOOK_URL not configured")
|
||||
set.status = 500
|
||||
return { error: "Service not configured. Please try again later." }
|
||||
}
|
||||
|
||||
const embed = {
|
||||
title: `[${CATEGORY_LABELS[payload.category]}] ${payload.subject}`,
|
||||
description: payload.message.slice(0, 4096),
|
||||
color: CATEGORY_COLORS[payload.category] ?? 0x95a5a6,
|
||||
fields: [
|
||||
...(payload.name ? [{ name: "Name", value: payload.name, inline: true }] : []),
|
||||
...(payload.email ? [{ name: "Email", value: payload.email, inline: true }] : []),
|
||||
...(payload.gameUrl ? [{ name: "Game URL", value: payload.gameUrl, inline: false }] : []),
|
||||
{ name: "IP Hash", value: `\`${(request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ?? "unknown").slice(0, 8)}...\``, inline: true },
|
||||
],
|
||||
timestamp: new Date().toISOString(),
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(webhookUrl, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ embeds: [embed] }),
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
console.error("[Contact] Discord webhook failed:", res.status, await res.text())
|
||||
set.status = 500
|
||||
return { error: "Failed to submit. Please try again later." }
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("[Contact] Discord webhook error:", err)
|
||||
set.status = 500
|
||||
return { error: "Failed to submit. Please try again later." }
|
||||
}
|
||||
|
||||
return { success: true }
|
||||
},
|
||||
{
|
||||
body: t.Object({
|
||||
category: t.String(),
|
||||
name: t.Optional(t.String()),
|
||||
email: t.Optional(t.String()),
|
||||
subject: t.String(),
|
||||
message: t.String(),
|
||||
gameUrl: t.Optional(t.String()),
|
||||
honeypot: t.Optional(t.String()),
|
||||
_timestamp: t.Optional(t.String()),
|
||||
}),
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,162 @@
|
||||
import { Elysia, t } from "elysia"
|
||||
import { db } from "@/lib/db/index"
|
||||
import { storageObjects } from "@/lib/db/schema"
|
||||
import { eq, sql } from "drizzle-orm"
|
||||
import { deleteObject, isR2Configured } from "@/lib/storage"
|
||||
|
||||
// ── Task Result Type ────────────────────────────────────────────────
|
||||
interface CronTaskResult {
|
||||
name: string
|
||||
status: "success" | "skipped" | "error"
|
||||
durationMs: number
|
||||
details: Record<string, unknown>
|
||||
}
|
||||
|
||||
// ── Task Registry ───────────────────────────────────────────────────
|
||||
type CronTask = () => Promise<CronTaskResult>
|
||||
|
||||
export const taskRegistry = new Map<string, CronTask>()
|
||||
|
||||
export function registerCronTask(name: string, task: CronTask): void {
|
||||
taskRegistry.set(name, task)
|
||||
}
|
||||
|
||||
// ── Storage Cleanup Task ────────────────────────────────────────────
|
||||
registerCronTask("storage_cleanup", async () => {
|
||||
const start = Date.now()
|
||||
const details: Record<string, unknown> = {}
|
||||
|
||||
if (!isR2Configured()) {
|
||||
return { name: "storage_cleanup", status: "skipped" as const, durationMs: Date.now() - start, details: { reason: "R2 not configured" } }
|
||||
}
|
||||
|
||||
try {
|
||||
// Find orphaned storage objects
|
||||
const orphaned = await db
|
||||
.select()
|
||||
.from(storageObjects)
|
||||
.where(eq(storageObjects.isOrphaned, true))
|
||||
|
||||
let deletedCount = 0
|
||||
let errorCount = 0
|
||||
|
||||
// Process in batches of 100
|
||||
for (let i = 0; i < orphaned.length; i += 100) {
|
||||
const batch = orphaned.slice(i, i + 100)
|
||||
await Promise.allSettled(
|
||||
batch.map(async (obj) => {
|
||||
try {
|
||||
await deleteObject(obj.key)
|
||||
deletedCount++
|
||||
} catch {
|
||||
errorCount++
|
||||
}
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
// Remove deleted objects from tracking table
|
||||
if (orphaned.length > 0) {
|
||||
await db
|
||||
.delete(storageObjects)
|
||||
.where(eq(storageObjects.isOrphaned, true))
|
||||
}
|
||||
|
||||
details.deletedCount = deletedCount
|
||||
details.errorCount = errorCount
|
||||
details.totalOrphans = orphaned.length
|
||||
|
||||
return { name: "storage_cleanup", status: "success" as const, durationMs: Date.now() - start, details }
|
||||
} catch (err) {
|
||||
details.error = err instanceof Error ? err.message : String(err)
|
||||
return { name: "storage_cleanup", status: "error" as const, durationMs: Date.now() - start, details }
|
||||
}
|
||||
})
|
||||
|
||||
// ── Orphan Detection (separate task for future extensibility) ──────
|
||||
// This marks objects as orphaned based on their entityType/entityId references
|
||||
registerCronTask("orphan_detection", async () => {
|
||||
const start = Date.now()
|
||||
const details: Record<string, unknown> = {}
|
||||
|
||||
try {
|
||||
// Avatar orphans: storage_objects with entityType='avatar' where user doesn't exist
|
||||
// or user.image doesn't contain this object's key
|
||||
const avatarOrphans = await db.execute(sql`
|
||||
UPDATE storage_objects
|
||||
SET is_orphaned = true
|
||||
WHERE entity_type = 'avatar'
|
||||
AND is_orphaned = false
|
||||
AND entity_id NOT IN (SELECT id FROM "user")
|
||||
`)
|
||||
|
||||
details.avatarOrphansMarked = avatarOrphans.rowCount ?? 0
|
||||
|
||||
// Game cover orphans: storage_objects with entityType='game_cover' where game doesn't exist
|
||||
const gameOrphans = await db.execute(sql`
|
||||
UPDATE storage_objects
|
||||
SET is_orphaned = true
|
||||
WHERE entity_type = 'game_cover'
|
||||
AND is_orphaned = false
|
||||
AND entity_id NOT IN (SELECT id FROM games)
|
||||
`)
|
||||
|
||||
details.gameCoverOrphansMarked = gameOrphans.rowCount ?? 0
|
||||
|
||||
return { name: "orphan_detection", status: "success" as const, durationMs: Date.now() - start, details }
|
||||
} catch (err) {
|
||||
details.error = err instanceof Error ? err.message : String(err)
|
||||
return { name: "orphan_detection", status: "error" as const, durationMs: Date.now() - start, details }
|
||||
}
|
||||
})
|
||||
|
||||
// ── Cron Route ──────────────────────────────────────────────────────
|
||||
export const cronRoutes = new Elysia({ prefix: "/cron", detail: { tags: ["Cron"] } }).post(
|
||||
"/daily",
|
||||
async ({ query, set }) => {
|
||||
const cronSecret = process.env.CRON_SECRET
|
||||
|
||||
// If CRON_SECRET is not configured, disable the endpoint
|
||||
if (!cronSecret) {
|
||||
set.status = 404
|
||||
return { error: "Cron endpoint not configured" }
|
||||
}
|
||||
|
||||
// Validate cron secret from query parameter
|
||||
if (query.secret !== cronSecret) {
|
||||
set.status = 401
|
||||
return { error: "Unauthorized" }
|
||||
}
|
||||
|
||||
const overallStart = Date.now()
|
||||
|
||||
// Determine which tasks to run
|
||||
const taskNames = query.tasks
|
||||
? query.tasks.split(",").filter((t) => taskRegistry.has(t))
|
||||
: Array.from(taskRegistry.keys())
|
||||
|
||||
const results: CronTaskResult[] = []
|
||||
|
||||
for (const taskName of taskNames) {
|
||||
const task = taskRegistry.get(taskName)
|
||||
if (task) {
|
||||
const result = await task()
|
||||
results.push(result)
|
||||
}
|
||||
}
|
||||
|
||||
const overallDuration = Date.now() - overallStart
|
||||
|
||||
return {
|
||||
success: true,
|
||||
duration: overallDuration,
|
||||
tasks: results,
|
||||
}
|
||||
},
|
||||
{
|
||||
query: t.Object({
|
||||
secret: t.String(),
|
||||
tasks: t.Optional(t.String()),
|
||||
}),
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,335 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import { Elysia, t } from "elysia"
|
||||
import { db } from "@/lib/db/index"
|
||||
import { getTableColumns } from "drizzle-orm"
|
||||
import {
|
||||
type AnyPgTable,
|
||||
type PgColumn,
|
||||
} from "drizzle-orm/pg-core"
|
||||
import {
|
||||
eq,
|
||||
desc,
|
||||
asc,
|
||||
ilike,
|
||||
sql,
|
||||
type SQL,
|
||||
and,
|
||||
or,
|
||||
} from "drizzle-orm"
|
||||
import { requireRole } from "@/lib/auth/guard"
|
||||
import { fuzzySearchTerm } from "@/lib/db/search"
|
||||
|
||||
/** Which columns are text-searchable via ilike */
|
||||
export type CrudSearchConfig = {
|
||||
fields: string[]
|
||||
}
|
||||
|
||||
/** Which columns support exact-match filtering via ?filter[col]=val */
|
||||
export type CrudFilterConfig = {
|
||||
fields: string[]
|
||||
}
|
||||
|
||||
/** Auth requirements per operation */
|
||||
export type CrudAuthConfig = {
|
||||
read: "public" | "auth"
|
||||
write: "user" | "contributor" | "admin"
|
||||
delete: "admin" | "contributor"
|
||||
}
|
||||
|
||||
/**
|
||||
* Create typed CRUD routes for a Drizzle table.
|
||||
*
|
||||
* @param table - Drizzle pgTable definition
|
||||
* @param config.prefix - URL prefix (e.g., "/games")
|
||||
* @param config.auth - Auth requirements per operation
|
||||
* @param config.search - Text search configuration
|
||||
* @param config.filter - Exact-match filter configuration
|
||||
* @param config.name - Human-readable name for error messages
|
||||
* @param config.primaryKey - Column name used as primary key (default: "id")
|
||||
* @param config.paramName - URL parameter name (defaults to primaryKey)
|
||||
* @param config.softDelete - If true, DELETE sets isRemoved=true instead of deleting
|
||||
*/
|
||||
export function createCrudRoutes<T extends AnyPgTable>(
|
||||
table: T,
|
||||
config: {
|
||||
prefix: string
|
||||
auth: CrudAuthConfig
|
||||
search?: CrudSearchConfig
|
||||
filter?: CrudFilterConfig
|
||||
name?: string
|
||||
primaryKey?: string
|
||||
paramName?: string
|
||||
softDelete?: boolean
|
||||
tags?: string[]
|
||||
},
|
||||
) {
|
||||
const {
|
||||
prefix,
|
||||
auth: authConfig,
|
||||
search,
|
||||
filter,
|
||||
name = "resource",
|
||||
primaryKey = "id",
|
||||
paramName = primaryKey,
|
||||
softDelete = false,
|
||||
tags,
|
||||
} = config
|
||||
|
||||
const columns = getTableColumns(table) as Record<string, PgColumn>
|
||||
const pkColumn = columns[primaryKey]
|
||||
|
||||
if (!pkColumn) {
|
||||
throw new Error(`Primary key column "${primaryKey}" not found on table`)
|
||||
}
|
||||
|
||||
const routes = new Elysia({
|
||||
prefix,
|
||||
...(tags ? { detail: { tags } } : {}),
|
||||
})
|
||||
|
||||
// ── LIST ──────────────────────────────────────────────────────────
|
||||
routes.get(
|
||||
"/",
|
||||
async ({ query }) => {
|
||||
const limit = Math.min(Number(query.limit) || 20, 100)
|
||||
const offset = Number(query.offset) || 0
|
||||
const sortCol = columns[query.sort as string] || pkColumn
|
||||
const order = query.order === "asc" ? asc : desc
|
||||
|
||||
const conditions: SQL[] = []
|
||||
|
||||
// Search
|
||||
if (query.search && search) {
|
||||
const searchStr = query.search
|
||||
const searchConditions = search.fields
|
||||
.map((field) => {
|
||||
const col = columns[field]
|
||||
if (!col) return null
|
||||
const pattern = field === "title"
|
||||
? fuzzySearchTerm(searchStr)
|
||||
: `%${searchStr}%`
|
||||
return ilike(col, pattern)
|
||||
})
|
||||
.filter(Boolean) as SQL[]
|
||||
if (searchConditions.length > 0) {
|
||||
conditions.push(or(...searchConditions)!)
|
||||
}
|
||||
}
|
||||
|
||||
// Filters
|
||||
if (filter) {
|
||||
for (const field of filter.fields) {
|
||||
const val = (query as any)[`filter_${field}`]
|
||||
if (val !== undefined) {
|
||||
const col = columns[field]
|
||||
if (col) {
|
||||
conditions.push(eq(col, val))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const where = conditions.length > 0 ? and(...conditions) : undefined
|
||||
|
||||
const [data, countResult] = await Promise.all([
|
||||
db
|
||||
.select()
|
||||
.from(table as any)
|
||||
.where(where)
|
||||
.orderBy(order(sortCol))
|
||||
.limit(limit)
|
||||
.offset(offset),
|
||||
db
|
||||
.select({ count: sql<number>`count(*)::int` })
|
||||
.from(table as any)
|
||||
.where(where),
|
||||
])
|
||||
|
||||
return {
|
||||
data,
|
||||
total: countResult[0]?.count ?? 0,
|
||||
limit,
|
||||
offset,
|
||||
}
|
||||
},
|
||||
{
|
||||
query: t.Object({
|
||||
limit: t.Optional(t.String()),
|
||||
offset: t.Optional(t.String()),
|
||||
sort: t.Optional(t.String()),
|
||||
order: t.Optional(t.String()),
|
||||
search: t.Optional(t.String()),
|
||||
// Dynamic filter fields are too varied for static TypeBox,
|
||||
// so we allow any string keys with filter_ prefix
|
||||
}),
|
||||
detail: {
|
||||
summary: `List ${name}s`,
|
||||
description: `Returns a paginated list of ${name}s with optional search and filtering.`,
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
// ── GET BY ID ─────────────────────────────────────────────────────
|
||||
routes.get(
|
||||
`/:${paramName}`,
|
||||
async ({ params, set }) => {
|
||||
const id = (params as any)[paramName]
|
||||
|
||||
const [record] = await db
|
||||
.select()
|
||||
.from(table as any)
|
||||
.where(eq(pkColumn, id))
|
||||
.limit(1)
|
||||
|
||||
if (!record) {
|
||||
set.status = 404
|
||||
return { error: `${name} not found` }
|
||||
}
|
||||
|
||||
return record
|
||||
},
|
||||
{
|
||||
params: t.Object({
|
||||
[paramName]: t.String(),
|
||||
}),
|
||||
detail: {
|
||||
summary: `Get ${name} by ID`,
|
||||
description: `Returns a single ${name} by its unique identifier.`,
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
// ── CREATE ────────────────────────────────────────────────────────
|
||||
routes.post(
|
||||
"/",
|
||||
async ({ body, request, set }) => {
|
||||
// Auth check
|
||||
const roleMap: Record<string, string[]> = {
|
||||
user: ["user", "contributor", "admin"],
|
||||
contributor: ["contributor", "admin"],
|
||||
admin: ["admin"],
|
||||
}
|
||||
const allowedRoles = roleMap[authConfig.write]
|
||||
const guard = await requireRole(request.headers, allowedRoles)
|
||||
|
||||
if (!guard.ok) {
|
||||
set.status = guard.status
|
||||
return { error: guard.error }
|
||||
}
|
||||
|
||||
const [created] = (await db.insert(table as any).values(body as any).returning()) as any[]
|
||||
|
||||
set.status = 201
|
||||
return created
|
||||
},
|
||||
{
|
||||
body: t.Record(t.String(), t.Any()),
|
||||
detail: {
|
||||
summary: `Create ${name}`,
|
||||
description: `Creates a new ${name}. Requires authentication.`,
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
// ── UPDATE ────────────────────────────────────────────────────────
|
||||
routes.patch(
|
||||
`/:${paramName}`,
|
||||
async ({ params, body, request, set }) => {
|
||||
const roleMap: Record<string, string[]> = {
|
||||
user: ["user", "contributor", "admin"],
|
||||
contributor: ["contributor", "admin"],
|
||||
admin: ["admin"],
|
||||
}
|
||||
const allowedRoles = roleMap[authConfig.write]
|
||||
const guard = await requireRole(request.headers, allowedRoles)
|
||||
|
||||
if (!guard.ok) {
|
||||
set.status = guard.status
|
||||
return { error: guard.error }
|
||||
}
|
||||
|
||||
const id = (params as any)[paramName]
|
||||
|
||||
// Add updatedAt if column exists
|
||||
const updateData = columns["updatedAt"]
|
||||
? { ...body, updatedAt: new Date() }
|
||||
: body
|
||||
|
||||
const [updated] = (await db
|
||||
.update(table as any)
|
||||
.set(updateData as any)
|
||||
.where(eq(pkColumn, id))
|
||||
.returning()) as any[]
|
||||
|
||||
if (!updated) {
|
||||
set.status = 404
|
||||
return { error: `${name} not found` }
|
||||
}
|
||||
|
||||
return updated
|
||||
},
|
||||
{
|
||||
params: t.Object({
|
||||
[paramName]: t.String(),
|
||||
}),
|
||||
body: t.Record(t.String(), t.Any()),
|
||||
detail: {
|
||||
summary: `Update ${name}`,
|
||||
description: `Updates an existing ${name} by ID. Requires authentication.`,
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
// ── DELETE ────────────────────────────────────────────────────────
|
||||
routes.delete(
|
||||
`/:${paramName}`,
|
||||
async ({ params, request, set }) => {
|
||||
const guard = await requireRole(request.headers, [authConfig.delete])
|
||||
|
||||
if (!guard.ok) {
|
||||
set.status = guard.status
|
||||
return { error: guard.error }
|
||||
}
|
||||
|
||||
const id = (params as any)[paramName]
|
||||
|
||||
if (softDelete && columns["isRemoved"]) {
|
||||
const [updated] = (await db
|
||||
.update(table as any)
|
||||
.set({ isRemoved: true, updatedAt: new Date() } as any)
|
||||
.where(eq(pkColumn, id))
|
||||
.returning()) as any[]
|
||||
|
||||
if (!updated) {
|
||||
set.status = 404
|
||||
return { error: `${name} not found` }
|
||||
}
|
||||
|
||||
return { success: true }
|
||||
}
|
||||
|
||||
const [deleted] = (await db
|
||||
.delete(table as any)
|
||||
.where(eq(pkColumn, id))
|
||||
.returning()) as any[]
|
||||
|
||||
if (!deleted) {
|
||||
set.status = 404
|
||||
return { error: `${name} not found` }
|
||||
}
|
||||
|
||||
return { success: true }
|
||||
},
|
||||
{
|
||||
params: t.Object({
|
||||
[paramName]: t.String(),
|
||||
}),
|
||||
detail: {
|
||||
summary: `Delete ${name}`,
|
||||
description: `Deletes a ${name} by ID. Requires admin or contributor role.`,
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
return routes
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
import { Elysia } from "elysia"
|
||||
import { db } from "@/lib/db/index"
|
||||
import { sql } from "drizzle-orm"
|
||||
|
||||
const SEVEN_DAYS_AGO = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000)
|
||||
|
||||
export const dashboardPublicRoutes = new Elysia({ prefix: "/dashboard", detail: { tags: ["Dashboard"] } })
|
||||
|
||||
// ── Trending Games (7-day activity) ────────────────────────────────
|
||||
.get(
|
||||
"/trending",
|
||||
async () => {
|
||||
const trending = await db.execute(sql`
|
||||
WITH recent_benchmarks AS (
|
||||
SELECT gv.game_id, COUNT(*) AS cnt
|
||||
FROM performance_entries pe
|
||||
JOIN game_versions gv ON pe.version_id = gv.id
|
||||
WHERE pe.is_removed = false
|
||||
AND pe.created_at >= ${SEVEN_DAYS_AGO}
|
||||
GROUP BY gv.game_id
|
||||
),
|
||||
recent_comments AS (
|
||||
SELECT gc.game_id, COUNT(*) AS cnt
|
||||
FROM game_comments gc
|
||||
WHERE gc.is_removed = false
|
||||
AND gc.created_at >= ${SEVEN_DAYS_AGO}
|
||||
GROUP BY gc.game_id
|
||||
),
|
||||
recent_upvotes AS (
|
||||
SELECT gv.game_id, SUM(pe.upvotes) AS total_upvotes
|
||||
FROM performance_entries pe
|
||||
JOIN game_versions gv ON pe.version_id = gv.id
|
||||
WHERE pe.is_removed = false
|
||||
AND pe.updated_at >= ${SEVEN_DAYS_AGO}
|
||||
GROUP BY gv.game_id
|
||||
)
|
||||
SELECT
|
||||
g.id,
|
||||
g.title,
|
||||
g.capsule_image,
|
||||
g.header_image,
|
||||
g.playability_status,
|
||||
COALESCE(rb.cnt, 0) AS benchmark_count,
|
||||
COALESCE(rc.cnt, 0) AS comment_count,
|
||||
COALESCE(ru.total_upvotes, 0) AS upvote_count,
|
||||
(COALESCE(rb.cnt, 0) * 3 + COALESCE(rc.cnt, 0) * 2 + COALESCE(ru.total_upvotes, 0) * 1) AS activity_score
|
||||
FROM games g
|
||||
LEFT JOIN recent_benchmarks rb ON rb.game_id = g.id
|
||||
LEFT JOIN recent_comments rc ON rc.game_id = g.id
|
||||
LEFT JOIN recent_upvotes ru ON ru.game_id = g.id
|
||||
WHERE (rb.cnt IS NOT NULL OR rc.cnt IS NOT NULL OR ru.total_upvotes IS NOT NULL)
|
||||
ORDER BY activity_score DESC
|
||||
LIMIT 10
|
||||
`)
|
||||
|
||||
return trending.rows
|
||||
},
|
||||
{ detail: { description: "Trending games based on 7-day benchmark and comment activity." } },
|
||||
)
|
||||
|
||||
// ── Best Performing New Releases ─────────────────────────────────
|
||||
.get(
|
||||
"/best-new-releases",
|
||||
async () => {
|
||||
const results = await db.execute(sql`
|
||||
SELECT
|
||||
g.id,
|
||||
g.title,
|
||||
g.capsule_image,
|
||||
g.header_image,
|
||||
g.release_date,
|
||||
g.created_at,
|
||||
g.playability_status,
|
||||
AVG(pe.fps_avg) AS avg_fps,
|
||||
COUNT(pe.id) AS benchmark_count
|
||||
FROM games g
|
||||
JOIN game_versions gv ON gv.game_id = g.id
|
||||
JOIN performance_entries pe ON pe.version_id = gv.id
|
||||
WHERE pe.is_removed = false
|
||||
AND (g.created_at >= ${new Date(Date.now() - 30 * 24 * 60 * 60 * 1000)}
|
||||
OR g.release_date IS NOT NULL)
|
||||
GROUP BY g.id, g.title, g.capsule_image, g.header_image, g.release_date, g.created_at, g.playability_status
|
||||
HAVING COUNT(pe.id) >= 3
|
||||
ORDER BY avg_fps DESC
|
||||
LIMIT 10
|
||||
`)
|
||||
|
||||
return results.rows
|
||||
},
|
||||
{ detail: { description: "Best performing new releases from the last 30 days with at least 3 benchmarks." } },
|
||||
)
|
||||
|
||||
// ── Most Tested Games ─────────────────────────────────────────────
|
||||
.get(
|
||||
"/most-tested",
|
||||
async () => {
|
||||
const results = await db.execute(sql`
|
||||
SELECT
|
||||
g.id,
|
||||
g.title,
|
||||
g.capsule_image,
|
||||
g.header_image,
|
||||
g.playability_status,
|
||||
COUNT(pe.id) AS benchmark_count,
|
||||
AVG(pe.fps_avg) AS avg_fps
|
||||
FROM games g
|
||||
JOIN game_versions gv ON gv.game_id = g.id
|
||||
JOIN performance_entries pe ON pe.version_id = gv.id
|
||||
WHERE pe.is_removed = false
|
||||
GROUP BY g.id, g.title, g.capsule_image, g.header_image, g.playability_status
|
||||
ORDER BY benchmark_count DESC
|
||||
LIMIT 10
|
||||
`)
|
||||
|
||||
return results.rows
|
||||
},
|
||||
{ detail: { description: "Games with the most benchmark entries, ordered by count descending." } },
|
||||
)
|
||||
|
||||
// ── Recently Added Benchmarks ──────────────────────────────────────
|
||||
.get(
|
||||
"/recent-benchmarks",
|
||||
async () => {
|
||||
const results = await db.execute(sql`
|
||||
SELECT
|
||||
g.id,
|
||||
g.title,
|
||||
g.capsule_image,
|
||||
g.header_image,
|
||||
g.playability_status,
|
||||
COUNT(pe.id) AS benchmark_count,
|
||||
AVG(pe.fps_avg) AS avg_fps,
|
||||
MAX(pe.created_at) AS latest_benchmark_at
|
||||
FROM games g
|
||||
JOIN game_versions gv ON gv.game_id = g.id
|
||||
JOIN performance_entries pe ON pe.version_id = gv.id
|
||||
WHERE pe.is_removed = false
|
||||
GROUP BY g.id, g.title, g.capsule_image, g.header_image, g.playability_status
|
||||
ORDER BY MAX(pe.created_at) DESC
|
||||
LIMIT 10
|
||||
`)
|
||||
|
||||
return results.rows
|
||||
},
|
||||
{ detail: { description: "Games with the most recently added benchmarks, ordered by latest entry date." } },
|
||||
)
|
||||
|
||||
// ── Most Reported Games ────────────────────────────────────────────
|
||||
.get(
|
||||
"/most-reported",
|
||||
async () => {
|
||||
const results = await db.execute(sql`
|
||||
SELECT
|
||||
g.id,
|
||||
g.title,
|
||||
g.capsule_image,
|
||||
g.header_image,
|
||||
COUNT(DISTINCT r.id) AS report_count
|
||||
FROM games g
|
||||
JOIN game_versions gv ON gv.game_id = g.id
|
||||
JOIN performance_entries pe ON pe.version_id = gv.id
|
||||
JOIN reports r ON r.entry_id = pe.id
|
||||
WHERE r.status = 'open'
|
||||
GROUP BY g.id, g.title, g.capsule_image, g.header_image
|
||||
ORDER BY report_count DESC
|
||||
LIMIT 10
|
||||
`)
|
||||
|
||||
return results.rows
|
||||
},
|
||||
{ detail: { description: "Games with the most open reports against their performance entries." } },
|
||||
)
|
||||
|
||||
// ── On Sale & Performing Well ────────────────────────────────────
|
||||
.get(
|
||||
"/on-sale",
|
||||
async () => {
|
||||
const results = await db.execute(sql`
|
||||
SELECT
|
||||
g.id,
|
||||
g.title,
|
||||
g.capsule_image,
|
||||
g.header_image,
|
||||
g.price_current,
|
||||
g.price_initial,
|
||||
g.price_currency,
|
||||
g.playability_status,
|
||||
g.steam_review_score,
|
||||
AVG(pe.fps_avg) AS avg_fps,
|
||||
MAX(pe.fps_avg) AS best_fps,
|
||||
COUNT(pe.id) AS benchmark_count
|
||||
FROM games g
|
||||
JOIN game_versions gv ON gv.game_id = g.id
|
||||
JOIN performance_entries pe ON pe.version_id = gv.id
|
||||
WHERE g.source = 'steam'
|
||||
AND g.price_current > 0
|
||||
AND g.price_initial > 0
|
||||
AND g.price_current < g.price_initial
|
||||
AND pe.is_removed = false
|
||||
AND g.last_sync >= NOW() - INTERVAL '7 days'
|
||||
GROUP BY g.id
|
||||
HAVING COUNT(pe.id) >= 3
|
||||
ORDER BY avg_fps DESC
|
||||
LIMIT 10
|
||||
`)
|
||||
|
||||
return results.rows
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,215 @@
|
||||
import { Elysia } from "elysia";
|
||||
import { db } from "@/lib/db/index";
|
||||
import {
|
||||
games,
|
||||
performanceEntries,
|
||||
reports,
|
||||
communitySuggestions,
|
||||
user,
|
||||
} from "@/lib/db/schema";
|
||||
import { eq, count, sql, gte, and, desc } from "drizzle-orm";
|
||||
import { requireContributorOrAdmin } from "@/lib/auth/guard";
|
||||
|
||||
// In-memory cache for dashboard stats (TTL 300s)
|
||||
interface DashboardStatsResult {
|
||||
overview: {
|
||||
totalGames: number;
|
||||
totalBenchmarks: number;
|
||||
totalUsers: number;
|
||||
pendingReports: number;
|
||||
pendingSuggestions: number;
|
||||
};
|
||||
recent: {
|
||||
benchmarksLast30Days: number;
|
||||
gamesLast30Days: number;
|
||||
};
|
||||
topContributors: Array<{
|
||||
userId: string | null;
|
||||
name: string;
|
||||
count: number;
|
||||
}>;
|
||||
syncHealth: Record<string, number>;
|
||||
gamesBySource: Record<string, number>;
|
||||
playabilityDistribution: Record<string, number>;
|
||||
}
|
||||
|
||||
interface CacheEntry<T> {
|
||||
data: T;
|
||||
expiresAt: number;
|
||||
}
|
||||
const statsCache = new Map<string, CacheEntry<unknown>>();
|
||||
const STATS_CACHE_TTL = 300_000; // 5 minutes
|
||||
|
||||
function getCached<T>(key: string): T | null {
|
||||
const entry = statsCache.get(key) as CacheEntry<T> | undefined;
|
||||
if (!entry) return null;
|
||||
if (Date.now() > entry.expiresAt) {
|
||||
statsCache.delete(key);
|
||||
return null;
|
||||
}
|
||||
return entry.data;
|
||||
}
|
||||
|
||||
function setCached<T>(key: string, data: T): void {
|
||||
statsCache.set(key, { data, expiresAt: Date.now() + STATS_CACHE_TTL });
|
||||
}
|
||||
|
||||
|
||||
export const dashboardRoutes = new Elysia({ prefix: "/dashboard", detail: { tags: ["Dashboard"] } }).get(
|
||||
"/stats",
|
||||
async ({ request, set }) => {
|
||||
const guard = await requireContributorOrAdmin(request.headers);
|
||||
if (!guard.ok) {
|
||||
set.status = guard.status;
|
||||
return { error: guard.error };
|
||||
}
|
||||
|
||||
// Check cache
|
||||
const cached = getCached<DashboardStatsResult>("dashboard_stats");
|
||||
if (cached) return cached;
|
||||
|
||||
const thirtyDaysAgo = new Date();
|
||||
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
|
||||
|
||||
const [
|
||||
totalGames,
|
||||
totalBenchmarks,
|
||||
totalUsers,
|
||||
pendingReports,
|
||||
pendingSuggestions,
|
||||
recentBenchmarks,
|
||||
recentGames,
|
||||
topContributors,
|
||||
syncHealth,
|
||||
gamesBySource,
|
||||
playabilityDistribution,
|
||||
] = await Promise.all([
|
||||
// Total games
|
||||
db.select({ count: count() }).from(games),
|
||||
|
||||
// Total benchmarks
|
||||
db
|
||||
.select({ count: count() })
|
||||
.from(performanceEntries)
|
||||
.where(eq(performanceEntries.isRemoved, false)),
|
||||
|
||||
// Total users
|
||||
db.select({ count: count() }).from(user),
|
||||
|
||||
// Pending reports
|
||||
db
|
||||
.select({ count: count() })
|
||||
.from(reports)
|
||||
.where(eq(reports.status, "open")),
|
||||
|
||||
// Pending suggestions
|
||||
db
|
||||
.select({ count: count() })
|
||||
.from(communitySuggestions)
|
||||
.where(eq(communitySuggestions.status, "pending")),
|
||||
|
||||
// Benchmarks in last 30 days
|
||||
db
|
||||
.select({ count: count() })
|
||||
.from(performanceEntries)
|
||||
.where(
|
||||
and(
|
||||
eq(performanceEntries.isRemoved, false),
|
||||
gte(performanceEntries.createdAt, thirtyDaysAgo),
|
||||
),
|
||||
),
|
||||
|
||||
// Games added in last 30 days
|
||||
db
|
||||
.select({ count: count() })
|
||||
.from(games)
|
||||
.where(gte(games.createdAt, thirtyDaysAgo)),
|
||||
|
||||
// Top contributors
|
||||
db
|
||||
.select({
|
||||
userId: performanceEntries.userId,
|
||||
userName: user.name,
|
||||
entryCount: count(),
|
||||
})
|
||||
.from(performanceEntries)
|
||||
.leftJoin(user, eq(performanceEntries.userId, user.id))
|
||||
.where(eq(performanceEntries.isRemoved, false))
|
||||
.groupBy(performanceEntries.userId, user.name)
|
||||
.orderBy(desc(count()))
|
||||
.limit(10),
|
||||
|
||||
// Sync health
|
||||
db
|
||||
.select({
|
||||
status: games.syncStatus,
|
||||
count: count(),
|
||||
})
|
||||
.from(games)
|
||||
.where(sql`${games.steamAppId} IS NOT NULL`)
|
||||
.groupBy(games.syncStatus),
|
||||
|
||||
// Games by source
|
||||
db
|
||||
.select({
|
||||
source: games.source,
|
||||
count: count(),
|
||||
})
|
||||
.from(games)
|
||||
.groupBy(games.source),
|
||||
|
||||
// Playability distribution
|
||||
db
|
||||
.select({
|
||||
status: games.playabilityStatus,
|
||||
count: count(),
|
||||
})
|
||||
.from(games)
|
||||
.where(sql`${games.playabilityStatus} IS NOT NULL`)
|
||||
.groupBy(games.playabilityStatus),
|
||||
]);
|
||||
|
||||
const result = {
|
||||
overview: {
|
||||
totalGames: totalGames[0]?.count ?? 0,
|
||||
totalBenchmarks: totalBenchmarks[0]?.count ?? 0,
|
||||
totalUsers: totalUsers[0]?.count ?? 0,
|
||||
pendingReports: pendingReports[0]?.count ?? 0,
|
||||
pendingSuggestions: pendingSuggestions[0]?.count ?? 0,
|
||||
},
|
||||
recent: {
|
||||
benchmarksLast30Days: recentBenchmarks[0]?.count ?? 0,
|
||||
gamesLast30Days: recentGames[0]?.count ?? 0,
|
||||
},
|
||||
topContributors: topContributors.map((c) => ({
|
||||
userId: c.userId,
|
||||
name: c.userName ?? "Anonymous",
|
||||
count: c.entryCount,
|
||||
})),
|
||||
syncHealth: syncHealth.reduce(
|
||||
(acc, s) => {
|
||||
acc[s.status ?? "unknown"] = s.count;
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, number>,
|
||||
),
|
||||
gamesBySource: gamesBySource.reduce(
|
||||
(acc, s) => {
|
||||
acc[s.source ?? "unknown"] = s.count;
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, number>,
|
||||
),
|
||||
playabilityDistribution: playabilityDistribution.reduce(
|
||||
(acc, p) => {
|
||||
acc[p.status ?? "unknown"] = p.count;
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, number>,
|
||||
),
|
||||
};
|
||||
|
||||
setCached("dashboard_stats", result);
|
||||
return result;
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,376 @@
|
||||
import { Elysia, t } from "elysia"
|
||||
import { db } from "@/lib/db/index"
|
||||
import {
|
||||
games,
|
||||
gameVersions,
|
||||
performanceEntries,
|
||||
hardware,
|
||||
} from "@/lib/db/schema"
|
||||
import { eq, and, inArray, sql } from "drizzle-orm"
|
||||
|
||||
export const gameStatsRoutes = new Elysia({ prefix: "/games", detail: { tags: ["Games"] } }).get(
|
||||
"/:gameId/stats",
|
||||
async ({ params, set }) => {
|
||||
const { gameId } = params
|
||||
|
||||
try {
|
||||
// Verify game exists
|
||||
const [game] = await db
|
||||
.select({ id: games.id, steamAppId: games.steamAppId })
|
||||
.from(games)
|
||||
.where(eq(games.id, gameId))
|
||||
.limit(1)
|
||||
|
||||
if (!game) {
|
||||
set.status = 404
|
||||
return { error: "Game not found" }
|
||||
}
|
||||
|
||||
// ── 1. All non-removed entries with joins ──────────────────────
|
||||
const entries = await db
|
||||
.select({
|
||||
id: performanceEntries.id,
|
||||
hardwareSlug: performanceEntries.hardwareSlug,
|
||||
hardwareName: hardware.name,
|
||||
fpsAvg: performanceEntries.fpsAvg,
|
||||
fpsLow: performanceEntries.fpsLow,
|
||||
fpsHigh: performanceEntries.fpsHigh,
|
||||
fpsOnePercentLow: performanceEntries.fpsOnePercentLow,
|
||||
upscalerType: performanceEntries.upscalerType,
|
||||
upscalerVersion: performanceEntries.upscalerVersion,
|
||||
frameGenMethod: performanceEntries.frameGenMethod,
|
||||
protonVersion: performanceEntries.protonVersion,
|
||||
osVersion: performanceEntries.osVersion,
|
||||
upvotes: performanceEntries.upvotes,
|
||||
downvotes: performanceEntries.downvotes,
|
||||
verifiedAt: performanceEntries.verifiedAt,
|
||||
userNotes: performanceEntries.userNotes,
|
||||
createdAt: performanceEntries.createdAt,
|
||||
versionId: performanceEntries.versionId,
|
||||
tdpWatts: performanceEntries.tdpWatts,
|
||||
})
|
||||
.from(performanceEntries)
|
||||
.innerJoin(
|
||||
gameVersions,
|
||||
eq(performanceEntries.versionId, gameVersions.id),
|
||||
)
|
||||
.innerJoin(hardware, eq(performanceEntries.hardwareSlug, hardware.slug))
|
||||
.where(
|
||||
and(
|
||||
eq(gameVersions.gameId, gameId),
|
||||
eq(performanceEntries.isRemoved, false),
|
||||
),
|
||||
)
|
||||
|
||||
if (entries.length === 0) {
|
||||
return {
|
||||
summary: {
|
||||
totalEntries: 0,
|
||||
avgFps: null,
|
||||
bestDevice: null,
|
||||
verifiedCount: 0,
|
||||
versionCount: 0,
|
||||
},
|
||||
isRawPerformer: false,
|
||||
boxplot: [],
|
||||
historical: [],
|
||||
upscalerStats: [],
|
||||
fpsRange: [],
|
||||
deviceBreakdown: [],
|
||||
filterOptions: { protonVersions: [], osVersions: [] },
|
||||
batteryLife: [],
|
||||
}
|
||||
}
|
||||
|
||||
// ── 2. Summary stats ──────────────────────────────────────────
|
||||
const totalEntries = entries.length
|
||||
const avgFps =
|
||||
entries.reduce((sum, e) => sum + (e.fpsAvg ?? 0), 0) / totalEntries
|
||||
const verifiedCount = entries.filter((e) => e.verifiedAt !== null).length
|
||||
|
||||
// Best device by mean fpsAvg
|
||||
const deviceFpsMap = new Map<string, number[]>()
|
||||
for (const e of entries) {
|
||||
const arr = deviceFpsMap.get(e.hardwareSlug) || []
|
||||
arr.push(e.fpsAvg ?? 0)
|
||||
deviceFpsMap.set(e.hardwareSlug, arr)
|
||||
}
|
||||
let bestDevice: string | null = null
|
||||
let bestDeviceAvg = 0
|
||||
for (const [slug, fpsArr] of deviceFpsMap) {
|
||||
const mean = fpsArr.reduce((a, b) => a + b, 0) / fpsArr.length
|
||||
if (mean > bestDeviceAvg) {
|
||||
bestDeviceAvg = mean
|
||||
bestDevice = slug
|
||||
}
|
||||
}
|
||||
|
||||
// Version count
|
||||
const [versionRow] = await db
|
||||
.select({ count: sql<number>`count(*)::int` })
|
||||
.from(gameVersions)
|
||||
.where(eq(gameVersions.gameId, gameId))
|
||||
const versionCount = versionRow?.count ?? 0
|
||||
|
||||
// ── 3. Raw Performer check (handheld devices only) ─────────────
|
||||
const handheldEntries = entries.filter((e) =>
|
||||
e.hardwareSlug.startsWith("steamdeck"),
|
||||
)
|
||||
const isRawPerformer = handheldEntries.some(
|
||||
(e) =>
|
||||
(e.fpsAvg ?? 0) >= 60 &&
|
||||
e.upscalerType === "none" &&
|
||||
e.frameGenMethod === "none",
|
||||
)
|
||||
|
||||
// ── 3b. Poor Performance check (handheld devices only) ─────────
|
||||
const isPoorPerformance = handheldEntries.some((e) => (e.fpsAvg ?? 0) < 30)
|
||||
|
||||
// ── 4. Boxplot per device ─────────────────────────────────────
|
||||
const boxplotMap = new Map<
|
||||
string,
|
||||
{ hardwareName: string; values: number[] }
|
||||
>()
|
||||
for (const e of entries) {
|
||||
const existing = boxplotMap.get(e.hardwareSlug) || {
|
||||
hardwareName: e.hardwareName,
|
||||
values: [],
|
||||
}
|
||||
existing.values.push(e.fpsAvg ?? 0)
|
||||
boxplotMap.set(e.hardwareSlug, existing)
|
||||
}
|
||||
|
||||
const boxplot = Array.from(boxplotMap.entries()).map(
|
||||
([slug, { hardwareName, values }]) => {
|
||||
const sorted = [...values].sort((a, b) => a - b)
|
||||
const n = sorted.length
|
||||
const q1Idx = Math.floor(n * 0.25)
|
||||
const medIdx = Math.floor(n * 0.5)
|
||||
const q3Idx = Math.floor(n * 0.75)
|
||||
const onePercentLow = sorted.length > 0 ? sorted[Math.max(0, Math.floor(sorted.length * 0.01))] : sorted[0]
|
||||
return {
|
||||
hardwareSlug: slug,
|
||||
hardwareName,
|
||||
min: sorted[0],
|
||||
q1: sorted[q1Idx],
|
||||
median: sorted[medIdx],
|
||||
onePercentLow,
|
||||
q3: sorted[q3Idx],
|
||||
max: sorted[n - 1],
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
// ── 5. Historical (group by month + device) ───────────────────
|
||||
const histMap = new Map<
|
||||
string,
|
||||
Map<string, { sum: number; count: number }>
|
||||
>()
|
||||
for (const e of entries) {
|
||||
const date = new Date(e.createdAt)
|
||||
const month = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}`
|
||||
if (!histMap.has(month)) histMap.set(month, new Map())
|
||||
const deviceMap = histMap.get(month)!
|
||||
const existing = deviceMap.get(e.hardwareSlug) || {
|
||||
sum: 0,
|
||||
count: 0,
|
||||
}
|
||||
existing.sum += e.fpsAvg ?? 0
|
||||
existing.count++
|
||||
deviceMap.set(e.hardwareSlug, existing)
|
||||
}
|
||||
|
||||
const historical = Array.from(histMap.entries())
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(([month, deviceMap]) => ({
|
||||
period: month,
|
||||
entries: Array.from(deviceMap.entries()).map(([slug, data]) => ({
|
||||
hardwareSlug: slug,
|
||||
avgFps: Math.round((data.sum / data.count) * 10) / 10,
|
||||
count: data.count,
|
||||
})),
|
||||
}))
|
||||
|
||||
// ── 6. Upscaler/framegen stats ────────────────────────────────
|
||||
const upscalerMap = new Map<
|
||||
string,
|
||||
{ hardwareSlug: string; sum: number; count: number }
|
||||
>()
|
||||
for (const e of entries) {
|
||||
const key = `${e.upscalerType}|${e.upscalerVersion ?? ''}|${e.frameGenMethod}|${e.hardwareSlug}`
|
||||
const existing = upscalerMap.get(key) || {
|
||||
hardwareSlug: e.hardwareSlug,
|
||||
sum: 0,
|
||||
count: 0,
|
||||
}
|
||||
existing.sum += e.fpsAvg ?? 0
|
||||
existing.count++
|
||||
upscalerMap.set(key, existing)
|
||||
}
|
||||
|
||||
const upscalerStats = Array.from(upscalerMap.entries()).map(
|
||||
([key, data]) => {
|
||||
const [upscalerType, upscalerVersion, frameGenMethod] = key.split("|")
|
||||
return {
|
||||
upscalerType,
|
||||
upscalerVersion: upscalerVersion || null,
|
||||
frameGenMethod,
|
||||
hardwareSlug: data.hardwareSlug,
|
||||
avgFps: Math.round((data.sum / data.count) * 10) / 10,
|
||||
count: data.count,
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
// ── 7. FPS range per entry ────────────────────────────────────
|
||||
const fpsRange = entries
|
||||
.filter((e) => e.fpsLow !== null && e.fpsHigh !== null)
|
||||
.map((e) => ({
|
||||
id: e.id,
|
||||
hardwareSlug: e.hardwareSlug,
|
||||
fpsLow: e.fpsLow!,
|
||||
fpsAvg: e.fpsAvg ?? 0,
|
||||
fpsHigh: e.fpsHigh!,
|
||||
fpsOnePercentLow: e.fpsOnePercentLow ?? null,
|
||||
isRawPerformer:
|
||||
(e.fpsAvg ?? 0) >= 60 &&
|
||||
e.upscalerType === "none" &&
|
||||
e.frameGenMethod === "none",
|
||||
isPoorPerformer: (e.fpsAvg ?? 0) < 30,
|
||||
}))
|
||||
|
||||
// Stability score computation
|
||||
const stabilityScores = entries
|
||||
.filter(e => e.fpsOnePercentLow != null && (e.fpsAvg ?? 0) > 0)
|
||||
.map(e => Math.min(1, e.fpsOnePercentLow! / (e.fpsAvg ?? 1)))
|
||||
const avgStability = stabilityScores.length > 0
|
||||
? Math.round((stabilityScores.reduce((a, b) => a + b, 0) / stabilityScores.length) * 100) / 100
|
||||
: null
|
||||
const bestOnePercentLow = entries.reduce((best, e) =>
|
||||
e.fpsOnePercentLow != null && e.fpsOnePercentLow > (best ?? 0) ? e.fpsOnePercentLow : best, null as number | null)
|
||||
|
||||
// Fetch hardware wattHours for the relevant devices (used by deviceBreakdown + batteryLife)
|
||||
const deviceSlugs = [...new Set(entries.map((e) => e.hardwareSlug))]
|
||||
const deviceData = await db
|
||||
.select({
|
||||
slug: hardware.slug,
|
||||
wattHours: hardware.wattHours,
|
||||
tdpMax: hardware.tdpMax,
|
||||
deviceType: hardware.deviceType,
|
||||
})
|
||||
.from(hardware)
|
||||
.where(inArray(hardware.slug, deviceSlugs))
|
||||
|
||||
const deviceWattHoursMap = new Map(
|
||||
deviceData.map((d) => [d.slug, d]),
|
||||
)
|
||||
|
||||
// ── 8. Device breakdown ───────────────────────────────────────
|
||||
const deviceBreakdown = Array.from(boxplotMap.entries()).map(
|
||||
([slug, { hardwareName, values }]) => {
|
||||
const dev = deviceWattHoursMap.get(slug)
|
||||
return {
|
||||
hardwareSlug: slug,
|
||||
hardwareName,
|
||||
count: values.length,
|
||||
wattHours: dev?.wattHours ? Number(dev.wattHours) : null,
|
||||
tdpMax: dev?.tdpMax ? Number(dev.tdpMax) : null,
|
||||
deviceType: dev?.deviceType ?? null,
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
// ── 9. Performance tier breakdown per device ────────────────
|
||||
const tierMap = new Map<string, { unplayable: number; playable: number; smooth: number; excellent: number }>()
|
||||
for (const e of entries) {
|
||||
const existing = tierMap.get(e.hardwareSlug) || { unplayable: 0, playable: 0, smooth: 0, excellent: 0 }
|
||||
if (e.fpsAvg < 30) existing.unplayable++
|
||||
else if (e.fpsAvg < 60) existing.playable++
|
||||
else if (e.fpsAvg < 120) existing.smooth++
|
||||
else existing.excellent++
|
||||
tierMap.set(e.hardwareSlug, existing)
|
||||
}
|
||||
|
||||
const performanceTiers = Array.from(tierMap.entries()).map(([slug, tiers]) => ({
|
||||
hardwareSlug: slug,
|
||||
...tiers,
|
||||
}))
|
||||
|
||||
// ── 10. Stability scatter data ──────────────────────────────
|
||||
const stabilityScatter = entries
|
||||
.filter((e) => e.fpsOnePercentLow != null)
|
||||
.map((e) => ({
|
||||
id: e.id,
|
||||
hardwareSlug: e.hardwareSlug,
|
||||
fpsAvg: e.fpsAvg ?? 0,
|
||||
fpsOnePercentLow: e.fpsOnePercentLow!,
|
||||
stabilityRatio: e.fpsAvg > 0 ? Math.min(1, e.fpsOnePercentLow! / e.fpsAvg) : 0,
|
||||
}))
|
||||
|
||||
// ── 12. Battery life estimates ─────────────────────────────
|
||||
const batteryLife = entries
|
||||
.filter((e) => e.tdpWatts != null && e.tdpWatts > 0)
|
||||
.map((e) => {
|
||||
const device = deviceWattHoursMap.get(e.hardwareSlug)
|
||||
const wh = device?.wattHours ? Number(device.wattHours) : null
|
||||
const tdpMax = device?.tdpMax ? Number(device.tdpMax) : null
|
||||
if (!wh) return null
|
||||
const estimatedBatteryHours = wh / e.tdpWatts!
|
||||
const estimatedBatteryMin = estimatedBatteryHours * 60
|
||||
const estimatedAtMaxTdpMin = tdpMax ? (wh / tdpMax) * 60 : null
|
||||
return {
|
||||
id: e.id,
|
||||
hardwareSlug: e.hardwareSlug,
|
||||
tdpWatts: Number(e.tdpWatts),
|
||||
estimatedBatteryMin: Math.round(estimatedBatteryMin),
|
||||
estimatedBatteryHours: Math.round(estimatedBatteryHours * 10) / 10,
|
||||
wattHours: wh,
|
||||
tdpMax,
|
||||
estimatedAtMaxTdpMin: estimatedAtMaxTdpMin
|
||||
? Math.round(estimatedAtMaxTdpMin)
|
||||
: null,
|
||||
}
|
||||
})
|
||||
.filter(Boolean)
|
||||
|
||||
// ── 11. Filter options ────────────────────────────────────────
|
||||
const protonVersions = [
|
||||
...new Set(entries.map((e) => e.protonVersion).filter(Boolean)),
|
||||
] as string[]
|
||||
const osVersions = [
|
||||
...new Set(entries.map((e) => e.osVersion).filter(Boolean)),
|
||||
] as string[]
|
||||
|
||||
return {
|
||||
summary: {
|
||||
totalEntries,
|
||||
avgFps: Math.round(avgFps * 10) / 10,
|
||||
bestDevice,
|
||||
verifiedCount,
|
||||
versionCount,
|
||||
avgStability,
|
||||
bestOnePercentLow,
|
||||
},
|
||||
isRawPerformer,
|
||||
isPoorPerformance,
|
||||
boxplot,
|
||||
historical,
|
||||
upscalerStats,
|
||||
fpsRange,
|
||||
deviceBreakdown,
|
||||
performanceTiers,
|
||||
stabilityScatter,
|
||||
batteryLife,
|
||||
filterOptions: { protonVersions, osVersions },
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Error computing game stats:", err)
|
||||
set.status = 500
|
||||
return { error: "Failed to compute game stats" }
|
||||
}
|
||||
},
|
||||
{
|
||||
params: t.Object({ gameId: t.String() }),
|
||||
detail: { description: "Aggregated performance statistics for a game — boxplot, historical, upscaler stats, FPS ranges, device breakdown, and battery life estimates." },
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,33 @@
|
||||
import { Elysia, t } from "elysia"
|
||||
import { ensureSteamGame } from "@/lib/steam/sync"
|
||||
|
||||
export const gameStubRoutes = new Elysia({ prefix: "/games", detail: { tags: ["Games"] } }).post(
|
||||
"/stub",
|
||||
async ({ body, set }) => {
|
||||
const result = await ensureSteamGame(body.steamAppId)
|
||||
|
||||
if (!result.game) {
|
||||
set.status = 500
|
||||
return { error: "Failed to create or retrieve game" }
|
||||
}
|
||||
|
||||
// If game already existed, return 200 with created:false
|
||||
if (!result.created) {
|
||||
return { game: result.game, created: false }
|
||||
}
|
||||
|
||||
// If sync failed but stub exists, still return 201 with error info
|
||||
if (result.error) {
|
||||
set.status = 201
|
||||
return { game: result.game, created: true, syncError: result.error }
|
||||
}
|
||||
|
||||
set.status = 201
|
||||
return { game: result.game, created: true }
|
||||
},
|
||||
{
|
||||
body: t.Object({
|
||||
steamAppId: t.Number(),
|
||||
}),
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,495 @@
|
||||
import { Elysia, t } from "elysia"
|
||||
import { db } from "@/lib/db/index"
|
||||
import {
|
||||
games,
|
||||
gameVersions,
|
||||
performanceEntries,
|
||||
gamePlatformSupport,
|
||||
hardware,
|
||||
} from "@/lib/db/schema"
|
||||
import { ilike, or, sql, eq, and, desc, asc, inArray, gte, lte, type SQL } from "drizzle-orm"
|
||||
import { fuzzySearchTerm } from "@/lib/db/search"
|
||||
|
||||
const MAX_OFFSET = 10000
|
||||
const PAGE_SIZE = 24
|
||||
|
||||
export const gamesListingRoutes = new Elysia({ prefix: "/games/listing", detail: { tags: ["Games"] } }).get(
|
||||
"/",
|
||||
async ({ query }) => {
|
||||
const offset = Math.min(Number(query.offset) || 0, MAX_OFFSET)
|
||||
const limit = Math.min(Number(query.limit) || PAGE_SIZE, 100)
|
||||
const search = query.search || ""
|
||||
const genre = query.genre || ""
|
||||
const device = query.device || ""
|
||||
const sort = query.sort || "recent"
|
||||
const order = query.order === "asc" ? asc : desc
|
||||
|
||||
// ── New filter parameters ─────────────────────────────────────
|
||||
const minFps = query.minFps
|
||||
const maxFps = query.maxFps
|
||||
const fsrSupport = query.fsrSupport
|
||||
const protonNative = query.protonNative
|
||||
const antiCheatStatus = query.antiCheatStatus
|
||||
const playabilityStatus = query.playabilityStatus
|
||||
const steamReviewScore = query.steamReviewScore
|
||||
const isFree = query.isFree
|
||||
const hasMultiplayer = query.hasMultiplayer
|
||||
|
||||
// Build where conditions
|
||||
const conditions = []
|
||||
|
||||
// Search filter (title, developer, publisher)
|
||||
if (search) {
|
||||
const titleTerm = fuzzySearchTerm(search)
|
||||
const term = `%${search}%`
|
||||
conditions.push(
|
||||
or(
|
||||
ilike(games.title, titleTerm),
|
||||
ilike(games.developer, term),
|
||||
ilike(games.publisher, term),
|
||||
)!,
|
||||
)
|
||||
}
|
||||
|
||||
// Genre filter (supports comma-separated list with OR logic)
|
||||
if (genre) {
|
||||
const genres = genre.split(",").filter(Boolean)
|
||||
if (genres.length === 1) {
|
||||
conditions.push(sql`${games.genres} @> ${JSON.stringify([genres[0]])}::jsonb`)
|
||||
} else if (genres.length > 1) {
|
||||
// OR logic: game matches ANY of the selected genres
|
||||
const genreConditions = genres.map(
|
||||
(g) => sql`${games.genres} @> ${JSON.stringify([g])}::jsonb`,
|
||||
)
|
||||
conditions.push(or(...genreConditions)!)
|
||||
}
|
||||
}
|
||||
|
||||
// Device filter
|
||||
if (device) {
|
||||
// Get games with platform support
|
||||
const supportedIds = await db
|
||||
.select({ gameId: gamePlatformSupport.gameId })
|
||||
.from(gamePlatformSupport)
|
||||
.where(
|
||||
and(
|
||||
eq(gamePlatformSupport.hardwareSlug, device),
|
||||
eq(gamePlatformSupport.isSupported, true),
|
||||
),
|
||||
)
|
||||
|
||||
// Also get games with performance entries for this hardware
|
||||
const gamesWithBenchmarks = await db
|
||||
.select({ gameId: gameVersions.gameId })
|
||||
.from(performanceEntries)
|
||||
.innerJoin(gameVersions, eq(performanceEntries.versionId, gameVersions.id))
|
||||
.where(eq(performanceEntries.hardwareSlug, device))
|
||||
.groupBy(gameVersions.gameId)
|
||||
|
||||
// Combine both sets
|
||||
const deviceGameIds = new Set([
|
||||
...supportedIds.map((s) => s.gameId),
|
||||
...gamesWithBenchmarks.map((b) => b.gameId),
|
||||
])
|
||||
|
||||
if (deviceGameIds.size > 0) {
|
||||
conditions.push(inArray(games.id, Array.from(deviceGameIds)))
|
||||
} else {
|
||||
return { data: [], total: 0, limit, offset, genres: [], devices: [] }
|
||||
}
|
||||
}
|
||||
|
||||
// ── FPS range filter (games with benchmarks in this range) ────
|
||||
if (minFps || maxFps) {
|
||||
const fpsConditions: (SQL | undefined)[] = [
|
||||
eq(performanceEntries.isRemoved, false),
|
||||
minFps ? gte(performanceEntries.fpsAvg, Number(minFps)) : undefined,
|
||||
maxFps ? lte(performanceEntries.fpsAvg, Number(maxFps)) : undefined,
|
||||
// Scope to active device when device filter is set
|
||||
device ? eq(performanceEntries.hardwareSlug, device) : undefined,
|
||||
].filter((c): c is SQL => c !== undefined)
|
||||
|
||||
const fpsSubquery = db
|
||||
.select({ gameId: gameVersions.gameId })
|
||||
.from(performanceEntries)
|
||||
.innerJoin(gameVersions, eq(performanceEntries.versionId, gameVersions.id))
|
||||
.where(and(...fpsConditions))
|
||||
.groupBy(gameVersions.gameId)
|
||||
|
||||
conditions.push(sql`${games.id} IN (SELECT "gameId" FROM (${fpsSubquery}) AS fps_sub)`)
|
||||
}
|
||||
|
||||
// ── FSR support filter ────────────────────────────────────────
|
||||
if (fsrSupport === "true") {
|
||||
const fsrSubquery = db
|
||||
.select({ gameId: gameVersions.gameId })
|
||||
.from(performanceEntries)
|
||||
.innerJoin(gameVersions, eq(performanceEntries.versionId, gameVersions.id))
|
||||
.where(
|
||||
and(
|
||||
eq(performanceEntries.isRemoved, false),
|
||||
eq(performanceEntries.upscalerType, "fsr"),
|
||||
),
|
||||
)
|
||||
.groupBy(gameVersions.gameId)
|
||||
|
||||
conditions.push(sql`${games.id} IN (SELECT "gameId" FROM (${fsrSubquery}) AS fsr_sub)`)
|
||||
}
|
||||
|
||||
// ── Proton / Native filter ────────────────────────────────────
|
||||
if (protonNative && ["proton", "native", "both"].includes(protonNative)) {
|
||||
const protonConditions: SQL[] = []
|
||||
|
||||
if (protonNative === "proton" || protonNative === "both") {
|
||||
protonConditions.push(eq(gamePlatformSupport.protonStatus, "proton"))
|
||||
}
|
||||
if (protonNative === "native" || protonNative === "both") {
|
||||
protonConditions.push(eq(gamePlatformSupport.protonStatus, "native"))
|
||||
}
|
||||
|
||||
const protonSubquery = db
|
||||
.select({ gameId: gamePlatformSupport.gameId })
|
||||
.from(gamePlatformSupport)
|
||||
.where(or(...protonConditions))
|
||||
.groupBy(gamePlatformSupport.gameId)
|
||||
|
||||
conditions.push(sql`${games.id} IN (SELECT "gameId" FROM (${protonSubquery}) AS proton_sub)`)
|
||||
}
|
||||
|
||||
// ── Anti-cheat status filter ──────────────────────────────────
|
||||
const validAcStatuses = ["supported", "unsupported", "unknown", "none"]
|
||||
if (antiCheatStatus && antiCheatStatus !== "any" && validAcStatuses.includes(antiCheatStatus)) {
|
||||
const acConditions: SQL[] = [
|
||||
eq(gamePlatformSupport.antiCheatRelevant, true),
|
||||
eq(gamePlatformSupport.antiCheatStatus, antiCheatStatus as "none" | "supported" | "unsupported" | "unknown"),
|
||||
]
|
||||
|
||||
const acSubquery = db
|
||||
.select({ gameId: gamePlatformSupport.gameId })
|
||||
.from(gamePlatformSupport)
|
||||
.where(and(...acConditions))
|
||||
.groupBy(gamePlatformSupport.gameId)
|
||||
|
||||
conditions.push(sql`${games.id} IN (SELECT "gameId" FROM (${acSubquery}) AS ac_sub)`)
|
||||
}
|
||||
|
||||
// ── Playability status filter ─────────────────────────────────
|
||||
const validPlayStatuses = ["great", "playable", "needs_tweaks", "unplayable", "unknown"]
|
||||
if (playabilityStatus && validPlayStatuses.includes(playabilityStatus)) {
|
||||
conditions.push(eq(games.playabilityStatus, playabilityStatus as "great" | "playable" | "needs_tweaks" | "unplayable" | "unknown"))
|
||||
}
|
||||
|
||||
// ── Steam review score filter (minimum score) ─────────────────
|
||||
if (steamReviewScore) {
|
||||
conditions.push(gte(games.steamReviewScore, Number(steamReviewScore)))
|
||||
}
|
||||
|
||||
// ── Free-to-play filter ───────────────────────────────────────
|
||||
if (isFree === "true") {
|
||||
conditions.push(eq(games.isFree, true))
|
||||
}
|
||||
|
||||
// ── Has multiplayer filter ────────────────────────────────────
|
||||
if (hasMultiplayer === "true") {
|
||||
conditions.push(
|
||||
or(
|
||||
eq(games.onlineMultiplayerStatus, "supported"),
|
||||
eq(games.onlineMultiplayerStatus, "unknown"),
|
||||
)!,
|
||||
)
|
||||
}
|
||||
|
||||
const where = conditions.length > 0 ? and(...conditions) : undefined
|
||||
|
||||
// Fetch all genres (for filter options)
|
||||
const genreRows = await db
|
||||
.select({ genres: games.genres })
|
||||
.from(games)
|
||||
.where(sql`${games.genres} IS NOT NULL`)
|
||||
|
||||
const genreSet = new Set<string>()
|
||||
for (const row of genreRows) {
|
||||
if (Array.isArray(row.genres)) {
|
||||
for (const g of row.genres) {
|
||||
if (typeof g === "string") genreSet.add(g)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch all hardware devices (for filter options)
|
||||
const deviceRows = await db
|
||||
.select({ slug: hardware.slug, name: hardware.name })
|
||||
.from(hardware)
|
||||
.orderBy(hardware.sortOrder)
|
||||
|
||||
// Count total games matching filters
|
||||
const countQuery = db
|
||||
.select({ count: sql<number>`count(*)::int` })
|
||||
.from(games)
|
||||
.where(where)
|
||||
|
||||
// Determine sort order
|
||||
let orderBy
|
||||
let needsPostSort = false
|
||||
let postSortField: string | null = null
|
||||
|
||||
switch (sort) {
|
||||
case "name":
|
||||
orderBy = order(games.title)
|
||||
break
|
||||
case "popularity":
|
||||
orderBy = order(games.recommendationsTotal)
|
||||
break
|
||||
case "release_date":
|
||||
orderBy = order(games.releaseDate)
|
||||
break
|
||||
case "steam_reviews":
|
||||
orderBy = order(games.steamReviewScore)
|
||||
break
|
||||
case "performance":
|
||||
// Performance sort requires a subquery — use SQL ORDER BY directly
|
||||
orderBy = sql`(
|
||||
SELECT AVG(pe."fps_avg")
|
||||
FROM ${performanceEntries} pe
|
||||
JOIN ${gameVersions} gv ON pe."version_id" = gv.id
|
||||
WHERE gv."game_id" = ${games.id} AND pe."is_removed" = false
|
||||
) DESC NULLS LAST`
|
||||
if (query.order === "asc") {
|
||||
orderBy = sql`(
|
||||
SELECT AVG(pe."fps_avg")
|
||||
FROM ${performanceEntries} pe
|
||||
JOIN ${gameVersions} gv ON pe."version_id" = gv.id
|
||||
WHERE gv."game_id" = ${games.id} AND pe."is_removed" = false
|
||||
) ASC NULLS LAST`
|
||||
}
|
||||
break
|
||||
case "benchmarks":
|
||||
needsPostSort = true
|
||||
postSortField = "benchmarkCount"
|
||||
orderBy = order(games.createdAt)
|
||||
break
|
||||
case "recent":
|
||||
default:
|
||||
orderBy = order(games.createdAt)
|
||||
break
|
||||
}
|
||||
|
||||
// Fetch games page
|
||||
const data = await db
|
||||
.select({
|
||||
id: games.id,
|
||||
steamAppId: games.steamAppId,
|
||||
title: games.title,
|
||||
developer: games.developer,
|
||||
capsuleImage: games.capsuleImage,
|
||||
headerImage: games.headerImage,
|
||||
genres: games.genres,
|
||||
source: games.source,
|
||||
createdAt: games.createdAt,
|
||||
isFree: games.isFree,
|
||||
releaseDate: games.releaseDate,
|
||||
steamReviewScore: games.steamReviewScore,
|
||||
recommendationsTotal: games.recommendationsTotal,
|
||||
playabilityStatus: games.playabilityStatus,
|
||||
onlineMultiplayerStatus: games.onlineMultiplayerStatus,
|
||||
})
|
||||
.from(games)
|
||||
.where(where)
|
||||
.orderBy(orderBy)
|
||||
.limit(limit)
|
||||
.offset(offset)
|
||||
|
||||
const gameIds = data.map((g) => g.id)
|
||||
|
||||
// Fetch benchmark counts for the returned games (separate query to avoid subquery ambiguity)
|
||||
const benchmarkCounts = gameIds.length > 0
|
||||
? await db
|
||||
.select({
|
||||
gameId: gameVersions.gameId,
|
||||
count: sql<number>`count(*)::int`,
|
||||
})
|
||||
.from(performanceEntries)
|
||||
.innerJoin(gameVersions, eq(performanceEntries.versionId, gameVersions.id))
|
||||
.where(
|
||||
and(
|
||||
inArray(gameVersions.gameId, gameIds),
|
||||
eq(performanceEntries.isRemoved, false),
|
||||
),
|
||||
)
|
||||
.groupBy(gameVersions.gameId)
|
||||
: []
|
||||
|
||||
const benchmarkMap = new Map<string, number>()
|
||||
for (const row of benchmarkCounts) {
|
||||
benchmarkMap.set(row.gameId, row.count)
|
||||
}
|
||||
|
||||
// Fetch platform support for the returned games (prioritise Steam Deck)
|
||||
const platformMap = new Map<string, string>()
|
||||
const antiCheatMap = new Map<string, { antiCheatRelevant: boolean; antiCheatStatus: string | null }>()
|
||||
if (gameIds.length > 0) {
|
||||
const platformRows = await db
|
||||
.select({
|
||||
gameId: gamePlatformSupport.gameId,
|
||||
hardwareSlug: gamePlatformSupport.hardwareSlug,
|
||||
protonStatus: gamePlatformSupport.protonStatus,
|
||||
antiCheatRelevant: gamePlatformSupport.antiCheatRelevant,
|
||||
antiCheatStatus: gamePlatformSupport.antiCheatStatus,
|
||||
})
|
||||
.from(gamePlatformSupport)
|
||||
.where(inArray(gamePlatformSupport.gameId, gameIds))
|
||||
|
||||
for (const row of platformRows) {
|
||||
const isSteamDeck = row.hardwareSlug.startsWith("steamdeck")
|
||||
const existing = platformMap.get(row.gameId)
|
||||
if (!existing || (!existing.startsWith("steamdeck") && isSteamDeck)) {
|
||||
platformMap.set(row.gameId, row.protonStatus)
|
||||
}
|
||||
|
||||
const existingAc = antiCheatMap.get(row.gameId)
|
||||
if (row.antiCheatRelevant) {
|
||||
if (!existingAc || (!existingAc.antiCheatRelevant && isSteamDeck) || (!existingAc.antiCheatRelevant)) {
|
||||
antiCheatMap.set(row.gameId, {
|
||||
antiCheatRelevant: row.antiCheatRelevant,
|
||||
antiCheatStatus: row.antiCheatStatus,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Performance stats: best FPS, raw performer, poor performance ──
|
||||
const rawPerformerMap = new Map<string, boolean>()
|
||||
const poorPerformerMap = new Map<string, boolean>()
|
||||
const bestFpsMap = new Map<string, number>()
|
||||
const batteryMinMap = new Map<string, number>()
|
||||
|
||||
if (gameIds.length > 0) {
|
||||
const perfStats = await db
|
||||
.select({
|
||||
gameId: gameVersions.gameId,
|
||||
bestFps: sql<number>`MAX(${performanceEntries.fpsAvg})::real`,
|
||||
isRawPerformer: sql<boolean>`BOOL_OR(
|
||||
${performanceEntries.fpsAvg} >= 60
|
||||
AND ${performanceEntries.upscalerType} = 'none'
|
||||
AND ${performanceEntries.frameGenMethod} = 'none'
|
||||
)`,
|
||||
isPoorPerformance: sql<boolean>`BOOL_OR(${performanceEntries.fpsAvg} < 30)`,
|
||||
})
|
||||
.from(performanceEntries)
|
||||
.innerJoin(gameVersions, eq(performanceEntries.versionId, gameVersions.id))
|
||||
.innerJoin(hardware, and(
|
||||
eq(performanceEntries.hardwareSlug, hardware.slug),
|
||||
eq(hardware.deviceType, "handheld"),
|
||||
))
|
||||
.where(
|
||||
and(
|
||||
inArray(gameVersions.gameId, gameIds),
|
||||
eq(performanceEntries.isRemoved, false),
|
||||
),
|
||||
)
|
||||
.groupBy(gameVersions.gameId)
|
||||
|
||||
for (const row of perfStats) {
|
||||
bestFpsMap.set(row.gameId, row.bestFps)
|
||||
rawPerformerMap.set(row.gameId, row.isRawPerformer)
|
||||
poorPerformerMap.set(row.gameId, row.isPoorPerformance)
|
||||
}
|
||||
|
||||
// Battery estimate for handheld devices
|
||||
const batteryStats = await db
|
||||
.select({
|
||||
gameId: gameVersions.gameId,
|
||||
estimatedBatteryMin: sql<number>`ROUND(
|
||||
(${hardware.wattHours}::real / ${performanceEntries.tdpWatts}) * 60
|
||||
)::int`,
|
||||
})
|
||||
.from(performanceEntries)
|
||||
.innerJoin(gameVersions, eq(performanceEntries.versionId, gameVersions.id))
|
||||
.innerJoin(hardware, eq(performanceEntries.hardwareSlug, hardware.slug))
|
||||
.where(
|
||||
and(
|
||||
inArray(gameVersions.gameId, gameIds),
|
||||
eq(performanceEntries.isRemoved, false),
|
||||
eq(hardware.deviceType, "handheld"),
|
||||
sql`${performanceEntries.tdpWatts} IS NOT NULL AND ${performanceEntries.tdpWatts} > 0`,
|
||||
sql`${hardware.wattHours} IS NOT NULL`,
|
||||
),
|
||||
)
|
||||
.orderBy(desc(performanceEntries.fpsAvg))
|
||||
|
||||
const seenGames = new Set<string>()
|
||||
for (const row of batteryStats) {
|
||||
if (!seenGames.has(row.gameId)) {
|
||||
seenGames.add(row.gameId)
|
||||
batteryMinMap.set(row.gameId, row.estimatedBatteryMin)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const enrichedData = data.map((g) => ({
|
||||
id: g.id,
|
||||
steamAppId: g.steamAppId,
|
||||
title: g.title,
|
||||
developer: g.developer,
|
||||
capsuleImage: g.capsuleImage,
|
||||
headerImage: g.headerImage,
|
||||
genres: g.genres,
|
||||
source: g.source,
|
||||
isFree: g.isFree,
|
||||
releaseDate: g.releaseDate,
|
||||
steamReviewScore: g.steamReviewScore,
|
||||
recommendationsTotal: g.recommendationsTotal,
|
||||
playabilityStatus: g.playabilityStatus,
|
||||
onlineMultiplayerStatus: g.onlineMultiplayerStatus,
|
||||
benchmarkCount: benchmarkMap.get(g.id) ?? 0,
|
||||
deckStatus: platformMap.get(g.id) ?? null,
|
||||
antiCheatRelevant: antiCheatMap.get(g.id)?.antiCheatRelevant ?? false,
|
||||
antiCheatStatus: antiCheatMap.get(g.id)?.antiCheatStatus ?? null,
|
||||
bestFps: bestFpsMap.get(g.id) ?? null,
|
||||
isRawPerformer: rawPerformerMap.get(g.id) ?? false,
|
||||
isPoorPerformance: poorPerformerMap.get(g.id) ?? false,
|
||||
estimatedBatteryMin: batteryMinMap.get(g.id) ?? null,
|
||||
}))
|
||||
|
||||
// If sorting by benchmarks, re-sort the enriched data
|
||||
if (needsPostSort && postSortField === "benchmarkCount") {
|
||||
const sortFn = query.order === "asc"
|
||||
? (a: { benchmarkCount: number }, b: { benchmarkCount: number }) => a.benchmarkCount - b.benchmarkCount
|
||||
: (a: { benchmarkCount: number }, b: { benchmarkCount: number }) => b.benchmarkCount - a.benchmarkCount
|
||||
enrichedData.sort(sortFn)
|
||||
}
|
||||
|
||||
const [{ count: total }] = await countQuery
|
||||
|
||||
return {
|
||||
data: enrichedData,
|
||||
total,
|
||||
limit,
|
||||
offset,
|
||||
genres: Array.from(genreSet).sort(),
|
||||
devices: deviceRows,
|
||||
}
|
||||
},
|
||||
{
|
||||
query: t.Object({
|
||||
offset: t.Optional(t.String()),
|
||||
limit: t.Optional(t.String()),
|
||||
search: t.Optional(t.String()),
|
||||
genre: t.Optional(t.String()),
|
||||
device: t.Optional(t.String()),
|
||||
sort: t.Optional(t.String()),
|
||||
order: t.Optional(t.String()),
|
||||
// New filter parameters
|
||||
minFps: t.Optional(t.String()),
|
||||
maxFps: t.Optional(t.String()),
|
||||
fsrSupport: t.Optional(t.String()),
|
||||
protonNative: t.Optional(t.String()),
|
||||
antiCheatStatus: t.Optional(t.String()),
|
||||
playabilityStatus: t.Optional(t.String()),
|
||||
steamReviewScore: t.Optional(t.String()),
|
||||
isFree: t.Optional(t.String()),
|
||||
hasMultiplayer: t.Optional(t.String()),
|
||||
}),
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,182 @@
|
||||
import { Elysia, t } from "elysia"
|
||||
import { db } from "@/lib/db/index"
|
||||
import { games, gameVersions, gamePlatformSupport } from "@/lib/db/schema"
|
||||
import { ilike, eq } from "drizzle-orm"
|
||||
import { requireRole } from "@/lib/auth/guard"
|
||||
import { generateSlug } from "@/lib/utils/slug"
|
||||
|
||||
export const gamesManualRoutes = new Elysia({ prefix: "/games", detail: { tags: ["Games"] } })
|
||||
.post(
|
||||
"/manual",
|
||||
async ({ request, body, set }) => {
|
||||
const guard = await requireRole(request.headers, ["user", "contributor", "admin"])
|
||||
if (!guard.ok) {
|
||||
set.status = guard.status
|
||||
return { error: guard.error }
|
||||
}
|
||||
|
||||
// Duplicate check: exact title match (case-insensitive)
|
||||
const existing = await db
|
||||
.select({ id: games.id, title: games.title, source: games.source })
|
||||
.from(games)
|
||||
.where(ilike(games.title, body.title))
|
||||
.limit(5)
|
||||
|
||||
if (existing.length > 0) {
|
||||
const exactMatch = existing.find(
|
||||
(g) => g.title.toLowerCase() === body.title.toLowerCase()
|
||||
)
|
||||
if (exactMatch) {
|
||||
set.status = 409
|
||||
return { error: "Game already exists", existingGame: exactMatch }
|
||||
}
|
||||
}
|
||||
|
||||
// Generate slug for non-Steam games
|
||||
const slug = generateSlug(body.title)
|
||||
let finalSlug: string | null = slug
|
||||
if (slug) {
|
||||
let suffix = 2
|
||||
while (true) {
|
||||
const existingSlug = await db
|
||||
.select({ id: games.id })
|
||||
.from(games)
|
||||
.where(eq(games.slug, finalSlug))
|
||||
.limit(1)
|
||||
if (existingSlug.length === 0) break
|
||||
finalSlug = `${slug}-${suffix}`
|
||||
suffix++
|
||||
}
|
||||
} else {
|
||||
finalSlug = null
|
||||
}
|
||||
|
||||
// Create the game
|
||||
const [game] = await db
|
||||
.insert(games)
|
||||
.values({
|
||||
title: body.title,
|
||||
developer: body.developer || null,
|
||||
publisher: body.publisher || null,
|
||||
description: body.description || null,
|
||||
source: body.source || "manual",
|
||||
slug: finalSlug,
|
||||
headerImage: body.headerImage || null,
|
||||
capsuleImage: body.capsuleImage || null,
|
||||
storeUrl: body.storeUrl || null,
|
||||
genres: body.genres || null,
|
||||
releaseDate: body.releaseDate || null,
|
||||
createdBy: guard.user.id,
|
||||
})
|
||||
.returning()
|
||||
|
||||
// Create platform support entries
|
||||
if (body.platformSupport && body.platformSupport.length > 0) {
|
||||
await db.insert(gamePlatformSupport).values(
|
||||
body.platformSupport.map((ps) => ({
|
||||
gameId: game.id,
|
||||
hardwareSlug: ps.hardwareSlug,
|
||||
isSupported: ps.isSupported,
|
||||
protonStatus: ps.protonStatus as "native" | "proton" | "unsupported" | "unknown",
|
||||
}))
|
||||
)
|
||||
}
|
||||
|
||||
// Create initial game version
|
||||
await db.insert(gameVersions).values({
|
||||
gameId: game.id,
|
||||
isLatest: true,
|
||||
})
|
||||
|
||||
set.status = 201
|
||||
return { game }
|
||||
},
|
||||
{
|
||||
body: t.Object({
|
||||
title: t.String({ minLength: 1, maxLength: 200 }),
|
||||
developer: t.Optional(t.String()),
|
||||
publisher: t.Optional(t.String()),
|
||||
description: t.Optional(t.String()),
|
||||
source: t.Optional(t.Union([t.Literal("manual"), t.Literal("gog"), t.Literal("epic")])),
|
||||
headerImage: t.Optional(t.String()),
|
||||
capsuleImage: t.Optional(t.String()),
|
||||
storeUrl: t.Optional(t.String()),
|
||||
genres: t.Optional(t.Array(t.String())),
|
||||
releaseDate: t.Optional(t.String()),
|
||||
platformSupport: t.Optional(
|
||||
t.Array(
|
||||
t.Object({
|
||||
hardwareSlug: t.String(),
|
||||
isSupported: t.Boolean(),
|
||||
protonStatus: t.String(),
|
||||
})
|
||||
)
|
||||
),
|
||||
}),
|
||||
}
|
||||
)
|
||||
.put(
|
||||
"/:gameId/manual",
|
||||
async ({ request, params, body, set }) => {
|
||||
const guard = await requireRole(request.headers, ["user", "contributor", "admin"])
|
||||
if (!guard.ok) {
|
||||
set.status = guard.status
|
||||
return { error: guard.error }
|
||||
}
|
||||
|
||||
const [game] = await db
|
||||
.select()
|
||||
.from(games)
|
||||
.where(eq(games.id, params.gameId))
|
||||
.limit(1)
|
||||
|
||||
if (!game) {
|
||||
set.status = 404
|
||||
return { error: "Game not found" }
|
||||
}
|
||||
|
||||
if (game.source === "steam") {
|
||||
set.status = 403
|
||||
return { error: "Steam games are synced automatically and cannot be manually edited" }
|
||||
}
|
||||
|
||||
// Only creator or admin can edit
|
||||
if (game.createdBy && game.createdBy !== guard.user.id && guard.user.role !== "admin") {
|
||||
set.status = 403
|
||||
return { error: "Only the creator or an admin can edit this game" }
|
||||
}
|
||||
|
||||
const [updated] = await db
|
||||
.update(games)
|
||||
.set({
|
||||
title: body.title ?? game.title,
|
||||
developer: body.developer ?? game.developer,
|
||||
publisher: body.publisher ?? game.publisher,
|
||||
description: body.description ?? game.description,
|
||||
headerImage: body.headerImage ?? game.headerImage,
|
||||
capsuleImage: body.capsuleImage ?? game.capsuleImage,
|
||||
storeUrl: body.storeUrl ?? game.storeUrl,
|
||||
genres: body.genres ?? game.genres,
|
||||
releaseDate: body.releaseDate ?? game.releaseDate,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(games.id, params.gameId))
|
||||
.returning()
|
||||
|
||||
return { game: updated }
|
||||
},
|
||||
{
|
||||
params: t.Object({ gameId: t.String() }),
|
||||
body: t.Object({
|
||||
title: t.Optional(t.String()),
|
||||
developer: t.Optional(t.String()),
|
||||
publisher: t.Optional(t.String()),
|
||||
description: t.Optional(t.String()),
|
||||
headerImage: t.Optional(t.String()),
|
||||
capsuleImage: t.Optional(t.String()),
|
||||
storeUrl: t.Optional(t.String()),
|
||||
genres: t.Optional(t.Array(t.String())),
|
||||
releaseDate: t.Optional(t.String()),
|
||||
}),
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,189 @@
|
||||
import { Elysia, t } from "elysia"
|
||||
import { db } from "@/lib/db/index"
|
||||
import {
|
||||
gameVersions,
|
||||
performanceEntries,
|
||||
hardware,
|
||||
user,
|
||||
gamePlatformSupport,
|
||||
entryScreenshots,
|
||||
} from "@/lib/db/schema"
|
||||
import { and, desc, eq, sql } from "drizzle-orm"
|
||||
import { getR2PublicUrl } from "@/lib/storage"
|
||||
|
||||
/**
|
||||
* GET /api/games/:gameId/performance
|
||||
*
|
||||
* Returns all non-removed performance entries (presets) for a game,
|
||||
* with joined user, hardware, platform support, and screenshot data.
|
||||
* Ordered by isPinned desc, then upvotes desc.
|
||||
*/
|
||||
export const gamesPerformanceRoutes = new Elysia({
|
||||
prefix: "/games",
|
||||
detail: { tags: ["Games"] },
|
||||
}).get(
|
||||
"/:gameId/performance",
|
||||
async ({ params, set }) => {
|
||||
const { gameId } = params
|
||||
|
||||
const presetRows = await db
|
||||
.select({
|
||||
id: performanceEntries.id,
|
||||
hardwareSlug: performanceEntries.hardwareSlug,
|
||||
hardwareName: hardware.name,
|
||||
upvotes: performanceEntries.upvotes,
|
||||
settingsJson: performanceEntries.settingsJson,
|
||||
fpsAvg: performanceEntries.fpsAvg,
|
||||
fpsLow: performanceEntries.fpsLow,
|
||||
fpsHigh: performanceEntries.fpsHigh,
|
||||
fpsOnePercentLow: performanceEntries.fpsOnePercentLow,
|
||||
upscalerType: performanceEntries.upscalerType,
|
||||
upscalerVersion: performanceEntries.upscalerVersion,
|
||||
frameGenMethod: performanceEntries.frameGenMethod,
|
||||
protonVersion: performanceEntries.protonVersion,
|
||||
osVersion: performanceEntries.osVersion,
|
||||
createdAt: performanceEntries.createdAt,
|
||||
userId: performanceEntries.userId,
|
||||
userName: user.name,
|
||||
userImage: user.image,
|
||||
downvotes: performanceEntries.downvotes,
|
||||
launchOptions: performanceEntries.launchOptions,
|
||||
loadTimeSsd: performanceEntries.loadTimeSsd,
|
||||
loadTimeSd: performanceEntries.loadTimeSd,
|
||||
tdpWatts: performanceEntries.tdpWatts,
|
||||
youtubeVideoId: performanceEntries.youtubeVideoId,
|
||||
customSystem: performanceEntries.customSystem,
|
||||
userNotes: performanceEntries.userNotes,
|
||||
versionString: gameVersions.versionString,
|
||||
buildId: gameVersions.buildId,
|
||||
gameAntiCheatName: gamePlatformSupport.antiCheatName,
|
||||
gameAntiCheatStatus: gamePlatformSupport.antiCheatStatus,
|
||||
verifiedAt: performanceEntries.verifiedAt,
|
||||
isPinned: performanceEntries.isPinned,
|
||||
pinnedAt: performanceEntries.pinnedAt,
|
||||
})
|
||||
.from(performanceEntries)
|
||||
.innerJoin(
|
||||
gameVersions,
|
||||
eq(performanceEntries.versionId, gameVersions.id),
|
||||
)
|
||||
.innerJoin(
|
||||
hardware,
|
||||
eq(performanceEntries.hardwareSlug, hardware.slug),
|
||||
)
|
||||
.innerJoin(user, eq(performanceEntries.userId, user.id))
|
||||
.innerJoin(
|
||||
gamePlatformSupport,
|
||||
and(
|
||||
eq(gamePlatformSupport.gameId, gameVersions.gameId),
|
||||
eq(
|
||||
gamePlatformSupport.hardwareSlug,
|
||||
performanceEntries.hardwareSlug,
|
||||
),
|
||||
),
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
eq(gameVersions.gameId, gameId),
|
||||
eq(performanceEntries.isRemoved, false),
|
||||
sql`${performanceEntries.settingsJson} IS NOT NULL`,
|
||||
),
|
||||
)
|
||||
.orderBy(
|
||||
desc(performanceEntries.isPinned),
|
||||
desc(performanceEntries.upvotes),
|
||||
)
|
||||
|
||||
if (presetRows.length === 0) {
|
||||
return []
|
||||
}
|
||||
|
||||
const publicUrl = getR2PublicUrl()
|
||||
|
||||
// Fetch screenshots and hardware details per preset
|
||||
const presets = await Promise.all(
|
||||
presetRows.map(async (p) => {
|
||||
const screenshots = await db
|
||||
.select({
|
||||
id: entryScreenshots.id,
|
||||
storageKey: entryScreenshots.storageKey,
|
||||
orderIndex: entryScreenshots.orderIndex,
|
||||
width: entryScreenshots.width,
|
||||
height: entryScreenshots.height,
|
||||
})
|
||||
.from(entryScreenshots)
|
||||
.where(eq(entryScreenshots.entryId, p.id))
|
||||
.orderBy(entryScreenshots.orderIndex)
|
||||
|
||||
const [hw] = await db
|
||||
.select({
|
||||
wattHours: hardware.wattHours,
|
||||
deviceType: hardware.deviceType,
|
||||
})
|
||||
.from(hardware)
|
||||
.where(eq(hardware.slug, p.hardwareSlug))
|
||||
.limit(1)
|
||||
|
||||
const settingsCount = Array.isArray(p.settingsJson)
|
||||
? (p.settingsJson as Array<{ settings: unknown[] }>).reduce(
|
||||
(sum, cat) => sum + cat.settings.length,
|
||||
0,
|
||||
)
|
||||
: 0
|
||||
|
||||
return {
|
||||
id: p.id,
|
||||
gameId,
|
||||
hardwareSlug: p.hardwareSlug,
|
||||
hardwareName: p.hardwareName,
|
||||
upvotes: p.upvotes,
|
||||
downvotes: p.downvotes,
|
||||
settingsCount,
|
||||
fpsAvg: p.fpsAvg,
|
||||
fpsLow: p.fpsLow,
|
||||
fpsHigh: p.fpsHigh,
|
||||
fpsOnePercentLow: p.fpsOnePercentLow ?? null,
|
||||
upscalerType: p.upscalerType,
|
||||
upscalerVersion: p.upscalerVersion,
|
||||
frameGenMethod: p.frameGenMethod,
|
||||
protonVersion: p.protonVersion,
|
||||
osVersion: p.osVersion,
|
||||
createdAt: p.createdAt.toISOString(),
|
||||
settingsJson: p.settingsJson,
|
||||
launchOptions: p.launchOptions,
|
||||
loadTimeSsd: p.loadTimeSsd ?? null,
|
||||
loadTimeSd: p.loadTimeSd ?? null,
|
||||
tdpWatts: p.tdpWatts ?? null,
|
||||
youtubeVideoId: p.youtubeVideoId ?? null,
|
||||
screenshots: screenshots.map((ss) => ({
|
||||
id: ss.id,
|
||||
url: `${publicUrl}/${ss.storageKey}`,
|
||||
width: ss.width,
|
||||
height: ss.height,
|
||||
orderIndex: ss.orderIndex,
|
||||
})),
|
||||
hardwareWattHours: hw?.wattHours ? Number(hw.wattHours) : null,
|
||||
hardwareDeviceType: hw?.deviceType ?? null,
|
||||
customSystem: p.customSystem ?? false,
|
||||
userNotes: p.userNotes,
|
||||
versionString: p.versionString ?? null,
|
||||
buildId: p.buildId ?? null,
|
||||
gameAntiCheatName: p.gameAntiCheatName ?? null,
|
||||
gameAntiCheatStatus: p.gameAntiCheatStatus ?? null,
|
||||
userId: p.userId,
|
||||
userName: p.userName,
|
||||
userImage: p.userImage,
|
||||
verifiedAt: p.verifiedAt ? p.verifiedAt.toISOString() : null,
|
||||
isPinned: p.isPinned,
|
||||
pinnedAt: p.pinnedAt ? p.pinnedAt.toISOString() : null,
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
return presets
|
||||
},
|
||||
{
|
||||
params: t.Object({ gameId: t.String() }),
|
||||
detail: { description: "Returns all non-removed performance presets for a game with user, hardware, platform support, and screenshot data. Ordered by pinned then upvotes." },
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,359 @@
|
||||
import { Elysia, t } from "elysia"
|
||||
import { createCrudRoutes } from "./crud-builder"
|
||||
import { games, gameVersions } from "@/lib/db/schema"
|
||||
import { db } from "@/lib/db/index"
|
||||
import { eq, and, or, desc, sql } from "drizzle-orm"
|
||||
import { syncSteamGame } from "@/lib/steam/sync"
|
||||
import { requireRole } from "@/lib/auth/guard"
|
||||
|
||||
// ── Games CRUD (uses builder) ─────────────────────────────────────
|
||||
export const gamesRoutes = createCrudRoutes(games, {
|
||||
prefix: "/games",
|
||||
name: "Game",
|
||||
tags: ["Games"],
|
||||
auth: { read: "public", write: "contributor", delete: "admin" },
|
||||
search: { fields: ["title", "developer", "publisher"] },
|
||||
filter: { fields: ["source", "onlineMultiplayerStatus", "syncStatus"] },
|
||||
paramName: "gameId",
|
||||
})
|
||||
|
||||
// ── Game Versions (nested under /games/:gameId/versions) ──────────
|
||||
export const gameVersionsRoutes = new Elysia({
|
||||
prefix: "/games/:gameId/versions",
|
||||
detail: { tags: ["Games"] },
|
||||
})
|
||||
// LIST versions for a game
|
||||
.get(
|
||||
"/",
|
||||
async ({ params, query }) => {
|
||||
const limit = Math.min(Number(query.limit) || 20, 100)
|
||||
const offset = Number(query.offset) || 0
|
||||
|
||||
const data = await db
|
||||
.select()
|
||||
.from(gameVersions)
|
||||
.where(eq(gameVersions.gameId, params.gameId))
|
||||
.orderBy(desc(gameVersions.createdAt))
|
||||
.limit(limit)
|
||||
.offset(offset)
|
||||
|
||||
const [{ count }] = await db
|
||||
.select({ count: sql<number>`count(*)::int` })
|
||||
.from(gameVersions)
|
||||
.where(eq(gameVersions.gameId, params.gameId))
|
||||
|
||||
return { data, total: count, limit, offset }
|
||||
},
|
||||
{
|
||||
params: t.Object({ gameId: t.String() }),
|
||||
query: t.Object({
|
||||
limit: t.Optional(t.String()),
|
||||
offset: t.Optional(t.String()),
|
||||
}),
|
||||
},
|
||||
)
|
||||
// GET single version
|
||||
.get(
|
||||
"/:id",
|
||||
async ({ params, set }) => {
|
||||
const [version] = await db
|
||||
.select()
|
||||
.from(gameVersions)
|
||||
.where(
|
||||
and(
|
||||
eq(gameVersions.id, params.id),
|
||||
eq(gameVersions.gameId, params.gameId),
|
||||
),
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
if (!version) {
|
||||
set.status = 404
|
||||
return { error: "Version not found" }
|
||||
}
|
||||
|
||||
return version
|
||||
},
|
||||
{
|
||||
params: t.Object({ gameId: t.String(), id: t.String() }),
|
||||
},
|
||||
)
|
||||
// CREATE version (contributor+ only)
|
||||
.post(
|
||||
"/",
|
||||
async ({ params, body, request, set }) => {
|
||||
const guard = await requireRole(request.headers, [
|
||||
"contributor",
|
||||
"admin",
|
||||
])
|
||||
if (!guard.ok) {
|
||||
set.status = guard.status
|
||||
return { error: guard.error }
|
||||
}
|
||||
|
||||
// Verify game exists
|
||||
const [game] = await db
|
||||
.select({ id: games.id })
|
||||
.from(games)
|
||||
.where(eq(games.id, params.gameId))
|
||||
.limit(1)
|
||||
|
||||
if (!game) {
|
||||
set.status = 404
|
||||
return { error: "Game not found" }
|
||||
}
|
||||
|
||||
const [created] = await db
|
||||
.insert(gameVersions)
|
||||
.values({ ...body, gameId: params.gameId })
|
||||
.returning()
|
||||
|
||||
set.status = 201
|
||||
return created
|
||||
},
|
||||
{
|
||||
params: t.Object({ gameId: t.String() }),
|
||||
body: t.Object({
|
||||
buildId: t.Optional(t.String()),
|
||||
versionString: t.Optional(t.String()),
|
||||
isLatest: t.Optional(t.Boolean()),
|
||||
}),
|
||||
},
|
||||
)
|
||||
// UPDATE version
|
||||
.patch(
|
||||
"/:id",
|
||||
async ({ params, body, request, set }) => {
|
||||
const guard = await requireRole(request.headers, [
|
||||
"contributor",
|
||||
"admin",
|
||||
])
|
||||
if (!guard.ok) {
|
||||
set.status = guard.status
|
||||
return { error: guard.error }
|
||||
}
|
||||
|
||||
const [updated] = await db
|
||||
.update(gameVersions)
|
||||
.set(body)
|
||||
.where(
|
||||
and(
|
||||
eq(gameVersions.id, params.id),
|
||||
eq(gameVersions.gameId, params.gameId),
|
||||
),
|
||||
)
|
||||
.returning()
|
||||
|
||||
if (!updated) {
|
||||
set.status = 404
|
||||
return { error: "Version not found" }
|
||||
}
|
||||
|
||||
return updated
|
||||
},
|
||||
{
|
||||
params: t.Object({ gameId: t.String(), id: t.String() }),
|
||||
body: t.Object({
|
||||
buildId: t.Optional(t.String()),
|
||||
versionString: t.Optional(t.String()),
|
||||
isLatest: t.Optional(t.Boolean()),
|
||||
}),
|
||||
},
|
||||
)
|
||||
// DELETE version (admin only)
|
||||
.delete(
|
||||
"/:id",
|
||||
async ({ params, request, set }) => {
|
||||
const guard = await requireRole(request.headers, ["admin"])
|
||||
if (!guard.ok) {
|
||||
set.status = guard.status
|
||||
return { error: guard.error }
|
||||
}
|
||||
|
||||
const [deleted] = await db
|
||||
.delete(gameVersions)
|
||||
.where(
|
||||
and(
|
||||
eq(gameVersions.id, params.id),
|
||||
eq(gameVersions.gameId, params.gameId),
|
||||
),
|
||||
)
|
||||
.returning()
|
||||
|
||||
if (!deleted) {
|
||||
set.status = 404
|
||||
return { error: "Version not found" }
|
||||
}
|
||||
|
||||
return { success: true }
|
||||
},
|
||||
{
|
||||
params: t.Object({ gameId: t.String(), id: t.String() }),
|
||||
},
|
||||
)
|
||||
|
||||
// ── Game Sync Routes ────────────────────────────────────────────────
|
||||
const MAX_BULK_SYNC = 1000
|
||||
|
||||
|
||||
export const gameSyncRoutes = new Elysia({
|
||||
prefix: "/games",
|
||||
detail: { tags: ["Admin"] },
|
||||
})
|
||||
// Bulk sync with streaming progress (defined before /:gameId/sync to avoid route conflict)
|
||||
.post(
|
||||
"/sync/bulk",
|
||||
async ({ body, request, set }) => {
|
||||
const guard = await requireRole(request.headers, ["admin"]);
|
||||
if (!guard.ok) {
|
||||
set.status = guard.status;
|
||||
return { error: guard.error };
|
||||
}
|
||||
|
||||
let gamesToSync: { id: string; steamAppId: number | null }[] = [];
|
||||
|
||||
if (body.mode === "all") {
|
||||
gamesToSync = await db
|
||||
.select({ id: games.id, steamAppId: games.steamAppId })
|
||||
.from(games)
|
||||
.where(sql`${games.steamAppId} IS NOT NULL`);
|
||||
} else if (body.mode === "stale") {
|
||||
gamesToSync = await db
|
||||
.select({ id: games.id, steamAppId: games.steamAppId })
|
||||
.from(games)
|
||||
.where(
|
||||
and(
|
||||
sql`${games.steamAppId} IS NOT NULL`,
|
||||
or(
|
||||
sql`${games.lastSync} IS NULL`,
|
||||
sql`${games.lastSync} < NOW() - INTERVAL '7 days'`
|
||||
)
|
||||
)
|
||||
);
|
||||
} else {
|
||||
const gameIds = (body.gameIds || []).slice(0, MAX_BULK_SYNC);
|
||||
if (gameIds.length > 0) {
|
||||
gamesToSync = await db
|
||||
.select({ id: games.id, steamAppId: games.steamAppId })
|
||||
.from(games)
|
||||
.where(
|
||||
and(
|
||||
sql`${games.steamAppId} IS NOT NULL`,
|
||||
sql`${games.id} IN (${sql.join(gameIds.map(id => sql`${id}`), sql`, `)})`
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (gamesToSync.length === 0) {
|
||||
return { total: 0, synced: 0, failed: 0, message: "No games to sync" };
|
||||
}
|
||||
|
||||
// Return streaming response for real-time progress
|
||||
const encoder = new TextEncoder()
|
||||
const stream = new ReadableStream({
|
||||
async start(controller) {
|
||||
type ProgressEvent = { type: string; current?: number; total: number; synced: number; failed: number; currentGame?: string | null }
|
||||
const send = (data: ProgressEvent) => {
|
||||
controller.enqueue(encoder.encode(JSON.stringify(data) + "\n"))
|
||||
}
|
||||
|
||||
// Send initial progress
|
||||
send({ type: "progress", current: 0, total: gamesToSync.length, synced: 0, failed: 0, currentGame: null })
|
||||
|
||||
let synced = 0
|
||||
let failed = 0
|
||||
|
||||
// Process syncs sequentially with delay to avoid rate limiting
|
||||
for (let i = 0; i < gamesToSync.length; i++) {
|
||||
const game = gamesToSync[i]
|
||||
if (!game.steamAppId) continue
|
||||
|
||||
try {
|
||||
const result = await syncSteamGame(game.steamAppId, { forceRetry: true })
|
||||
if (result.success) synced++
|
||||
else failed++
|
||||
} catch {
|
||||
failed++
|
||||
}
|
||||
|
||||
// Send progress update
|
||||
send({
|
||||
type: "progress",
|
||||
current: i + 1,
|
||||
total: gamesToSync.length,
|
||||
synced,
|
||||
failed,
|
||||
currentGame: null,
|
||||
})
|
||||
|
||||
// Delay between syncs to avoid rate limiting (1.5 seconds)
|
||||
if (i < gamesToSync.length - 1) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1500))
|
||||
}
|
||||
}
|
||||
|
||||
// Send completion
|
||||
send({ type: "complete", total: gamesToSync.length, synced, failed })
|
||||
controller.close()
|
||||
}
|
||||
})
|
||||
|
||||
return new Response(stream, {
|
||||
headers: {
|
||||
"Content-Type": "text/event-stream",
|
||||
"Cache-Control": "no-cache",
|
||||
"Connection": "keep-alive",
|
||||
},
|
||||
})
|
||||
},
|
||||
{
|
||||
body: t.Object({
|
||||
gameIds: t.Optional(t.Array(t.String())),
|
||||
mode: t.Union([
|
||||
t.Literal("selected"),
|
||||
t.Literal("all"),
|
||||
t.Literal("stale"),
|
||||
]),
|
||||
}),
|
||||
}
|
||||
)
|
||||
// Single game sync
|
||||
.post(
|
||||
"/:gameId/sync",
|
||||
async ({ params, request, set }) => {
|
||||
const guard = await requireRole(request.headers, ["admin"]);
|
||||
if (!guard.ok) {
|
||||
set.status = guard.status;
|
||||
return { error: guard.error };
|
||||
}
|
||||
|
||||
const [game] = await db
|
||||
.select({ id: games.id, steamAppId: games.steamAppId })
|
||||
.from(games)
|
||||
.where(eq(games.id, params.gameId))
|
||||
.limit(1);
|
||||
|
||||
if (!game) {
|
||||
set.status = 404;
|
||||
return { error: "Game not found" };
|
||||
}
|
||||
|
||||
if (!game.steamAppId) {
|
||||
set.status = 400;
|
||||
return { error: "Game has no Steam App ID" };
|
||||
}
|
||||
|
||||
const result = await syncSteamGame(game.steamAppId, { forceRetry: true });
|
||||
|
||||
if (!result.success) {
|
||||
set.status = 502;
|
||||
return { status: "failed", error: result.error };
|
||||
}
|
||||
|
||||
return { status: "synced", gameId: params.gameId };
|
||||
},
|
||||
{
|
||||
params: t.Object({ gameId: t.String() }),
|
||||
}
|
||||
);
|
||||
@@ -0,0 +1,299 @@
|
||||
import { Elysia, t } from "elysia"
|
||||
import { db } from "@/lib/db/index"
|
||||
import {
|
||||
hardware,
|
||||
performanceEntries,
|
||||
gameVersions,
|
||||
games,
|
||||
} from "@/lib/db/schema"
|
||||
import { eq, and, sql, desc } from "drizzle-orm"
|
||||
|
||||
export const hardwareStatsRoutes = new Elysia({ prefix: "/hardware", detail: { tags: ["Hardware"] } })
|
||||
// ── All devices with aggregated stats ──────────────────────
|
||||
.get(
|
||||
"/stats",
|
||||
async () => {
|
||||
// Get all hardware devices ordered by sortOrder
|
||||
const devices = await db
|
||||
.select({
|
||||
slug: hardware.slug,
|
||||
name: hardware.name,
|
||||
deviceType: hardware.deviceType,
|
||||
sortOrder: hardware.sortOrder,
|
||||
wattHours: hardware.wattHours,
|
||||
tdpMax: hardware.tdpMax,
|
||||
})
|
||||
.from(hardware)
|
||||
.orderBy(hardware.sortOrder)
|
||||
|
||||
// Get aggregated stats per device
|
||||
const statsPerDevice = await db
|
||||
.select({
|
||||
hardwareSlug: performanceEntries.hardwareSlug,
|
||||
totalBenchmarks: sql<number>`count(*)::int`,
|
||||
avgFps: sql<number>`round(avg(${performanceEntries.fpsAvg})::numeric, 1)`,
|
||||
verifiedCount: sql<number>`count(*) filter (where ${performanceEntries.verifiedAt} is not null)::int`,
|
||||
gameCount: sql<number>`count(distinct ${gameVersions.gameId})::int`,
|
||||
})
|
||||
.from(performanceEntries)
|
||||
.innerJoin(gameVersions, eq(performanceEntries.versionId, gameVersions.id))
|
||||
.innerJoin(games, eq(gameVersions.gameId, games.id))
|
||||
.innerJoin(hardware, eq(performanceEntries.hardwareSlug, hardware.slug))
|
||||
.where(eq(performanceEntries.isRemoved, false))
|
||||
.groupBy(performanceEntries.hardwareSlug)
|
||||
|
||||
const statsMap = new Map(statsPerDevice.map((s) => [s.hardwareSlug, s]))
|
||||
|
||||
// Best game per device (highest avg FPS)
|
||||
const bestGames = await db
|
||||
.select({
|
||||
hardwareSlug: performanceEntries.hardwareSlug,
|
||||
gameId: games.id,
|
||||
gameTitle: games.title,
|
||||
gameHeaderImage: games.headerImage,
|
||||
fpsAvg: sql<number>`round(avg(${performanceEntries.fpsAvg})::numeric, 1)`,
|
||||
})
|
||||
.from(performanceEntries)
|
||||
.innerJoin(gameVersions, eq(performanceEntries.versionId, gameVersions.id))
|
||||
.innerJoin(games, eq(gameVersions.gameId, games.id))
|
||||
.where(eq(performanceEntries.isRemoved, false))
|
||||
.groupBy(performanceEntries.hardwareSlug, games.id, games.title, games.headerImage)
|
||||
.orderBy(desc(sql`avg(${performanceEntries.fpsAvg})`))
|
||||
|
||||
// For each device, pick the best game (first result per device slug)
|
||||
const bestGameMap = new Map<string, { id: string; title: string; headerImage: string | null; fpsAvg: number }>()
|
||||
for (const bg of bestGames) {
|
||||
if (!bestGameMap.has(bg.hardwareSlug)) {
|
||||
bestGameMap.set(bg.hardwareSlug, {
|
||||
id: bg.gameId,
|
||||
title: bg.gameTitle,
|
||||
headerImage: bg.gameHeaderImage,
|
||||
fpsAvg: Number(bg.fpsAvg),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return devices.map((device) => {
|
||||
const stats = statsMap.get(device.slug)
|
||||
const bestGame = bestGameMap.get(device.slug)
|
||||
return {
|
||||
slug: device.slug,
|
||||
name: device.name,
|
||||
deviceType: device.deviceType,
|
||||
sortOrder: device.sortOrder,
|
||||
wattHours: device.wattHours ? Number(device.wattHours) : null,
|
||||
tdpMax: device.tdpMax ? Number(device.tdpMax) : null,
|
||||
totalBenchmarks: stats?.totalBenchmarks ?? 0,
|
||||
avgFps: stats?.avgFps ? Number(stats.avgFps) : null,
|
||||
gameCount: stats?.gameCount ?? 0,
|
||||
verifiedCount: stats?.verifiedCount ?? 0,
|
||||
bestGame: bestGame ?? null,
|
||||
}
|
||||
})
|
||||
},
|
||||
)
|
||||
// ── Single device detailed stats ───────────────────────────
|
||||
.get(
|
||||
"/:slug/stats",
|
||||
async ({ params, set }) => {
|
||||
const { slug } = params
|
||||
|
||||
// Verify hardware exists
|
||||
const [device] = await db
|
||||
.select({
|
||||
slug: hardware.slug,
|
||||
name: hardware.name,
|
||||
deviceType: hardware.deviceType,
|
||||
wattHours: hardware.wattHours,
|
||||
tdpMax: hardware.tdpMax,
|
||||
})
|
||||
.from(hardware)
|
||||
.where(eq(hardware.slug, slug))
|
||||
.limit(1)
|
||||
|
||||
if (!device) {
|
||||
set.status = 404
|
||||
return { error: "Device not found" }
|
||||
}
|
||||
|
||||
// All entries for this device
|
||||
const entries = await db
|
||||
.select({
|
||||
id: performanceEntries.id,
|
||||
gameId: games.id,
|
||||
gameTitle: games.title,
|
||||
gameHeaderImage: games.headerImage,
|
||||
fpsAvg: performanceEntries.fpsAvg,
|
||||
fpsLow: performanceEntries.fpsLow,
|
||||
fpsHigh: performanceEntries.fpsHigh,
|
||||
upscalerType: performanceEntries.upscalerType,
|
||||
upscalerVersion: performanceEntries.upscalerVersion,
|
||||
frameGenMethod: performanceEntries.frameGenMethod,
|
||||
protonVersion: performanceEntries.protonVersion,
|
||||
osVersion: performanceEntries.osVersion,
|
||||
upvotes: performanceEntries.upvotes,
|
||||
downvotes: performanceEntries.downvotes,
|
||||
verifiedAt: performanceEntries.verifiedAt,
|
||||
createdAt: performanceEntries.createdAt,
|
||||
genres: games.genres,
|
||||
})
|
||||
.from(performanceEntries)
|
||||
.innerJoin(gameVersions, eq(performanceEntries.versionId, gameVersions.id))
|
||||
.innerJoin(games, eq(gameVersions.gameId, games.id))
|
||||
.where(
|
||||
and(
|
||||
eq(performanceEntries.hardwareSlug, slug),
|
||||
eq(performanceEntries.isRemoved, false),
|
||||
)
|
||||
)
|
||||
|
||||
if (entries.length === 0) {
|
||||
return {
|
||||
...device,
|
||||
wattHours: null,
|
||||
tdpMax: null,
|
||||
totalBenchmarks: 0,
|
||||
avgFps: null,
|
||||
verifiedCount: 0,
|
||||
gameCount: 0,
|
||||
boxplot: [],
|
||||
historical: [],
|
||||
topGames: [],
|
||||
genreBreakdown: [],
|
||||
protonBreakdown: [],
|
||||
upscalerBreakdown: [],
|
||||
}
|
||||
}
|
||||
|
||||
const totalBenchmarks = entries.length
|
||||
// Filter out null FPS entries before computing average (SQL avg() ignores nulls)
|
||||
const fpsEntries = entries.filter((e) => e.fpsAvg !== null)
|
||||
const avgFps =
|
||||
fpsEntries.length > 0
|
||||
? Math.round(
|
||||
(fpsEntries.reduce((s, e) => s + e.fpsAvg!, 0) / fpsEntries.length) * 10
|
||||
) / 10
|
||||
: null
|
||||
const verifiedCount = entries.filter((e) => e.verifiedAt !== null).length
|
||||
|
||||
// Unique game count
|
||||
const gameIds = new Set(entries.map((e) => e.gameId))
|
||||
const gameCount = gameIds.size
|
||||
|
||||
// ── Boxplot: FPS distribution per game ───────────────
|
||||
const gameFpsMap = new Map<string, { title: string; values: number[] }>()
|
||||
for (const e of entries) {
|
||||
if (!gameFpsMap.has(e.gameId)) {
|
||||
gameFpsMap.set(e.gameId, { title: e.gameTitle, values: [] })
|
||||
}
|
||||
gameFpsMap.get(e.gameId)!.values.push(e.fpsAvg ?? 0)
|
||||
}
|
||||
|
||||
// Top 10 games by benchmark count for boxplot
|
||||
const topGameEntries = [...gameFpsMap.entries()]
|
||||
.sort((a, b) => b[1].values.length - a[1].values.length)
|
||||
.slice(0, 10)
|
||||
|
||||
const boxplot = topGameEntries.map(([gameId, { title, values }]) => {
|
||||
const sorted = [...values].sort((a, b) => a - b)
|
||||
const n = sorted.length
|
||||
return {
|
||||
gameId,
|
||||
gameTitle: title,
|
||||
min: sorted[0],
|
||||
q1: sorted[Math.floor(n * 0.25)] ?? sorted[0],
|
||||
median: sorted[Math.floor(n * 0.5)] ?? sorted[0],
|
||||
q3: sorted[Math.floor(n * 0.75)] ?? sorted[n - 1],
|
||||
max: sorted[n - 1],
|
||||
count: n,
|
||||
}
|
||||
})
|
||||
|
||||
// ── Historical: avg FPS per month ───────────────────
|
||||
const monthMap = new Map<string, { sum: number; count: number }>()
|
||||
for (const e of entries) {
|
||||
const month = `${e.createdAt.getFullYear()}-${String(e.createdAt.getMonth() + 1).padStart(2, "0")}`
|
||||
if (!monthMap.has(month)) monthMap.set(month, { sum: 0, count: 0 })
|
||||
const m = monthMap.get(month)!
|
||||
m.sum += e.fpsAvg ?? 0
|
||||
m.count++
|
||||
}
|
||||
|
||||
const historical = [...monthMap.entries()]
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(([period, { sum, count }]) => ({
|
||||
period,
|
||||
avgFps: Math.round((sum / count) * 10) / 10,
|
||||
count,
|
||||
}))
|
||||
|
||||
// ── Top Games by avg FPS ────────────────────────────
|
||||
const topGames = [...gameFpsMap.entries()]
|
||||
.map(([gameId, { title, values }]) => ({
|
||||
gameId,
|
||||
gameTitle: title,
|
||||
headerImage: entries.find((e) => e.gameId === gameId)?.gameHeaderImage ?? null,
|
||||
avgFps: Math.round((values.reduce((a, b) => a + b, 0) / values.length) * 10) / 10,
|
||||
benchmarkCount: values.length,
|
||||
}))
|
||||
.sort((a, b) => b.avgFps - a.avgFps)
|
||||
.slice(0, 20)
|
||||
|
||||
// ── Genre breakdown ─────────────────────────────────
|
||||
const genreMap = new Map<string, number>()
|
||||
for (const e of entries) {
|
||||
if (e.genres && Array.isArray(e.genres)) {
|
||||
for (const g of e.genres) {
|
||||
genreMap.set(g, (genreMap.get(g) || 0) + 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
const genreBreakdown = [...genreMap.entries()]
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.slice(0, 10)
|
||||
.map(([genre, count]) => ({ genre, count }))
|
||||
|
||||
// ── Proton breakdown ────────────────────────────────
|
||||
const protonMap = new Map<string, number>()
|
||||
for (const e of entries) {
|
||||
if (e.protonVersion) {
|
||||
protonMap.set(e.protonVersion, (protonMap.get(e.protonVersion) || 0) + 1)
|
||||
}
|
||||
}
|
||||
const protonBreakdown = [...protonMap.entries()]
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.map(([version, count]) => ({ version, count }))
|
||||
|
||||
// ── Upscaler breakdown ───────────────────────────────────
|
||||
const upscalerMap = new Map<string, { count: number; avgFps: number }>()
|
||||
for (const e of entries) {
|
||||
const key = e.upscalerType ?? "none"
|
||||
if (!upscalerMap.has(key)) upscalerMap.set(key, { count: 0, avgFps: 0 })
|
||||
const f = upscalerMap.get(key)!
|
||||
f.count++
|
||||
f.avgFps += e.fpsAvg ?? 0
|
||||
}
|
||||
const upscalerBreakdown = [...upscalerMap.entries()].map(([type, data]) => ({
|
||||
upscalerType: type,
|
||||
count: data.count,
|
||||
avgFps: Math.round((data.avgFps / data.count) * 10) / 10,
|
||||
}))
|
||||
|
||||
return {
|
||||
...device,
|
||||
totalBenchmarks,
|
||||
avgFps,
|
||||
verifiedCount,
|
||||
gameCount,
|
||||
boxplot,
|
||||
historical,
|
||||
topGames,
|
||||
genreBreakdown,
|
||||
protonBreakdown,
|
||||
upscalerBreakdown,
|
||||
}
|
||||
},
|
||||
{
|
||||
params: t.Object({ slug: t.String() }),
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,11 @@
|
||||
import { createCrudRoutes } from "./crud-builder"
|
||||
import { hardware } from "@/lib/db/schema"
|
||||
|
||||
export const hardwareRoutes = createCrudRoutes(hardware, {
|
||||
prefix: "/hardware",
|
||||
name: "Hardware",
|
||||
tags: ["Hardware"],
|
||||
primaryKey: "slug",
|
||||
auth: { read: "public", write: "admin", delete: "admin" },
|
||||
filter: { fields: ["deviceType"] },
|
||||
})
|
||||
@@ -0,0 +1,19 @@
|
||||
import { Elysia } from "elysia"
|
||||
|
||||
export const healthRoutes = new Elysia({
|
||||
prefix: "/health",
|
||||
detail: { tags: ["Health"] },
|
||||
}).get(
|
||||
"/",
|
||||
() => ({
|
||||
status: "ok",
|
||||
timestamp: new Date().toISOString(),
|
||||
service: "deckyvault-api",
|
||||
}),
|
||||
{
|
||||
detail: {
|
||||
summary: "Health check",
|
||||
description: "Returns the current health status of the API.",
|
||||
},
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,32 @@
|
||||
export { healthRoutes } from "./health"
|
||||
export { userRoutes } from "./user"
|
||||
export { gamesRoutes, gameVersionsRoutes, gameSyncRoutes } from "./games"
|
||||
export { hardwareRoutes } from "./hardware"
|
||||
export { performanceRoutes, performanceVerifyRoutes } from "./performance"
|
||||
export { performanceSubmitRoutes } from "./performance-submit"
|
||||
export { commentsRoutes } from "./comments"
|
||||
export { gamesListingRoutes } from "./games-listing"
|
||||
export { gameStatsRoutes } from "./game-stats"
|
||||
export { gamesPerformanceRoutes } from "./games-performance"
|
||||
export { hardwareStatsRoutes } from "./hardware-stats"
|
||||
export { savedGamesRoutes } from "./saved-games"
|
||||
export { reportRoutes } from "./reports"
|
||||
export { contactRoutes } from "./contact"
|
||||
export { adminReportRoutes } from "./admin-reports"
|
||||
export { adminPerformanceRoutes } from "./admin-performance"
|
||||
export { adminCommentRoutes } from "./admin-comments"
|
||||
export { adminStorageRoutes } from "./admin-storage"
|
||||
export { adminAnalyticsRoutes } from "./admin-analytics"
|
||||
export { steamdbVersionRoutes } from "./steamdb-version"
|
||||
export { steamgridProxyRoutes } from "./steamgrid-proxy"
|
||||
export { gamesManualRoutes } from "./games-manual"
|
||||
export { compareRoutes } from "./compare"
|
||||
export { playabilityRoutes } from "./playability"
|
||||
export { steamReviewRoutes } from "./steam-reviews"
|
||||
export { communitySuggestionRoutes } from "./community-suggestions"
|
||||
export { savedFilterRoutes } from "./saved-filters"
|
||||
export { dashboardRoutes } from "./dashboard"
|
||||
export { dashboardPublicRoutes } from "./dashboard-public"
|
||||
export { cronRoutes } from "./cron"
|
||||
export { profilePhotoRoutes } from "./profile-photo"
|
||||
export { mobileRoutes } from "./mobile"
|
||||
@@ -0,0 +1,966 @@
|
||||
import { Elysia, t } from "elysia"
|
||||
import { db } from "@/lib/db/index"
|
||||
import {
|
||||
games,
|
||||
gameVersions,
|
||||
performanceEntries,
|
||||
hardware,
|
||||
user,
|
||||
gamePlatformSupport,
|
||||
entryScreenshots,
|
||||
gameComments,
|
||||
} from "@/lib/db/schema"
|
||||
import { eq, and, desc, sql, isNull, inArray } from "drizzle-orm"
|
||||
import { getR2PublicUrl } from "@/lib/storage"
|
||||
|
||||
/**
|
||||
* GET /api/mobile/game/:gameId
|
||||
*
|
||||
* Consolidated mobile-optimized endpoint that bundles game details,
|
||||
* stats, presets, platform support, and first page of comments
|
||||
* into a single response — replacing 4+ separate API calls.
|
||||
*/
|
||||
export const mobileRoutes = new Elysia({
|
||||
prefix: "/mobile",
|
||||
detail: { tags: ["Mobile"] },
|
||||
}).get(
|
||||
"/game/:gameId",
|
||||
async ({ params, set }) => {
|
||||
const { gameId } = params
|
||||
|
||||
// ── 1. Fetch game ───────────────────────────────────────────
|
||||
const [game] = await db
|
||||
.select()
|
||||
.from(games)
|
||||
.where(eq(games.id, gameId))
|
||||
.limit(1)
|
||||
|
||||
if (!game) {
|
||||
set.status = 404
|
||||
return { error: "Game not found" }
|
||||
}
|
||||
|
||||
// ── 2. Fetch all non-removed performance entries with joins ──
|
||||
// Used for both stats and presets
|
||||
const entries = await db
|
||||
.select({
|
||||
id: performanceEntries.id,
|
||||
hardwareSlug: performanceEntries.hardwareSlug,
|
||||
hardwareName: hardware.name,
|
||||
fpsAvg: performanceEntries.fpsAvg,
|
||||
fpsLow: performanceEntries.fpsLow,
|
||||
fpsHigh: performanceEntries.fpsHigh,
|
||||
fpsOnePercentLow: performanceEntries.fpsOnePercentLow,
|
||||
upscalerType: performanceEntries.upscalerType,
|
||||
upscalerVersion: performanceEntries.upscalerVersion,
|
||||
frameGenMethod: performanceEntries.frameGenMethod,
|
||||
protonVersion: performanceEntries.protonVersion,
|
||||
osVersion: performanceEntries.osVersion,
|
||||
upvotes: performanceEntries.upvotes,
|
||||
downvotes: performanceEntries.downvotes,
|
||||
verifiedAt: performanceEntries.verifiedAt,
|
||||
userNotes: performanceEntries.userNotes,
|
||||
createdAt: performanceEntries.createdAt,
|
||||
versionId: performanceEntries.versionId,
|
||||
tdpWatts: performanceEntries.tdpWatts,
|
||||
settingsJson: performanceEntries.settingsJson,
|
||||
userId: performanceEntries.userId,
|
||||
userName: user.name,
|
||||
userImage: user.image,
|
||||
launchOptions: performanceEntries.launchOptions,
|
||||
loadTimeSsd: performanceEntries.loadTimeSsd,
|
||||
loadTimeSd: performanceEntries.loadTimeSd,
|
||||
youtubeVideoId: performanceEntries.youtubeVideoId,
|
||||
customSystem: performanceEntries.customSystem,
|
||||
isPinned: performanceEntries.isPinned,
|
||||
pinnedAt: performanceEntries.pinnedAt,
|
||||
versionString: gameVersions.versionString,
|
||||
buildId: gameVersions.buildId,
|
||||
gameAntiCheatName: gamePlatformSupport.antiCheatName,
|
||||
gameAntiCheatStatus: gamePlatformSupport.antiCheatStatus,
|
||||
})
|
||||
.from(performanceEntries)
|
||||
.innerJoin(gameVersions, eq(performanceEntries.versionId, gameVersions.id))
|
||||
.innerJoin(hardware, eq(performanceEntries.hardwareSlug, hardware.slug))
|
||||
.innerJoin(user, eq(performanceEntries.userId, user.id))
|
||||
.innerJoin(
|
||||
gamePlatformSupport,
|
||||
and(
|
||||
eq(gamePlatformSupport.gameId, gameVersions.gameId),
|
||||
eq(gamePlatformSupport.hardwareSlug, performanceEntries.hardwareSlug),
|
||||
),
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
eq(gameVersions.gameId, gameId),
|
||||
eq(performanceEntries.isRemoved, false),
|
||||
),
|
||||
)
|
||||
.orderBy(desc(performanceEntries.isPinned), desc(performanceEntries.upvotes))
|
||||
|
||||
// ── 3. Fetch hardware details for all devices involved ───────
|
||||
const deviceSlugs = [...new Set(entries.map((e) => e.hardwareSlug))]
|
||||
const deviceData = deviceSlugs.length > 0
|
||||
? await db
|
||||
.select({
|
||||
slug: hardware.slug,
|
||||
name: hardware.name,
|
||||
wattHours: hardware.wattHours,
|
||||
tdpMax: hardware.tdpMax,
|
||||
deviceType: hardware.deviceType,
|
||||
})
|
||||
.from(hardware)
|
||||
.where(inArray(hardware.slug, deviceSlugs))
|
||||
: []
|
||||
|
||||
const deviceWattHoursMap = new Map(
|
||||
deviceData.map((d) => [d.slug, d]),
|
||||
)
|
||||
|
||||
// ── 4. Compute stats ────────────────────────────────────────
|
||||
const stats = (() => {
|
||||
if (entries.length === 0) {
|
||||
return {
|
||||
summary: {
|
||||
totalEntries: 0,
|
||||
avgFps: null as number | null,
|
||||
bestDevice: null as string | null,
|
||||
verifiedCount: 0,
|
||||
versionCount: 0,
|
||||
},
|
||||
isRawPerformer: false,
|
||||
isPoorPerformance: false,
|
||||
boxplot: [] as Array<{
|
||||
hardwareSlug: string
|
||||
hardwareName: string
|
||||
min: number
|
||||
q1: number
|
||||
median: number
|
||||
onePercentLow: number
|
||||
q3: number
|
||||
max: number
|
||||
}>,
|
||||
fpsRange: [] as Array<{
|
||||
id: string
|
||||
hardwareSlug: string
|
||||
fpsLow: number
|
||||
fpsAvg: number
|
||||
fpsHigh: number
|
||||
fpsOnePercentLow: number | null
|
||||
isRawPerformer: boolean
|
||||
isPoorPerformer: boolean
|
||||
}>,
|
||||
deviceBreakdown: [] as Array<{
|
||||
hardwareSlug: string
|
||||
hardwareName: string
|
||||
count: number
|
||||
wattHours: number | null
|
||||
tdpMax: number | null
|
||||
deviceType: string | null
|
||||
}>,
|
||||
batteryLife: [] as Array<{
|
||||
id: string
|
||||
hardwareSlug: string
|
||||
tdpWatts: number
|
||||
estimatedBatteryMin: number
|
||||
estimatedBatteryHours: number
|
||||
wattHours: number | null
|
||||
tdpMax: number | null
|
||||
estimatedAtMaxTdpMin: number | null
|
||||
}>,
|
||||
filterOptions: { protonVersions: [] as string[], osVersions: [] as string[] },
|
||||
}
|
||||
}
|
||||
|
||||
const totalEntries = entries.length
|
||||
const avgFps =
|
||||
Math.round(
|
||||
(entries.reduce((sum, e) => sum + (e.fpsAvg ?? 0), 0) / totalEntries) * 10,
|
||||
) / 10
|
||||
const verifiedCount = entries.filter((e) => e.verifiedAt !== null).length
|
||||
|
||||
// Best device by mean fpsAvg
|
||||
const deviceFpsMap = new Map<string, number[]>()
|
||||
for (const e of entries) {
|
||||
const arr = deviceFpsMap.get(e.hardwareSlug) || []
|
||||
arr.push(e.fpsAvg ?? 0)
|
||||
deviceFpsMap.set(e.hardwareSlug, arr)
|
||||
}
|
||||
let bestDevice: string | null = null
|
||||
let bestDeviceAvg = 0
|
||||
for (const [slug, fpsArr] of deviceFpsMap) {
|
||||
const mean = fpsArr.reduce((a, b) => a + b, 0) / fpsArr.length
|
||||
if (mean > bestDeviceAvg) {
|
||||
bestDeviceAvg = mean
|
||||
bestDevice = slug
|
||||
}
|
||||
}
|
||||
|
||||
// Version count
|
||||
const versionCount = new Set(entries.map((e) => e.versionId)).size
|
||||
|
||||
// Flags
|
||||
const isRawPerformer = entries.some(
|
||||
(e) =>
|
||||
(e.fpsAvg ?? 0) >= 60 &&
|
||||
e.upscalerType === "none" &&
|
||||
e.frameGenMethod === "none",
|
||||
)
|
||||
const isPoorPerformance = entries.some((e) => (e.fpsAvg ?? 0) < 30)
|
||||
|
||||
// Boxplot per device
|
||||
const boxplotMap = new Map<
|
||||
string,
|
||||
{ hardwareName: string; values: number[] }
|
||||
>()
|
||||
for (const e of entries) {
|
||||
const existing = boxplotMap.get(e.hardwareSlug) || {
|
||||
hardwareName: e.hardwareName,
|
||||
values: [],
|
||||
}
|
||||
existing.values.push(e.fpsAvg ?? 0)
|
||||
boxplotMap.set(e.hardwareSlug, existing)
|
||||
}
|
||||
|
||||
const boxplot = Array.from(boxplotMap.entries()).map(
|
||||
([slug, { hardwareName, values }]) => {
|
||||
const sorted = [...values].sort((a, b) => a - b)
|
||||
const n = sorted.length
|
||||
const q1Idx = Math.floor(n * 0.25)
|
||||
const medIdx = Math.floor(n * 0.5)
|
||||
const q3Idx = Math.floor(n * 0.75)
|
||||
const onePercentLow =
|
||||
sorted.length > 0
|
||||
? sorted[Math.max(0, Math.floor(sorted.length * 0.01))]
|
||||
: sorted[0]
|
||||
return {
|
||||
hardwareSlug: slug,
|
||||
hardwareName,
|
||||
min: sorted[0],
|
||||
q1: sorted[q1Idx],
|
||||
median: sorted[medIdx],
|
||||
onePercentLow,
|
||||
q3: sorted[q3Idx],
|
||||
max: sorted[n - 1],
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
// FPS range per entry
|
||||
const fpsRange = entries
|
||||
.filter((e) => e.fpsLow !== null && e.fpsHigh !== null)
|
||||
.map((e) => ({
|
||||
id: e.id,
|
||||
hardwareSlug: e.hardwareSlug,
|
||||
fpsLow: e.fpsLow!,
|
||||
fpsAvg: e.fpsAvg ?? 0,
|
||||
fpsHigh: e.fpsHigh!,
|
||||
fpsOnePercentLow: e.fpsOnePercentLow ?? null,
|
||||
isRawPerformer:
|
||||
(e.fpsAvg ?? 0) >= 60 &&
|
||||
e.upscalerType === "none" &&
|
||||
e.frameGenMethod === "none",
|
||||
isPoorPerformer: (e.fpsAvg ?? 0) < 30,
|
||||
}))
|
||||
|
||||
// Device breakdown with hardware info
|
||||
const deviceBreakdown = Array.from(boxplotMap.entries()).map(
|
||||
([slug, { hardwareName, values }]) => {
|
||||
const dev = deviceWattHoursMap.get(slug)
|
||||
return {
|
||||
hardwareSlug: slug,
|
||||
hardwareName,
|
||||
count: values.length,
|
||||
wattHours: dev?.wattHours ? Number(dev.wattHours) : null,
|
||||
tdpMax: dev?.tdpMax ? Number(dev.tdpMax) : null,
|
||||
deviceType: dev?.deviceType ?? null,
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
// Battery life estimates
|
||||
const batteryLife = entries
|
||||
.filter((e) => e.tdpWatts != null && e.tdpWatts > 0)
|
||||
.map((e) => {
|
||||
const device = deviceWattHoursMap.get(e.hardwareSlug)
|
||||
const wh = device?.wattHours ? Number(device.wattHours) : null
|
||||
const tdpMax = device?.tdpMax ? Number(device.tdpMax) : null
|
||||
if (!wh) return null
|
||||
const estimatedBatteryHours = wh / e.tdpWatts!
|
||||
const estimatedBatteryMin = estimatedBatteryHours * 60
|
||||
const estimatedAtMaxTdpMin = tdpMax ? (wh / tdpMax) * 60 : null
|
||||
return {
|
||||
id: e.id,
|
||||
hardwareSlug: e.hardwareSlug,
|
||||
tdpWatts: Number(e.tdpWatts),
|
||||
estimatedBatteryMin: Math.round(estimatedBatteryMin),
|
||||
estimatedBatteryHours: Math.round(estimatedBatteryHours * 10) / 10,
|
||||
wattHours: wh,
|
||||
tdpMax,
|
||||
estimatedAtMaxTdpMin: estimatedAtMaxTdpMin
|
||||
? Math.round(estimatedAtMaxTdpMin)
|
||||
: null,
|
||||
}
|
||||
})
|
||||
.filter(Boolean) as Array<{
|
||||
id: string
|
||||
hardwareSlug: string
|
||||
tdpWatts: number
|
||||
estimatedBatteryMin: number
|
||||
estimatedBatteryHours: number
|
||||
wattHours: number | null
|
||||
tdpMax: number | null
|
||||
estimatedAtMaxTdpMin: number | null
|
||||
}>
|
||||
|
||||
// Filter options
|
||||
const protonVersions = [
|
||||
...new Set(entries.map((e) => e.protonVersion).filter(Boolean)),
|
||||
] as string[]
|
||||
const osVersions = [
|
||||
...new Set(entries.map((e) => e.osVersion).filter(Boolean)),
|
||||
] as string[]
|
||||
|
||||
return {
|
||||
summary: {
|
||||
totalEntries,
|
||||
avgFps,
|
||||
bestDevice,
|
||||
verifiedCount,
|
||||
versionCount,
|
||||
},
|
||||
isRawPerformer,
|
||||
isPoorPerformance,
|
||||
boxplot,
|
||||
fpsRange,
|
||||
deviceBreakdown,
|
||||
batteryLife,
|
||||
filterOptions: { protonVersions, osVersions },
|
||||
}
|
||||
})()
|
||||
|
||||
// ── 5. Build presets (entries with settingsJson) ──────────────
|
||||
const presetEntries = entries.filter((e) => e.settingsJson !== null)
|
||||
const publicUrl = getR2PublicUrl()
|
||||
|
||||
const presetEntryIds = presetEntries.map((e) => e.id)
|
||||
|
||||
// Batch-fetch screenshots for all presets
|
||||
const screenshotsMap = new Map<
|
||||
string,
|
||||
Array<{
|
||||
id: string
|
||||
url: string
|
||||
width: number
|
||||
height: number
|
||||
orderIndex: number
|
||||
}>
|
||||
>()
|
||||
|
||||
if (presetEntryIds.length > 0) {
|
||||
const allScreenshots = await db
|
||||
.select({
|
||||
id: entryScreenshots.id,
|
||||
storageKey: entryScreenshots.storageKey,
|
||||
orderIndex: entryScreenshots.orderIndex,
|
||||
width: entryScreenshots.width,
|
||||
height: entryScreenshots.height,
|
||||
entryId: entryScreenshots.entryId,
|
||||
})
|
||||
.from(entryScreenshots)
|
||||
.where(inArray(entryScreenshots.entryId, presetEntryIds))
|
||||
.orderBy(entryScreenshots.orderIndex)
|
||||
|
||||
for (const ss of allScreenshots) {
|
||||
const arr = screenshotsMap.get(ss.entryId) || []
|
||||
arr.push({
|
||||
id: ss.id,
|
||||
url: `${publicUrl}/${ss.storageKey}`,
|
||||
width: ss.width,
|
||||
height: ss.height,
|
||||
orderIndex: ss.orderIndex,
|
||||
})
|
||||
screenshotsMap.set(ss.entryId, arr)
|
||||
}
|
||||
}
|
||||
|
||||
const presets = presetEntries.map((p) => {
|
||||
const settingsCount = Array.isArray(p.settingsJson)
|
||||
? (p.settingsJson as Array<{ settings: unknown[] }>).reduce(
|
||||
(sum, cat) => sum + cat.settings.length,
|
||||
0,
|
||||
)
|
||||
: 0
|
||||
|
||||
const hw = deviceWattHoursMap.get(p.hardwareSlug)
|
||||
|
||||
return {
|
||||
id: p.id,
|
||||
gameId,
|
||||
hardwareSlug: p.hardwareSlug,
|
||||
hardwareName: p.hardwareName,
|
||||
upvotes: p.upvotes,
|
||||
downvotes: p.downvotes,
|
||||
settingsCount,
|
||||
fpsAvg: p.fpsAvg,
|
||||
fpsLow: p.fpsLow,
|
||||
fpsHigh: p.fpsHigh,
|
||||
fpsOnePercentLow: p.fpsOnePercentLow ?? null,
|
||||
upscalerType: p.upscalerType,
|
||||
upscalerVersion: p.upscalerVersion,
|
||||
frameGenMethod: p.frameGenMethod,
|
||||
protonVersion: p.protonVersion,
|
||||
osVersion: p.osVersion,
|
||||
createdAt: p.createdAt.toISOString(),
|
||||
settingsJson: p.settingsJson,
|
||||
launchOptions: p.launchOptions,
|
||||
loadTimeSsd: p.loadTimeSsd ?? null,
|
||||
loadTimeSd: p.loadTimeSd ?? null,
|
||||
tdpWatts: p.tdpWatts ?? null,
|
||||
youtubeVideoId: p.youtubeVideoId ?? null,
|
||||
screenshots: screenshotsMap.get(p.id) || [],
|
||||
hardwareWattHours: hw?.wattHours ? Number(hw.wattHours) : null,
|
||||
hardwareDeviceType: hw?.deviceType ?? null,
|
||||
customSystem: p.customSystem ?? false,
|
||||
userNotes: p.userNotes,
|
||||
versionString: p.versionString ?? null,
|
||||
buildId: p.buildId ?? null,
|
||||
gameAntiCheatName: p.gameAntiCheatName ?? null,
|
||||
gameAntiCheatStatus: p.gameAntiCheatStatus ?? null,
|
||||
userId: p.userId,
|
||||
userName: p.userName,
|
||||
userImage: p.userImage,
|
||||
verifiedAt: p.verifiedAt ? p.verifiedAt.toISOString() : null,
|
||||
isPinned: p.isPinned,
|
||||
pinnedAt: p.pinnedAt ? p.pinnedAt.toISOString() : null,
|
||||
}
|
||||
})
|
||||
|
||||
// ── 6. Fetch platform support ────────────────────────────────
|
||||
const platformSupport = await db
|
||||
.select({
|
||||
id: gamePlatformSupport.id,
|
||||
hardwareSlug: gamePlatformSupport.hardwareSlug,
|
||||
isSupported: gamePlatformSupport.isSupported,
|
||||
protonStatus: gamePlatformSupport.protonStatus,
|
||||
antiCheatRelevant: gamePlatformSupport.antiCheatRelevant,
|
||||
antiCheatName: gamePlatformSupport.antiCheatName,
|
||||
antiCheatVersion: gamePlatformSupport.antiCheatVersion,
|
||||
antiCheatStatus: gamePlatformSupport.antiCheatStatus,
|
||||
playabilityStatus: gamePlatformSupport.playabilityStatus,
|
||||
playabilityOverride: gamePlatformSupport.playabilityOverride,
|
||||
playabilityCalculatedAt: gamePlatformSupport.playabilityCalculatedAt,
|
||||
createdAt: gamePlatformSupport.createdAt,
|
||||
updatedAt: gamePlatformSupport.updatedAt,
|
||||
})
|
||||
.from(gamePlatformSupport)
|
||||
.where(eq(gamePlatformSupport.gameId, gameId))
|
||||
|
||||
// ── 7. Fetch first page of comments ──────────────────────────
|
||||
const commentLimit = 20
|
||||
const commentConditions = [
|
||||
eq(gameComments.gameId, gameId),
|
||||
eq(gameComments.isRemoved, false),
|
||||
isNull(gameComments.parentId),
|
||||
]
|
||||
|
||||
const [commentData, [{ count: commentTotal }]] = await Promise.all([
|
||||
db
|
||||
.select({
|
||||
id: gameComments.id,
|
||||
gameId: gameComments.gameId,
|
||||
userId: gameComments.userId,
|
||||
parentId: gameComments.parentId,
|
||||
content: gameComments.content,
|
||||
upvotes: gameComments.upvotes,
|
||||
createdAt: gameComments.createdAt,
|
||||
updatedAt: gameComments.updatedAt,
|
||||
userName: user.name,
|
||||
userImage: user.image,
|
||||
})
|
||||
.from(gameComments)
|
||||
.innerJoin(user, eq(gameComments.userId, user.id))
|
||||
.where(and(...commentConditions))
|
||||
.orderBy(desc(gameComments.createdAt))
|
||||
.limit(commentLimit),
|
||||
db
|
||||
.select({ count: sql<number>`count(*)::int` })
|
||||
.from(gameComments)
|
||||
.where(and(...commentConditions)),
|
||||
])
|
||||
|
||||
// ── 8. Assemble response ─────────────────────────────────────
|
||||
return {
|
||||
game: {
|
||||
id: game.id,
|
||||
steamAppId: game.steamAppId,
|
||||
title: game.title,
|
||||
description: game.description,
|
||||
developer: game.developer,
|
||||
publisher: game.publisher,
|
||||
genres: game.genres,
|
||||
headerImage: game.headerImage,
|
||||
capsuleImage: game.capsuleImage,
|
||||
storeUrl: game.storeUrl,
|
||||
source: game.source,
|
||||
lastSync: game.lastSync?.toISOString() ?? null,
|
||||
syncStatus: game.syncStatus,
|
||||
createdAt: game.createdAt.toISOString(),
|
||||
systemRequirements: game.systemRequirements,
|
||||
metacriticScore: game.metacriticScore,
|
||||
metacriticUrl: game.metacriticUrl,
|
||||
recommendationsTotal: game.recommendationsTotal,
|
||||
priceCurrent: game.priceCurrent,
|
||||
priceInitial: game.priceInitial,
|
||||
priceCurrency: game.priceCurrency,
|
||||
isFree: game.isFree,
|
||||
releaseDate: game.releaseDate,
|
||||
categories: game.categories,
|
||||
platforms: game.platforms,
|
||||
steamReviewScore: game.steamReviewScore,
|
||||
steamReviewSentiment: game.steamReviewSentiment,
|
||||
steamReviewCount: game.steamReviewCount,
|
||||
},
|
||||
stats,
|
||||
presets,
|
||||
platformSupport,
|
||||
comments: {
|
||||
data: commentData,
|
||||
total: commentTotal,
|
||||
},
|
||||
}
|
||||
},
|
||||
{
|
||||
params: t.Object({ gameId: t.String() }),
|
||||
response: t.Union([
|
||||
t.Object({
|
||||
game: t.Any(),
|
||||
stats: t.Any(),
|
||||
presets: t.Array(t.Any()),
|
||||
platformSupport: t.Array(t.Any()),
|
||||
comments: t.Object({
|
||||
data: t.Array(t.Any()),
|
||||
total: t.Number(),
|
||||
}),
|
||||
}),
|
||||
t.Object({ error: t.String() }),
|
||||
]),
|
||||
},
|
||||
)
|
||||
// ── Mobile Search (DB-synced games only, no Steam results) ──────
|
||||
.get(
|
||||
"/search",
|
||||
async ({ query, set }) => {
|
||||
if (!query.q || query.q.length < 2) {
|
||||
set.status = 400
|
||||
return { error: "Query must be at least 2 characters" }
|
||||
}
|
||||
|
||||
const { ilike, or, sql: dsql, eq: deq, and: dand, desc: ddesc, inArray, gte } = await import("drizzle-orm")
|
||||
const { fuzzySearchTerm } = await import("@/lib/db/search")
|
||||
|
||||
const titleTerm = fuzzySearchTerm(query.q)
|
||||
const term = `%${query.q}%`
|
||||
|
||||
// Build filter conditions
|
||||
const baseFilterConditions = [
|
||||
or(
|
||||
ilike(games.title, titleTerm),
|
||||
ilike(games.developer, term),
|
||||
ilike(games.publisher, term),
|
||||
),
|
||||
]
|
||||
|
||||
if (query.playabilityStatus) {
|
||||
baseFilterConditions.push(dsql`${games.playabilityStatus} = ${query.playabilityStatus}`)
|
||||
}
|
||||
|
||||
// Search local database only
|
||||
const localGames = await db
|
||||
.select({
|
||||
id: games.id,
|
||||
steamAppId: games.steamAppId,
|
||||
title: games.title,
|
||||
capsuleImage: games.capsuleImage,
|
||||
headerImage: games.headerImage,
|
||||
playabilityStatus: games.playabilityStatus,
|
||||
steamReviewScore: games.steamReviewScore,
|
||||
})
|
||||
.from(games)
|
||||
.where(dand(...baseFilterConditions))
|
||||
.limit(40)
|
||||
|
||||
if (localGames.length === 0) {
|
||||
return { results: [], total: 0 }
|
||||
}
|
||||
|
||||
const gameIds = localGames.map((g) => g.id)
|
||||
|
||||
// Fetch benchmark counts
|
||||
const benchmarkCounts = await db
|
||||
.select({ gameId: gameVersions.gameId, count: sql<number>`count(*)::int` })
|
||||
.from(performanceEntries)
|
||||
.innerJoin(gameVersions, deq(performanceEntries.versionId, gameVersions.id))
|
||||
.where(dand(inArray(gameVersions.gameId, gameIds), deq(performanceEntries.isRemoved, false)))
|
||||
.groupBy(gameVersions.gameId)
|
||||
|
||||
const bmMap = new Map<string, number>()
|
||||
for (const r of benchmarkCounts) bmMap.set(r.gameId, r.count)
|
||||
|
||||
// Fetch comment counts
|
||||
const commentCounts = await db
|
||||
.select({ gameId: gameComments.gameId, count: sql<number>`count(*)::int` })
|
||||
.from(gameComments)
|
||||
.where(inArray(gameComments.gameId, gameIds))
|
||||
.groupBy(gameComments.gameId)
|
||||
|
||||
const cmMap = new Map<string, number>()
|
||||
for (const r of commentCounts) cmMap.set(r.gameId, r.count)
|
||||
|
||||
// Platform support (for playability status per game)
|
||||
const platformRows = await db
|
||||
.select({ gameId: gamePlatformSupport.gameId, hardwareSlug: gamePlatformSupport.hardwareSlug, protonStatus: gamePlatformSupport.protonStatus })
|
||||
.from(gamePlatformSupport)
|
||||
.where(inArray(gamePlatformSupport.gameId, gameIds))
|
||||
|
||||
const platformMap = new Map<string, string>()
|
||||
for (const r of platformRows) {
|
||||
const existing = platformMap.get(r.gameId)
|
||||
if (!existing || (!existing.startsWith("steamdeck") && r.hardwareSlug.startsWith("steamdeck"))) {
|
||||
platformMap.set(r.gameId, r.protonStatus)
|
||||
}
|
||||
}
|
||||
|
||||
// Performance stats (raw performer, poor performance, best FPS)
|
||||
const perfStats = await db
|
||||
.select({
|
||||
gameId: gameVersions.gameId,
|
||||
bestFps: sql<number>`MAX(${performanceEntries.fpsAvg})::real`,
|
||||
isRawPerformer: sql<boolean>`BOOL_OR(${performanceEntries.fpsAvg} >= 60 AND ${performanceEntries.upscalerType} = 'none' AND ${performanceEntries.frameGenMethod} = 'none')`,
|
||||
isPoorPerformance: sql<boolean>`BOOL_OR(${performanceEntries.fpsAvg} < 30)`,
|
||||
})
|
||||
.from(performanceEntries)
|
||||
.innerJoin(gameVersions, deq(performanceEntries.versionId, gameVersions.id))
|
||||
.where(dand(inArray(gameVersions.gameId, gameIds), deq(performanceEntries.isRemoved, false)))
|
||||
.groupBy(gameVersions.gameId)
|
||||
|
||||
const perfMap = new Map<string, { bestFps: number | null; isRawPerformer: boolean; isPoorPerformance: boolean }>()
|
||||
for (const r of perfStats) {
|
||||
perfMap.set(r.gameId, { bestFps: r.bestFps, isRawPerformer: r.isRawPerformer, isPoorPerformance: r.isPoorPerformance })
|
||||
}
|
||||
|
||||
// Battery estimates (handheld only)
|
||||
const batteryStats = await db
|
||||
.select({
|
||||
gameId: gameVersions.gameId,
|
||||
estimatedBatteryMin: sql<number>`ROUND((${hardware.wattHours}::real / ${performanceEntries.tdpWatts}) * 60)::int`,
|
||||
})
|
||||
.from(performanceEntries)
|
||||
.innerJoin(gameVersions, deq(performanceEntries.versionId, gameVersions.id))
|
||||
.innerJoin(hardware, deq(performanceEntries.hardwareSlug, hardware.slug))
|
||||
.where(
|
||||
dand(
|
||||
inArray(gameVersions.gameId, gameIds),
|
||||
deq(performanceEntries.isRemoved, false),
|
||||
deq(hardware.deviceType, "handheld"),
|
||||
dsql`${performanceEntries.tdpWatts} IS NOT NULL AND ${performanceEntries.tdpWatts} > 0`,
|
||||
dsql`${hardware.wattHours} IS NOT NULL`,
|
||||
),
|
||||
)
|
||||
.orderBy(ddesc(performanceEntries.fpsAvg))
|
||||
|
||||
const batteryMap = new Map<string, number>()
|
||||
const seen = new Set<string>()
|
||||
for (const r of batteryStats) {
|
||||
if (!seen.has(r.gameId)) { seen.add(r.gameId); batteryMap.set(r.gameId, r.estimatedBatteryMin) }
|
||||
}
|
||||
|
||||
// Build results
|
||||
const results = localGames.map((g) => {
|
||||
const p = perfMap.get(g.id)
|
||||
return {
|
||||
id: g.id,
|
||||
title: g.title,
|
||||
capsuleImage: g.capsuleImage,
|
||||
headerImage: g.headerImage,
|
||||
playabilityStatus: g.playabilityStatus ?? null,
|
||||
platformStatus: platformMap.get(g.id) ?? null,
|
||||
isRawPerformer: p?.isRawPerformer ?? false,
|
||||
isPoorPerformance: p?.isPoorPerformance ?? false,
|
||||
bestFps: p?.bestFps ?? null,
|
||||
estimatedBatteryMin: batteryMap.get(g.id) ?? null,
|
||||
benchmarkCount: bmMap.get(g.id) ?? 0,
|
||||
commentCount: cmMap.get(g.id) ?? 0,
|
||||
steamReviewScore: g.steamReviewScore ?? null,
|
||||
}
|
||||
})
|
||||
|
||||
return { results, total: results.length }
|
||||
},
|
||||
{
|
||||
query: t.Object({
|
||||
q: t.String(),
|
||||
playabilityStatus: t.Optional(t.String()),
|
||||
}),
|
||||
detail: {
|
||||
description: "Search synced games only — returns mobile-optimized results with performance tags. No Steam-only entries.",
|
||||
},
|
||||
response: t.Union([
|
||||
t.Object({
|
||||
results: t.Array(t.Object({
|
||||
id: t.String(),
|
||||
title: t.String(),
|
||||
capsuleImage: t.Union([t.String(), t.Null()]),
|
||||
headerImage: t.Union([t.String(), t.Null()]),
|
||||
playabilityStatus: t.Union([t.String(), t.Null()]),
|
||||
platformStatus: t.Union([t.String(), t.Null()]),
|
||||
isRawPerformer: t.Boolean(),
|
||||
isPoorPerformance: t.Boolean(),
|
||||
bestFps: t.Union([t.Number(), t.Null()]),
|
||||
estimatedBatteryMin: t.Union([t.Number(), t.Null()]),
|
||||
benchmarkCount: t.Number(),
|
||||
commentCount: t.Number(),
|
||||
steamReviewScore: t.Union([t.Number(), t.Null()]),
|
||||
})),
|
||||
total: t.Number(),
|
||||
}),
|
||||
t.Object({ error: t.String() }),
|
||||
]),
|
||||
},
|
||||
)
|
||||
// ── Benchmark Detail (structured sections) ────────────────────
|
||||
.get(
|
||||
"/benchmark/:entryId",
|
||||
async ({ params, set }) => {
|
||||
const { entryId } = params
|
||||
|
||||
const [entry] = await db
|
||||
.select({
|
||||
id: performanceEntries.id,
|
||||
fpsAvg: performanceEntries.fpsAvg,
|
||||
fpsLow: performanceEntries.fpsLow,
|
||||
fpsHigh: performanceEntries.fpsHigh,
|
||||
fpsOnePercentLow: performanceEntries.fpsOnePercentLow,
|
||||
hardwareSlug: performanceEntries.hardwareSlug,
|
||||
tdpWatts: performanceEntries.tdpWatts,
|
||||
upscalerType: performanceEntries.upscalerType,
|
||||
upscalerVersion: performanceEntries.upscalerVersion,
|
||||
frameGenMethod: performanceEntries.frameGenMethod,
|
||||
protonVersion: performanceEntries.protonVersion,
|
||||
osVersion: performanceEntries.osVersion,
|
||||
launchOptions: performanceEntries.launchOptions,
|
||||
loadTimeSsd: performanceEntries.loadTimeSsd,
|
||||
loadTimeSd: performanceEntries.loadTimeSd,
|
||||
youtubeVideoId: performanceEntries.youtubeVideoId,
|
||||
userNotes: performanceEntries.userNotes,
|
||||
customSystem: performanceEntries.customSystem,
|
||||
userId: performanceEntries.userId,
|
||||
verifiedAt: performanceEntries.verifiedAt,
|
||||
isPinned: performanceEntries.isPinned,
|
||||
createdAt: performanceEntries.createdAt,
|
||||
upvotes: performanceEntries.upvotes,
|
||||
downvotes: performanceEntries.downvotes,
|
||||
settingsJson: performanceEntries.settingsJson,
|
||||
hardwareName: hardware.name,
|
||||
hardwareDeviceType: hardware.deviceType,
|
||||
hardwareWattHours: hardware.wattHours,
|
||||
hardwareTdpMax: hardware.tdpMax,
|
||||
gameId: gameVersions.gameId,
|
||||
gameTitle: games.title,
|
||||
gameCapsuleImage: games.capsuleImage,
|
||||
versionString: gameVersions.versionString,
|
||||
buildId: gameVersions.buildId,
|
||||
userName: user.name,
|
||||
userImage: user.image,
|
||||
gameAntiCheatName: gamePlatformSupport.antiCheatName,
|
||||
gameAntiCheatStatus: gamePlatformSupport.antiCheatStatus,
|
||||
})
|
||||
.from(performanceEntries)
|
||||
.innerJoin(hardware, eq(performanceEntries.hardwareSlug, hardware.slug))
|
||||
.innerJoin(gameVersions, eq(performanceEntries.versionId, gameVersions.id))
|
||||
.innerJoin(games, eq(gameVersions.gameId, games.id))
|
||||
.innerJoin(user, eq(performanceEntries.userId, user.id))
|
||||
.innerJoin(
|
||||
gamePlatformSupport,
|
||||
and(
|
||||
eq(gamePlatformSupport.gameId, gameVersions.gameId),
|
||||
eq(gamePlatformSupport.hardwareSlug, performanceEntries.hardwareSlug),
|
||||
),
|
||||
)
|
||||
.where(and(eq(performanceEntries.id, entryId), eq(performanceEntries.isRemoved, false)))
|
||||
.limit(1)
|
||||
|
||||
if (!entry) {
|
||||
set.status = 404
|
||||
return { error: "Benchmark entry not found" }
|
||||
}
|
||||
|
||||
const publicUrl = getR2PublicUrl()
|
||||
const screenshotRows = await db
|
||||
.select({ id: entryScreenshots.id, storageKey: entryScreenshots.storageKey, orderIndex: entryScreenshots.orderIndex, width: entryScreenshots.width, height: entryScreenshots.height })
|
||||
.from(entryScreenshots)
|
||||
.where(eq(entryScreenshots.entryId, entryId))
|
||||
.orderBy(entryScreenshots.orderIndex)
|
||||
|
||||
const screenshots = screenshotRows.map((ss) => ({
|
||||
id: ss.id,
|
||||
url: `${publicUrl}/${ss.storageKey}`,
|
||||
width: ss.width,
|
||||
height: ss.height,
|
||||
orderIndex: ss.orderIndex,
|
||||
}))
|
||||
|
||||
// Compute battery estimates
|
||||
const wh = entry.hardwareWattHours ? Number(entry.hardwareWattHours) : null
|
||||
const tdpMax = entry.hardwareTdpMax ? Number(entry.hardwareTdpMax) : null
|
||||
const isHandheld = entry.hardwareDeviceType === "handheld"
|
||||
const estimatedBatteryHours = (isHandheld && wh && entry.tdpWatts) ? wh / Number(entry.tdpWatts) : null
|
||||
const estimatedBatteryMin = estimatedBatteryHours ? Math.round(estimatedBatteryHours * 60) : null
|
||||
const estimatedAtMaxTdpMin = (isHandheld && wh && tdpMax) ? Math.round((wh / tdpMax) * 60) : null
|
||||
|
||||
return {
|
||||
benchmark: {
|
||||
id: entry.id,
|
||||
gameId: entry.gameId,
|
||||
gameTitle: entry.gameTitle,
|
||||
gameCapsuleImage: entry.gameCapsuleImage,
|
||||
hardwareSlug: entry.hardwareSlug,
|
||||
hardwareName: entry.hardwareName,
|
||||
hardwareDeviceType: entry.hardwareDeviceType,
|
||||
createdAt: entry.createdAt.toISOString(),
|
||||
userName: entry.userName,
|
||||
userImage: entry.userImage,
|
||||
verifiedAt: entry.verifiedAt ? entry.verifiedAt.toISOString() : null,
|
||||
isPinned: entry.isPinned,
|
||||
upvotes: entry.upvotes,
|
||||
downvotes: entry.downvotes,
|
||||
},
|
||||
performance: {
|
||||
fpsAvg: entry.fpsAvg,
|
||||
fpsLow: entry.fpsLow,
|
||||
fpsHigh: entry.fpsHigh,
|
||||
fpsOnePercentLow: entry.fpsOnePercentLow ?? null,
|
||||
loadTimeSsd: entry.loadTimeSsd ?? null,
|
||||
loadTimeSd: entry.loadTimeSd ?? null,
|
||||
},
|
||||
hardwarePower: {
|
||||
tdpWatts: entry.tdpWatts ? Number(entry.tdpWatts) : null,
|
||||
hardwareWattHours: wh,
|
||||
estimatedBatteryHours,
|
||||
estimatedBatteryMin,
|
||||
estimatedAtMaxTdpMin,
|
||||
},
|
||||
software: {
|
||||
protonVersion: entry.protonVersion ?? null,
|
||||
osVersion: entry.osVersion ?? null,
|
||||
upscalerType: entry.upscalerType ?? null,
|
||||
upscalerVersion: entry.upscalerVersion ?? null,
|
||||
frameGenMethod: entry.frameGenMethod ?? null,
|
||||
launchOptions: entry.launchOptions ?? null,
|
||||
customSystem: entry.customSystem ?? false,
|
||||
},
|
||||
gameInfo: {
|
||||
versionString: entry.versionString ?? null,
|
||||
buildId: entry.buildId ?? null,
|
||||
gameAntiCheatName: entry.gameAntiCheatName ?? null,
|
||||
gameAntiCheatStatus: entry.gameAntiCheatStatus ?? null,
|
||||
},
|
||||
settingsJson: entry.settingsJson as any,
|
||||
screenshots,
|
||||
youtubeVideoId: entry.youtubeVideoId ?? null,
|
||||
userNotes: entry.userNotes ?? null,
|
||||
}
|
||||
},
|
||||
{
|
||||
params: t.Object({ entryId: t.String() }),
|
||||
detail: {
|
||||
description: "Full benchmark entry detail with structured sections for mobile display — Performance, Hardware & Power, Software, and Game Info.",
|
||||
},
|
||||
response: t.Union([
|
||||
t.Object({
|
||||
benchmark: t.Any(),
|
||||
performance: t.Any(),
|
||||
hardwarePower: t.Any(),
|
||||
software: t.Any(),
|
||||
gameInfo: t.Any(),
|
||||
settingsJson: t.Union([t.Array(t.Any()), t.Null()]),
|
||||
screenshots: t.Array(t.Any()),
|
||||
youtubeVideoId: t.Union([t.String(), t.Null()]),
|
||||
userNotes: t.Union([t.String(), t.Null()]),
|
||||
}),
|
||||
t.Object({ error: t.String() }),
|
||||
]),
|
||||
},
|
||||
)
|
||||
// ── Dashboard (consolidated home screen) ───────────────────────
|
||||
.get(
|
||||
"/dashboard",
|
||||
async () => {
|
||||
const { sql: dsql } = await import("drizzle-orm")
|
||||
|
||||
const SEVEN_DAYS_AGO = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000)
|
||||
|
||||
const recentBenchmarks = await db.execute(dsql`
|
||||
SELECT g.id, g.title, g.capsule_image, g.header_image, g.playability_status,
|
||||
COUNT(pe.id) AS benchmark_count, AVG(pe.fps_avg) AS avg_fps, MAX(pe.created_at) AS latest_benchmark_at
|
||||
FROM games g
|
||||
JOIN game_versions gv ON gv.game_id = g.id
|
||||
JOIN performance_entries pe ON pe.version_id = gv.id
|
||||
WHERE pe.is_removed = false
|
||||
GROUP BY g.id, g.title, g.capsule_image, g.header_image, g.playability_status
|
||||
ORDER BY MAX(pe.created_at) DESC
|
||||
LIMIT 10
|
||||
`)
|
||||
|
||||
const trending = await db.execute(dsql`
|
||||
WITH recent_benchmarks AS (
|
||||
SELECT gv.game_id, COUNT(*) AS cnt FROM performance_entries pe
|
||||
JOIN game_versions gv ON pe.version_id = gv.id
|
||||
WHERE pe.is_removed = false AND pe.created_at >= ${SEVEN_DAYS_AGO}
|
||||
GROUP BY gv.game_id
|
||||
),
|
||||
recent_comments AS (
|
||||
SELECT gc.game_id, COUNT(*) AS cnt FROM game_comments gc
|
||||
WHERE gc.is_removed = false AND gc.created_at >= ${SEVEN_DAYS_AGO}
|
||||
GROUP BY gc.game_id
|
||||
)
|
||||
SELECT g.id, g.title, g.capsule_image, g.header_image, g.playability_status,
|
||||
COALESCE(rb.cnt, 0) AS benchmark_count, COALESCE(rc.cnt, 0) AS comment_count,
|
||||
(COALESCE(rb.cnt, 0) * 3 + COALESCE(rc.cnt, 0) * 2) AS activity_score
|
||||
FROM games g
|
||||
LEFT JOIN recent_benchmarks rb ON rb.game_id = g.id
|
||||
LEFT JOIN recent_comments rc ON rc.game_id = g.id
|
||||
WHERE (rb.cnt IS NOT NULL OR rc.cnt IS NOT NULL)
|
||||
ORDER BY activity_score DESC
|
||||
LIMIT 10
|
||||
`)
|
||||
|
||||
const mostTested = await db.execute(dsql`
|
||||
SELECT g.id, g.title, g.capsule_image, g.header_image, g.playability_status,
|
||||
COUNT(pe.id) AS benchmark_count, AVG(pe.fps_avg) AS avg_fps
|
||||
FROM games g
|
||||
JOIN game_versions gv ON gv.game_id = g.id
|
||||
JOIN performance_entries pe ON pe.version_id = gv.id
|
||||
WHERE pe.is_removed = false
|
||||
GROUP BY g.id, g.title, g.capsule_image, g.header_image, g.playability_status
|
||||
ORDER BY benchmark_count DESC
|
||||
LIMIT 10
|
||||
`)
|
||||
|
||||
return {
|
||||
recentBenchmarks: recentBenchmarks.rows,
|
||||
trending: trending.rows,
|
||||
mostTested: mostTested.rows,
|
||||
}
|
||||
},
|
||||
{
|
||||
detail: {
|
||||
description: "Consolidated home screen data — recent benchmarks, trending, and most tested in one call.",
|
||||
},
|
||||
response: t.Object({
|
||||
recentBenchmarks: t.Array(t.Any()),
|
||||
trending: t.Array(t.Any()),
|
||||
mostTested: t.Array(t.Any()),
|
||||
}),
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,473 @@
|
||||
import { Elysia, t } from "elysia"
|
||||
import { db } from "@/lib/db/index"
|
||||
import {
|
||||
performanceEntries,
|
||||
gameVersions,
|
||||
hardware,
|
||||
gamePlatformSupport,
|
||||
entryScreenshots,
|
||||
storageObjects,
|
||||
} from "@/lib/db/schema"
|
||||
import type { GameSettingCategory } from "@/lib/db/schema"
|
||||
import { eq, and, sql } from "drizzle-orm"
|
||||
import { requireRole } from "@/lib/auth/guard"
|
||||
import { recalculatePlayability } from "./playability"
|
||||
import { uploadObject, deleteObject, isR2Configured } from "@/lib/storage/r2-client"
|
||||
import { processScreenshot, isAllowedMimeType, validateMagicBytes } from "@/lib/image-processing"
|
||||
|
||||
const MAX_SCREENSHOTS_PER_ENTRY = 2
|
||||
const MAX_UPLOAD_SIZE = 10 * 1024 * 1024 // 10 MB
|
||||
|
||||
export const performanceSubmitRoutes = new Elysia({ prefix: "/performance", detail: { tags: ["Performance"] } })
|
||||
.get(
|
||||
"/autocomplete",
|
||||
async ({ query }) => {
|
||||
const field = query.field
|
||||
|
||||
if (field !== "protonVersion" && field !== "osVersion") {
|
||||
return { data: [] }
|
||||
}
|
||||
|
||||
const results = await db
|
||||
.select({ value: performanceEntries[field] })
|
||||
.from(performanceEntries)
|
||||
.where(sql`${performanceEntries[field]} IS NOT NULL`)
|
||||
.groupBy(performanceEntries[field])
|
||||
.orderBy(sql`count(*) DESC`)
|
||||
.limit(20)
|
||||
|
||||
return {
|
||||
data: results
|
||||
.map((r) => r.value)
|
||||
.filter((v): v is string => v !== null),
|
||||
}
|
||||
},
|
||||
{
|
||||
query: t.Object({
|
||||
field: t.Union([
|
||||
t.Literal("protonVersion"),
|
||||
t.Literal("osVersion"),
|
||||
]),
|
||||
}),
|
||||
},
|
||||
)
|
||||
.get(
|
||||
"/hardware",
|
||||
async () => {
|
||||
const devices = await db
|
||||
.select({
|
||||
slug: hardware.slug,
|
||||
name: hardware.name,
|
||||
deviceType: hardware.deviceType,
|
||||
wattHours: hardware.wattHours,
|
||||
tdpMax: hardware.tdpMax,
|
||||
})
|
||||
.from(hardware)
|
||||
.orderBy(hardware.sortOrder)
|
||||
|
||||
return { data: devices }
|
||||
},
|
||||
)
|
||||
.post(
|
||||
"/submit",
|
||||
async ({ request, set }) => {
|
||||
const guard = await requireRole(request.headers, [
|
||||
"user",
|
||||
"contributor",
|
||||
"admin",
|
||||
])
|
||||
if (!guard.ok) {
|
||||
set.status = guard.status
|
||||
return { error: guard.error }
|
||||
}
|
||||
|
||||
// Parse multipart form data
|
||||
let formData: FormData
|
||||
try {
|
||||
formData = await request.formData()
|
||||
} catch {
|
||||
set.status = 400
|
||||
return { error: "Invalid multipart/form-data" }
|
||||
}
|
||||
|
||||
// Extract and parse the JSON payload field
|
||||
const payloadField = formData.get("payload")
|
||||
if (!payloadField || typeof payloadField !== "string") {
|
||||
set.status = 400
|
||||
return { error: "Missing or invalid payload field" }
|
||||
}
|
||||
|
||||
let payload: Record<string, unknown>
|
||||
try {
|
||||
payload = JSON.parse(payloadField)
|
||||
} catch {
|
||||
set.status = 400
|
||||
return { error: "Invalid JSON in payload field" }
|
||||
}
|
||||
|
||||
// Extract fields from payload
|
||||
const versionId = payload.versionId as string
|
||||
const hardwareSlug = payload.hardwareSlug as string
|
||||
const fpsAvg = payload.fpsAvg as number
|
||||
const fpsOnePercentLow = (payload.fpsOnePercentLow as number | null | undefined) ?? null
|
||||
const fpsLow = (payload.fpsLow as number | null | undefined) ?? null
|
||||
const fpsHigh = (payload.fpsHigh as number | null | undefined) ?? null
|
||||
const protonVersion = (payload.protonVersion as string | null | undefined) ?? null
|
||||
const osVersion = (payload.osVersion as string | null | undefined) ?? null
|
||||
const VALID_UPSCALER_TYPES = ["none", "fsr", "dlss", "xess", "lsfg", "other"] as const
|
||||
const VALID_FRAME_GEN_METHODS = ["none", "fsr_fg", "dlss_fg", "lsfg", "other"] as const
|
||||
const VALID_ANTICHEAT_STATUSES = ["none", "supported", "unsupported", "unknown"] as const
|
||||
type UpscalerType = (typeof VALID_UPSCALER_TYPES)[number]
|
||||
type FrameGenMethod = (typeof VALID_FRAME_GEN_METHODS)[number]
|
||||
type AntiCheatStatus = (typeof VALID_ANTICHEAT_STATUSES)[number]
|
||||
|
||||
const rawUpscalerType = payload.upscalerType as string | undefined
|
||||
const upscalerType: UpscalerType = rawUpscalerType && VALID_UPSCALER_TYPES.includes(rawUpscalerType as UpscalerType) ? (rawUpscalerType as UpscalerType) : "none"
|
||||
const upscalerVersion = (payload.upscalerVersion as string | null | undefined) ?? null
|
||||
const customSystem = (payload.customSystem as boolean | undefined) ?? false
|
||||
const rawFrameGenMethod = payload.frameGenMethod as string | undefined
|
||||
const frameGenMethod: FrameGenMethod = rawFrameGenMethod && VALID_FRAME_GEN_METHODS.includes(rawFrameGenMethod as FrameGenMethod) ? (rawFrameGenMethod as FrameGenMethod) : "none"
|
||||
const loadTimeSsd = (payload.loadTimeSsd as number | null | undefined) ?? null
|
||||
const loadTimeSd = (payload.loadTimeSd as number | null | undefined) ?? null
|
||||
const tdpWatts = (payload.tdpWatts as number | null | undefined) ?? null
|
||||
const youtubeVideoId = (payload.youtubeVideoId as string | null | undefined) ?? null
|
||||
const launchOptions = (payload.launchOptions as string | null | undefined) ?? null
|
||||
const settingsJson = (payload.settingsJson as GameSettingCategory[] | null | undefined) ?? null
|
||||
const userNotes = (payload.userNotes as string | null | undefined) ?? null
|
||||
const antiCheatRelevant = (payload.antiCheatRelevant as boolean | undefined) ?? false
|
||||
const antiCheatName = (payload.antiCheatName as string | null | undefined) ?? null
|
||||
const rawAntiCheatStatus = payload.antiCheatStatus as string | undefined
|
||||
const antiCheatStatus: AntiCheatStatus = rawAntiCheatStatus && VALID_ANTICHEAT_STATUSES.includes(rawAntiCheatStatus as AntiCheatStatus) ? (rawAntiCheatStatus as AntiCheatStatus) : "unknown"
|
||||
|
||||
// Validate required fields
|
||||
if (!versionId || !hardwareSlug || fpsAvg === undefined || fpsAvg === null) {
|
||||
set.status = 400
|
||||
return { error: "Missing required fields: versionId, hardwareSlug, fpsAvg" }
|
||||
}
|
||||
|
||||
// ── Validation: fpsAvg bounds ───────────────────────────────
|
||||
if (typeof fpsAvg !== "number" || fpsAvg < 1 || fpsAvg > 500) {
|
||||
set.status = 400
|
||||
return { error: "fpsAvg must be between 1 and 500" }
|
||||
}
|
||||
|
||||
// ── Validation: optional FPS bounds ─────────────────────────
|
||||
if (fpsLow !== null && (fpsLow < 0 || fpsLow > 500)) {
|
||||
set.status = 400
|
||||
return { error: "fpsLow must be between 0 and 500" }
|
||||
}
|
||||
if (fpsHigh !== null && (fpsHigh < 0 || fpsHigh > 500)) {
|
||||
set.status = 400
|
||||
return { error: "fpsHigh must be between 0 and 500" }
|
||||
}
|
||||
if (fpsOnePercentLow !== null && (fpsOnePercentLow < 0 || fpsOnePercentLow > 500)) {
|
||||
set.status = 400
|
||||
return { error: "fpsOnePercentLow must be between 0 and 500" }
|
||||
}
|
||||
|
||||
// ── Validation: settingsJson size limits ────────────────────
|
||||
if (settingsJson) {
|
||||
if (!Array.isArray(settingsJson)) {
|
||||
set.status = 400
|
||||
return { error: "settingsJson must be an array" }
|
||||
}
|
||||
if (settingsJson.length > 20) {
|
||||
set.status = 400
|
||||
return { error: "Maximum 20 settings categories allowed" }
|
||||
}
|
||||
for (const cat of settingsJson) {
|
||||
if (cat.settings && Array.isArray(cat.settings) && cat.settings.length > 50) {
|
||||
set.status = 400
|
||||
return { error: `Maximum 50 settings per category (exceeded in "${cat.category}")` }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Validation: userNotes length ────────────────────────────
|
||||
if (userNotes && typeof userNotes === "string" && userNotes.length > 5000) {
|
||||
set.status = 400
|
||||
return { error: "userNotes must be 5000 characters or less" }
|
||||
}
|
||||
|
||||
// Verify the game version exists
|
||||
const [version] = await db
|
||||
.select({ id: gameVersions.id, gameId: gameVersions.gameId })
|
||||
.from(gameVersions)
|
||||
.where(eq(gameVersions.id, versionId))
|
||||
.limit(1)
|
||||
|
||||
if (!version) {
|
||||
set.status = 404
|
||||
return { error: "Game version not found" }
|
||||
}
|
||||
|
||||
// Verify hardware exists
|
||||
const [device] = await db
|
||||
.select({ slug: hardware.slug })
|
||||
.from(hardware)
|
||||
.where(eq(hardware.slug, hardwareSlug))
|
||||
.limit(1)
|
||||
|
||||
if (!device) {
|
||||
set.status = 404
|
||||
return { error: "Hardware not found" }
|
||||
}
|
||||
|
||||
// Validate YouTube video ID format (11 alphanumeric + dash/underscore)
|
||||
if (youtubeVideoId) {
|
||||
const ytId = youtubeVideoId.trim()
|
||||
if (!/^[a-zA-Z0-9_-]{11}$/.test(ytId)) {
|
||||
set.status = 400
|
||||
return { error: "Invalid YouTube video ID format (must be 11 characters)" }
|
||||
}
|
||||
}
|
||||
|
||||
// Validate TDP
|
||||
if (tdpWatts !== null && tdpWatts <= 0) {
|
||||
set.status = 400
|
||||
return { error: "TDP must be greater than 0" }
|
||||
}
|
||||
|
||||
// ── Process screenshots ──────────────────────────────────────────
|
||||
const screenshotFiles = formData.getAll("screenshots").filter((f): f is File => f instanceof File)
|
||||
|
||||
if (screenshotFiles.length > MAX_SCREENSHOTS_PER_ENTRY) {
|
||||
set.status = 400
|
||||
return { error: `Maximum ${MAX_SCREENSHOTS_PER_ENTRY} screenshots allowed` }
|
||||
}
|
||||
|
||||
// Process all screenshots with sharp BEFORE any DB writes
|
||||
const processedScreenshots: Array<{
|
||||
buffer: Buffer
|
||||
width: number
|
||||
height: number
|
||||
mimeType: string
|
||||
size: number
|
||||
originalName: string | null
|
||||
}> = []
|
||||
|
||||
for (const file of screenshotFiles) {
|
||||
if (file.size > MAX_UPLOAD_SIZE) {
|
||||
set.status = 400
|
||||
return { error: `Screenshot "${file.name}" exceeds 10 MB limit` }
|
||||
}
|
||||
|
||||
if (!isAllowedMimeType(file.type)) {
|
||||
set.status = 400
|
||||
return { error: `Screenshot "${file.name}" has unsupported MIME type: ${file.type}` }
|
||||
}
|
||||
|
||||
const arrayBuffer = await file.arrayBuffer()
|
||||
const rawBuffer = Buffer.from(arrayBuffer)
|
||||
|
||||
if (!validateMagicBytes(rawBuffer, file.type)) {
|
||||
set.status = 400
|
||||
return { error: `Screenshot "${file.name}" content does not match declared type` }
|
||||
}
|
||||
|
||||
try {
|
||||
const processed = await processScreenshot(rawBuffer, file.type)
|
||||
processedScreenshots.push({
|
||||
...processed,
|
||||
originalName: file.name || null,
|
||||
})
|
||||
} catch (err) {
|
||||
set.status = 400
|
||||
return { error: `Failed to process screenshot "${file.name}": ${err instanceof Error ? err.message : "Unknown error"}` }
|
||||
}
|
||||
}
|
||||
|
||||
// ── Create performance entry ────────────────────────────────────
|
||||
const [entry] = await db
|
||||
.insert(performanceEntries)
|
||||
.values({
|
||||
versionId,
|
||||
hardwareSlug,
|
||||
userId: guard.user.id,
|
||||
fpsAvg,
|
||||
fpsOnePercentLow,
|
||||
fpsLow,
|
||||
fpsHigh,
|
||||
protonVersion,
|
||||
osVersion,
|
||||
upscalerType,
|
||||
upscalerVersion,
|
||||
customSystem,
|
||||
frameGenMethod,
|
||||
loadTimeSsd,
|
||||
loadTimeSd,
|
||||
tdpWatts,
|
||||
youtubeVideoId: youtubeVideoId ? youtubeVideoId.trim() : null,
|
||||
launchOptions,
|
||||
settingsJson,
|
||||
userNotes,
|
||||
})
|
||||
.returning()
|
||||
|
||||
// ── Upload screenshots to R2 ─────────────────────────────────────
|
||||
const uploadedKeys: string[] = []
|
||||
const uploadedStorageIds: string[] = []
|
||||
const screenshotResults: Array<{
|
||||
id: string
|
||||
storageKey: string
|
||||
url: string
|
||||
width: number
|
||||
height: number
|
||||
mimeType: string
|
||||
size: number
|
||||
originalName: string | null
|
||||
orderIndex: number
|
||||
}> = []
|
||||
|
||||
let uploadFailed = false
|
||||
let uploadError = ""
|
||||
|
||||
for (let i = 0; i < processedScreenshots.length; i++) {
|
||||
const shot = processedScreenshots[i]
|
||||
const r2Key = `screenshots/${entry.id}/${crypto.randomUUID()}.jpg`
|
||||
|
||||
try {
|
||||
if (!isR2Configured()) {
|
||||
throw new Error("R2 storage is not configured")
|
||||
}
|
||||
const url = await uploadObject(r2Key, shot.buffer, shot.mimeType, {
|
||||
entryId: entry.id,
|
||||
orderIndex: String(i),
|
||||
})
|
||||
uploadedKeys.push(r2Key)
|
||||
|
||||
// Insert into storageObjects
|
||||
const [storageObj] = await db
|
||||
.insert(storageObjects)
|
||||
.values({
|
||||
key: r2Key,
|
||||
bucket: "deckyvault",
|
||||
size: shot.size,
|
||||
mimeType: shot.mimeType,
|
||||
entityType: "entry_screenshot",
|
||||
entityId: entry.id,
|
||||
uploadedBy: guard.user.id,
|
||||
})
|
||||
.returning()
|
||||
uploadedStorageIds.push(storageObj.id)
|
||||
|
||||
// Insert into entryScreenshots
|
||||
const [screenshotRow] = await db
|
||||
.insert(entryScreenshots)
|
||||
.values({
|
||||
entryId: entry.id,
|
||||
storageKey: r2Key,
|
||||
orderIndex: i,
|
||||
mimeType: shot.mimeType,
|
||||
width: shot.width,
|
||||
height: shot.height,
|
||||
originalName: shot.originalName,
|
||||
})
|
||||
.returning()
|
||||
|
||||
screenshotResults.push({
|
||||
id: screenshotRow.id,
|
||||
storageKey: r2Key,
|
||||
url,
|
||||
width: shot.width,
|
||||
height: shot.height,
|
||||
mimeType: shot.mimeType,
|
||||
size: shot.size,
|
||||
originalName: shot.originalName,
|
||||
orderIndex: i,
|
||||
})
|
||||
} catch (err) {
|
||||
uploadFailed = true
|
||||
uploadError = err instanceof Error ? err.message : "Upload failed"
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Roll back if any upload failed
|
||||
if (uploadFailed) {
|
||||
// Delete uploaded R2 objects
|
||||
for (const key of uploadedKeys) {
|
||||
try {
|
||||
await deleteObject(key)
|
||||
} catch {
|
||||
// Best-effort cleanup
|
||||
}
|
||||
}
|
||||
|
||||
// Delete storageObjects rows
|
||||
for (const id of uploadedStorageIds) {
|
||||
try {
|
||||
await db.delete(storageObjects).where(eq(storageObjects.id, id))
|
||||
} catch {
|
||||
// Best-effort cleanup
|
||||
}
|
||||
}
|
||||
|
||||
// Delete entry screenshots (should cascade, but be explicit)
|
||||
try {
|
||||
await db.delete(entryScreenshots).where(eq(entryScreenshots.entryId, entry.id))
|
||||
} catch {
|
||||
// Best-effort cleanup
|
||||
}
|
||||
|
||||
// Delete the performance entry
|
||||
try {
|
||||
await db.delete(performanceEntries).where(eq(performanceEntries.id, entry.id))
|
||||
} catch {
|
||||
// Best-effort cleanup
|
||||
}
|
||||
|
||||
set.status = 500
|
||||
return { error: `Screenshot upload failed: ${uploadError}` }
|
||||
}
|
||||
|
||||
// ── Update / create gamePlatformSupport ──────────────────────────
|
||||
const [existingSupport] = await db
|
||||
.select()
|
||||
.from(gamePlatformSupport)
|
||||
.where(
|
||||
and(
|
||||
eq(gamePlatformSupport.gameId, version.gameId),
|
||||
eq(gamePlatformSupport.hardwareSlug, hardwareSlug),
|
||||
),
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
if (existingSupport) {
|
||||
await db
|
||||
.update(gamePlatformSupport)
|
||||
.set({
|
||||
antiCheatRelevant: antiCheatRelevant ?? existingSupport.antiCheatRelevant,
|
||||
antiCheatName: antiCheatRelevant
|
||||
? (antiCheatName ?? existingSupport.antiCheatName)
|
||||
: null,
|
||||
antiCheatStatus: antiCheatStatus ?? existingSupport.antiCheatStatus,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(gamePlatformSupport.id, existingSupport.id))
|
||||
} else {
|
||||
await db.insert(gamePlatformSupport).values({
|
||||
gameId: version.gameId,
|
||||
hardwareSlug,
|
||||
isSupported: true,
|
||||
protonStatus: "unknown",
|
||||
antiCheatRelevant,
|
||||
antiCheatName: antiCheatRelevant ? antiCheatName : null,
|
||||
antiCheatStatus,
|
||||
playabilityStatus: "unknown",
|
||||
})
|
||||
}
|
||||
|
||||
// Recalculate playability for this game (fire and forget)
|
||||
recalculatePlayability(version.gameId).catch((err) =>
|
||||
console.error("Failed to recalculate playability:", err),
|
||||
)
|
||||
|
||||
set.status = 201
|
||||
return {
|
||||
id: entry.id,
|
||||
createdAt: entry.createdAt.toISOString(),
|
||||
screenshots: screenshotResults,
|
||||
}
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,756 @@
|
||||
import { Elysia, t } from "elysia"
|
||||
import { createCrudRoutes } from "./crud-builder"
|
||||
import { performanceEntries, games, gameVersions, hardware, user, gamePlatformSupport, entryScreenshots, storageObjects } from "@/lib/db/schema"
|
||||
import { db } from "@/lib/db/index"
|
||||
import { eq, and, desc, sql } from "drizzle-orm"
|
||||
import { requireRole } from "@/lib/auth/guard"
|
||||
import { checkAndAutoPin } from "./auto-pin"
|
||||
import { uploadObject, deleteObject, isR2Configured } from "@/lib/storage/r2-client"
|
||||
import { processScreenshot, isAllowedMimeType, validateMagicBytes } from "@/lib/image-processing"
|
||||
|
||||
const MAX_SCREENSHOTS_PER_ENTRY = 2
|
||||
const MAX_UPLOAD_SIZE = 10 * 1024 * 1024 // 10 MB
|
||||
|
||||
// ── Performance Entries CRUD ──────────────────────────────────────
|
||||
export const performanceRoutes = createCrudRoutes(performanceEntries, {
|
||||
prefix: "/performance",
|
||||
name: "Performance Entry",
|
||||
tags: ["Performance"],
|
||||
auth: { read: "public", write: "user", delete: "admin" },
|
||||
softDelete: true,
|
||||
search: { fields: ["userNotes"] },
|
||||
filter: { fields: ["hardwareSlug", "upscalerType", "upscalerVersion", "frameGenMethod"] },
|
||||
})
|
||||
|
||||
// ── Verify endpoint (admin/mod) ───────────────────────────────────
|
||||
export const performanceVerifyRoutes = new Elysia({
|
||||
prefix: "/performance",
|
||||
detail: { tags: ["Performance"] },
|
||||
})
|
||||
.post(
|
||||
"/:id/verify",
|
||||
async ({ params, request, set }) => {
|
||||
const guard = await requireRole(request.headers, ["contributor", "admin"])
|
||||
if (!guard.ok) {
|
||||
set.status = guard.status
|
||||
return { error: guard.error }
|
||||
}
|
||||
|
||||
const [entry] = await db
|
||||
.select()
|
||||
.from(performanceEntries)
|
||||
.where(eq(performanceEntries.id, params.id))
|
||||
.limit(1)
|
||||
|
||||
if (!entry) {
|
||||
set.status = 404
|
||||
return { error: "Performance entry not found" }
|
||||
}
|
||||
|
||||
if (entry.verifiedAt) {
|
||||
set.status = 409
|
||||
return { error: "Entry already verified" }
|
||||
}
|
||||
|
||||
const [updated] = await db
|
||||
.update(performanceEntries)
|
||||
.set({
|
||||
verifiedAt: new Date(),
|
||||
verifiedBy: guard.user.id,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(performanceEntries.id, params.id))
|
||||
.returning()
|
||||
|
||||
return updated
|
||||
},
|
||||
{
|
||||
params: t.Object({ id: t.String() }),
|
||||
},
|
||||
)
|
||||
// ── Upvote ────────────────────────────────────────────────────────
|
||||
.post(
|
||||
"/:id/upvote",
|
||||
async ({ params, request, set }) => {
|
||||
const guard = await requireRole(request.headers, [
|
||||
"user",
|
||||
"contributor",
|
||||
"admin",
|
||||
])
|
||||
if (!guard.ok) {
|
||||
set.status = guard.status
|
||||
return { error: guard.error }
|
||||
}
|
||||
|
||||
const [updated] = await db
|
||||
.update(performanceEntries)
|
||||
.set({
|
||||
upvotes: sql`${performanceEntries.upvotes} + 1`,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(performanceEntries.id, params.id),
|
||||
eq(performanceEntries.isRemoved, false),
|
||||
),
|
||||
)
|
||||
.returning()
|
||||
|
||||
if (!updated) {
|
||||
set.status = 404
|
||||
return { error: "Performance entry not found" }
|
||||
}
|
||||
|
||||
// Auto-pin check (fire and forget, result doesn't affect response)
|
||||
checkAndAutoPin(updated.id).catch((err) =>
|
||||
console.error("Auto-pin check failed:", err),
|
||||
)
|
||||
|
||||
return updated
|
||||
},
|
||||
{
|
||||
params: t.Object({ id: t.String() }),
|
||||
},
|
||||
)
|
||||
// ── Downvote ──────────────────────────────────────────────────────
|
||||
.post(
|
||||
"/:id/downvote",
|
||||
async ({ params, request, set }) => {
|
||||
const guard = await requireRole(request.headers, [
|
||||
"user",
|
||||
"contributor",
|
||||
"admin",
|
||||
])
|
||||
if (!guard.ok) {
|
||||
set.status = guard.status
|
||||
return { error: guard.error }
|
||||
}
|
||||
|
||||
const [updated] = await db
|
||||
.update(performanceEntries)
|
||||
.set({
|
||||
downvotes: sql`${performanceEntries.downvotes} + 1`,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(performanceEntries.id, params.id),
|
||||
eq(performanceEntries.isRemoved, false),
|
||||
),
|
||||
)
|
||||
.returning()
|
||||
|
||||
if (!updated) {
|
||||
set.status = 404
|
||||
return { error: "Performance entry not found" }
|
||||
}
|
||||
|
||||
// Auto-pin check (fire and forget, result doesn't affect response)
|
||||
checkAndAutoPin(updated.id).catch((err) =>
|
||||
console.error("Auto-pin check failed:", err),
|
||||
)
|
||||
|
||||
return updated
|
||||
},
|
||||
{
|
||||
params: t.Object({ id: t.String() }),
|
||||
},
|
||||
)
|
||||
// ── Pin a preset (admin only) ──────────────────────────────────────
|
||||
.post(
|
||||
"/:id/pin",
|
||||
async ({ request, params, set }) => {
|
||||
const guard = await requireRole(request.headers, ["admin"])
|
||||
if (!guard.ok) { set.status = guard.status; return { error: guard.error } }
|
||||
|
||||
const [entry] = await db
|
||||
.update(performanceEntries)
|
||||
.set({ isPinned: true, pinnedAt: new Date() })
|
||||
.where(eq(performanceEntries.id, params.id))
|
||||
.returning()
|
||||
|
||||
if (!entry) { set.status = 404; return { error: "Entry not found" } }
|
||||
return { entry }
|
||||
},
|
||||
{ params: t.Object({ id: t.String() }) },
|
||||
)
|
||||
// ── Unpin a preset (admin only) ────────────────────────────────────
|
||||
.delete(
|
||||
"/:id/pin",
|
||||
async ({ request, params, set }) => {
|
||||
const guard = await requireRole(request.headers, ["admin"])
|
||||
if (!guard.ok) { set.status = guard.status; return { error: guard.error } }
|
||||
|
||||
const [entry] = await db
|
||||
.update(performanceEntries)
|
||||
.set({ isPinned: false, pinnedAt: null })
|
||||
.where(eq(performanceEntries.id, params.id))
|
||||
.returning()
|
||||
|
||||
if (!entry) { set.status = 404; return { error: "Entry not found" } }
|
||||
return { entry }
|
||||
},
|
||||
{ params: t.Object({ id: t.String() }) },
|
||||
)
|
||||
// ── User-scoped soft delete (owner or admin) ────────────────────
|
||||
.delete(
|
||||
"/:id/user-delete",
|
||||
async ({ params, body, request, set }) => {
|
||||
const guard = await requireRole(request.headers, [
|
||||
"user",
|
||||
"contributor",
|
||||
"admin",
|
||||
])
|
||||
if (!guard.ok) {
|
||||
set.status = guard.status
|
||||
return { error: guard.error }
|
||||
}
|
||||
|
||||
const [entry] = await db
|
||||
.select({
|
||||
id: performanceEntries.id,
|
||||
userId: performanceEntries.userId,
|
||||
})
|
||||
.from(performanceEntries)
|
||||
.where(eq(performanceEntries.id, params.id))
|
||||
.limit(1)
|
||||
|
||||
if (!entry) {
|
||||
set.status = 404
|
||||
return { error: "Performance entry not found" }
|
||||
}
|
||||
|
||||
if (entry.userId !== guard.user.id && guard.user.role !== "admin") {
|
||||
set.status = 403
|
||||
return { error: "Not authorized to delete this entry" }
|
||||
}
|
||||
|
||||
const reason = body?.reason as string | undefined
|
||||
|
||||
const [updated] = await db
|
||||
.update(performanceEntries)
|
||||
.set({
|
||||
isRemoved: true,
|
||||
removedReason: reason ?? "User deleted",
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(performanceEntries.id, params.id))
|
||||
.returning()
|
||||
|
||||
if (!updated) {
|
||||
set.status = 404
|
||||
return { error: "Performance entry not found" }
|
||||
}
|
||||
|
||||
return { success: true }
|
||||
},
|
||||
{
|
||||
params: t.Object({ id: t.String() }),
|
||||
body: t.Optional(t.Object({ reason: t.Optional(t.String()) })),
|
||||
},
|
||||
)
|
||||
// ── Edit entry (owner or admin) ───────────────────────────────────
|
||||
.patch(
|
||||
"/:id/edit",
|
||||
async ({ params, request, set }) => {
|
||||
const guard = await requireRole(request.headers, [
|
||||
"user",
|
||||
"contributor",
|
||||
"admin",
|
||||
])
|
||||
if (!guard.ok) {
|
||||
set.status = guard.status
|
||||
return { error: guard.error }
|
||||
}
|
||||
|
||||
const [entry] = await db
|
||||
.select({
|
||||
id: performanceEntries.id,
|
||||
userId: performanceEntries.userId,
|
||||
})
|
||||
.from(performanceEntries)
|
||||
.where(eq(performanceEntries.id, params.id))
|
||||
.limit(1)
|
||||
|
||||
if (!entry) {
|
||||
set.status = 404
|
||||
return { error: "Performance entry not found" }
|
||||
}
|
||||
|
||||
if (entry.userId !== guard.user.id && guard.user.role !== "admin") {
|
||||
set.status = 403
|
||||
return { error: "Not authorized to edit this entry" }
|
||||
}
|
||||
|
||||
// Parse multipart/form-data
|
||||
let formData: FormData
|
||||
try {
|
||||
formData = await request.formData()
|
||||
} catch {
|
||||
set.status = 400
|
||||
return { error: "Invalid multipart/form-data" }
|
||||
}
|
||||
|
||||
// Extract and parse the JSON payload field
|
||||
const payloadStr = formData.get("payload")
|
||||
if (!payloadStr || typeof payloadStr !== "string") {
|
||||
set.status = 400
|
||||
return { error: "Missing or invalid payload field" }
|
||||
}
|
||||
|
||||
let payload: Record<string, unknown>
|
||||
try {
|
||||
payload = JSON.parse(payloadStr)
|
||||
} catch {
|
||||
set.status = 400
|
||||
return { error: "Invalid JSON in payload field" }
|
||||
}
|
||||
|
||||
// Extract screenshot files
|
||||
const screenshotFiles = formData.getAll("screenshots").filter((f): f is File => f instanceof File)
|
||||
|
||||
// ── Process new screenshots with sharp BEFORE any DB changes ──
|
||||
const processedScreenshots: Array<{
|
||||
buffer: Buffer
|
||||
width: number
|
||||
height: number
|
||||
mimeType: string
|
||||
size: number
|
||||
originalName: string | null
|
||||
}> = []
|
||||
|
||||
if (screenshotFiles.length > MAX_SCREENSHOTS_PER_ENTRY) {
|
||||
set.status = 400
|
||||
return { error: `Maximum ${MAX_SCREENSHOTS_PER_ENTRY} screenshots allowed` }
|
||||
}
|
||||
|
||||
for (const file of screenshotFiles) {
|
||||
if (file.size > MAX_UPLOAD_SIZE) {
|
||||
set.status = 400
|
||||
return { error: `Screenshot "${file.name}" exceeds 10 MB limit` }
|
||||
}
|
||||
|
||||
if (!isAllowedMimeType(file.type)) {
|
||||
set.status = 400
|
||||
return { error: `Screenshot "${file.name}" has unsupported MIME type: ${file.type}` }
|
||||
}
|
||||
|
||||
const arrayBuffer = await file.arrayBuffer()
|
||||
const rawBuffer = Buffer.from(arrayBuffer)
|
||||
|
||||
if (!validateMagicBytes(rawBuffer, file.type)) {
|
||||
set.status = 400
|
||||
return { error: `Screenshot "${file.name}" content does not match declared type` }
|
||||
}
|
||||
|
||||
try {
|
||||
const processed = await processScreenshot(rawBuffer, file.type)
|
||||
processedScreenshots.push({
|
||||
...processed,
|
||||
originalName: file.name || null,
|
||||
})
|
||||
} catch (err) {
|
||||
set.status = 400
|
||||
return { error: `Failed to process screenshot "${file.name}": ${err instanceof Error ? err.message : "Unknown error"}` }
|
||||
}
|
||||
}
|
||||
|
||||
// ── Build updateData from payload ────────────────────────────────
|
||||
const updateData: Partial<typeof performanceEntries.$inferInsert> = {
|
||||
updatedAt: new Date(),
|
||||
}
|
||||
|
||||
if (payload.fpsAvg !== undefined) updateData.fpsAvg = payload.fpsAvg as number | undefined
|
||||
if (payload.fpsOnePercentLow !== undefined) updateData.fpsOnePercentLow = payload.fpsOnePercentLow as number | undefined
|
||||
if (payload.fpsLow !== undefined) updateData.fpsLow = payload.fpsLow as number | undefined
|
||||
if (payload.fpsHigh !== undefined) updateData.fpsHigh = payload.fpsHigh as number | undefined
|
||||
if (payload.protonVersion !== undefined)
|
||||
updateData.protonVersion = payload.protonVersion as string | null
|
||||
if (payload.osVersion !== undefined)
|
||||
updateData.osVersion = payload.osVersion as string | null
|
||||
if (payload.upscalerType !== undefined)
|
||||
updateData.upscalerType = (payload.upscalerType as "none" | "fsr" | "dlss" | "xess" | "lsfg" | "other" | null) ?? "none"
|
||||
if (payload.upscalerVersion !== undefined)
|
||||
updateData.upscalerVersion = payload.upscalerVersion as string | null
|
||||
if (payload.frameGenMethod !== undefined)
|
||||
updateData.frameGenMethod = (payload.frameGenMethod as "none" | "fsr_fg" | "dlss_fg" | "lsfg" | "other" | null) ?? "none"
|
||||
if (payload.launchOptions !== undefined)
|
||||
updateData.launchOptions = payload.launchOptions as string | null
|
||||
if (payload.settingsJson !== undefined)
|
||||
updateData.settingsJson = payload.settingsJson as typeof performanceEntries.$inferInsert["settingsJson"]
|
||||
if (payload.userNotes !== undefined)
|
||||
updateData.userNotes = payload.userNotes as string | null
|
||||
if (payload.tdpWatts !== undefined) updateData.tdpWatts = payload.tdpWatts as number | undefined
|
||||
if (payload.youtubeVideoId !== undefined) {
|
||||
const ytIdRaw = payload.youtubeVideoId as string | null
|
||||
if (ytIdRaw !== null) {
|
||||
const ytId = ytIdRaw.trim()
|
||||
if (!/^[a-zA-Z0-9_-]{11}$/.test(ytId)) {
|
||||
set.status = 400
|
||||
return { error: "Invalid YouTube video ID format" }
|
||||
}
|
||||
updateData.youtubeVideoId = ytId
|
||||
} else {
|
||||
updateData.youtubeVideoId = null
|
||||
}
|
||||
}
|
||||
|
||||
// ── Update the performance entry ──────────────────────────────────
|
||||
const [updated] = await db
|
||||
.update(performanceEntries)
|
||||
.set(updateData)
|
||||
.where(eq(performanceEntries.id, params.id))
|
||||
.returning()
|
||||
|
||||
// ── Remove screenshots marked for deletion ────────────────────────
|
||||
const removedIds: string[] = Array.isArray(payload.removedScreenshotIds)
|
||||
? payload.removedScreenshotIds
|
||||
: []
|
||||
|
||||
if (removedIds.length > 0) {
|
||||
// Fetch storage keys before deleting rows
|
||||
const toRemove = await db
|
||||
.select({ id: entryScreenshots.id, storageKey: entryScreenshots.storageKey })
|
||||
.from(entryScreenshots)
|
||||
.where(
|
||||
and(
|
||||
eq(entryScreenshots.entryId, params.id),
|
||||
sql`${entryScreenshots.id} = ANY(${removedIds})`,
|
||||
),
|
||||
)
|
||||
|
||||
for (const ss of toRemove) {
|
||||
try { await deleteObject(ss.storageKey) } catch { /* best-effort */ }
|
||||
try {
|
||||
await db.delete(storageObjects).where(eq(storageObjects.key, ss.storageKey))
|
||||
} catch { /* best-effort */ }
|
||||
}
|
||||
|
||||
// Delete the screenshot rows
|
||||
if (toRemove.length > 0) {
|
||||
await db.delete(entryScreenshots).where(
|
||||
sql`${entryScreenshots.id} = ANY(${toRemove.map((s) => s.id)})`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Atomic screenshot replacement ─────────────────────────────────
|
||||
if (screenshotFiles.length > 0) {
|
||||
// a. Upload all new screenshots to R2 FIRST (before deleting old ones)
|
||||
const uploadedKeys: string[] = []
|
||||
const uploadedStorageIds: string[] = []
|
||||
const uploadedScreenshotIds: string[] = []
|
||||
|
||||
let uploadFailed = false
|
||||
let uploadError = ""
|
||||
|
||||
for (let i = 0; i < processedScreenshots.length; i++) {
|
||||
const shot = processedScreenshots[i]
|
||||
const r2Key = `screenshots/${entry.id}/${crypto.randomUUID()}.jpg`
|
||||
|
||||
try {
|
||||
if (!isR2Configured()) {
|
||||
throw new Error("R2 storage is not configured")
|
||||
}
|
||||
await uploadObject(r2Key, shot.buffer, shot.mimeType, {
|
||||
entryId: entry.id,
|
||||
orderIndex: String(i),
|
||||
})
|
||||
uploadedKeys.push(r2Key)
|
||||
|
||||
// Insert into storageObjects
|
||||
const [storageObj] = await db
|
||||
.insert(storageObjects)
|
||||
.values({
|
||||
key: r2Key,
|
||||
bucket: "deckyvault",
|
||||
size: shot.size,
|
||||
mimeType: shot.mimeType,
|
||||
entityType: "entry_screenshot",
|
||||
entityId: entry.id,
|
||||
uploadedBy: guard.user.id,
|
||||
})
|
||||
.returning()
|
||||
uploadedStorageIds.push(storageObj.id)
|
||||
|
||||
// Insert into entryScreenshots
|
||||
const [screenshotRow] = await db
|
||||
.insert(entryScreenshots)
|
||||
.values({
|
||||
entryId: entry.id,
|
||||
storageKey: r2Key,
|
||||
orderIndex: i,
|
||||
mimeType: shot.mimeType,
|
||||
width: shot.width,
|
||||
height: shot.height,
|
||||
originalName: shot.originalName,
|
||||
})
|
||||
.returning()
|
||||
uploadedScreenshotIds.push(screenshotRow.id)
|
||||
} catch (err) {
|
||||
uploadFailed = true
|
||||
uploadError = err instanceof Error ? err.message : "Upload failed"
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (uploadFailed) {
|
||||
// Roll back: delete just-uploaded R2 objects, storageObjects rows, entryScreenshots rows
|
||||
for (const key of uploadedKeys) {
|
||||
try { await deleteObject(key) } catch { /* best-effort */ }
|
||||
}
|
||||
for (const id of uploadedStorageIds) {
|
||||
try { await db.delete(storageObjects).where(eq(storageObjects.id, id)) } catch { /* best-effort */ }
|
||||
}
|
||||
for (const id of uploadedScreenshotIds) {
|
||||
try { await db.delete(entryScreenshots).where(eq(entryScreenshots.id, id)) } catch { /* best-effort */ }
|
||||
}
|
||||
|
||||
set.status = 500
|
||||
return { error: `Screenshot upload failed: ${uploadError}` }
|
||||
}
|
||||
|
||||
// b. All new uploads succeeded — now safe to delete old screenshots
|
||||
const oldScreenshots = await db
|
||||
.select()
|
||||
.from(entryScreenshots)
|
||||
.where(eq(entryScreenshots.entryId, entry.id))
|
||||
|
||||
for (const old of oldScreenshots) {
|
||||
// Skip any that were just uploaded (shouldn't overlap, but safety check)
|
||||
if (uploadedScreenshotIds.includes(old.id)) continue
|
||||
|
||||
try { await deleteObject(old.storageKey) } catch { /* best-effort R2 delete */ }
|
||||
try { await db.delete(storageObjects).where(eq(storageObjects.key, old.storageKey)) } catch { /* best-effort */ }
|
||||
}
|
||||
|
||||
// Delete old entryScreenshots rows (excluding newly inserted ones)
|
||||
if (oldScreenshots.length > 0) {
|
||||
const oldIds = oldScreenshots
|
||||
.filter((s) => !uploadedScreenshotIds.includes(s.id))
|
||||
.map((s) => s.id)
|
||||
if (oldIds.length > 0) {
|
||||
await db.delete(entryScreenshots).where(
|
||||
sql`${entryScreenshots.id} = ANY(${oldIds})`
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Update gamePlatformSupport anti-cheat info if provided ──────
|
||||
if (
|
||||
payload.antiCheatRelevant !== undefined ||
|
||||
payload.antiCheatName !== undefined ||
|
||||
payload.antiCheatStatus !== undefined
|
||||
) {
|
||||
// Need versionId to resolve gameId
|
||||
const [entryVersion] = await db
|
||||
.select({ versionId: performanceEntries.versionId })
|
||||
.from(performanceEntries)
|
||||
.where(eq(performanceEntries.id, params.id))
|
||||
.limit(1)
|
||||
|
||||
if (entryVersion) {
|
||||
const [gv] = await db
|
||||
.select({ gameId: gameVersions.gameId })
|
||||
.from(gameVersions)
|
||||
.where(eq(gameVersions.id, entryVersion.versionId))
|
||||
.limit(1)
|
||||
|
||||
if (gv) {
|
||||
const [existingSupport] = await db
|
||||
.select()
|
||||
.from(gamePlatformSupport)
|
||||
.where(
|
||||
and(
|
||||
eq(gamePlatformSupport.gameId, gv.gameId),
|
||||
eq(gamePlatformSupport.hardwareSlug, updated.hardwareSlug),
|
||||
),
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
if (existingSupport) {
|
||||
await db
|
||||
.update(gamePlatformSupport)
|
||||
.set({
|
||||
antiCheatRelevant:
|
||||
payload.antiCheatRelevant !== undefined
|
||||
? (payload.antiCheatRelevant as boolean)
|
||||
: existingSupport.antiCheatRelevant,
|
||||
antiCheatName:
|
||||
payload.antiCheatName !== undefined
|
||||
? (payload.antiCheatName as string | null)
|
||||
: existingSupport.antiCheatName,
|
||||
antiCheatStatus:
|
||||
payload.antiCheatStatus !== undefined
|
||||
? ((payload.antiCheatStatus ?? "unknown") as "none" | "supported" | "unsupported" | "unknown")
|
||||
: existingSupport.antiCheatStatus,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(gamePlatformSupport.id, existingSupport.id))
|
||||
} else {
|
||||
await db.insert(gamePlatformSupport).values({
|
||||
gameId: gv.gameId,
|
||||
hardwareSlug: updated.hardwareSlug,
|
||||
isSupported: true,
|
||||
protonStatus: "unknown",
|
||||
antiCheatRelevant: (payload.antiCheatRelevant as boolean) ?? false,
|
||||
antiCheatName: (payload.antiCheatName as string | null) ?? null,
|
||||
antiCheatStatus: (payload.antiCheatStatus ?? "unknown") as "none" | "supported" | "unsupported" | "unknown",
|
||||
playabilityStatus: "unknown",
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return updated
|
||||
},
|
||||
{
|
||||
params: t.Object({ id: t.String() }),
|
||||
},
|
||||
)
|
||||
// ── Best entry: highest-rated for latest version ──────────────────
|
||||
.get(
|
||||
"/best",
|
||||
async ({ query, set }) => {
|
||||
const { gameId, hardwareSlug } = query as {
|
||||
gameId?: string
|
||||
hardwareSlug?: string
|
||||
}
|
||||
|
||||
if (!gameId) {
|
||||
set.status = 400
|
||||
return { error: "gameId query parameter is required" }
|
||||
}
|
||||
|
||||
// Find the latest version for this game
|
||||
const [latestVersion] = await db
|
||||
.select()
|
||||
.from(gameVersions)
|
||||
.where(
|
||||
and(eq(gameVersions.gameId, gameId), eq(gameVersions.isLatest, true)),
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
if (!latestVersion) {
|
||||
set.status = 404
|
||||
return { error: "No versions found for this game" }
|
||||
}
|
||||
|
||||
const conditions = [
|
||||
eq(performanceEntries.versionId, latestVersion.id),
|
||||
eq(performanceEntries.isRemoved, false),
|
||||
]
|
||||
|
||||
if (hardwareSlug) {
|
||||
conditions.push(eq(performanceEntries.hardwareSlug, hardwareSlug))
|
||||
}
|
||||
|
||||
const [bestEntry] = await db
|
||||
.select({
|
||||
id: performanceEntries.id,
|
||||
versionId: performanceEntries.versionId,
|
||||
hardwareSlug: performanceEntries.hardwareSlug,
|
||||
fpsAvg: performanceEntries.fpsAvg,
|
||||
fpsLow: performanceEntries.fpsLow,
|
||||
fpsHigh: performanceEntries.fpsHigh,
|
||||
upscalerType: performanceEntries.upscalerType,
|
||||
upscalerVersion: performanceEntries.upscalerVersion,
|
||||
frameGenMethod: performanceEntries.frameGenMethod,
|
||||
settingsJson: performanceEntries.settingsJson,
|
||||
userNotes: performanceEntries.userNotes,
|
||||
upvotes: performanceEntries.upvotes,
|
||||
downvotes: performanceEntries.downvotes,
|
||||
verifiedAt: performanceEntries.verifiedAt,
|
||||
createdAt: performanceEntries.createdAt,
|
||||
userName: user.name,
|
||||
userImage: user.image,
|
||||
hardwareName: hardware.name,
|
||||
versionString: gameVersions.versionString,
|
||||
})
|
||||
.from(performanceEntries)
|
||||
.innerJoin(user, eq(performanceEntries.userId, user.id))
|
||||
.innerJoin(hardware, eq(performanceEntries.hardwareSlug, hardware.slug))
|
||||
.innerJoin(gameVersions, eq(performanceEntries.versionId, gameVersions.id))
|
||||
.where(and(...conditions))
|
||||
.orderBy(
|
||||
desc(
|
||||
sql`${performanceEntries.upvotes} - ${performanceEntries.downvotes}`,
|
||||
),
|
||||
desc(performanceEntries.upvotes),
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
if (!bestEntry) {
|
||||
set.status = 404
|
||||
return { error: "No performance entries found" }
|
||||
}
|
||||
|
||||
return bestEntry
|
||||
},
|
||||
{
|
||||
query: t.Object({
|
||||
gameId: t.String(),
|
||||
hardwareSlug: t.Optional(t.String()),
|
||||
}),
|
||||
},
|
||||
)
|
||||
// ── Stats endpoint: aggregated performance for a game+hardware combo ──
|
||||
.get(
|
||||
"/stats",
|
||||
async ({ query, set }) => {
|
||||
const { gameId, hardwareSlug, upscalerType, upscalerVersion } = query as {
|
||||
gameId?: string
|
||||
hardwareSlug?: string
|
||||
upscalerType?: string
|
||||
upscalerVersion?: string
|
||||
}
|
||||
|
||||
if (!gameId) {
|
||||
set.status = 400
|
||||
return { error: "gameId query parameter is required" }
|
||||
}
|
||||
|
||||
const conditions = [
|
||||
eq(games.id, gameId),
|
||||
eq(performanceEntries.isRemoved, false),
|
||||
]
|
||||
|
||||
if (hardwareSlug) {
|
||||
conditions.push(eq(performanceEntries.hardwareSlug, hardwareSlug))
|
||||
}
|
||||
if (upscalerType) {
|
||||
conditions.push(eq(performanceEntries.upscalerType, upscalerType as any)) // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
}
|
||||
if (upscalerVersion) {
|
||||
conditions.push(eq(performanceEntries.upscalerVersion, upscalerVersion))
|
||||
}
|
||||
|
||||
// Join through gameVersions to get to games
|
||||
const stats = await db
|
||||
.select({
|
||||
count: sql<number>`count(*)::int`,
|
||||
fpsAvg: sql<number>`avg(${performanceEntries.fpsAvg})::real`,
|
||||
fpsLow: sql<number>`percentile_cont(0.1) within group (order by ${performanceEntries.fpsAvg})::real`,
|
||||
fpsHigh: sql<number>`percentile_cont(0.9) within group (order by ${performanceEntries.fpsAvg})::real`,
|
||||
})
|
||||
.from(performanceEntries)
|
||||
.innerJoin(
|
||||
gameVersions,
|
||||
eq(performanceEntries.versionId, gameVersions.id),
|
||||
)
|
||||
.innerJoin(games, eq(gameVersions.gameId, games.id))
|
||||
.where(and(...conditions))
|
||||
|
||||
return stats[0] ?? { count: 0, fpsAvg: null, fpsLow: null, fpsHigh: null }
|
||||
},
|
||||
{
|
||||
query: t.Object({
|
||||
gameId: t.String(),
|
||||
hardwareSlug: t.Optional(t.String()),
|
||||
upscalerType: t.Optional(t.String()),
|
||||
upscalerVersion: t.Optional(t.String()),
|
||||
}),
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,222 @@
|
||||
import { Elysia } from "elysia";
|
||||
import { db } from "@/lib/db/index";
|
||||
import { games, gamePlatformSupport, performanceEntries, gameVersions, playabilityStatusEnum } from "@/lib/db/schema";
|
||||
import { eq, and, avg, count, sql } from "drizzle-orm";
|
||||
import { requireContributorOrAdmin } from "@/lib/auth/guard";
|
||||
|
||||
/**
|
||||
* Playability calculation rules:
|
||||
* - great: avg FPS >= 55, no upscaler/frame-gen dependency
|
||||
* - playable: avg FPS >= 30, or >= 55 with upscaler dependency
|
||||
* - needs_tweaks: avg FPS >= 20 but < 30
|
||||
* - unplayable: avg FPS < 20, OR anti-cheat is relevant AND unsupported
|
||||
* - unknown: no benchmark data
|
||||
*
|
||||
* IMPORTANT: Anti-cheat only blocks playability if the game actually uses anti-cheat
|
||||
* (antiCheatRelevant = true). Games without anti-cheat are unaffected.
|
||||
*/
|
||||
function calculatePlayability(stats: {
|
||||
avgFps: number | null;
|
||||
antiCheatRelevant: boolean;
|
||||
antiCheatStatus: string | null;
|
||||
hasUpscalerDependency: boolean;
|
||||
entryCount: number;
|
||||
}): "great" | "playable" | "needs_tweaks" | "unplayable" | "unknown" {
|
||||
if (!stats.avgFps || stats.entryCount === 0) return "unknown";
|
||||
|
||||
// Anti-cheat unsupported = unplayable ONLY if the game actually uses anti-cheat
|
||||
if (stats.antiCheatRelevant && stats.antiCheatStatus === "unsupported") {
|
||||
return "unplayable";
|
||||
}
|
||||
|
||||
const fps = stats.avgFps;
|
||||
|
||||
if (fps >= 55 && !stats.hasUpscalerDependency) return "great";
|
||||
if (fps >= 55 && stats.hasUpscalerDependency) return "playable";
|
||||
if (fps >= 30) return "playable";
|
||||
if (fps >= 20) return "needs_tweaks";
|
||||
return "unplayable";
|
||||
}
|
||||
|
||||
/**
|
||||
* Recalculate playability for a game (all devices).
|
||||
* Called automatically after benchmark submission and Steam sync.
|
||||
* Skips devices with manual overrides.
|
||||
*/
|
||||
export async function recalculatePlayability(gameId: string): Promise<{
|
||||
gamePlayability: string;
|
||||
deviceResults: Array<{ hardwareSlug: string; playabilityStatus: string }>;
|
||||
}> {
|
||||
// Get all platform support entries for this game
|
||||
const platformEntries = await db
|
||||
.select()
|
||||
.from(gamePlatformSupport)
|
||||
.where(eq(gamePlatformSupport.gameId, gameId));
|
||||
|
||||
// Get performance stats per device
|
||||
const deviceStats = await db
|
||||
.select({
|
||||
hardwareSlug: performanceEntries.hardwareSlug,
|
||||
avgFps: avg(performanceEntries.fpsAvg).mapWith(Number),
|
||||
entryCount: count(performanceEntries.id),
|
||||
upscalerEntries:
|
||||
sql<number>`count(case when ${performanceEntries.upscalerType} != 'none' then 1 end)`.mapWith(
|
||||
Number,
|
||||
),
|
||||
frameGenEntries:
|
||||
sql<number>`count(case when ${performanceEntries.frameGenMethod} != 'none' then 1 end)`.mapWith(
|
||||
Number,
|
||||
),
|
||||
})
|
||||
.from(performanceEntries)
|
||||
.innerJoin(gameVersions, eq(performanceEntries.versionId, gameVersions.id))
|
||||
.where(and(eq(gameVersions.gameId, gameId), eq(performanceEntries.isRemoved, false)))
|
||||
.groupBy(performanceEntries.hardwareSlug);
|
||||
|
||||
const results: Array<{ hardwareSlug: string; playabilityStatus: string }> = [];
|
||||
|
||||
for (const stat of deviceStats) {
|
||||
const platformEntry = platformEntries.find((p) => p.hardwareSlug === stat.hardwareSlug);
|
||||
|
||||
const hasUpscalerDependency =
|
||||
stat.upscalerEntries > stat.entryCount * 0.5 ||
|
||||
stat.frameGenEntries > stat.entryCount * 0.5;
|
||||
|
||||
const status = calculatePlayability({
|
||||
avgFps: stat.avgFps,
|
||||
antiCheatRelevant: platformEntry?.antiCheatRelevant ?? false,
|
||||
antiCheatStatus: platformEntry?.antiCheatStatus ?? null,
|
||||
hasUpscalerDependency,
|
||||
entryCount: stat.entryCount,
|
||||
});
|
||||
|
||||
// Only update if not manually overridden
|
||||
if (platformEntry && !platformEntry.playabilityOverride) {
|
||||
await db
|
||||
.update(gamePlatformSupport)
|
||||
.set({
|
||||
playabilityStatus: status as typeof playabilityStatusEnum.enumValues[number],
|
||||
playabilityCalculatedAt: new Date(),
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(gamePlatformSupport.gameId, gameId),
|
||||
eq(gamePlatformSupport.hardwareSlug, stat.hardwareSlug),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
results.push({ hardwareSlug: stat.hardwareSlug, playabilityStatus: status });
|
||||
}
|
||||
|
||||
// Update aggregate game-level playability (worst of all devices)
|
||||
const priority = { unplayable: 0, needs_tweaks: 1, playable: 2, great: 3, unknown: 4 };
|
||||
let worstStatus: typeof playabilityStatusEnum.enumValues[number] = "unknown";
|
||||
for (const r of results) {
|
||||
if (
|
||||
priority[r.playabilityStatus as keyof typeof priority] <
|
||||
priority[worstStatus as keyof typeof priority]
|
||||
) {
|
||||
worstStatus = r.playabilityStatus as typeof playabilityStatusEnum.enumValues[number];
|
||||
}
|
||||
}
|
||||
|
||||
// Only update game-level if not manually overridden
|
||||
const [game] = await db
|
||||
.select({ playabilityOverride: games.playabilityOverride })
|
||||
.from(games)
|
||||
.where(eq(games.id, gameId))
|
||||
.limit(1);
|
||||
|
||||
if (!game?.playabilityOverride) {
|
||||
await db
|
||||
.update(games)
|
||||
.set({
|
||||
playabilityStatus: worstStatus as typeof playabilityStatusEnum.enumValues[number],
|
||||
playabilityCalculatedAt: new Date(),
|
||||
})
|
||||
.where(eq(games.id, gameId));
|
||||
}
|
||||
|
||||
return { gamePlayability: worstStatus, deviceResults: results };
|
||||
}
|
||||
|
||||
export const playabilityRoutes = new Elysia({ prefix: "/playability", detail: { tags: ["Games"] } })
|
||||
// Manual trigger for recalculation (admin/contributor)
|
||||
.post("/calculate/:gameId", async ({ params, request, set }) => {
|
||||
const guard = await requireContributorOrAdmin(request.headers);
|
||||
if (!guard.ok) {
|
||||
set.status = guard.status;
|
||||
return { error: guard.error };
|
||||
}
|
||||
|
||||
return recalculatePlayability(params.gameId);
|
||||
})
|
||||
|
||||
// Manual override for a game (admin/contributor)
|
||||
.post("/override/:gameId", async ({ params, body, request, set }) => {
|
||||
const guard = await requireContributorOrAdmin(request.headers);
|
||||
if (!guard.ok) {
|
||||
set.status = guard.status;
|
||||
return { error: guard.error };
|
||||
}
|
||||
|
||||
const { status, hardwareSlug } = body as {
|
||||
status: string;
|
||||
hardwareSlug?: string;
|
||||
};
|
||||
|
||||
if (!["great", "playable", "needs_tweaks", "unplayable"].includes(status)) {
|
||||
set.status = 400;
|
||||
return { error: "Invalid playability status" };
|
||||
}
|
||||
|
||||
if (hardwareSlug) {
|
||||
await db
|
||||
.update(gamePlatformSupport)
|
||||
.set({ playabilityStatus: status as typeof playabilityStatusEnum.enumValues[number], playabilityOverride: true })
|
||||
.where(
|
||||
and(
|
||||
eq(gamePlatformSupport.gameId, params.gameId),
|
||||
eq(gamePlatformSupport.hardwareSlug, hardwareSlug),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
await db
|
||||
.update(games)
|
||||
.set({ playabilityStatus: status as typeof playabilityStatusEnum.enumValues[number], playabilityOverride: true })
|
||||
.where(eq(games.id, params.gameId));
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
})
|
||||
|
||||
// Clear override (revert to auto-calculated)
|
||||
.post("/clear-override/:gameId", async ({ params, body, request, set }) => {
|
||||
const guard = await requireContributorOrAdmin(request.headers);
|
||||
if (!guard.ok) {
|
||||
set.status = guard.status;
|
||||
return { error: guard.error };
|
||||
}
|
||||
|
||||
const { hardwareSlug } = body as { hardwareSlug?: string };
|
||||
|
||||
if (hardwareSlug) {
|
||||
await db
|
||||
.update(gamePlatformSupport)
|
||||
.set({ playabilityOverride: false })
|
||||
.where(
|
||||
and(
|
||||
eq(gamePlatformSupport.gameId, params.gameId),
|
||||
eq(gamePlatformSupport.hardwareSlug, hardwareSlug),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
await db
|
||||
.update(games)
|
||||
.set({ playabilityOverride: false })
|
||||
.where(eq(games.id, params.gameId));
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
});
|
||||
@@ -0,0 +1,171 @@
|
||||
import { Elysia } from "elysia"
|
||||
import { db } from "@/lib/db/index"
|
||||
import { user } from "@/lib/db/schema"
|
||||
import { eq } from "drizzle-orm"
|
||||
import { requireAuth } from "@/lib/auth/guard"
|
||||
import {
|
||||
uploadObject,
|
||||
deleteObject,
|
||||
isR2Configured,
|
||||
getR2PublicUrl,
|
||||
isR2Url,
|
||||
} from "@/lib/storage"
|
||||
import { storageObjects } from "@/lib/db/schema"
|
||||
|
||||
const ALLOWED_MIME_TYPES = ["image/jpeg", "image/png", "image/webp"]
|
||||
const MAX_FILE_SIZE = 5 * 1024 * 1024 // 5MB
|
||||
|
||||
// Magic byte signatures for file type validation
|
||||
const MAGIC_BYTES: Record<string, number[]> = {
|
||||
"image/jpeg": [0xff, 0xd8, 0xff],
|
||||
"image/png": [0x89, 0x50, 0x4e, 0x47],
|
||||
"image/webp": [0x52, 0x49, 0x46, 0x46], // RIFF header (WEBP container)
|
||||
}
|
||||
|
||||
function validateMagicBytes(buffer: Buffer, declaredMime: string): boolean {
|
||||
const expected = MAGIC_BYTES[declaredMime]
|
||||
if (!expected) return false
|
||||
if (buffer.length < expected.length) return false
|
||||
return expected.every((byte, i) => buffer[i] === byte)
|
||||
}
|
||||
|
||||
export const profilePhotoRoutes = new Elysia({ prefix: "/user", detail: { tags: ["Users"] } })
|
||||
|
||||
// ── Upload Profile Photo ──────────────────────────────────────────
|
||||
.post(
|
||||
"/profile-photo",
|
||||
async ({ request, set }) => {
|
||||
const guard = await requireAuth(request.headers)
|
||||
if (!guard.ok) {
|
||||
set.status = guard.status
|
||||
return { error: guard.error }
|
||||
}
|
||||
|
||||
if (!isR2Configured()) {
|
||||
set.status = 503
|
||||
return { error: "Storage not configured" }
|
||||
}
|
||||
|
||||
// Parse multipart form data
|
||||
const formData = await request.formData()
|
||||
const file = formData.get("photo")
|
||||
if (!file || !(file instanceof File)) {
|
||||
set.status = 400
|
||||
return { error: "No file provided" }
|
||||
}
|
||||
|
||||
// Validate MIME type
|
||||
if (!ALLOWED_MIME_TYPES.includes(file.type)) {
|
||||
set.status = 400
|
||||
return { error: `Invalid file type. Allowed: ${ALLOWED_MIME_TYPES.join(", ")}` }
|
||||
}
|
||||
|
||||
// Validate file size
|
||||
if (file.size > MAX_FILE_SIZE) {
|
||||
set.status = 400
|
||||
return { error: `File too large. Maximum size: ${MAX_FILE_SIZE / 1024 / 1024}MB` }
|
||||
}
|
||||
|
||||
// Read file buffer and validate magic bytes
|
||||
const arrayBuffer = await file.arrayBuffer()
|
||||
const buffer = Buffer.from(arrayBuffer)
|
||||
|
||||
if (!validateMagicBytes(buffer, file.type)) {
|
||||
set.status = 400
|
||||
return { error: "File content does not match declared type" }
|
||||
}
|
||||
|
||||
// Generate unique key
|
||||
const timestamp = Date.now()
|
||||
const ext = file.type === "image/png" ? "png" : file.type === "image/webp" ? "webp" : "jpg"
|
||||
const actualKey = `avatars/${guard.user.id}-${timestamp}.${ext}`
|
||||
const publicUrl = await uploadObject(actualKey, buffer, file.type, {
|
||||
userId: guard.user.id,
|
||||
entityType: "avatar",
|
||||
})
|
||||
|
||||
// Track in storage_objects
|
||||
await db.insert(storageObjects).values({
|
||||
key: actualKey,
|
||||
bucket: process.env.R2_BUCKET_NAME ?? "deckyvault",
|
||||
size: buffer.length,
|
||||
mimeType: file.type,
|
||||
entityType: "avatar",
|
||||
entityId: guard.user.id,
|
||||
uploadedBy: guard.user.id,
|
||||
})
|
||||
|
||||
// Check if user had a previous custom avatar and delete it
|
||||
const [currentUser] = await db
|
||||
.select({ image: user.image })
|
||||
.from(user)
|
||||
.where(eq(user.id, guard.user.id))
|
||||
.limit(1)
|
||||
|
||||
if (currentUser?.image && isR2Url(currentUser.image)) {
|
||||
// Extract the key from the URL (everything after R2_PUBLIC_URL/)
|
||||
const oldKey = currentUser.image.replace(`${getR2PublicUrl()}/`, "")
|
||||
try {
|
||||
await deleteObject(oldKey)
|
||||
} catch {
|
||||
// Log but don't block — daily cron will clean up orphaned objects
|
||||
console.warn(`Failed to delete old avatar: ${oldKey}`)
|
||||
}
|
||||
// Remove old tracking record
|
||||
await db
|
||||
.delete(storageObjects)
|
||||
.where(eq(storageObjects.key, oldKey))
|
||||
}
|
||||
|
||||
// Update user.image
|
||||
await db
|
||||
.update(user)
|
||||
.set({ image: publicUrl, updatedAt: new Date() })
|
||||
.where(eq(user.id, guard.user.id))
|
||||
|
||||
return { url: publicUrl }
|
||||
},
|
||||
)
|
||||
|
||||
// ── Delete Profile Photo ──────────────────────────────────────────
|
||||
.delete(
|
||||
"/profile-photo",
|
||||
async ({ request, set }) => {
|
||||
const guard = await requireAuth(request.headers)
|
||||
if (!guard.ok) {
|
||||
set.status = guard.status
|
||||
return { error: guard.error }
|
||||
}
|
||||
|
||||
const [currentUser] = await db
|
||||
.select({ image: user.image })
|
||||
.from(user)
|
||||
.where(eq(user.id, guard.user.id))
|
||||
.limit(1)
|
||||
|
||||
if (!currentUser?.image || !isR2Url(currentUser.image)) {
|
||||
return { success: true, message: "No custom photo to delete" }
|
||||
}
|
||||
|
||||
// Delete from R2
|
||||
const oldKey = currentUser.image.replace(`${getR2PublicUrl()}/`, "")
|
||||
try {
|
||||
await deleteObject(oldKey)
|
||||
} catch {
|
||||
console.warn(`Failed to delete avatar from R2: ${oldKey}`)
|
||||
}
|
||||
|
||||
// Remove tracking record
|
||||
await db
|
||||
.delete(storageObjects)
|
||||
.where(eq(storageObjects.key, oldKey))
|
||||
|
||||
// Clear user.image
|
||||
await db
|
||||
.update(user)
|
||||
.set({ image: null, updatedAt: new Date() })
|
||||
.where(eq(user.id, guard.user.id))
|
||||
|
||||
return { success: true }
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,61 @@
|
||||
import { Elysia, t } from "elysia"
|
||||
import { db } from "@/lib/db/index"
|
||||
import { reports } from "@/lib/db/schema"
|
||||
import { eq, and } from "drizzle-orm"
|
||||
import { requireRole } from "@/lib/auth/guard"
|
||||
|
||||
export const reportRoutes = new Elysia({ prefix: "/performance", detail: { tags: ["Reports"] } }).post(
|
||||
"/:id/report",
|
||||
async ({ params, body, request, set }) => {
|
||||
const guard = await requireRole(request.headers, [
|
||||
"user",
|
||||
"contributor",
|
||||
"admin",
|
||||
])
|
||||
if (!guard.ok) {
|
||||
set.status = guard.status
|
||||
return { error: guard.error }
|
||||
}
|
||||
|
||||
// Check if user already reported this entry
|
||||
const [existing] = await db
|
||||
.select()
|
||||
.from(reports)
|
||||
.where(
|
||||
and(
|
||||
eq(reports.entryId, params.id),
|
||||
eq(reports.reporterId, guard.user.id),
|
||||
),
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
if (existing) {
|
||||
set.status = 409
|
||||
return { error: "You have already reported this entry" }
|
||||
}
|
||||
|
||||
const [created] = await db
|
||||
.insert(reports)
|
||||
.values({
|
||||
entryId: params.id,
|
||||
reporterId: guard.user.id,
|
||||
reason: body.reason,
|
||||
details: body.details ?? null,
|
||||
})
|
||||
.returning()
|
||||
|
||||
return created
|
||||
},
|
||||
{
|
||||
params: t.Object({ id: t.String() }),
|
||||
body: t.Object({
|
||||
reason: t.Union([
|
||||
t.Literal("inaccurate"),
|
||||
t.Literal("spam"),
|
||||
t.Literal("inappropriate"),
|
||||
t.Literal("other"),
|
||||
]),
|
||||
details: t.Optional(t.String()),
|
||||
}),
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,139 @@
|
||||
import { Elysia, t } from "elysia"
|
||||
import { db } from "@/lib/db/index"
|
||||
import { savedFilters } from "@/lib/db/schema"
|
||||
import { eq, and, desc } from "drizzle-orm"
|
||||
import { requireAuth } from "@/lib/auth/guard"
|
||||
|
||||
export const savedFilterRoutes = new Elysia({ prefix: "/saved-filters", detail: { tags: ["Games"] } })
|
||||
|
||||
// Get user's saved filters
|
||||
.get("/", async ({ request, set }) => {
|
||||
const guard = await requireAuth(request.headers)
|
||||
if (!guard.ok) {
|
||||
set.status = guard.status
|
||||
return { error: guard.error }
|
||||
}
|
||||
|
||||
const filters = await db
|
||||
.select()
|
||||
.from(savedFilters)
|
||||
.where(eq(savedFilters.userId, guard.user.id))
|
||||
.orderBy(desc(savedFilters.updatedAt))
|
||||
|
||||
return filters
|
||||
})
|
||||
|
||||
// Create a saved filter
|
||||
.post(
|
||||
"/",
|
||||
async ({ body, request, set }) => {
|
||||
const guard = await requireAuth(request.headers)
|
||||
if (!guard.ok) {
|
||||
set.status = guard.status
|
||||
return { error: guard.error }
|
||||
}
|
||||
|
||||
const { name, filters: filterData } = body
|
||||
|
||||
const [saved] = await db
|
||||
.insert(savedFilters)
|
||||
.values({
|
||||
userId: guard.user.id,
|
||||
name,
|
||||
filters: filterData,
|
||||
})
|
||||
.returning()
|
||||
|
||||
return saved
|
||||
},
|
||||
{
|
||||
body: t.Object({
|
||||
name: t.String(),
|
||||
filters: t.Record(t.String(), t.Any()),
|
||||
}),
|
||||
},
|
||||
)
|
||||
|
||||
// Update a saved filter
|
||||
.patch(
|
||||
"/:id",
|
||||
async ({ params, body, request, set }) => {
|
||||
const guard = await requireAuth(request.headers)
|
||||
if (!guard.ok) {
|
||||
set.status = guard.status
|
||||
return { error: guard.error }
|
||||
}
|
||||
|
||||
const { name, filters: filterData } = body
|
||||
|
||||
// Verify ownership
|
||||
const [existing] = await db
|
||||
.select()
|
||||
.from(savedFilters)
|
||||
.where(
|
||||
and(
|
||||
eq(savedFilters.id, params.id),
|
||||
eq(savedFilters.userId, guard.user.id),
|
||||
),
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
if (!existing) {
|
||||
set.status = 404
|
||||
return { error: "Saved filter not found" }
|
||||
}
|
||||
|
||||
const [updated] = await db
|
||||
.update(savedFilters)
|
||||
.set({
|
||||
...(name && { name }),
|
||||
...(filterData && { filters: filterData }),
|
||||
})
|
||||
.where(eq(savedFilters.id, params.id))
|
||||
.returning()
|
||||
|
||||
return updated
|
||||
},
|
||||
{
|
||||
params: t.Object({ id: t.String() }),
|
||||
body: t.Object({
|
||||
name: t.Optional(t.String()),
|
||||
filters: t.Optional(t.Record(t.String(), t.Any())),
|
||||
}),
|
||||
},
|
||||
)
|
||||
|
||||
// Delete a saved filter
|
||||
.delete(
|
||||
"/:id",
|
||||
async ({ params, request, set }) => {
|
||||
const guard = await requireAuth(request.headers)
|
||||
if (!guard.ok) {
|
||||
set.status = guard.status
|
||||
return { error: guard.error }
|
||||
}
|
||||
|
||||
const [existing] = await db
|
||||
.select()
|
||||
.from(savedFilters)
|
||||
.where(
|
||||
and(
|
||||
eq(savedFilters.id, params.id),
|
||||
eq(savedFilters.userId, guard.user.id),
|
||||
),
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
if (!existing) {
|
||||
set.status = 404
|
||||
return { error: "Saved filter not found" }
|
||||
}
|
||||
|
||||
await db.delete(savedFilters).where(eq(savedFilters.id, params.id))
|
||||
|
||||
return { success: true }
|
||||
},
|
||||
{
|
||||
params: t.Object({ id: t.String() }),
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,162 @@
|
||||
import { Elysia, t } from "elysia"
|
||||
import { auth } from "@/lib/auth"
|
||||
import { db } from "@/lib/db/index"
|
||||
import { savedGames, games } from "@/lib/db/schema"
|
||||
import { eq, and, sql } from "drizzle-orm"
|
||||
|
||||
export const savedGamesRoutes = new Elysia({ prefix: "/user/me/saved-games", detail: { tags: ["Games"] } })
|
||||
.post(
|
||||
"/",
|
||||
async ({ request, body, set }) => {
|
||||
const session = await auth.api.getSession({
|
||||
headers: request.headers,
|
||||
})
|
||||
|
||||
if (!session) {
|
||||
set.status = 401
|
||||
return { error: "Unauthorized" }
|
||||
}
|
||||
|
||||
// Check if game exists
|
||||
const [game] = await db
|
||||
.select({ id: games.id })
|
||||
.from(games)
|
||||
.where(eq(games.id, body.gameId))
|
||||
.limit(1)
|
||||
|
||||
if (!game) {
|
||||
set.status = 404
|
||||
return { error: "Game not found" }
|
||||
}
|
||||
|
||||
// Check if already saved
|
||||
const [existing] = await db
|
||||
.select({ id: savedGames.id })
|
||||
.from(savedGames)
|
||||
.where(
|
||||
and(
|
||||
eq(savedGames.userId, session.user.id),
|
||||
eq(savedGames.gameId, body.gameId),
|
||||
),
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
if (existing) {
|
||||
set.status = 409
|
||||
return { error: "Game already saved" }
|
||||
}
|
||||
|
||||
const [saved] = await db
|
||||
.insert(savedGames)
|
||||
.values({
|
||||
userId: session.user.id,
|
||||
gameId: body.gameId,
|
||||
})
|
||||
.returning()
|
||||
|
||||
return { id: saved.id, gameId: saved.gameId, createdAt: saved.createdAt.toISOString() }
|
||||
},
|
||||
{
|
||||
body: t.Object({
|
||||
gameId: t.String(),
|
||||
}),
|
||||
},
|
||||
)
|
||||
.delete(
|
||||
"/:gameId",
|
||||
async ({ request, params, set }) => {
|
||||
const session = await auth.api.getSession({
|
||||
headers: request.headers,
|
||||
})
|
||||
|
||||
if (!session) {
|
||||
set.status = 401
|
||||
return { error: "Unauthorized" }
|
||||
}
|
||||
|
||||
const [deleted] = await db
|
||||
.delete(savedGames)
|
||||
.where(
|
||||
and(
|
||||
eq(savedGames.userId, session.user.id),
|
||||
eq(savedGames.gameId, params.gameId),
|
||||
),
|
||||
)
|
||||
.returning()
|
||||
|
||||
if (!deleted) {
|
||||
set.status = 404
|
||||
return { error: "Saved game not found" }
|
||||
}
|
||||
|
||||
return { success: true }
|
||||
},
|
||||
{
|
||||
params: t.Object({
|
||||
gameId: t.String(),
|
||||
}),
|
||||
},
|
||||
)
|
||||
.get(
|
||||
"/",
|
||||
async ({ request, set }) => {
|
||||
const session = await auth.api.getSession({
|
||||
headers: request.headers,
|
||||
})
|
||||
|
||||
if (!session) {
|
||||
set.status = 401
|
||||
return { error: "Unauthorized" }
|
||||
}
|
||||
|
||||
const saved = await db
|
||||
.select({
|
||||
id: savedGames.id,
|
||||
gameId: savedGames.gameId,
|
||||
createdAt: savedGames.createdAt,
|
||||
gameTitle: games.title,
|
||||
gameHeaderImage: games.headerImage,
|
||||
gameCapsuleImage: games.capsuleImage,
|
||||
gameSteamAppId: games.steamAppId,
|
||||
})
|
||||
.from(savedGames)
|
||||
.innerJoin(games, eq(savedGames.gameId, games.id))
|
||||
.where(eq(savedGames.userId, session.user.id))
|
||||
.orderBy(sql`${savedGames.createdAt} DESC`)
|
||||
|
||||
return saved.map((s) => ({
|
||||
...s,
|
||||
createdAt: s.createdAt.toISOString(),
|
||||
}))
|
||||
},
|
||||
)
|
||||
.get(
|
||||
"/check/:gameId",
|
||||
async ({ request, params }) => {
|
||||
const session = await auth.api.getSession({
|
||||
headers: request.headers,
|
||||
})
|
||||
|
||||
if (!session) {
|
||||
return { saved: false }
|
||||
}
|
||||
|
||||
const [existing] = await db
|
||||
.select({ id: savedGames.id })
|
||||
.from(savedGames)
|
||||
.where(
|
||||
and(
|
||||
eq(savedGames.userId, session.user.id),
|
||||
eq(savedGames.gameId, params.gameId),
|
||||
),
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
return { saved: !!existing }
|
||||
},
|
||||
{
|
||||
params: t.Object({
|
||||
gameId: t.String(),
|
||||
}),
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,257 @@
|
||||
import { Elysia, t } from "elysia"
|
||||
import { db } from "@/lib/db/index"
|
||||
import { performanceEntries, entryScreenshots, storageObjects } from "@/lib/db/schema"
|
||||
import { eq, sql } from "drizzle-orm"
|
||||
import { requireRole } from "@/lib/auth/guard"
|
||||
import { uploadObject, deleteObject, getR2PublicUrl, isR2Configured } from "@/lib/storage"
|
||||
import { processScreenshot, isAllowedMimeType } from "@/lib/image-processing"
|
||||
|
||||
const MAX_SCREENSHOTS_PER_ENTRY = 2
|
||||
const MAX_UPLOAD_SIZE = 10 * 1024 * 1024 // 10 MB raw
|
||||
|
||||
export const screenshotRoutes = new Elysia({ prefix: "/performance", detail: { tags: ["Performance"] } })
|
||||
|
||||
// ── Upload screenshots ─────────────────────────────────────────
|
||||
.post(
|
||||
"/:id/screenshots",
|
||||
async ({ params, request, set }) => {
|
||||
const guard = await requireRole(request.headers, ["user", "contributor", "admin"])
|
||||
if (!guard.ok) {
|
||||
set.status = guard.status
|
||||
return { error: guard.error }
|
||||
}
|
||||
|
||||
if (!isR2Configured()) {
|
||||
set.status = 503
|
||||
return { error: "Storage not configured" }
|
||||
}
|
||||
|
||||
// Verify entry exists and user is owner or admin
|
||||
const [entry] = await db
|
||||
.select({
|
||||
id: performanceEntries.id,
|
||||
userId: performanceEntries.userId,
|
||||
})
|
||||
.from(performanceEntries)
|
||||
.where(eq(performanceEntries.id, params.id))
|
||||
.limit(1)
|
||||
|
||||
if (!entry) {
|
||||
set.status = 404
|
||||
return { error: "Performance entry not found" }
|
||||
}
|
||||
|
||||
if (entry.userId !== guard.user.id && guard.user.role !== "admin") {
|
||||
set.status = 403
|
||||
return { error: "Not authorized to upload screenshots for this entry" }
|
||||
}
|
||||
|
||||
// Check existing screenshot count
|
||||
const [countResult] = await db
|
||||
.select({ count: sql<number>`count(*)::int` })
|
||||
.from(entryScreenshots)
|
||||
.where(eq(entryScreenshots.entryId, params.id))
|
||||
|
||||
if ((countResult?.count ?? 0) >= MAX_SCREENSHOTS_PER_ENTRY) {
|
||||
set.status = 400
|
||||
return { error: `Maximum ${MAX_SCREENSHOTS_PER_ENTRY} screenshots per entry` }
|
||||
}
|
||||
|
||||
// Parse multipart form data
|
||||
const formData = await request.formData()
|
||||
const files = formData.getAll("screenshots")
|
||||
|
||||
if (!files || files.length === 0) {
|
||||
set.status = 400
|
||||
return { error: "No files provided" }
|
||||
}
|
||||
|
||||
// Remaining slots
|
||||
const remaining = MAX_SCREENSHOTS_PER_ENTRY - (countResult?.count ?? 0)
|
||||
const toProcess = files.slice(0, remaining).filter((f): f is File => f instanceof File)
|
||||
|
||||
if (toProcess.length === 0) {
|
||||
set.status = 400
|
||||
return { error: "No valid files provided or entry already has maximum screenshots" }
|
||||
}
|
||||
|
||||
// Get existing max order index
|
||||
const [maxOrder] = await db
|
||||
.select({ maxIndex: sql<number>`coalesce(max(${entryScreenshots.orderIndex}), -1)` })
|
||||
.from(entryScreenshots)
|
||||
.where(eq(entryScreenshots.entryId, params.id))
|
||||
|
||||
const startIndex = (maxOrder?.maxIndex ?? -1) + 1
|
||||
|
||||
const results = []
|
||||
|
||||
for (let i = 0; i < toProcess.length; i++) {
|
||||
const file = toProcess[i]
|
||||
|
||||
// Validate MIME type
|
||||
if (!isAllowedMimeType(file.type)) {
|
||||
continue // Skip invalid types
|
||||
}
|
||||
|
||||
// Validate file size
|
||||
if (file.size > MAX_UPLOAD_SIZE) {
|
||||
continue // Skip oversized files
|
||||
}
|
||||
|
||||
try {
|
||||
const arrayBuffer = await file.arrayBuffer()
|
||||
const inputBuffer = Buffer.from(arrayBuffer)
|
||||
|
||||
// Process and compress
|
||||
const processed = await processScreenshot(inputBuffer, file.type)
|
||||
|
||||
// Upload to R2
|
||||
const storageKey = `screenshots/${params.id}/${crypto.randomUUID()}.jpg`
|
||||
const publicUrl = await uploadObject(
|
||||
storageKey,
|
||||
processed.buffer,
|
||||
processed.mimeType,
|
||||
{ userId: guard.user.id, entityType: "entry_screenshot", entityId: params.id },
|
||||
)
|
||||
|
||||
// Track in storage_objects
|
||||
await db.insert(storageObjects).values({
|
||||
key: storageKey,
|
||||
bucket: process.env.R2_BUCKET_NAME ?? "deckyvault",
|
||||
size: processed.size,
|
||||
mimeType: processed.mimeType,
|
||||
entityType: "entry_screenshot",
|
||||
entityId: params.id,
|
||||
uploadedBy: guard.user.id,
|
||||
})
|
||||
|
||||
// Create screenshot record
|
||||
const [screenshot] = await db
|
||||
.insert(entryScreenshots)
|
||||
.values({
|
||||
entryId: params.id,
|
||||
storageKey,
|
||||
orderIndex: startIndex + i,
|
||||
mimeType: processed.mimeType,
|
||||
width: processed.width,
|
||||
height: processed.height,
|
||||
originalName: file.name || null,
|
||||
})
|
||||
.returning()
|
||||
|
||||
results.push({
|
||||
id: screenshot.id,
|
||||
entryId: screenshot.entryId,
|
||||
url: publicUrl,
|
||||
orderIndex: screenshot.orderIndex,
|
||||
width: processed.width,
|
||||
height: processed.height,
|
||||
})
|
||||
} catch (err) {
|
||||
console.error(`Failed to process screenshot ${i}:`, err)
|
||||
}
|
||||
}
|
||||
|
||||
if (results.length === 0) {
|
||||
set.status = 500
|
||||
return { error: "Failed to process any screenshots" }
|
||||
}
|
||||
|
||||
return { data: results }
|
||||
},
|
||||
{
|
||||
params: t.Object({ id: t.String() }),
|
||||
},
|
||||
)
|
||||
|
||||
// ── List screenshots ────────────────────────────────────────────
|
||||
.get(
|
||||
"/:id/screenshots",
|
||||
async ({ params }) => {
|
||||
const screenshots = await db
|
||||
.select({
|
||||
id: entryScreenshots.id,
|
||||
entryId: entryScreenshots.entryId,
|
||||
storageKey: entryScreenshots.storageKey,
|
||||
orderIndex: entryScreenshots.orderIndex,
|
||||
mimeType: entryScreenshots.mimeType,
|
||||
width: entryScreenshots.width,
|
||||
height: entryScreenshots.height,
|
||||
originalName: entryScreenshots.originalName,
|
||||
createdAt: entryScreenshots.createdAt,
|
||||
})
|
||||
.from(entryScreenshots)
|
||||
.where(eq(entryScreenshots.entryId, params.id))
|
||||
.orderBy(entryScreenshots.orderIndex)
|
||||
|
||||
const publicUrl = getR2PublicUrl()
|
||||
|
||||
return {
|
||||
data: screenshots.map((s) => ({
|
||||
...s,
|
||||
url: `${publicUrl}/${s.storageKey}`,
|
||||
})),
|
||||
}
|
||||
},
|
||||
{
|
||||
params: t.Object({ id: t.String() }),
|
||||
},
|
||||
)
|
||||
|
||||
// ── Delete screenshot ────────────────────────────────────────────
|
||||
.delete(
|
||||
"/:id/screenshots/:sid",
|
||||
async ({ params, request, set }) => {
|
||||
const guard = await requireRole(request.headers, ["user", "contributor", "admin"])
|
||||
if (!guard.ok) {
|
||||
set.status = guard.status
|
||||
return { error: guard.error }
|
||||
}
|
||||
|
||||
// Find the screenshot
|
||||
const [screenshot] = await db
|
||||
.select()
|
||||
.from(entryScreenshots)
|
||||
.where(eq(entryScreenshots.id, params.sid))
|
||||
.limit(1)
|
||||
|
||||
if (!screenshot) {
|
||||
set.status = 404
|
||||
return { error: "Screenshot not found" }
|
||||
}
|
||||
|
||||
// Verify ownership or admin
|
||||
const [entry] = await db
|
||||
.select({ userId: performanceEntries.userId })
|
||||
.from(performanceEntries)
|
||||
.where(eq(performanceEntries.id, screenshot.entryId))
|
||||
.limit(1)
|
||||
|
||||
if (entry && entry.userId !== guard.user.id && guard.user.role !== "admin") {
|
||||
set.status = 403
|
||||
return { error: "Not authorized to delete this screenshot" }
|
||||
}
|
||||
|
||||
// Delete from R2
|
||||
try {
|
||||
await deleteObject(screenshot.storageKey)
|
||||
} catch {
|
||||
console.warn(`Failed to delete screenshot from R2: ${screenshot.storageKey}`)
|
||||
}
|
||||
|
||||
// Delete storage_objects record
|
||||
await db
|
||||
.delete(storageObjects)
|
||||
.where(eq(storageObjects.key, screenshot.storageKey))
|
||||
|
||||
// Delete screenshot record
|
||||
await db
|
||||
.delete(entryScreenshots)
|
||||
.where(eq(entryScreenshots.id, params.sid))
|
||||
|
||||
return { success: true }
|
||||
},
|
||||
{
|
||||
params: t.Object({ id: t.String(), sid: t.String() }),
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,540 @@
|
||||
import { Elysia, t } from "elysia"
|
||||
import { db } from "@/lib/db/index"
|
||||
import {
|
||||
games,
|
||||
gameVersions,
|
||||
performanceEntries,
|
||||
gameComments,
|
||||
hardware,
|
||||
} from "@/lib/db/schema"
|
||||
import { ilike, or, sql, eq, inArray, and, gte, desc } from "drizzle-orm"
|
||||
import { fuzzySearchTerm } from "@/lib/db/search"
|
||||
|
||||
interface SteamSearchItem {
|
||||
id: number
|
||||
name: string
|
||||
tiny_image: string
|
||||
metascore: string
|
||||
price?: { currency: string; initial: number; final: number }
|
||||
platforms: { windows: boolean; mac: boolean; linux: boolean }
|
||||
controller_support?: string
|
||||
}
|
||||
|
||||
interface SteamSearchResponse {
|
||||
items: SteamSearchItem[]
|
||||
total: number
|
||||
}
|
||||
|
||||
export const searchUnifiedRoutes = new Elysia({ prefix: "/search", detail: { tags: ["Search"] } }).get(
|
||||
"/unified",
|
||||
async ({ query, set }) => {
|
||||
if (!query.q || query.q.length < 2) {
|
||||
set.status = 400
|
||||
return { error: "Query must be at least 2 characters" }
|
||||
}
|
||||
|
||||
const titleTerm = fuzzySearchTerm(query.q)
|
||||
const term = `%${query.q}%`
|
||||
|
||||
// ── Build filter conditions for columns on the games table ────
|
||||
const baseFilterConditions = [
|
||||
or(
|
||||
ilike(games.title, titleTerm),
|
||||
ilike(games.developer, term),
|
||||
ilike(games.publisher, term),
|
||||
),
|
||||
]
|
||||
|
||||
if (query.playabilityStatus) {
|
||||
baseFilterConditions.push(sql`${games.playabilityStatus} = ${query.playabilityStatus}`)
|
||||
}
|
||||
if (query.steamReviewScore) {
|
||||
const minScore = parseInt(query.steamReviewScore, 10)
|
||||
if (!isNaN(minScore)) {
|
||||
baseFilterConditions.push(gte(games.steamReviewScore, minScore))
|
||||
}
|
||||
}
|
||||
if (query.isFree === "true") {
|
||||
baseFilterConditions.push(eq(games.isFree, true))
|
||||
}
|
||||
if (query.hasMultiplayer === "true") {
|
||||
baseFilterConditions.push(sql`${games.onlineMultiplayerStatus} = 'supported'`)
|
||||
}
|
||||
|
||||
// ── 1. Search local database ────────────────────────────────────
|
||||
const localGames = await db
|
||||
.select()
|
||||
.from(games)
|
||||
.where(and(...baseFilterConditions))
|
||||
.limit(40)
|
||||
|
||||
// ── 1b. Post-process filters requiring joins ───────────────────
|
||||
let filteredGameIds = new Set(localGames.map((g) => g.id))
|
||||
|
||||
// Device filter: keep only games that have at least one benchmark for the device
|
||||
if (query.device && filteredGameIds.size > 0) {
|
||||
const matchingGameIds = await db
|
||||
.select({ gameId: gameVersions.gameId })
|
||||
.from(performanceEntries)
|
||||
.innerJoin(gameVersions, eq(performanceEntries.versionId, gameVersions.id))
|
||||
.where(
|
||||
and(
|
||||
inArray(gameVersions.gameId, [...filteredGameIds]),
|
||||
eq(performanceEntries.hardwareSlug, query.device),
|
||||
eq(performanceEntries.isRemoved, false),
|
||||
),
|
||||
)
|
||||
.groupBy(gameVersions.gameId)
|
||||
filteredGameIds = new Set(matchingGameIds.map((r) => r.gameId))
|
||||
}
|
||||
|
||||
// FSR support filter: keep games with at least one benchmark using upscaler !== 'none'
|
||||
if (query.fsrSupport === "true" && filteredGameIds.size > 0) {
|
||||
const matchingGameIds = await db
|
||||
.select({ gameId: gameVersions.gameId })
|
||||
.from(performanceEntries)
|
||||
.innerJoin(gameVersions, eq(performanceEntries.versionId, gameVersions.id))
|
||||
.where(
|
||||
and(
|
||||
inArray(gameVersions.gameId, [...filteredGameIds]),
|
||||
sql`${performanceEntries.upscalerType} != 'none'`,
|
||||
eq(performanceEntries.isRemoved, false),
|
||||
),
|
||||
)
|
||||
.groupBy(gameVersions.gameId)
|
||||
filteredGameIds = new Set(matchingGameIds.map((r) => r.gameId))
|
||||
}
|
||||
|
||||
// FPS range filter: keep games whose bestFps falls within [minFps, maxFps]
|
||||
const minFps = query.minFps ? parseInt(query.minFps, 10) : undefined
|
||||
const maxFps = query.maxFps ? parseInt(query.maxFps, 10) : undefined
|
||||
if ((minFps !== undefined || maxFps !== undefined) && filteredGameIds.size > 0) {
|
||||
const fpsStats = await db
|
||||
.select({
|
||||
gameId: gameVersions.gameId,
|
||||
bestFps: sql<number>`MAX(${performanceEntries.fpsAvg})::real`,
|
||||
})
|
||||
.from(performanceEntries)
|
||||
.innerJoin(gameVersions, eq(performanceEntries.versionId, gameVersions.id))
|
||||
.where(
|
||||
and(
|
||||
inArray(gameVersions.gameId, [...filteredGameIds]),
|
||||
eq(performanceEntries.isRemoved, false),
|
||||
),
|
||||
)
|
||||
.groupBy(gameVersions.gameId)
|
||||
|
||||
const fpsMatchIds = new Set<string>()
|
||||
for (const row of fpsStats) {
|
||||
if (minFps !== undefined && row.bestFps < minFps) continue
|
||||
if (maxFps !== undefined && row.bestFps > maxFps) continue
|
||||
fpsMatchIds.add(row.gameId)
|
||||
}
|
||||
filteredGameIds = fpsMatchIds
|
||||
}
|
||||
|
||||
// ── Filter local games to only those that passed all filters so far ──
|
||||
const filteredLocalGames = localGames.filter((g) => filteredGameIds.has(g.id))
|
||||
const filteredIds = filteredLocalGames.map((g) => g.id)
|
||||
|
||||
// Fetch platform support + anti-cheat for local games
|
||||
const platformSupportMap = new Map<
|
||||
string,
|
||||
{
|
||||
isSupported: boolean
|
||||
protonStatus: string
|
||||
antiCheatRelevant: boolean
|
||||
antiCheatName: string | null
|
||||
antiCheatStatus: string
|
||||
}
|
||||
>()
|
||||
if (filteredIds.length > 0) {
|
||||
const { gamePlatformSupport } = await import("@/lib/db/schema")
|
||||
const supportRows = await db
|
||||
.select({
|
||||
gameId: gamePlatformSupport.gameId,
|
||||
isSupported: gamePlatformSupport.isSupported,
|
||||
protonStatus: gamePlatformSupport.protonStatus,
|
||||
antiCheatRelevant: gamePlatformSupport.antiCheatRelevant,
|
||||
antiCheatName: gamePlatformSupport.antiCheatName,
|
||||
antiCheatStatus: gamePlatformSupport.antiCheatStatus,
|
||||
})
|
||||
.from(gamePlatformSupport)
|
||||
.where(inArray(gamePlatformSupport.gameId, filteredIds))
|
||||
|
||||
for (const row of supportRows) {
|
||||
platformSupportMap.set(row.gameId, {
|
||||
isSupported: row.isSupported,
|
||||
protonStatus: row.protonStatus,
|
||||
antiCheatRelevant: row.antiCheatRelevant,
|
||||
antiCheatName: row.antiCheatName,
|
||||
antiCheatStatus: row.antiCheatStatus,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ── 1c. Proton/Native and Anti-cheat post-filters ─────────────
|
||||
if (query.protonNative && query.protonNative !== "any" && filteredIds.length > 0) {
|
||||
const protonMatchIds = new Set<string>()
|
||||
for (const g of filteredLocalGames) {
|
||||
const platform = platformSupportMap.get(g.id)
|
||||
const protonStatus = platform
|
||||
? platform.protonStatus
|
||||
: g.platforms?.linux
|
||||
? "native"
|
||||
: g.platforms?.windows
|
||||
? "proton"
|
||||
: "unknown"
|
||||
if (
|
||||
(query.protonNative === "native" && protonStatus === "native") ||
|
||||
(query.protonNative === "proton" && protonStatus === "proton")
|
||||
) {
|
||||
protonMatchIds.add(g.id)
|
||||
}
|
||||
}
|
||||
filteredGameIds = protonMatchIds
|
||||
}
|
||||
|
||||
if (query.antiCheatStatus && query.antiCheatStatus !== "any" && filteredIds.length > 0) {
|
||||
const acMatchIds = new Set<string>()
|
||||
for (const g of filteredLocalGames) {
|
||||
const platform = platformSupportMap.get(g.id)
|
||||
const acStatus = platform ? platform.antiCheatStatus : "unknown"
|
||||
if (acStatus === query.antiCheatStatus) {
|
||||
acMatchIds.add(g.id)
|
||||
}
|
||||
}
|
||||
filteredGameIds = acMatchIds
|
||||
}
|
||||
|
||||
// Final local games after all filters
|
||||
const finalLocalGames = filteredLocalGames.filter((g) => filteredGameIds.has(g.id))
|
||||
const finalIds = finalLocalGames.map((g) => g.id)
|
||||
const finalSteamAppIds = new Set(
|
||||
finalLocalGames.map((g) => g.steamAppId).filter(Boolean),
|
||||
)
|
||||
|
||||
// ── 2. Count related data for local games ───────────────────────
|
||||
let benchmarkCounts: { gameId: string; count: number }[] = []
|
||||
let presetCounts: { gameId: string; count: number }[] = []
|
||||
let commentCounts: { gameId: string; count: number }[] = []
|
||||
|
||||
if (finalIds.length > 0) {
|
||||
const [bCounts, pCounts, cCounts] = await Promise.all([
|
||||
db
|
||||
.select({
|
||||
gameId: gameVersions.gameId,
|
||||
count: sql<number>`count(*)::int`,
|
||||
})
|
||||
.from(performanceEntries)
|
||||
.innerJoin(
|
||||
gameVersions,
|
||||
eq(performanceEntries.versionId, gameVersions.id),
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
inArray(gameVersions.gameId, finalIds),
|
||||
eq(performanceEntries.isRemoved, false),
|
||||
),
|
||||
)
|
||||
.groupBy(gameVersions.gameId),
|
||||
db
|
||||
.select({
|
||||
gameId: gameVersions.gameId,
|
||||
count: sql<number>`count(*)::int`,
|
||||
})
|
||||
.from(performanceEntries)
|
||||
.innerJoin(
|
||||
gameVersions,
|
||||
eq(performanceEntries.versionId, gameVersions.id),
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
inArray(gameVersions.gameId, finalIds),
|
||||
eq(performanceEntries.isRemoved, false),
|
||||
sql`${performanceEntries.settingsJson} IS NOT NULL`,
|
||||
),
|
||||
)
|
||||
.groupBy(gameVersions.gameId),
|
||||
db
|
||||
.select({
|
||||
gameId: gameComments.gameId,
|
||||
count: sql<number>`count(*)::int`,
|
||||
})
|
||||
.from(gameComments)
|
||||
.where(inArray(gameComments.gameId, finalIds))
|
||||
.groupBy(gameComments.gameId),
|
||||
])
|
||||
benchmarkCounts = bCounts
|
||||
presetCounts = pCounts
|
||||
commentCounts = cCounts
|
||||
}
|
||||
|
||||
const countMap = new Map<
|
||||
string,
|
||||
{ benchmarks: number; presets: number; comments: number }
|
||||
>()
|
||||
for (const g of finalLocalGames) {
|
||||
countMap.set(g.id, { benchmarks: 0, presets: 0, comments: 0 })
|
||||
}
|
||||
for (const c of benchmarkCounts) {
|
||||
countMap.get(c.gameId)!.benchmarks = c.count
|
||||
}
|
||||
for (const c of presetCounts) {
|
||||
countMap.get(c.gameId)!.presets = c.count
|
||||
}
|
||||
for (const c of commentCounts) {
|
||||
countMap.get(c.gameId)!.comments = c.count
|
||||
}
|
||||
|
||||
// ── 2b. Raw Performer + Poor Performance + best FPS ────────────
|
||||
const rawPerformerMap = new Map<string, boolean>()
|
||||
const poorPerformerMap = new Map<string, boolean>()
|
||||
const bestFpsMap = new Map<string, number>()
|
||||
|
||||
if (finalIds.length > 0) {
|
||||
const perfStats = await db
|
||||
.select({
|
||||
gameId: gameVersions.gameId,
|
||||
bestFps: sql<number>`MAX(${performanceEntries.fpsAvg})::real`,
|
||||
isRawPerformer: sql<boolean>`BOOL_OR(
|
||||
${performanceEntries.fpsAvg} >= 60
|
||||
AND ${performanceEntries.upscalerType} = 'none'
|
||||
AND ${performanceEntries.frameGenMethod} = 'none'
|
||||
)`,
|
||||
isPoorPerformance: sql<boolean>`BOOL_OR(${performanceEntries.fpsAvg} < 30)`,
|
||||
})
|
||||
.from(performanceEntries)
|
||||
.innerJoin(
|
||||
gameVersions,
|
||||
eq(performanceEntries.versionId, gameVersions.id),
|
||||
)
|
||||
.innerJoin(hardware, and(
|
||||
eq(performanceEntries.hardwareSlug, hardware.slug),
|
||||
eq(hardware.deviceType, "handheld"),
|
||||
))
|
||||
.where(
|
||||
and(
|
||||
inArray(gameVersions.gameId, finalIds),
|
||||
eq(performanceEntries.isRemoved, false),
|
||||
),
|
||||
)
|
||||
.groupBy(gameVersions.gameId)
|
||||
|
||||
for (const row of perfStats) {
|
||||
bestFpsMap.set(row.gameId, row.bestFps)
|
||||
rawPerformerMap.set(row.gameId, row.isRawPerformer)
|
||||
poorPerformerMap.set(row.gameId, row.isPoorPerformance)
|
||||
}
|
||||
}
|
||||
|
||||
// ── 2b2. Battery estimate for handheld devices ────────────────────
|
||||
const batteryMinMap = new Map<string, number>()
|
||||
|
||||
if (finalIds.length > 0) {
|
||||
const { hardware: hardwareTable } = await import("@/lib/db/schema")
|
||||
|
||||
const batteryStats = await db
|
||||
.select({
|
||||
gameId: gameVersions.gameId,
|
||||
estimatedBatteryMin: sql<number>`ROUND(
|
||||
(${hardwareTable.wattHours}::real / ${performanceEntries.tdpWatts}) * 60
|
||||
)::int`,
|
||||
})
|
||||
.from(performanceEntries)
|
||||
.innerJoin(gameVersions, eq(performanceEntries.versionId, gameVersions.id))
|
||||
.innerJoin(hardwareTable, eq(performanceEntries.hardwareSlug, hardwareTable.slug))
|
||||
.where(
|
||||
and(
|
||||
inArray(gameVersions.gameId, finalIds),
|
||||
eq(performanceEntries.isRemoved, false),
|
||||
eq(hardwareTable.deviceType, "handheld"),
|
||||
sql`${performanceEntries.tdpWatts} IS NOT NULL AND ${performanceEntries.tdpWatts} > 0`,
|
||||
sql`${hardwareTable.wattHours} IS NOT NULL`,
|
||||
),
|
||||
)
|
||||
.orderBy(desc(performanceEntries.fpsAvg))
|
||||
|
||||
// Deduplicate — keep only the first (best fps) entry per game
|
||||
const seenGames = new Set<string>()
|
||||
for (const row of batteryStats) {
|
||||
if (!seenGames.has(row.gameId)) {
|
||||
seenGames.add(row.gameId)
|
||||
batteryMinMap.set(row.gameId, row.estimatedBatteryMin)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── 2c. Latest version ──────────────────────────────────────────
|
||||
const latestVersionMap = new Map<string, string>()
|
||||
|
||||
if (finalIds.length > 0) {
|
||||
const versionRows = await db
|
||||
.select({
|
||||
gameId: gameVersions.gameId,
|
||||
versionString: gameVersions.versionString,
|
||||
})
|
||||
.from(gameVersions)
|
||||
.where(
|
||||
and(
|
||||
inArray(gameVersions.gameId, finalIds),
|
||||
eq(gameVersions.isLatest, true),
|
||||
),
|
||||
)
|
||||
|
||||
for (const row of versionRows) {
|
||||
if (row.versionString) {
|
||||
latestVersionMap.set(row.gameId, row.versionString)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── 3. Search Steam ─────────────────────────────────────────────
|
||||
let steamItems: SteamSearchItem[] = []
|
||||
try {
|
||||
const url = new URL("https://store.steampowered.com/api/storesearch/")
|
||||
url.searchParams.set("term", query.q)
|
||||
url.searchParams.set("cc", "US")
|
||||
url.searchParams.set("l", "en")
|
||||
|
||||
const res = await fetch(url.toString(), {
|
||||
headers: { Accept: "application/json" },
|
||||
})
|
||||
if (res.ok) {
|
||||
const data = (await res.json()) as SteamSearchResponse
|
||||
steamItems = (data.items || []).filter((item) => {
|
||||
const name = item.name.toLowerCase()
|
||||
const exclude = [
|
||||
"soundtrack",
|
||||
" original soundtrack",
|
||||
" ost",
|
||||
" - ost",
|
||||
"dlc",
|
||||
"expansion",
|
||||
"season pass",
|
||||
" deluxe edition",
|
||||
" ultimate edition",
|
||||
" premium edition",
|
||||
" demo",
|
||||
" trial",
|
||||
" playtest",
|
||||
" beta",
|
||||
" artbook",
|
||||
" soundtrack bundle",
|
||||
]
|
||||
return !exclude.some((kw) => name.includes(kw))
|
||||
})
|
||||
}
|
||||
} catch {
|
||||
// Steam search failure is non-fatal
|
||||
}
|
||||
|
||||
// ── 4. Build unified results ────────────────────────────────────
|
||||
const results = []
|
||||
|
||||
// Add local games
|
||||
for (const g of finalLocalGames) {
|
||||
const counts = countMap.get(g.id)!
|
||||
const platform = platformSupportMap.get(g.id)
|
||||
results.push({
|
||||
kind: "local" as const,
|
||||
id: g.id,
|
||||
appId: g.steamAppId,
|
||||
title: g.title,
|
||||
image: g.capsuleImage || g.headerImage,
|
||||
tinyImage: g.capsuleImage || g.headerImage || null,
|
||||
developer: g.developer,
|
||||
publisher: g.publisher,
|
||||
description: g.description,
|
||||
genres: g.genres,
|
||||
source: g.source,
|
||||
counts,
|
||||
platforms: g.platforms,
|
||||
platformSupport: platform
|
||||
? {
|
||||
isSupported: platform.isSupported,
|
||||
protonStatus: platform.protonStatus,
|
||||
antiCheatRelevant: platform.antiCheatRelevant,
|
||||
antiCheatName: platform.antiCheatName,
|
||||
antiCheatStatus: platform.antiCheatStatus,
|
||||
}
|
||||
: g.platforms
|
||||
? {
|
||||
isSupported: g.platforms.linux || g.platforms.windows || false,
|
||||
protonStatus: g.platforms.linux
|
||||
? "native"
|
||||
: g.platforms.windows
|
||||
? "proton"
|
||||
: "unsupported",
|
||||
antiCheatRelevant: false,
|
||||
antiCheatName: null,
|
||||
antiCheatStatus: "unknown",
|
||||
}
|
||||
: null,
|
||||
isRawPerformer: rawPerformerMap.get(g.id) ?? false,
|
||||
isPoorPerformance: poorPerformerMap.get(g.id) ?? false,
|
||||
bestFps: bestFpsMap.get(g.id) ?? null,
|
||||
estimatedBatteryMin: batteryMinMap.get(g.id) ?? null,
|
||||
latestVersion: latestVersionMap.get(g.id) ?? null,
|
||||
playabilityStatus: g.playabilityStatus,
|
||||
steamReviewScore: g.steamReviewScore,
|
||||
steamReviewSentiment: g.steamReviewSentiment,
|
||||
antiCheatRelevant: platform?.antiCheatRelevant ?? null,
|
||||
antiCheatStatus: platform?.antiCheatStatus ?? null,
|
||||
antiCheatName: platform?.antiCheatName ?? null,
|
||||
})
|
||||
}
|
||||
|
||||
// Add Steam-only games
|
||||
for (const item of steamItems) {
|
||||
if (finalSteamAppIds.has(item.id)) continue
|
||||
results.push({
|
||||
kind: "steam" as const,
|
||||
appId: item.id,
|
||||
title: item.name,
|
||||
image: `https://cdn.akamai.steamstatic.com/steam/apps/${item.id}/library_600x900.jpg`,
|
||||
tinyImage: item.tiny_image || null,
|
||||
developer: null,
|
||||
publisher: null,
|
||||
description: null,
|
||||
genres: null,
|
||||
source: "steam" as const,
|
||||
counts: null,
|
||||
platformSupport: null,
|
||||
metascore: item.metascore || null,
|
||||
price: item.price
|
||||
? {
|
||||
currency: item.price.currency,
|
||||
initial: item.price.initial,
|
||||
final: item.price.final,
|
||||
}
|
||||
: null,
|
||||
platforms: item.platforms,
|
||||
controllerSupport: item.controller_support || null,
|
||||
isRawPerformer: false,
|
||||
bestFps: null,
|
||||
latestVersion: null,
|
||||
})
|
||||
}
|
||||
|
||||
return { results, total: results.length }
|
||||
},
|
||||
{
|
||||
query: t.Object({
|
||||
q: t.String(),
|
||||
device: t.Optional(t.String()),
|
||||
minFps: t.Optional(t.String()),
|
||||
maxFps: t.Optional(t.String()),
|
||||
fsrSupport: t.Optional(t.String()),
|
||||
protonNative: t.Optional(t.String()),
|
||||
antiCheatStatus: t.Optional(t.String()),
|
||||
playabilityStatus: t.Optional(t.String()),
|
||||
steamReviewScore: t.Optional(t.String()),
|
||||
isFree: t.Optional(t.String()),
|
||||
hasMultiplayer: t.Optional(t.String()),
|
||||
}),
|
||||
detail: {
|
||||
description: "Unified search across local database and Steam store. Returns both synced games (with performance data) and Steam-only results.",
|
||||
},
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,108 @@
|
||||
import { Elysia } from "elysia"
|
||||
import { db } from "@/lib/db/index"
|
||||
import { games } from "@/lib/db/schema"
|
||||
import { eq } from "drizzle-orm"
|
||||
|
||||
// In-memory cache for reviews (key -> { data, expires })
|
||||
const reviewCache = new Map<
|
||||
string,
|
||||
{ data: SteamReviewResponse; expires: number }
|
||||
>()
|
||||
|
||||
const CACHE_TTL = 60 * 60 * 1000 // 1 hour
|
||||
|
||||
interface SteamReview {
|
||||
recommendationid: string
|
||||
author: {
|
||||
steamid: string
|
||||
num_games_owned: number
|
||||
num_reviews: number
|
||||
playtime_forever: number
|
||||
playtime_last_two_weeks: number
|
||||
playtime_at_review: number
|
||||
last_played: number
|
||||
}
|
||||
language: string
|
||||
review: string
|
||||
timestamp_created: number
|
||||
timestamp_updated: number
|
||||
voted_up: boolean
|
||||
votes_up: number
|
||||
votes_funny: number
|
||||
comment_count: number
|
||||
steam_purchase: boolean
|
||||
received_for_free: boolean
|
||||
written_during_early_access: boolean
|
||||
}
|
||||
|
||||
interface SteamReviewResponse {
|
||||
success: number
|
||||
query_summary: {
|
||||
num_reviews: number
|
||||
review_score_desc: string
|
||||
total_positive: number
|
||||
total_negative: number
|
||||
total_reviews: number
|
||||
}
|
||||
reviews: SteamReview[]
|
||||
}
|
||||
|
||||
export const steamReviewRoutes = new Elysia({ prefix: "/steam-reviews", detail: { tags: ["Steam"] } })
|
||||
|
||||
// Get embedded Steam reviews for a game
|
||||
.get(
|
||||
"/:gameId",
|
||||
async ({ params, query, set }) => {
|
||||
const gameId = params.gameId
|
||||
const offset = Number(query.offset) || 0
|
||||
const limit = Math.min(Number(query.limit) || 10, 20)
|
||||
const language = query.language || "english"
|
||||
|
||||
// Get Steam App ID
|
||||
const [game] = await db
|
||||
.select({ steamAppId: games.steamAppId })
|
||||
.from(games)
|
||||
.where(eq(games.id, gameId))
|
||||
.limit(1)
|
||||
|
||||
if (!game?.steamAppId) {
|
||||
set.status = 404
|
||||
return { error: "Game not found or has no Steam App ID" }
|
||||
}
|
||||
|
||||
const cacheKey = `${game.steamAppId}-${language}-${offset}-${limit}`
|
||||
const cached = reviewCache.get(cacheKey)
|
||||
if (cached && cached.expires > Date.now()) {
|
||||
return cached.data
|
||||
}
|
||||
|
||||
try {
|
||||
// Steam API uses cursor-based pagination, but for simplicity we fetch more and slice
|
||||
const response = await fetch(
|
||||
`https://store.steampowered.com/appreviews/${game.steamAppId}?json=1&language=${language}&purchase_type=all&num_per_page=${offset + limit}&filter=recent&review_type=all`,
|
||||
{ signal: AbortSignal.timeout(10000) }
|
||||
)
|
||||
|
||||
if (!response.ok) {
|
||||
set.status = 502
|
||||
return { error: "Failed to fetch Steam reviews" }
|
||||
}
|
||||
|
||||
const data: SteamReviewResponse = await response.json()
|
||||
|
||||
// Slice reviews for the requested page
|
||||
const slicedData = {
|
||||
...data,
|
||||
reviews: (data.reviews ?? []).slice(offset, offset + limit),
|
||||
}
|
||||
|
||||
// Cache the result
|
||||
reviewCache.set(cacheKey, { data: slicedData, expires: Date.now() + CACHE_TTL })
|
||||
|
||||
return slicedData
|
||||
} catch {
|
||||
set.status = 502
|
||||
return { error: "Steam review API unavailable" }
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,87 @@
|
||||
import { Elysia, t } from "elysia"
|
||||
|
||||
interface SteamSearchItem {
|
||||
id: number
|
||||
name: string
|
||||
tiny_image: string
|
||||
metascore: string
|
||||
platforms: {
|
||||
windows: boolean
|
||||
mac: boolean
|
||||
linux: boolean
|
||||
}
|
||||
}
|
||||
|
||||
export const steamSearchRoutes = new Elysia({ prefix: "/search", detail: { tags: ["Search"] } })
|
||||
.get(
|
||||
"/steam",
|
||||
async ({ query, set }) => {
|
||||
if (!query.q || query.q.length < 2) {
|
||||
set.status = 400
|
||||
return { error: "Query must be at least 2 characters" }
|
||||
}
|
||||
|
||||
try {
|
||||
const url = new URL("https://store.steampowered.com/api/storesearch/")
|
||||
url.searchParams.set("term", query.q)
|
||||
url.searchParams.set("cc", "US")
|
||||
url.searchParams.set("l", "en")
|
||||
|
||||
const res = await fetch(url.toString(), {
|
||||
headers: { Accept: "application/json" },
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
set.status = 502
|
||||
return { error: "Failed to fetch from Steam" }
|
||||
}
|
||||
|
||||
const data = await res.json()
|
||||
|
||||
const items: SteamSearchItem[] = (data.items || []).filter(
|
||||
(item: SteamSearchItem) => {
|
||||
const name = item.name.toLowerCase()
|
||||
const exclude = [
|
||||
"soundtrack",
|
||||
" original soundtrack",
|
||||
" ost",
|
||||
" - ost",
|
||||
"dlc",
|
||||
"expansion",
|
||||
"season pass",
|
||||
" deluxe edition",
|
||||
" ultimate edition",
|
||||
" premium edition",
|
||||
" demo",
|
||||
" trial",
|
||||
" playtest",
|
||||
" beta",
|
||||
" artbook",
|
||||
" soundtrack bundle",
|
||||
]
|
||||
return !exclude.some((kw) => name.includes(kw))
|
||||
},
|
||||
)
|
||||
|
||||
return {
|
||||
items: items.map((item) => ({
|
||||
appId: item.id,
|
||||
title: item.name,
|
||||
image: `https://cdn.akamai.steamstatic.com/steam/apps/${item.id}/library_600x900.jpg`,
|
||||
platforms: item.platforms,
|
||||
metascore: item.metascore,
|
||||
})),
|
||||
total: data.total || 0,
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Steam search error:", err)
|
||||
set.status = 500
|
||||
return { error: "Internal server error" }
|
||||
}
|
||||
},
|
||||
{
|
||||
query: t.Object({
|
||||
q: t.String(),
|
||||
}),
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,172 @@
|
||||
import { Elysia, t } from "elysia"
|
||||
import { db } from "@/lib/db/index"
|
||||
import { games } from "@/lib/db/schema"
|
||||
import { eq } from "drizzle-orm"
|
||||
import { fetchAllVersions, SERVER_STRATEGIES, CLIENT_STRATEGIES, type VersionFetchResult } from "@/lib/version-fetchers/index"
|
||||
|
||||
export const steamdbVersionRoutes = new Elysia({
|
||||
prefix: "/games/:gameId",
|
||||
detail: { tags: ["Games"] },
|
||||
}).get(
|
||||
"/steamdb-version",
|
||||
async ({ params, set }) => {
|
||||
// Look up game — supports both DB UUID and numeric Steam App ID
|
||||
const isNumeric = /^\d+$/.test(params.gameId)
|
||||
let steamAppId: number | null = null
|
||||
|
||||
if (isNumeric) {
|
||||
// Already a Steam App ID — use directly
|
||||
steamAppId = Number(params.gameId)
|
||||
} else {
|
||||
// Look up by DB UUID
|
||||
const [game] = await db
|
||||
.select({ steamAppId: games.steamAppId })
|
||||
.from(games)
|
||||
.where(eq(games.id, params.gameId))
|
||||
.limit(1)
|
||||
|
||||
if (!game) {
|
||||
set.status = 404
|
||||
return { error: "Game not found" }
|
||||
}
|
||||
steamAppId = game.steamAppId
|
||||
}
|
||||
|
||||
if (steamAppId === null) {
|
||||
return { unavailable: true, reason: "no_steam_app_id" }
|
||||
}
|
||||
|
||||
// Run ONLY server-safe strategies (no client-preferred ones)
|
||||
// Client-side strategies should be called from the browser
|
||||
const { best, all } = await fetchAllVersions(
|
||||
steamAppId,
|
||||
SERVER_STRATEGIES,
|
||||
)
|
||||
|
||||
// If server strategies found nothing, tell the client which strategies to try
|
||||
const clientStrategies = best.versionString || best.buildId
|
||||
? [] // Server got something, client doesn't need to try more
|
||||
: CLIENT_STRATEGIES.map((s) => s.name) // Server got nothing, suggest client try these
|
||||
|
||||
if (best.versionString === null && best.buildId === null && clientStrategies.length === 0) {
|
||||
return {
|
||||
unavailable: true,
|
||||
reason: "not_found",
|
||||
results: all.map((r) => ({
|
||||
source: r.source,
|
||||
success: r.success,
|
||||
error: r.error,
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
versionString: best.versionString,
|
||||
buildId: best.buildId,
|
||||
steamAppId,
|
||||
source: best.source,
|
||||
clientStrategies,
|
||||
// Include detailed results for debugging
|
||||
results: all.map((r) => ({
|
||||
source: r.source,
|
||||
versionString: r.versionString,
|
||||
buildId: r.buildId,
|
||||
success: r.success,
|
||||
error: r.error,
|
||||
})),
|
||||
// If server didn't find named version, tell client to try
|
||||
needsClientFetch: clientStrategies.length > 0,
|
||||
}
|
||||
},
|
||||
{
|
||||
params: t.Object({ gameId: t.String() }),
|
||||
},
|
||||
)
|
||||
|
||||
/**
|
||||
* Client-friendly version fetch endpoint.
|
||||
* POST /api/games/:gameId/fetch-version-client
|
||||
*
|
||||
* Accepts results from client-side strategies and merges with server results.
|
||||
* The client calls this after running client-side strategies in the browser.
|
||||
*/
|
||||
export const clientVersionRoutes = new Elysia({
|
||||
prefix: "/games/:gameId",
|
||||
detail: { tags: ["Games"] },
|
||||
}).post(
|
||||
"/fetch-version-client",
|
||||
async ({ params, body, set }) => {
|
||||
// Look up game — supports both DB UUID and numeric Steam App ID
|
||||
const isNumeric = /^\d+$/.test(params.gameId)
|
||||
let steamAppId: number | null = null
|
||||
|
||||
if (isNumeric) {
|
||||
steamAppId = Number(params.gameId)
|
||||
} else {
|
||||
const [game] = await db
|
||||
.select({ steamAppId: games.steamAppId })
|
||||
.from(games)
|
||||
.where(eq(games.id, params.gameId))
|
||||
.limit(1)
|
||||
|
||||
if (!game) {
|
||||
set.status = 404
|
||||
return { error: "Game not found" }
|
||||
}
|
||||
steamAppId = game.steamAppId
|
||||
}
|
||||
|
||||
if (steamAppId === null) {
|
||||
return { unavailable: true, reason: "no_steam_app_id" }
|
||||
}
|
||||
|
||||
// Run server strategies
|
||||
const { best: serverBest } = await fetchAllVersions(
|
||||
steamAppId,
|
||||
SERVER_STRATEGIES,
|
||||
)
|
||||
|
||||
// Merge with client results
|
||||
const clientResults: VersionFetchResult[] = (body.clientResults ?? []).map(
|
||||
(r: { source: string; versionString: string | null; buildId: string | null; success: boolean; error?: string }) => ({
|
||||
...r,
|
||||
}),
|
||||
)
|
||||
|
||||
const allResults = [
|
||||
...SERVER_STRATEGIES.map((s) => {
|
||||
const existing = clientResults.find((r) => r.source === s.name)
|
||||
return existing ?? { versionString: null, buildId: null, source: s.name, success: false }
|
||||
}),
|
||||
...clientResults.filter((r) => !SERVER_STRATEGIES.some((s) => s.name === r.source)),
|
||||
]
|
||||
|
||||
// Find best: prefer versionString > buildId
|
||||
let best: VersionFetchResult = serverBest
|
||||
for (const r of allResults) {
|
||||
if (r.versionString && !best.versionString) best = r
|
||||
if (!best.versionString && !best.buildId && r.buildId) best = r
|
||||
}
|
||||
|
||||
return {
|
||||
versionString: best.versionString,
|
||||
buildId: best.buildId,
|
||||
source: best.source,
|
||||
allResults,
|
||||
}
|
||||
},
|
||||
{
|
||||
params: t.Object({ gameId: t.String() }),
|
||||
body: t.Object({
|
||||
clientResults: t.Array(
|
||||
t.Object({
|
||||
source: t.String(),
|
||||
versionString: t.Nullable(t.String()),
|
||||
buildId: t.Nullable(t.String()),
|
||||
success: t.Boolean(),
|
||||
error: t.Optional(t.String()),
|
||||
}),
|
||||
),
|
||||
}),
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,80 @@
|
||||
import { Elysia, t } from "elysia"
|
||||
|
||||
const STEAMGRIDDB_BASE = "https://www.steamgriddb.com/api/v2"
|
||||
|
||||
export const steamgridProxyRoutes = new Elysia({ prefix: "/steamgrid", detail: { tags: ["Steam"] } })
|
||||
.get(
|
||||
"/search",
|
||||
async ({ query, set }) => {
|
||||
const apiKey = process.env.STEAMGRIDDB_API_KEY
|
||||
if (!apiKey) {
|
||||
set.status = 503
|
||||
return { error: "SteamGridDB API key not configured" }
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(
|
||||
`${STEAMGRIDDB_BASE}/search/autocomplete/${encodeURIComponent(query.q)}`,
|
||||
{
|
||||
headers: { Authorization: `Bearer ${apiKey}` },
|
||||
}
|
||||
)
|
||||
|
||||
if (!res.ok) {
|
||||
set.status = res.status
|
||||
return { error: "SteamGridDB search failed" }
|
||||
}
|
||||
|
||||
const data = await res.json()
|
||||
return data
|
||||
} catch (err) {
|
||||
console.error("SteamGridDB search error:", err)
|
||||
set.status = 500
|
||||
return { error: "SteamGridDB search failed" }
|
||||
}
|
||||
},
|
||||
{
|
||||
query: t.Object({ q: t.String({ minLength: 2 }) }),
|
||||
}
|
||||
)
|
||||
.get(
|
||||
"/grids/:gameId",
|
||||
async ({ params, query, set }) => {
|
||||
const apiKey = process.env.STEAMGRIDDB_API_KEY
|
||||
if (!apiKey) {
|
||||
set.status = 503
|
||||
return { error: "SteamGridDB API key not configured" }
|
||||
}
|
||||
|
||||
try {
|
||||
const url = new URL(`${STEAMGRIDDB_BASE}/grids/game/${params.gameId}`)
|
||||
// Add dimensions filter for capsule-style images
|
||||
url.searchParams.set("dimensions", "600x900,342x482")
|
||||
if (query.styles) {
|
||||
url.searchParams.set("styles", query.styles)
|
||||
}
|
||||
|
||||
const res = await fetch(url.toString(), {
|
||||
headers: { Authorization: `Bearer ${apiKey}` },
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
set.status = res.status
|
||||
return { error: "SteamGridDB grids fetch failed" }
|
||||
}
|
||||
|
||||
const data = await res.json()
|
||||
return data
|
||||
} catch (err) {
|
||||
console.error("SteamGridDB grids fetch error:", err)
|
||||
set.status = 500
|
||||
return { error: "SteamGridDB grids fetch failed" }
|
||||
}
|
||||
},
|
||||
{
|
||||
params: t.Object({ gameId: t.String() }),
|
||||
query: t.Object({
|
||||
styles: t.Optional(t.String()),
|
||||
}),
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,327 @@
|
||||
import { Elysia, t } from "elysia"
|
||||
import { auth } from "@/lib/auth"
|
||||
import { db } from "@/lib/db/index"
|
||||
import { user, performanceEntries, games, gameVersions, hardware, account, passkey } from "@/lib/db/schema"
|
||||
import { eq, sql, and, desc } from "drizzle-orm"
|
||||
import { hashPassword } from "better-auth/crypto"
|
||||
|
||||
export const userRoutes = new Elysia({ prefix: "/user", detail: { tags: ["Users"] } })
|
||||
.get(
|
||||
"/profile/:id",
|
||||
async ({ params, set }) => {
|
||||
const [profile] = await db
|
||||
.select({
|
||||
id: user.id,
|
||||
name: user.name,
|
||||
image: user.image,
|
||||
role: user.role,
|
||||
createdAt: user.createdAt,
|
||||
emailVerified: user.emailVerified,
|
||||
})
|
||||
.from(user)
|
||||
.where(eq(user.id, params.id))
|
||||
.limit(1)
|
||||
|
||||
if (!profile) {
|
||||
set.status = 404
|
||||
return { error: "User not found" }
|
||||
}
|
||||
|
||||
// Count contributions (performance entries)
|
||||
const [{ count: contributions }] = await db
|
||||
.select({ count: sql<number>`count(*)::int` })
|
||||
.from(performanceEntries)
|
||||
.where(eq(performanceEntries.userId, params.id))
|
||||
|
||||
// Count verified entries
|
||||
const [{ count: verifiedEntries }] = await db
|
||||
.select({ count: sql<number>`count(*)::int` })
|
||||
.from(performanceEntries)
|
||||
.where(
|
||||
sql`${performanceEntries.userId} = ${params.id} AND ${performanceEntries.verifiedAt} IS NOT NULL`
|
||||
)
|
||||
|
||||
// Reputation = contributions * 10 (simple formula for now)
|
||||
const reputation = contributions * 10
|
||||
|
||||
const { emailVerified, ...publicProfile } = profile
|
||||
return {
|
||||
...publicProfile,
|
||||
createdAt: publicProfile.createdAt.toISOString(),
|
||||
contributions,
|
||||
verifiedEntries,
|
||||
reputation,
|
||||
verified: !!emailVerified,
|
||||
}
|
||||
},
|
||||
{
|
||||
params: t.Object({
|
||||
id: t.String(),
|
||||
}),
|
||||
},
|
||||
)
|
||||
.get(
|
||||
"/me",
|
||||
async ({ request, set }) => {
|
||||
const session = await auth.api.getSession({
|
||||
headers: request.headers,
|
||||
})
|
||||
|
||||
if (!session) {
|
||||
set.status = 401
|
||||
return { error: "Unauthorized" }
|
||||
}
|
||||
|
||||
const [profile] = await db
|
||||
.select({
|
||||
id: user.id,
|
||||
name: user.name,
|
||||
email: user.email,
|
||||
image: user.image,
|
||||
role: user.role,
|
||||
createdAt: user.createdAt,
|
||||
emailVerified: user.emailVerified,
|
||||
})
|
||||
.from(user)
|
||||
.where(eq(user.id, session.user.id))
|
||||
.limit(1)
|
||||
|
||||
if (!profile) {
|
||||
set.status = 404
|
||||
return { error: "User not found" }
|
||||
}
|
||||
|
||||
const [{ count: contributions }] = await db
|
||||
.select({ count: sql<number>`count(*)::int` })
|
||||
.from(performanceEntries)
|
||||
.where(eq(performanceEntries.userId, session.user.id))
|
||||
|
||||
// Count verified entries
|
||||
const [{ count: verifiedEntries }] = await db
|
||||
.select({ count: sql<number>`count(*)::int` })
|
||||
.from(performanceEntries)
|
||||
.where(
|
||||
sql`${performanceEntries.userId} = ${session.user.id} AND ${performanceEntries.verifiedAt} IS NOT NULL`
|
||||
)
|
||||
|
||||
return {
|
||||
id: profile.id,
|
||||
name: profile.name,
|
||||
email: profile.email,
|
||||
image: profile.image,
|
||||
role: profile.role,
|
||||
createdAt: profile.createdAt.toISOString(),
|
||||
contributions,
|
||||
verifiedEntries,
|
||||
reputation: contributions * 10,
|
||||
verified: !!profile.emailVerified,
|
||||
}
|
||||
},
|
||||
)
|
||||
.get(
|
||||
"/me/sessions",
|
||||
async ({ request, set }) => {
|
||||
const session = await auth.api.getSession({
|
||||
headers: request.headers,
|
||||
})
|
||||
|
||||
if (!session) {
|
||||
set.status = 401
|
||||
return { error: "Unauthorized" }
|
||||
}
|
||||
|
||||
const sessions = await auth.api.listSessions({
|
||||
headers: request.headers,
|
||||
})
|
||||
|
||||
return sessions
|
||||
},
|
||||
)
|
||||
.get(
|
||||
"/me/auth-methods",
|
||||
async ({ request, set }) => {
|
||||
const session = await auth.api.getSession({
|
||||
headers: request.headers,
|
||||
})
|
||||
|
||||
if (!session) {
|
||||
set.status = 401
|
||||
return { error: "Unauthorized" }
|
||||
}
|
||||
|
||||
// Count accounts by provider
|
||||
const accounts = await db
|
||||
.select({ providerId: account.providerId, id: account.id })
|
||||
.from(account)
|
||||
.where(eq(account.userId, session.user.id))
|
||||
|
||||
// Count passkeys via direct DB query (avoids auth.api.listPasskeys hanging)
|
||||
const passkeys = await db
|
||||
.select({ id: passkey.id })
|
||||
.from(passkey)
|
||||
.where(eq(passkey.userId, session.user.id))
|
||||
|
||||
// Check if user has a password (from accounts where providerId is "credential")
|
||||
const hasPassword = accounts.some((a) => a.providerId === "credential")
|
||||
|
||||
// OAuth providers
|
||||
const oauthProviders = accounts
|
||||
.filter((a) => a.providerId !== "credential")
|
||||
.map((a) => ({
|
||||
providerId: a.providerId,
|
||||
id: a.id,
|
||||
}))
|
||||
|
||||
// Total auth methods = passwords + passkeys + oauth accounts
|
||||
const passkeyCount = passkeys.length
|
||||
const totalAuthMethods =
|
||||
(hasPassword ? 1 : 0) + passkeyCount + oauthProviders.length
|
||||
|
||||
return {
|
||||
hasPassword,
|
||||
passkeyCount,
|
||||
oauthProviders,
|
||||
totalAuthMethods,
|
||||
}
|
||||
},
|
||||
)
|
||||
.post(
|
||||
"/me/set-password",
|
||||
async ({ request, body, set }) => {
|
||||
// Reject non-JSON content types
|
||||
const contentType = request.headers.get("content-type") || ""
|
||||
if (!contentType.includes("application/json")) {
|
||||
set.status = 415
|
||||
return { error: "Content-Type must be application/json" }
|
||||
}
|
||||
|
||||
const session = await auth.api.getSession({
|
||||
headers: request.headers,
|
||||
})
|
||||
|
||||
if (!session) {
|
||||
set.status = 401
|
||||
return { error: "Unauthorized" }
|
||||
}
|
||||
|
||||
// Check if user already has a password
|
||||
const existing = await db
|
||||
.select({ id: account.id })
|
||||
.from(account)
|
||||
.where(
|
||||
and(
|
||||
eq(account.userId, session.user.id),
|
||||
eq(account.providerId, "credential")
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
if (existing.length > 0) {
|
||||
set.status = 400
|
||||
return { error: "Password already set" }
|
||||
}
|
||||
|
||||
const hashed = await hashPassword(body.newPassword)
|
||||
|
||||
await db.insert(account).values({
|
||||
id: crypto.randomUUID(),
|
||||
userId: session.user.id,
|
||||
providerId: "credential",
|
||||
accountId: session.user.id,
|
||||
password: hashed,
|
||||
})
|
||||
|
||||
return { success: true }
|
||||
},
|
||||
{
|
||||
body: t.Object({
|
||||
newPassword: t.String({ minLength: 10 }),
|
||||
}),
|
||||
detail: {
|
||||
description: "Set a password for the authenticated user. Requires JSON body.",
|
||||
tags: ["Users"],
|
||||
},
|
||||
},
|
||||
)
|
||||
.get(
|
||||
"/profile/:id/contributions",
|
||||
async ({ params, query, set }) => {
|
||||
const [profile] = await db
|
||||
.select({ id: user.id })
|
||||
.from(user)
|
||||
.where(eq(user.id, params.id))
|
||||
.limit(1)
|
||||
|
||||
if (!profile) {
|
||||
set.status = 404
|
||||
return { error: "User not found" }
|
||||
}
|
||||
|
||||
const limit = Math.min(Number(query.limit) || 10, 50)
|
||||
const offset = Number(query.offset) || 0
|
||||
|
||||
const entries = await db
|
||||
.select({
|
||||
id: performanceEntries.id,
|
||||
fpsAvg: performanceEntries.fpsAvg,
|
||||
fpsLow: performanceEntries.fpsLow,
|
||||
fpsHigh: performanceEntries.fpsHigh,
|
||||
hardwareSlug: performanceEntries.hardwareSlug,
|
||||
hardwareName: hardware.name,
|
||||
upscalerType: performanceEntries.upscalerType,
|
||||
upscalerVersion: performanceEntries.upscalerVersion,
|
||||
frameGenMethod: performanceEntries.frameGenMethod,
|
||||
verifiedAt: performanceEntries.verifiedAt,
|
||||
createdAt: performanceEntries.createdAt,
|
||||
gameTitle: games.title,
|
||||
gameId: games.id,
|
||||
gameHeaderImage: games.headerImage,
|
||||
})
|
||||
.from(performanceEntries)
|
||||
.innerJoin(
|
||||
gameVersions,
|
||||
eq(performanceEntries.versionId, gameVersions.id),
|
||||
)
|
||||
.innerJoin(games, eq(gameVersions.gameId, games.id))
|
||||
.innerJoin(
|
||||
hardware,
|
||||
eq(performanceEntries.hardwareSlug, hardware.slug),
|
||||
)
|
||||
.where(and(
|
||||
eq(performanceEntries.userId, params.id),
|
||||
eq(performanceEntries.isRemoved, false)
|
||||
))
|
||||
.orderBy(desc(performanceEntries.createdAt))
|
||||
.limit(limit)
|
||||
.offset(offset)
|
||||
|
||||
const [{ count: total }] = await db
|
||||
.select({ count: sql<number>`count(*)::int` })
|
||||
.from(performanceEntries)
|
||||
.where(and(
|
||||
eq(performanceEntries.userId, params.id),
|
||||
eq(performanceEntries.isRemoved, false)
|
||||
))
|
||||
|
||||
return {
|
||||
data: entries.map((e) => ({
|
||||
...e,
|
||||
createdAt: e.createdAt.toISOString(),
|
||||
verifiedAt: e.verifiedAt?.toISOString() ?? null,
|
||||
})),
|
||||
total,
|
||||
limit,
|
||||
offset,
|
||||
}
|
||||
},
|
||||
{
|
||||
params: t.Object({
|
||||
id: t.String(),
|
||||
}),
|
||||
query: t.Object({
|
||||
limit: t.Optional(t.String()),
|
||||
offset: t.Optional(t.String()),
|
||||
}),
|
||||
},
|
||||
)
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
/**
|
||||
* Version Fetcher Test API
|
||||
*
|
||||
* Two endpoints:
|
||||
* 1. GET /api/games/:gameId/test-version-fetchers
|
||||
* Uses DB game ID (UUID or numeric Steam App ID) to look up the game,
|
||||
* then runs all strategies.
|
||||
*
|
||||
* 2. GET /api/version-test?steamAppId=730
|
||||
* Runs all strategies directly against a Steam App ID — no DB lookup needed.
|
||||
* This is the preferred testing endpoint.
|
||||
*/
|
||||
import { Elysia, t } from "elysia"
|
||||
import { db } from "@/lib/db/index"
|
||||
import { games } from "@/lib/db/schema"
|
||||
import { eq } from "drizzle-orm"
|
||||
import { ALL_STRATEGIES } from "@/lib/version-fetchers/index"
|
||||
|
||||
async function resolveGame(gameId: string): Promise<{
|
||||
steamAppId: number
|
||||
title: string | null
|
||||
dbId: string | null
|
||||
} | null> {
|
||||
// Try numeric (Steam App ID) first
|
||||
const isNumeric = /^\d+$/.test(gameId)
|
||||
const numId = isNumeric ? Number(gameId) : null
|
||||
|
||||
if (numId) {
|
||||
// Look up by Steam App ID in DB
|
||||
const [game] = await db
|
||||
.select({ id: games.id, steamAppId: games.steamAppId, title: games.title })
|
||||
.from(games)
|
||||
.where(eq(games.steamAppId, numId))
|
||||
.limit(1)
|
||||
|
||||
return {
|
||||
steamAppId: numId,
|
||||
title: game?.title ?? null,
|
||||
dbId: game?.id ?? null,
|
||||
}
|
||||
}
|
||||
|
||||
// Try UUID
|
||||
const [game] = await db
|
||||
.select({ id: games.id, steamAppId: games.steamAppId, title: games.title })
|
||||
.from(games)
|
||||
.where(eq(games.id, gameId))
|
||||
.limit(1)
|
||||
|
||||
if (!game || game.steamAppId === null) return null
|
||||
|
||||
return {
|
||||
steamAppId: game.steamAppId,
|
||||
title: game.title,
|
||||
dbId: game.id,
|
||||
}
|
||||
}
|
||||
|
||||
// ── Nested route: /api/games/:gameId/test-version-fetchers ──────
|
||||
export const versionTestRoutes = new Elysia({
|
||||
prefix: "/games/:gameId",
|
||||
detail: { tags: ["Games"] },
|
||||
}).get(
|
||||
"/test-version-fetchers",
|
||||
async ({ params, query, set }) => {
|
||||
// Support direct steamAppId override via query param
|
||||
let steamAppId: number
|
||||
let title: string | null = null
|
||||
let dbId: string | null = null
|
||||
|
||||
if (query.steamAppId) {
|
||||
// Use provided Steam App ID directly
|
||||
steamAppId = Number(query.steamAppId)
|
||||
} else {
|
||||
const resolved = await resolveGame(params.gameId)
|
||||
if (!resolved) {
|
||||
set.status = 404
|
||||
return { error: "Game not found. Try passing ?steamAppId=730 directly." }
|
||||
}
|
||||
steamAppId = resolved.steamAppId
|
||||
title = resolved.title
|
||||
dbId = resolved.dbId
|
||||
}
|
||||
|
||||
// Run all strategies
|
||||
const results = await Promise.all(
|
||||
ALL_STRATEGIES.map(async (s) => {
|
||||
const result = await s.fn(steamAppId)
|
||||
return {
|
||||
strategy: s.name,
|
||||
versionString: result.versionString,
|
||||
buildId: result.buildId,
|
||||
success: result.success,
|
||||
error: result.error ?? null,
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
// Determine best
|
||||
const withVersion = results.find((r) => r.versionString)
|
||||
const withBuild = results.find((r) => r.buildId)
|
||||
const best = withVersion ?? withBuild ?? null
|
||||
|
||||
return {
|
||||
game: {
|
||||
id: dbId ?? params.gameId,
|
||||
title: title ?? `Steam App ${steamAppId}`,
|
||||
steamAppId,
|
||||
},
|
||||
results,
|
||||
best: best
|
||||
? {
|
||||
strategy: best.strategy,
|
||||
versionString: best.versionString,
|
||||
buildId: best.buildId,
|
||||
}
|
||||
: null,
|
||||
}
|
||||
},
|
||||
{
|
||||
params: t.Object({ gameId: t.String() }),
|
||||
query: t.Object({
|
||||
steamAppId: t.Optional(t.String()),
|
||||
}),
|
||||
},
|
||||
)
|
||||
|
||||
// ── Standalone route: /api/version-test?steamAppId=730 ──────────
|
||||
export const standaloneVersionTestRoutes = new Elysia({
|
||||
prefix: "/version-test",
|
||||
detail: { tags: ["Games"] },
|
||||
}).get(
|
||||
"/",
|
||||
async ({ query }) => {
|
||||
const steamAppId = Number(query.steamAppId)
|
||||
if (!steamAppId || isNaN(steamAppId)) {
|
||||
return { error: "steamAppId query parameter is required (e.g., ?steamAppId=730)" }
|
||||
}
|
||||
|
||||
const results = await Promise.all(
|
||||
ALL_STRATEGIES.map(async (s) => {
|
||||
const result = await s.fn(steamAppId)
|
||||
return {
|
||||
strategy: s.name,
|
||||
versionString: result.versionString,
|
||||
buildId: result.buildId,
|
||||
success: result.success,
|
||||
error: result.error ?? null,
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
const withVersion = results.find((r) => r.versionString)
|
||||
const withBuild = results.find((r) => r.buildId)
|
||||
const best = withVersion ?? withBuild ?? null
|
||||
|
||||
return {
|
||||
steamAppId,
|
||||
results,
|
||||
best: best
|
||||
? {
|
||||
strategy: best.strategy,
|
||||
versionString: best.versionString,
|
||||
buildId: best.buildId,
|
||||
}
|
||||
: null,
|
||||
}
|
||||
},
|
||||
{
|
||||
query: t.Object({
|
||||
steamAppId: t.String(),
|
||||
}),
|
||||
},
|
||||
)
|
||||
Reference in New Issue
Block a user