merge: convert to bun workspaces monorepo

This commit is contained in:
2026-06-28 05:31:30 +08:00
351 changed files with 485 additions and 127 deletions
+137
View File
@@ -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")
})
})
+104
View File
@@ -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,
}
},
)
+161
View File
@@ -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() }),
},
)
+295
View File
@@ -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() }),
},
)
+194
View File
@@ -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")]),
}),
},
)
+321
View File
@@ -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,
},
}
},
)
+279
View File
@@ -0,0 +1,279 @@
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"
import { gamesLookupRoutes } from "@/lib/api/games-lookup"
import { performanceImportRoutes } from "@/lib/api/performance-import"
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(gamesLookupRoutes)
.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(performanceImportRoutes)
.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
+51
View File
@@ -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
}
+316
View File
@@ -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() }),
},
)
+345
View File
@@ -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()),
}),
},
)
+187
View File
@@ -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() }),
}
)
+144
View File
@@ -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()),
}),
},
)
+162
View File
@@ -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()),
}),
},
)
+335
View File
@@ -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
}
+209
View File
@@ -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
},
)
+215
View File
@@ -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;
},
);
+376
View File
@@ -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." },
},
)
+33
View File
@@ -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(),
}),
},
)
+495
View File
@@ -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()),
}),
},
)
+93
View File
@@ -0,0 +1,93 @@
import { Elysia, t } from "elysia"
import { db } from "@/lib/db/index"
import { games, gameVersions } from "@/lib/db/schema"
import { eq, and } from "drizzle-orm"
export const gamesLookupRoutes = new Elysia({
prefix: "/games",
detail: { tags: ["Games"] },
}).get(
"/lookup",
async ({ query, set }) => {
const { steamAppId } = query
if (!steamAppId) {
set.status = 400
return { error: "steamAppId query parameter is required" }
}
// Look up the game
const [game] = await db
.select({
id: games.id,
steamAppId: games.steamAppId,
title: games.title,
slug: games.slug,
headerImage: games.headerImage,
capsuleImage: games.capsuleImage,
developer: games.developer,
publisher: games.publisher,
source: games.source,
})
.from(games)
.where(eq(games.steamAppId, steamAppId))
.limit(1)
if (!game) {
set.status = 404
return { error: `No game found with steamAppId ${steamAppId}` }
}
// Find the latest version
const [latestVersion] = await db
.select({
id: gameVersions.id,
versionString: gameVersions.versionString,
buildId: gameVersions.buildId,
isLatest: gameVersions.isLatest,
createdAt: gameVersions.createdAt,
})
.from(gameVersions)
.where(
and(
eq(gameVersions.gameId, game.id),
eq(gameVersions.isLatest, true),
),
)
.limit(1)
// If no latest version, get the most recent one
const version =
latestVersion ??
(await db
.select({
id: gameVersions.id,
versionString: gameVersions.versionString,
buildId: gameVersions.buildId,
isLatest: gameVersions.isLatest,
createdAt: gameVersions.createdAt,
})
.from(gameVersions)
.where(eq(gameVersions.gameId, game.id))
.orderBy(gameVersions.createdAt)
.limit(1)
.then((rows) => rows[0] ?? null))
return {
game: {
...game,
steamAppId: game.steamAppId ?? null,
},
version,
}
},
{
query: t.Object({
steamAppId: t.Numeric(),
}),
detail: {
description:
"Look up a game and its latest version by Steam App ID. Used by the DeckyVault Decky Loader plugin to resolve game info before importing benchmarks.",
},
},
)
+182
View File
@@ -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()),
}),
}
)
+189
View File
@@ -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." },
},
)
+359
View File
@@ -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() }),
}
);
+299
View File
@@ -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() }),
},
)
+11
View File
@@ -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"] },
})
+19
View File
@@ -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.",
},
},
)
+34
View File
@@ -0,0 +1,34 @@
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"
export { gamesLookupRoutes } from "./games-lookup"
export { performanceImportRoutes } from "./performance-import"
+966
View File
@@ -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()),
}),
},
)
+284
View File
@@ -0,0 +1,284 @@
import { Elysia, t } from "elysia"
import { db } from "@/lib/db/index"
import {
performanceEntries,
gameVersions,
games,
hardware,
gamePlatformSupport,
} from "@/lib/db/schema"
import { eq, and } from "drizzle-orm"
import { requireAuthWithApiKeyFallback } from "@/lib/auth/api-key-guard"
import { recalculatePlayability } from "./playability"
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]
export const performanceImportRoutes = new Elysia({
prefix: "/performance",
detail: { tags: ["Performance"] },
}).post(
"/import",
async ({ body, request, set }) => {
// ── Auth: session or API key ──────────────────────────────────
const guard = await requireAuthWithApiKeyFallback(request.headers)
if (!guard.ok) {
set.status = guard.status
return { error: guard.error }
}
// ── Validate version (1 only for now) ─────────────────────────
if (body.version !== 1) {
set.status = 400
return { error: "Unsupported import format version" }
}
// ── Resolve game version from steamAppId ──────────────────────
const steamAppId = body.steamAppId
if (!steamAppId) {
set.status = 400
return { error: "steamAppId is required" }
}
const [game] = await db
.select({ id: games.id })
.from(games)
.where(eq(games.steamAppId, steamAppId))
.limit(1)
if (!game) {
set.status = 404
return {
error: `No game found with steamAppId ${steamAppId}. Submit the game on DeckyVault first.`,
}
}
// Find the latest version, or create one if needed
let [version] = await db
.select({ id: gameVersions.id })
.from(gameVersions)
.where(
and(
eq(gameVersions.gameId, game.id),
eq(gameVersions.isLatest, true),
),
)
.limit(1)
if (!version) {
// Get the most recent version
const [existing] = await db
.select({ id: gameVersions.id })
.from(gameVersions)
.where(eq(gameVersions.gameId, game.id))
.orderBy(gameVersions.createdAt)
.limit(1)
if (existing) {
version = existing
} else {
// Create a stub version so we can create the entry
const [newVersion] = await db
.insert(gameVersions)
.values({
gameId: game.id,
isLatest: true,
})
.returning({ id: gameVersions.id })
version = newVersion
}
}
// ── Validate hardware ─────────────────────────────────────────
const hardwareSlug = body.hardwareSlug
const [device] = await db
.select({ slug: hardware.slug, deviceType: hardware.deviceType })
.from(hardware)
.where(eq(hardware.slug, hardwareSlug))
.limit(1)
if (!device) {
set.status = 400
return {
error: `Unknown hardware slug: "${hardwareSlug}". Available devices: see /api/hardware`,
}
}
// ── Validate FPS fields ───────────────────────────────────────
const fpsAvg = Number(body.fpsAvg)
if (isNaN(fpsAvg) || fpsAvg < 1 || fpsAvg > 500) {
set.status = 400
return { error: "fpsAvg must be between 1 and 500" }
}
const fpsLow = body.fpsLow != null ? Number(body.fpsLow) : null
if (fpsLow !== null && (isNaN(fpsLow) || fpsLow < 0 || fpsLow > 500)) {
set.status = 400
return { error: "fpsLow must be between 0 and 500" }
}
const fpsOnePercentLow =
body.fpsOnePercentLow != null ? Number(body.fpsOnePercentLow) : null
if (
fpsOnePercentLow !== null &&
(isNaN(fpsOnePercentLow) || fpsOnePercentLow < 0 || fpsOnePercentLow > 500)
) {
set.status = 400
return { error: "fpsOnePercentLow must be between 0 and 500" }
}
const fpsHigh = body.fpsHigh != null ? Number(body.fpsHigh) : null
if (fpsHigh !== null && (isNaN(fpsHigh) || fpsHigh < 0 || fpsHigh > 500)) {
set.status = 400
return { error: "fpsHigh must be between 0 and 500" }
}
// ── Validate enums ────────────────────────────────────────────
const rawUpscalerType = body.upscalerType ?? "none"
const upscalerType: UpscalerType = VALID_UPSCALER_TYPES.includes(
rawUpscalerType as UpscalerType,
)
? (rawUpscalerType as UpscalerType)
: "none"
const rawFrameGenMethod = body.frameGenMethod ?? "none"
const frameGenMethod: FrameGenMethod = VALID_FRAME_GEN_METHODS.includes(
rawFrameGenMethod as FrameGenMethod,
)
? (rawFrameGenMethod as FrameGenMethod)
: "none"
// ── Validate other numeric fields ─────────────────────────────
const tdpWatts = body.tdpWatts != null ? Number(body.tdpWatts) : null
if (tdpWatts !== null && (isNaN(tdpWatts) || tdpWatts <= 0)) {
set.status = 400
return { error: "tdpWatts must be greater than 0" }
}
const loadTimeSsd = body.loadTimeSsd != null ? Number(body.loadTimeSsd) : null
const loadTimeSd = body.loadTimeSd != null ? Number(body.loadTimeSd) : null
// ── Validate userNotes length ─────────────────────────────────
const userNotes = body.userNotes ?? null
if (userNotes && typeof userNotes === "string" && userNotes.length > 5000) {
set.status = 400
return { error: "userNotes must be 5000 characters or less" }
}
// ── Create the performance entry ──────────────────────────────
const [entry] = await db
.insert(performanceEntries)
.values({
versionId: version.id,
hardwareSlug,
userId: guard.user.id,
fpsAvg,
fpsLow,
fpsOnePercentLow,
fpsHigh,
protonVersion: body.protonVersion ?? null,
osVersion: body.osVersion ?? null,
upscalerType,
upscalerVersion: body.upscalerVersion ?? null,
frameGenMethod,
loadTimeSsd,
loadTimeSd,
tdpWatts,
launchOptions: body.launchOptions ?? null,
settingsJson: body.settingsJson ?? null,
userNotes,
customSystem: body.customSystem ?? false,
})
.returning()
// ── Update / create gamePlatformSupport ──────────────────────
const [existingSupport] = await db
.select()
.from(gamePlatformSupport)
.where(
and(
eq(gamePlatformSupport.gameId, game.id),
eq(gamePlatformSupport.hardwareSlug, hardwareSlug),
),
)
.limit(1)
if (existingSupport) {
await db
.update(gamePlatformSupport)
.set({
antiCheatRelevant:
body.antiCheatRelevant ?? existingSupport.antiCheatRelevant,
antiCheatName: body.antiCheatRelevant
? (body.antiCheatName ?? existingSupport.antiCheatName)
: null,
antiCheatStatus: (body.antiCheatStatus ??
existingSupport.antiCheatStatus) as AntiCheatStatus,
updatedAt: new Date(),
})
.where(eq(gamePlatformSupport.id, existingSupport.id))
} else {
await db.insert(gamePlatformSupport).values({
gameId: game.id,
hardwareSlug,
isSupported: true,
protonStatus: "unknown",
antiCheatRelevant: body.antiCheatRelevant ?? false,
antiCheatName: body.antiCheatRelevant ? (body.antiCheatName ?? null) : null,
antiCheatStatus: (body.antiCheatStatus ?? "unknown") as AntiCheatStatus,
playabilityStatus: "unknown",
})
}
// Fire-and-forget playability recalculation
recalculatePlayability(game.id).catch((err) =>
console.error("Failed to recalculate playability:", err),
)
set.status = 201
return {
id: entry.id,
gameId: game.id,
versionId: version.id,
createdAt: entry.createdAt.toISOString(),
authMethod: guard.keyId ? "api-key" : "session",
}
},
{
body: t.Object({
version: t.Number(),
steamAppId: t.Number(),
hardwareSlug: t.String(),
fpsAvg: t.Number(),
fpsLow: t.Optional(t.Nullable(t.Number())),
fpsOnePercentLow: t.Optional(t.Nullable(t.Number())),
fpsHigh: t.Optional(t.Nullable(t.Number())),
protonVersion: t.Optional(t.Nullable(t.String())),
osVersion: t.Optional(t.Nullable(t.String())),
upscalerType: t.Optional(t.String()),
upscalerVersion: t.Optional(t.Nullable(t.String())),
frameGenMethod: t.Optional(t.String()),
tdpWatts: t.Optional(t.Nullable(t.Number())),
loadTimeSsd: t.Optional(t.Nullable(t.Number())),
loadTimeSd: t.Optional(t.Nullable(t.Number())),
launchOptions: t.Optional(t.Nullable(t.String())),
settingsJson: t.Optional(t.Nullable(t.Any())),
userNotes: t.Optional(t.Nullable(t.String())),
customSystem: t.Optional(t.Boolean()),
antiCheatRelevant: t.Optional(t.Boolean()),
antiCheatName: t.Optional(t.Nullable(t.String())),
antiCheatStatus: t.Optional(t.String()),
}),
detail: {
description:
"Import a performance benchmark from a DeckyVault plugin export (.deckyvault.json). " +
"Accepts either session cookies or an x-api-key header for authentication. " +
"The steamAppId is used to resolve the game and its latest version automatically.",
},
},
)
+473
View File
@@ -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,
}
},
)
+756
View File
@@ -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()),
}),
},
)
+222
View File
@@ -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 };
});
+171
View File
@@ -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 }
},
)
+61
View File
@@ -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()),
}),
},
)
+139
View File
@@ -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() }),
},
)
+162
View File
@@ -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(),
}),
},
)
+257
View File
@@ -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() }),
},
)
+540
View File
@@ -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.",
},
},
)
+108
View File
@@ -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" }
}
}
)
+87
View File
@@ -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(),
}),
},
)
+172
View File
@@ -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()),
}),
),
}),
},
)
+80
View File
@@ -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()),
}),
}
)
+327
View File
@@ -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()),
}),
},
)
+174
View File
@@ -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(),
}),
},
)