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
@@ -0,0 +1,211 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"
import {
PRODUCTION_URL,
getBaseUrl,
imageEntry,
toDate,
querySafe,
STATIC_PAGES,
} from "@/lib/sitemap-utils"
describe("PRODUCTION_URL", () => {
it("is the canonical production URL", () => {
expect(PRODUCTION_URL).toBe("https://deckyvault.xyz")
})
})
describe("getBaseUrl", () => {
afterEach(() => {
delete process.env.NEXT_PUBLIC_SITE_URL
})
it("returns PRODUCTION_URL when no env var is set", () => {
expect(getBaseUrl()).toBe("https://deckyvault.xyz")
})
it("returns PRODUCTION_URL when env var is localhost", () => {
process.env.NEXT_PUBLIC_SITE_URL = "http://localhost:3000"
expect(getBaseUrl()).toBe("https://deckyvault.xyz")
})
it("returns PRODUCTION_URL when env var is 127.0.0.1", () => {
process.env.NEXT_PUBLIC_SITE_URL = "http://127.0.0.1:8080"
expect(getBaseUrl()).toBe("https://deckyvault.xyz")
})
it("returns the custom env var when set to a real domain", () => {
process.env.NEXT_PUBLIC_SITE_URL = "https://staging.deckyvault.xyz"
expect(getBaseUrl()).toBe("https://staging.deckyvault.xyz")
})
it("strips trailing slash from env var", () => {
process.env.NEXT_PUBLIC_SITE_URL = "https://staging.deckyvault.xyz/"
expect(getBaseUrl()).toBe("https://staging.deckyvault.xyz")
})
})
describe("imageEntry", () => {
it("returns an images array for valid HTTPS capsule URLs", () => {
const result = imageEntry("https://cdn.example.com/capsule.jpg")
expect(result).toEqual({ images: ["https://cdn.example.com/capsule.jpg"] })
})
it("trims whitespace from the URL", () => {
const result = imageEntry(" https://cdn.example.com/capsule.jpg ")
expect(result).toEqual({ images: ["https://cdn.example.com/capsule.jpg"] })
})
it("returns empty object for non-HTTPS URLs", () => {
const result = imageEntry("http://cdn.example.com/capsule.jpg")
expect(result).toEqual({})
})
it("returns empty object for non-string values", () => {
expect(imageEntry(null)).toEqual({})
expect(imageEntry(undefined)).toEqual({})
expect(imageEntry(123)).toEqual({})
expect(imageEntry({})).toEqual({})
})
it("returns empty object for empty strings", () => {
expect(imageEntry("")).toEqual({})
expect(imageEntry(" ")).toEqual({})
})
it("returns empty object for URLs exceeding 2048 characters", () => {
const longUrl = "https://cdn.example.com/" + "a".repeat(2048)
expect(imageEntry(longUrl)).toEqual({})
})
it("accepts URLs exactly at the 2048 character limit", () => {
const maxUrl = "https://cdn.example.com/" + "a".repeat(2048 - 26)
expect(imageEntry(maxUrl)).toEqual({ images: [maxUrl] })
})
})
describe("toDate", () => {
it("returns the same Date object if passed a valid Date", () => {
const d = new Date("2025-01-15T10:00:00Z")
expect(toDate(d)).toBe(d)
})
it("returns undefined for invalid Date objects", () => {
const d = new Date("not-a-date")
expect(toDate(d)).toBeUndefined()
})
it("parses a valid ISO date string", () => {
const result = toDate("2025-01-15T10:00:00Z")
expect(result).toBeInstanceOf(Date)
expect(result!.toISOString()).toBe("2025-01-15T10:00:00.000Z")
})
it("returns undefined for invalid date strings", () => {
expect(toDate("not-a-date")).toBeUndefined()
})
it("parses a numeric timestamp", () => {
const result = toDate(1700000000000)
expect(result).toBeInstanceOf(Date)
expect(result!.getTime()).toBe(1700000000000)
})
it("returns undefined for null", () => {
expect(toDate(null)).toBeUndefined()
})
it("returns undefined for undefined", () => {
expect(toDate(undefined)).toBeUndefined()
})
it("returns undefined for non-date objects", () => {
expect(toDate({ foo: "bar" })).toBeUndefined()
})
})
describe("querySafe", () => {
beforeEach(() => {
vi.useFakeTimers()
})
afterEach(() => {
vi.useRealTimers()
})
it("returns the query result on success", async () => {
const query = vi.fn().mockResolvedValue([{ id: 1 }])
const result = await querySafe("test", query)
expect(result).toEqual([{ id: 1 }])
expect(query).toHaveBeenCalledOnce()
})
it("returns undefined when the query throws", async () => {
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {})
const query = vi.fn().mockRejectedValue(new Error("DB down"))
const result = await querySafe("test", query)
expect(result).toBeUndefined()
expect(consoleErrorSpy).toHaveBeenCalledWith(
"[Sitemap] test query failed:",
expect.any(Error),
)
consoleErrorSpy.mockRestore()
})
it("returns undefined when the query times out", async () => {
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {})
const query = vi.fn().mockImplementation(
() => new Promise((resolve) => setTimeout(() => resolve([{ id: 1 }]), 20_000)),
)
const resultPromise = querySafe("test", query, 5_000)
vi.advanceTimersByTime(5_001)
const result = await resultPromise
expect(result).toBeUndefined()
expect(consoleErrorSpy).toHaveBeenCalledWith(
"[Sitemap] test query failed:",
expect.any(Error),
)
consoleErrorSpy.mockRestore()
})
it("uses default timeout of 15s when not specified", async () => {
const query = vi.fn().mockResolvedValue([{ id: 1 }])
const result = await querySafe("test", query)
expect(result).toEqual([{ id: 1 }])
})
})
describe("STATIC_PAGES", () => {
it("contains exactly 7 entries", () => {
expect(STATIC_PAGES).toHaveLength(7)
})
it("first entry is the homepage with empty urlPath and priority 1", () => {
expect(STATIC_PAGES[0].urlPath).toBe("")
expect(STATIC_PAGES[0].priority).toBe(1.0)
})
it("includes /games with priority 0.9", () => {
const entry = STATIC_PAGES.find((p) => p.urlPath === "/games")
expect(entry).toBeDefined()
expect(entry!.priority).toBe(0.9)
})
it("includes /compare with priority 0.5", () => {
const entry = STATIC_PAGES.find((p) => p.urlPath === "/compare")
expect(entry).toBeDefined()
expect(entry!.priority).toBe(0.5)
})
it("includes /search with priority 0.3", () => {
const entry = STATIC_PAGES.find((p) => p.urlPath === "/search")
expect(entry).toBeDefined()
expect(entry!.priority).toBe(0.3)
})
it("every entry has a valid changeFrequency", () => {
const validFreqs = ["always", "hourly", "daily", "weekly", "monthly", "yearly", "never"]
for (const page of STATIC_PAGES) {
expect(validFreqs).toContain(page.changeFrequency)
}
})
})
@@ -0,0 +1,49 @@
import { describe, it, expect } from "vitest"
import fs from "node:fs"
import path from "node:path"
describe("version bump to 2026.2.2", () => {
const cwd = process.cwd()
it("package.json version is 2026.2.2", () => {
const pkg = JSON.parse(
fs.readFileSync(path.join(cwd, "package.json"), "utf8"),
)
expect(pkg.version).toBe("2026.2.2")
})
it("OpenAPI spec version is 2026.2.2", () => {
const apiContent = fs.readFileSync(
path.join(cwd, "lib", "api", "app.ts"),
"utf8",
)
expect(apiContent).toContain('version: "2026.2.2"')
})
it("CHANGELOG.md contains [2026.2.2] entry", () => {
const changelog = fs.readFileSync(
path.join(cwd, "CHANGELOG.md"),
"utf8",
)
expect(changelog).toContain("## [2026.2.2]")
expect(changelog).toContain("### Added")
expect(changelog).toContain("@deckyvault.xyz")
})
it("content/updates/2026-05-27-v2026.2.2.md exists with valid frontmatter", () => {
const updatePath = path.join(
cwd,
"content",
"updates",
"2026-05-27-v2026.2.2.md",
)
expect(fs.existsSync(updatePath)).toBe(true)
const content = fs.readFileSync(updatePath, "utf8")
expect(content.startsWith("---")).toBe(true)
expect(content).toContain('title:')
expect(content).toContain('date: "2026-05-27"')
expect(content).toContain('version: "2026.2.2"')
expect(content).toContain('summary:')
})
})
+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(),
}),
},
)
+17
View File
@@ -0,0 +1,17 @@
import { createAuthClient } from 'better-auth/react'
import { adminClient, emailOTPClient, lastLoginMethodClient } from 'better-auth/client/plugins'
import { passkeyClient } from '@better-auth/passkey/client'
import { apiKeyClient } from '@better-auth/api-key/client'
export const authClient = createAuthClient({
baseURL: process.env.NEXT_PUBLIC_SITE_URL || "https://localhost:3000",
plugins: [
emailOTPClient(),
passkeyClient(),
lastLoginMethodClient(),
adminClient(),
apiKeyClient(),
]
})
export const { signIn, signOut, signUp, getSession, useSession } = authClient
+123
View File
@@ -0,0 +1,123 @@
import { betterAuth } from 'better-auth'
import { admin, captcha, emailOTP, lastLoginMethod } from 'better-auth/plugins'
import { passkey } from '@better-auth/passkey'
import { expo } from '@better-auth/expo'
import { apiKey } from '@better-auth/api-key'
import { drizzleAdapter } from '@better-auth/drizzle-adapter'
import { db } from '@/lib/db/index'
import { ac, admin as adminRole, moderator, contributor, user } from '@/lib/auth/permissions'
import { sendOTP, OTP_EXPIRY_SECONDS } from '@/lib/auth/email'
// ── Origin setup ───────────────────────────────────────
// Web + mobile passkey origins
const webOrigin = process.env.BETTER_AUTH_URL ?? 'https://localhost:3000'
const appScheme = 'deckyvault://'
// Build trusted origins: web URL + app scheme + optional expo dev
const trustedOrigins = [
webOrigin,
appScheme,
]
// Add Expo dev URLs in development
if (process.env.NODE_ENV !== 'production') {
trustedOrigins.push('exp://*')
trustedOrigins.push('exp://192.168.*.*:*')
}
// Passkey origins: web URL + optional Android APK key hash
const passkeyOrigins = [
webOrigin,
...(process.env.ANDROID_APK_KEY_HASH
? [`android:apk-key-hash:${process.env.ANDROID_APK_KEY_HASH}`]
: []),
]
export const auth = betterAuth({
experimental: { joins: true },
database: drizzleAdapter(db, {
provider: 'pg'
}),
plugins: [
captcha({
provider: 'cloudflare-turnstile',
secretKey: process.env.TURNSTILE_SECRET_KEY!,
}),
emailOTP({
async sendVerificationOTP({ email, otp, type }) {
await sendOTP({ email, otp, type })
},
otpLength: 6,
expiresIn: OTP_EXPIRY_SECONDS,
allowedAttempts: 5,
}),
passkey({
rpID: process.env.RP_ID ?? 'localhost',
rpName: 'DeckyVault',
origin: passkeyOrigins,
advanced: {
webAuthnChallengeCookie: 'better-auth-passkey',
},
}),
lastLoginMethod({
storeInDatabase: true,
}),
admin({
ac,
roles: {
admin: adminRole,
moderator,
contributor,
user,
},
defaultRole: 'user',
adminRoles: ['admin'],
}),
apiKey({
defaultPrefix: 'dv_',
requireName: true,
keyExpiration: {
defaultExpiresIn: null,
disableCustomExpiresTime: false,
},
rateLimit: {
enabled: true,
timeWindow: 1000 * 60 * 60, // 1 hour
maxRequests: 1000,
},
}),
expo(),
],
socialProviders: {
google: {
clientId: process.env.GOOGLE_CLIENT_ID || "",
clientSecret: process.env.GOOGLE_CLIENT_SECRET || "",
},
discord: {
clientId: process.env.DISCORD_CLIENT_ID || "",
clientSecret: process.env.DISCORD_CLIENT_SECRET || "",
},
},
trustedOrigins,
rateLimit: {
enabled: false,
},
session: {
expiresIn: 60 * 60 * 24 * 7,
updateAge: 60 * 60 * 24,
cookieCache: {
enabled: true,
maxAge: 15 * 60,
},
},
account: {
accountLinking: {
enabled: true,
trustedProviders: ['google', 'discord'],
allowDifferentEmails: true
}
},
emailAndPassword: {
enabled: true,
},
})
+43
View File
@@ -0,0 +1,43 @@
import { describe, it, expect, beforeAll } from "vitest"
// Auth integration tests require a fully-functional PostgreSQL database with
// the application schema (set via DATABASE_URL). If the env var is missing or
// the DB is unreachable, every test is skipped rather than failing.
let dbUsable = false
let dbHelper: any
if (process.env.DATABASE_URL) {
try {
const { db } = await import("@/lib/db/index")
await db.execute("SELECT 1")
dbUsable = true
} catch {
dbUsable = false
}
}
describe(dbUsable ? "Better-Auth integration" : "Better-Auth integration (skipped: database not usable)", () => {
beforeAll(async () => {
if (!dbUsable) return
const { getTestHelpers } = await import("@/lib/auth/test")
dbHelper = await getTestHelpers()
})
it("should create a user", async () => {
if (!dbUsable || !dbHelper) return
const user = await dbHelper.createUser({ email: "test@example.com" })
expect(user.email).toBe("test@example.com")
})
it("should create a session for a user", async () => {
if (!dbUsable || !dbHelper) return
const user = await dbHelper.createUser({ email: "session-test@example.com" })
await dbHelper.saveUser(user)
const { session, headers } = await dbHelper.login({ userId: user.id })
expect(session.userId).toBe(user.id)
expect(headers.get("cookie")).toBeTruthy()
await dbHelper.deleteUser(user.id)
})
})
@@ -0,0 +1,51 @@
import { describe, it, expect } from "vitest"
import { isDeckyVaultEmail, DOMAIN_BLOCK_ERROR } from "../domain-block"
describe("isDeckyVaultEmail", () => {
it("returns true for a @deckyvault.xyz email (lowercase)", () => {
expect(isDeckyVaultEmail("user@deckyvault.xyz")).toBe(true)
})
it("returns true for a @deckyvault.xyz email (mixed case)", () => {
expect(isDeckyVaultEmail("User@DeckyVault.xyz")).toBe(true)
})
it("returns true for a @deckyvault.xyz email with plus addressing", () => {
expect(isDeckyVaultEmail("user+tag@deckyvault.xyz")).toBe(true)
})
it("returns true for a @deckyvault.xyz email with surrounding whitespace", () => {
expect(isDeckyVaultEmail(" admin@deckyvault.xyz ")).toBe(true)
})
it("returns false for a @gmail.com email", () => {
expect(isDeckyVaultEmail("user@gmail.com")).toBe(false)
})
it("returns false for a @example.com email", () => {
expect(isDeckyVaultEmail("user@example.com")).toBe(false)
})
it("returns false for a subdomain like @mail.deckyvault.xyz", () => {
expect(isDeckyVaultEmail("user@mail.deckyvault.xyz")).toBe(false)
})
it("returns false for an empty string", () => {
expect(isDeckyVaultEmail("")).toBe(false)
})
it("returns false for a string without @", () => {
expect(isDeckyVaultEmail("deckyvault.xyz")).toBe(false)
})
it("returns true for a .DECKYVAULT.XYZ email (uppercase domain)", () => {
expect(isDeckyVaultEmail("admin@DECKYVAULT.XYZ")).toBe(true)
})
})
describe("DOMAIN_BLOCK_ERROR", () => {
it("is a non-empty string", () => {
expect(DOMAIN_BLOCK_ERROR).toBeTruthy()
expect(typeof DOMAIN_BLOCK_ERROR).toBe("string")
})
})
+165
View File
@@ -0,0 +1,165 @@
import { describe, it, expect, vi, beforeEach, beforeAll } from "vitest"
// Mock the auth module — no real betterAuth initialisation should run.
vi.mock("@/lib/auth", () => ({
auth: {
api: {
getSession: vi.fn(),
},
},
}))
// Use dynamic imports so vi.mock is guaranteed to be registered before
// any module evaluation in bun test's shared-module-cache multi-file mode.
let requireAuth: typeof import("../guard")["requireAuth"]
let requireRole: typeof import("../guard")["requireRole"]
let requireAdmin: typeof import("../guard")["requireAdmin"]
let requireContributorOrAdmin: typeof import("../guard")["requireContributorOrAdmin"]
let requireModeratorOrAdmin: typeof import("../guard")["requireModeratorOrAdmin"]
let getSession: ReturnType<typeof vi.fn>
beforeAll(async () => {
const guard = await import("../guard")
requireAuth = guard.requireAuth
requireRole = guard.requireRole
requireAdmin = guard.requireAdmin
requireContributorOrAdmin = guard.requireContributorOrAdmin
requireModeratorOrAdmin = guard.requireModeratorOrAdmin
const mocked = await import("@/lib/auth")
getSession = mocked.auth.api.getSession
})
function mockSession(overrides: Record<string, unknown> = {}) {
getSession.mockResolvedValue({
user: {
id: "user-1",
name: "Test User",
email: "test@example.com",
role: "user",
...overrides,
},
session: {
id: "session-1",
userId: "user-1",
token: "token-abc",
},
})
}
function mockNoSession() {
getSession.mockResolvedValue(null)
}
beforeEach(() => {
getSession.mockReset()
})
describe("requireAuth", () => {
it("returns ok with user and session when authenticated", async () => {
mockSession()
const result = await requireAuth(new Headers())
expect(result.ok).toBe(true)
if (result.ok) {
expect(result.user.id).toBe("user-1")
expect(result.session.id).toBe("session-1")
}
})
it("returns error 401 when not authenticated", async () => {
mockNoSession()
const result = await requireAuth(new Headers())
expect(result.ok).toBe(false)
if (!result.ok) {
expect(result.status).toBe(401)
expect(result.error).toBe("Unauthorized")
}
})
})
describe("requireRole", () => {
it("allows user with matching role", async () => {
mockSession({ role: "admin" })
const result = await requireRole(new Headers(), ["admin"])
expect(result.ok).toBe(true)
})
it("denies user with non-matching role (403)", async () => {
mockSession({ role: "user" })
const result = await requireRole(new Headers(), ["admin"])
expect(result.ok).toBe(false)
if (!result.ok) {
expect(result.status).toBe(403)
expect(result.error).toBe("Forbidden")
}
})
it("returns 401 when not authenticated", async () => {
mockNoSession()
const result = await requireRole(new Headers(), ["admin"])
expect(result.ok).toBe(false)
if (!result.ok) {
expect(result.status).toBe(401)
}
})
it("defaults to 'user' role when role is null", async () => {
mockSession({ role: null })
const result = await requireRole(new Headers(), ["user"])
expect(result.ok).toBe(true)
})
})
describe("requireAdmin", () => {
it("allows admin user", async () => {
mockSession({ role: "admin" })
const result = await requireAdmin(new Headers())
expect(result.ok).toBe(true)
})
it("denies non-admin user (403)", async () => {
mockSession({ role: "moderator" })
const result = await requireAdmin(new Headers())
expect(result.ok).toBe(false)
})
})
describe("requireContributorOrAdmin", () => {
it("allows contributor", async () => {
mockSession({ role: "contributor" })
const result = await requireContributorOrAdmin(new Headers())
expect(result.ok).toBe(true)
})
it("allows admin", async () => {
mockSession({ role: "admin" })
const result = await requireContributorOrAdmin(new Headers())
expect(result.ok).toBe(true)
})
it("denies regular user", async () => {
mockSession({ role: "user" })
const result = await requireContributorOrAdmin(new Headers())
expect(result.ok).toBe(false)
})
})
describe("requireModeratorOrAdmin", () => {
it("allows moderator", async () => {
mockSession({ role: "moderator" })
const result = await requireModeratorOrAdmin(new Headers())
expect(result.ok).toBe(true)
})
it("allows admin", async () => {
mockSession({ role: "admin" })
const result = await requireModeratorOrAdmin(new Headers())
expect(result.ok).toBe(true)
})
it("denies contributor", async () => {
mockSession({ role: "contributor" })
const result = await requireModeratorOrAdmin(new Headers())
expect(result.ok).toBe(false)
})
})
@@ -0,0 +1,63 @@
import { describe, it, expect } from "vitest"
import { checkPasswordStrength } from "../password-strength"
describe("checkPasswordStrength", () => {
it("returns weak (score 0) for empty string", () => {
const result = checkPasswordStrength("")
expect(result.level).toBe("weak")
expect(result.score).toBe(0)
expect(result.feedback).toContain("Enter a password")
})
it("returns fair for 'password' (common pattern penalty)", () => {
const result = checkPasswordStrength("password")
expect(result.level).toBe("fair")
expect(result.feedback).toContain("Avoid common passwords")
})
it("returns fair for short password with only letters", () => {
const result = checkPasswordStrength("abc")
expect(result.level).toBe("fair")
})
it("returns good for 10-char lowercase-only password", () => {
const result = checkPasswordStrength("abcdefghij")
expect(result.level).toBe("good")
})
it("returns good for 10-char mixed case + numbers", () => {
const result = checkPasswordStrength("AbcDef1234")
expect(["good", "strong", "excellent"]).toContain(result.level)
})
it("returns strong for 14-char with all character types", () => {
const result = checkPasswordStrength("MyP@ssw0rd!2024")
expect(["strong", "excellent"]).toContain(result.level)
})
it("returns excellent for 18+ char complex password", () => {
const result = checkPasswordStrength("C0mpl3x!P@ssw0rd#2024")
expect(result.level).toBe("excellent")
})
it("flags repeated characters", () => {
const result = checkPasswordStrength("aaaBBB111@@@")
expect(result.feedback).toContain("Avoid repeated characters")
})
it("flags common patterns like 'letmein'", () => {
const result = checkPasswordStrength("letmein1234")
expect(result.feedback).toContain("Avoid common passwords")
})
it("gives positive feedback for excellent passwords", () => {
const result = checkPasswordStrength("Tr0ub4dor&3X7r3m3ly!")
expect(result.feedback).toContain("Great password!")
})
it("handles unicode characters", () => {
const result = checkPasswordStrength("パスワード1234Abc!")
expect(result.level).toBeDefined()
expect(result.score).toBeGreaterThan(0)
})
})
@@ -0,0 +1,236 @@
import { describe, it, expect } from "vitest"
import {
loginEmailSchema,
loginSchema,
signupSchema,
otpSchema,
forgotPasswordSchema,
resetPasswordSchema,
} from "../validation"
describe("loginEmailSchema", () => {
it("accepts a valid email", () => {
const result = loginEmailSchema.safeParse({ email: "user@example.com" })
expect(result.success).toBe(true)
})
it("rejects empty string", () => {
const result = loginEmailSchema.safeParse({ email: "" })
expect(result.success).toBe(false)
})
it("rejects invalid email format", () => {
const result = loginEmailSchema.safeParse({ email: "not-an-email" })
expect(result.success).toBe(false)
})
it("rejects missing email field", () => {
const result = loginEmailSchema.safeParse({})
expect(result.success).toBe(false)
})
})
describe("loginSchema", () => {
it("accepts valid email and password", () => {
const result = loginSchema.safeParse({
email: "user@example.com",
password: "password123",
})
expect(result.success).toBe(true)
})
it("rejects empty password", () => {
const result = loginSchema.safeParse({
email: "user@example.com",
password: "",
})
expect(result.success).toBe(false)
if (!result.success) {
expect(result.error.issues[0].message).toBe("Password is required")
}
})
it("rejects missing password field", () => {
const result = loginSchema.safeParse({ email: "user@example.com" })
expect(result.success).toBe(false)
})
})
describe("signupSchema", () => {
it("accepts valid name, email, and password (≥10 chars)", () => {
const result = signupSchema.safeParse({
name: "Test User",
email: "user@example.com",
password: "abcdefghij",
})
expect(result.success).toBe(true)
})
it("rejects short password (<10 chars)", () => {
const result = signupSchema.safeParse({
name: "Test User",
email: "user@example.com",
password: "short",
})
expect(result.success).toBe(false)
if (!result.success) {
expect(result.error.issues[0].message).toContain("at least 10 characters")
}
})
it("rejects empty name", () => {
const result = signupSchema.safeParse({
name: "",
email: "user@example.com",
password: "abcdefghij",
})
expect(result.success).toBe(false)
})
it("rejects name longer than 100 chars", () => {
const result = signupSchema.safeParse({
name: "a".repeat(101),
email: "user@example.com",
password: "abcdefghij",
})
expect(result.success).toBe(false)
})
})
describe("otpSchema", () => {
it("accepts exactly 6 digits", () => {
const result = otpSchema.safeParse({ otp: "123456" })
expect(result.success).toBe(true)
})
it("rejects 5 digits", () => {
const result = otpSchema.safeParse({ otp: "12345" })
expect(result.success).toBe(false)
})
it("rejects 7 digits", () => {
const result = otpSchema.safeParse({ otp: "1234567" })
expect(result.success).toBe(false)
})
it("accepts any 6-char string (schema only validates length)", () => {
const result = otpSchema.safeParse({ otp: "abc123" })
expect(result.success).toBe(true)
})
})
describe("forgotPasswordSchema", () => {
it("accepts valid email", () => {
const result = forgotPasswordSchema.safeParse({ email: "user@example.com" })
expect(result.success).toBe(true)
})
it("rejects invalid email", () => {
const result = forgotPasswordSchema.safeParse({ email: "bad" })
expect(result.success).toBe(false)
})
})
describe("resetPasswordSchema", () => {
it("accepts matching passwords with valid OTP", () => {
const result = resetPasswordSchema.safeParse({
otp: "123456",
newPassword: "abcdefghij",
confirmPassword: "abcdefghij",
})
expect(result.success).toBe(true)
})
it("rejects mismatched passwords", () => {
const result = resetPasswordSchema.safeParse({
otp: "123456",
newPassword: "abcdefghij",
confirmPassword: "different",
})
expect(result.success).toBe(false)
if (!result.success) {
expect(result.error.issues[0].message).toBe("Passwords do not match")
}
})
it("rejects short new password", () => {
const result = resetPasswordSchema.safeParse({
otp: "123456",
newPassword: "short",
confirmPassword: "short",
})
expect(result.success).toBe(false)
})
it("rejects empty confirm password", () => {
const result = resetPasswordSchema.safeParse({
otp: "123456",
newPassword: "abcdefghij",
confirmPassword: "",
})
expect(result.success).toBe(false)
})
it("rejects short OTP", () => {
const result = resetPasswordSchema.safeParse({
otp: "12345",
newPassword: "abcdefghij",
confirmPassword: "abcdefghij",
})
expect(result.success).toBe(false)
})
})
describe("signupSchema — domain block", () => {
it("rejects @deckyvault.xyz email when NODE_ENV is not development", () => {
const result = signupSchema.safeParse({
name: "Test User",
email: "user@deckyvault.xyz",
password: "abcdefghij",
})
expect(result.success).toBe(false)
if (!result.success) {
const emailIssues = result.error.issues.filter(
(i) => i.path[0] === "email"
)
expect(emailIssues.length).toBeGreaterThan(0)
expect(emailIssues[0].message).toContain("deckyvault.xyz")
}
})
it("rejects @DECKYVAULT.XYZ email (uppercase) when not in dev", () => {
const result = signupSchema.safeParse({
name: "Test User",
email: "admin@DECKYVAULT.XYZ",
password: "abcdefghij",
})
expect(result.success).toBe(false)
})
it("rejects @deckyvault.xyz with plus addressing when not in dev", () => {
const result = signupSchema.safeParse({
name: "Test User",
email: "user+spam@deckyvault.xyz",
password: "abcdefghij",
})
expect(result.success).toBe(false)
})
it("accepts @gmail.com email (non-deckyvault domain)", () => {
const result = signupSchema.safeParse({
name: "Test User",
email: "user@gmail.com",
password: "abcdefghij",
})
expect(result.success).toBe(true)
})
it("accepts valid email from any non-deckyvault domain", () => {
const result = signupSchema.safeParse({
name: "Test User",
email: "hello@outlook.com",
password: "abcdefghij",
})
expect(result.success).toBe(true)
})
})
+98
View File
@@ -0,0 +1,98 @@
import { auth } from "@/lib/auth"
import { db } from "@/lib/db/index"
import { user } from "@/lib/db/schema/auth"
import { eq } from "drizzle-orm"
import type { Session } from "better-auth"
type User = typeof auth.$Infer.Session.user
type ApiKeyGuardResult =
| { ok: true; user: User; session: Session | null; keyId: string }
| { ok: false; error: string; status: number }
/**
* Attempts to authenticate a request using an API key from the x-api-key header.
* Verifies the key via Better Auth and looks up the user from the database.
*/
export async function authenticateWithApiKey(
requestHeaders: Headers,
): Promise<ApiKeyGuardResult> {
const apiKey = requestHeaders.get("x-api-key")
if (!apiKey) {
return { ok: false, error: "Missing x-api-key header", status: 401 }
}
try {
const result = await auth.api.verifyApiKey({
body: {
key: apiKey,
},
})
if (!result.valid || !result.key) {
const errorMessage = String(result.error?.message ?? "Invalid API key")
return { ok: false, error: errorMessage, status: 401 }
}
const userId = result.key.referenceId
const keyId = result.key.id
// Look up the user directly from the database
const [dbUser] = await db
.select()
.from(user)
.where(eq(user.id, userId))
.limit(1)
if (!dbUser) {
return { ok: false, error: "User not found for API key", status: 401 }
}
// Build a minimal user object matching Better Auth's Session.user type
const authedUser: User = {
id: dbUser.id,
name: dbUser.name,
email: dbUser.email,
emailVerified: dbUser.emailVerified,
image: dbUser.image,
createdAt: dbUser.createdAt,
updatedAt: dbUser.updatedAt,
role: dbUser.role ?? "user",
banned: dbUser.banned ?? null,
banReason: dbUser.banReason ?? null,
banExpires: dbUser.banExpires ?? null,
}
return {
ok: true,
user: authedUser,
session: null,
keyId,
}
} catch (err) {
console.error("[api-key-guard] API key verification failed:", err)
return { ok: false, error: "API key verification failed", status: 500 }
}
}
/**
* Combined auth guard: first tries session auth (cookie), then falls back
* to API key auth (x-api-key header). Returns the authenticated user.
*/
export async function requireAuthWithApiKeyFallback(
requestHeaders: Headers,
): Promise<ApiKeyGuardResult> {
// Try session auth first
const session = await auth.api.getSession({ headers: requestHeaders })
if (session) {
return {
ok: true,
user: session.user,
session: session.session,
keyId: "",
}
}
// Fall back to API key
return authenticateWithApiKey(requestHeaders)
}
+18
View File
@@ -0,0 +1,18 @@
export const DOMAIN_BLOCK_ERROR =
"Signing up with a @deckyvault.xyz email is not allowed."
/**
* Returns true if the email address has a deckyvault.xyz domain.
* Handles case-insensitive comparison, whitespace trimming, and
* plus-notation aliases (user+tag@domain → domain).
*
* Only matches the exact domain "deckyvault.xyz" — NOT subdomains
* like "mail.deckyvault.xyz".
*/
export function isDeckyVaultEmail(email: string): boolean {
const trimmed = email.trim().toLowerCase()
const atIndex = trimmed.lastIndexOf("@")
if (atIndex === -1) return false
const domain = trimmed.slice(atIndex + 1)
return domain === "deckyvault.xyz"
}
+47
View File
@@ -0,0 +1,47 @@
export const OTP_EXPIRY_SECONDS = 300
type OTPParams = {
email: string
otp: string
type: "sign-in" | "email-verification" | "forget-password" | "change-email"
}
const subjects: Record<OTPParams["type"], string> = {
"sign-in": "Sign in to DeckyVault",
"email-verification": "Verify your DeckyVault email",
"forget-password": "Reset your DeckyVault password",
"change-email": "Change your DeckyVault email",
}
export async function sendOTP({ email, otp, type }: OTPParams) {
const subject = subjects[type]
// If RESEND_API_KEY is set, use Resend. Otherwise, log to console in dev.
if (process.env.RESEND_API_KEY) {
try {
const { Resend } = await import("resend")
const resend = new Resend(process.env.RESEND_API_KEY)
await resend.emails.send({
from: process.env.EMAIL_FROM ?? "DeckyVault <noreply@deckyvault.xyz>",
to: email,
subject,
html: `
<div style="font-family: sans-serif; max-width: 400px; margin: 0 auto;">
<h2 style="color: #eb3779;">${subject}</h2>
<p>Your verification code is:</p>
<p style="font-size: 32px; font-weight: bold; letter-spacing: 8px; color: #571b8b;">${otp}</p>
<p style="color: #666; font-size: 14px;">This code expires in ${OTP_EXPIRY_SECONDS / 60} minutes. If you didn't request this, ignore this email.</p>
</div>
`,
})
} catch (error) {
console.error("[EMAIL OTP] Failed to send via Resend:", error)
throw new Error("Failed to send verification email")
}
} else if (process.env.NODE_ENV === "development") {
console.log(`[EMAIL OTP] To: ${email} | Type: ${type} | OTP: ${otp}`)
} else {
throw new Error("RESEND_API_KEY is not configured")
}
}
+61
View File
@@ -0,0 +1,61 @@
import { auth } from "@/lib/auth"
import type { Session } from "better-auth"
/**
* Imperative permission guards for use inside route handlers or non-Elysia
* contexts. For blanket authentication on an Elysia route, prefer the `auth`
* macro defined in `app/api/[[...slugs]]/route.ts`.
*/
type User = typeof auth.$Infer.Session.user
type GuardResult =
| { ok: true; user: User; session: Session }
| { ok: false; error: string; status: number }
export async function requireAuth(headers: Headers): Promise<GuardResult> {
const session = await auth.api.getSession({ headers })
if (!session) {
return { ok: false, error: "Unauthorized", status: 401 }
}
return {
ok: true,
user: session.user,
session: session.session,
}
}
export async function requireRole(
headers: Headers,
roles: string[],
): Promise<GuardResult> {
const authResult = await requireAuth(headers)
if (!authResult.ok) return authResult
const userRole = authResult.user.role ?? "user"
if (!roles.includes(userRole)) {
return { ok: false, error: "Forbidden", status: 403 }
}
return authResult
}
export async function requireAdmin(headers: Headers): Promise<GuardResult> {
return requireRole(headers, ["admin"])
}
export async function requireContributorOrAdmin(
headers: Headers,
): Promise<GuardResult> {
return requireRole(headers, ["contributor", "admin"])
}
export async function requireModeratorOrAdmin(
headers: Headers,
): Promise<GuardResult> {
return requireRole(headers, ["moderator", "admin"])
}
+93
View File
@@ -0,0 +1,93 @@
export type StrengthLevel = "weak" | "fair" | "good" | "strong" | "excellent"
export interface PasswordStrength {
score: number
level: StrengthLevel
feedback: string[]
}
const COMMON_PATTERNS = [
"password",
"123456",
"12345678",
"qwerty",
"abc123",
"monkey",
"master",
"dragon",
"login",
"admin",
"letmein",
"welcome",
"shadow",
"sunshine",
"trustno1",
"iloveyou",
]
export function checkPasswordStrength(password: string): PasswordStrength {
if (!password) {
return { score: 0, level: "weak", feedback: ["Enter a password"] }
}
let score = 0
const feedback: string[] = []
// Length scoring
if (password.length >= 10) score += 20
else feedback.push("Use at least 10 characters")
if (password.length >= 14) score += 10
else if (password.length >= 10) feedback.push("Use 14+ characters for extra security")
if (password.length >= 18) score += 10
// Character variety
const hasUpper = /[A-Z]/.test(password)
const hasLower = /[a-z]/.test(password)
const hasNumber = /[0-9]/.test(password)
const hasSpecial = /[^A-Za-z0-9]/.test(password)
if (hasUpper) score += 15
else feedback.push("Add an uppercase letter")
if (hasLower) score += 15
else feedback.push("Add a lowercase letter")
if (hasNumber) score += 15
else feedback.push("Add a number")
if (hasSpecial) score += 15
else feedback.push("Add a special character (!@#$%^&*)")
// Common pattern check
const lower = password.toLowerCase()
const isCommon = COMMON_PATTERNS.some((p) => lower.includes(p))
if (!isCommon) score += 10
else feedback.push("Avoid common passwords")
// Repeated characters
const hasRepeated = /(.)\1{2,}/.test(password)
if (!hasRepeated) score += 5
else feedback.push("Avoid repeated characters")
// Mixed positions (not all numbers at end, not all caps at start)
const endsWithNumbers = /[0-9]+$/.test(password) && !/[0-9]/.test(password.slice(0, -3))
const startsWithCaps = /^[A-Z]{3,}/.test(password) && !/[A-Z]/.test(password.slice(3))
if (!endsWithNumbers && !startsWithCaps) score += 5
// Determine level
let level: StrengthLevel
if (score <= 20) level = "weak"
else if (score <= 40) level = "fair"
else if (score <= 60) level = "good"
else if (score <= 80) level = "strong"
else level = "excellent"
// If all checks pass, clear feedback
if (feedback.length === 0) {
feedback.push("Great password!")
}
return { score, level, feedback }
}
+46
View File
@@ -0,0 +1,46 @@
import { createAccessControl } from "better-auth/plugins/access"
import {
defaultStatements,
adminAc,
} from "better-auth/plugins/admin/access"
const statement = {
...defaultStatements,
game: ["create", "update", "delete", "review"],
performance: ["submit", "verify", "delete"],
hardware: ["create", "update"],
profile: ["view", "edit"],
} as const
export const ac = createAccessControl(statement)
export const user = ac.newRole({
profile: ["view"],
performance: ["submit"],
})
export const contributor = ac.newRole({
...user.statements,
profile: ["view", "edit"],
performance: ["submit", "verify"],
game: ["create", "update"],
hardware: ["update"],
})
export const moderator = ac.newRole({
...contributor.statements,
performance: ["submit", "verify", "delete"],
game: ["create", "update", "review"],
})
export const admin = ac.newRole({
...adminAc.statements,
...moderator.statements,
game: ["create", "update", "delete", "review"],
performance: ["submit", "verify", "delete"],
hardware: ["create", "update"],
profile: ["view", "edit"],
})
export const ROLES = ["user", "contributor", "moderator", "admin"] as const
export type RoleName = (typeof ROLES)[number]
+21
View File
@@ -0,0 +1,21 @@
import { betterAuth } from "better-auth"
import { testUtils } from "better-auth/plugins"
import { drizzleAdapter } from "@better-auth/drizzle-adapter"
import { db } from "@/lib/db/index"
export const testAuth = betterAuth({
database: drizzleAdapter(db, {
provider: "pg",
}),
plugins: [
testUtils({ captureOTP: true }),
],
emailAndPassword: {
enabled: true,
},
})
export async function getTestHelpers() {
const ctx = await testAuth.$context
return ctx.test
}
+60
View File
@@ -0,0 +1,60 @@
import { z } from "zod"
import { isDeckyVaultEmail, DOMAIN_BLOCK_ERROR } from "./domain-block"
export const loginEmailSchema = z.object({
email: z.string().email("Please enter a valid email address"),
})
export const loginSchema = z.object({
email: z.string().email("Please enter a valid email address"),
password: z.string().min(1, "Password is required"),
})
export const signupSchema = z
.object({
name: z
.string()
.min(1, "Name is required")
.max(100, "Name must be 100 characters or less"),
email: z.string().email("Please enter a valid email address"),
password: z
.string()
.min(10, "Password must be at least 10 characters"),
})
.refine(
(data) =>
process.env.NODE_ENV === "development" ||
!isDeckyVaultEmail(data.email),
{
message: DOMAIN_BLOCK_ERROR,
path: ["email"],
},
)
export const otpSchema = z.object({
otp: z.string().length(6, "OTP must be exactly 6 digits"),
})
export const forgotPasswordSchema = z.object({
email: z.string().email("Please enter a valid email address"),
})
export const resetPasswordSchema = z
.object({
otp: z.string().length(6, "OTP must be exactly 6 digits"),
newPassword: z
.string()
.min(10, "Password must be at least 10 characters"),
confirmPassword: z.string().min(1, "Please confirm your password"),
})
.refine((data) => data.newPassword === data.confirmPassword, {
message: "Passwords do not match",
path: ["confirmPassword"],
})
export type LoginEmailInput = z.infer<typeof loginEmailSchema>
export type LoginInput = z.infer<typeof loginSchema>
export type SignupInput = z.infer<typeof signupSchema>
export type OtpInput = z.infer<typeof otpSchema>
export type ForgotPasswordInput = z.infer<typeof forgotPasswordSchema>
export type ResetPasswordInput = z.infer<typeof resetPasswordSchema>
+9
View File
@@ -0,0 +1,9 @@
import { drizzle } from "drizzle-orm/node-postgres"
import { Pool } from "pg"
import * as schema from "./schema"
const pool = new Pool({
connectionString: process.env.DATABASE_URL,
})
export const db = drizzle(pool, { schema })
@@ -0,0 +1,50 @@
import { db } from "@/lib/db/index"
import { games } from "@/lib/db/schema"
import { generateSlug } from "@/lib/utils/slug"
import { ne, isNull, isNotNull, and, eq } from "drizzle-orm"
async function backfillSlugs() {
const nonSteamGames = await db
.select({ id: games.id, title: games.title })
.from(games)
.where(and(ne(games.source, "steam"), isNull(games.slug)))
console.log(`Found ${nonSteamGames.length} non-Steam games without slugs`)
const usedSlugs = new Set<string>()
const existing = await db
.select({ slug: games.slug })
.from(games)
.where(isNotNull(games.slug))
for (const row of existing) {
if (row.slug) usedSlugs.add(row.slug)
}
let updated = 0
let errors = 0
for (const game of nonSteamGames) {
let slug = generateSlug(game.title)
if (!slug) {
slug = `game-${game.id.slice(0, 8)}`
}
let candidate = slug
let suffix = 2
while (usedSlugs.has(candidate)) {
candidate = `${slug}-${suffix}`
suffix++
}
usedSlugs.add(candidate)
try {
await db.update(games).set({ slug: candidate }).where(eq(games.id, game.id))
updated++
} catch (err) {
console.error(`Failed to update ${game.title} (${game.id}):`, err)
errors++
}
}
console.log(`Backfill complete: ${updated} updated, ${errors} errors`)
}
backfillSlugs()
.then(() => process.exit(0))
.catch((err) => { console.error("Backfill failed:", err); process.exit(1) })
+180
View File
@@ -0,0 +1,180 @@
import { relations } from "drizzle-orm"
import {
pgTable,
text,
timestamp,
boolean,
integer,
index,
} from "drizzle-orm/pg-core"
export const user = pgTable("user", {
id: text("id").primaryKey(),
name: text("name").notNull(),
email: text("email").notNull().unique(),
emailVerified: boolean("email_verified").default(false).notNull(),
image: text("image"),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at")
.defaultNow()
.$onUpdate(() => /* @__PURE__ */ new Date())
.notNull(),
role: text("role"),
banned: boolean("banned").default(false),
banReason: text("ban_reason"),
banExpires: timestamp("ban_expires"),
lastLoginMethod: text("last_login_method"),
})
export const session = pgTable(
"session",
{
id: text("id").primaryKey(),
expiresAt: timestamp("expires_at").notNull(),
token: text("token").notNull().unique(),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at")
.$onUpdate(() => /* @__PURE__ */ new Date())
.notNull(),
ipAddress: text("ip_address"),
userAgent: text("user_agent"),
userId: text("user_id")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
impersonatedBy: text("impersonated_by"),
},
(table) => [index("session_userId_idx").on(table.userId)],
)
export const account = pgTable(
"account",
{
id: text("id").primaryKey(),
accountId: text("account_id").notNull(),
providerId: text("provider_id").notNull(),
userId: text("user_id")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
accessToken: text("access_token"),
refreshToken: text("refresh_token"),
idToken: text("id_token"),
accessTokenExpiresAt: timestamp("access_token_expires_at"),
refreshTokenExpiresAt: timestamp("refresh_token_expires_at"),
scope: text("scope"),
password: text("password"),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at")
.$onUpdate(() => /* @__PURE__ */ new Date())
.notNull(),
},
(table) => [index("account_userId_idx").on(table.userId)],
)
export const verification = pgTable(
"verification",
{
id: text("id").primaryKey(),
identifier: text("identifier").notNull(),
value: text("value").notNull(),
expiresAt: timestamp("expires_at").notNull(),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at")
.defaultNow()
.$onUpdate(() => /* @__PURE__ */ new Date())
.notNull(),
},
(table) => [index("verification_identifier_idx").on(table.identifier)],
)
export const passkey = pgTable(
"passkey",
{
id: text("id").primaryKey(),
name: text("name"),
publicKey: text("public_key").notNull(),
userId: text("user_id")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
credentialID: text("credential_id").notNull(),
counter: integer("counter").notNull(),
deviceType: text("device_type").notNull(),
backedUp: boolean("backed_up").notNull(),
transports: text("transports"),
createdAt: timestamp("created_at"),
aaguid: text("aaguid"),
},
(table) => [
index("passkey_userId_idx").on(table.userId),
index("passkey_credentialID_idx").on(table.credentialID),
],
)
export const userRelations = relations(user, ({ many }) => ({
sessions: many(session),
accounts: many(account),
passkeys: many(passkey),
}))
export const sessionRelations = relations(session, ({ one }) => ({
user: one(user, {
fields: [session.userId],
references: [user.id],
}),
}))
export const accountRelations = relations(account, ({ one }) => ({
user: one(user, {
fields: [account.userId],
references: [user.id],
}),
}))
export const passkeyRelations = relations(passkey, ({ one }) => ({
user: one(user, {
fields: [passkey.userId],
references: [user.id],
}),
}))
export const apikey = pgTable(
"apikey",
{
id: text("id").primaryKey(),
configId: text("config_id").notNull().default("default"),
name: text("name"),
start: text("start"),
referenceId: text("reference_id").notNull(),
prefix: text("prefix"),
key: text("key").notNull(),
refillInterval: integer("refill_interval"),
refillAmount: integer("refill_amount"),
lastRefillAt: timestamp("last_refill_at"),
enabled: boolean("enabled").default(true).notNull(),
rateLimitEnabled: boolean("rate_limit_enabled").default(true).notNull(),
rateLimitTimeWindow: integer("rate_limit_time_window"),
rateLimitMax: integer("rate_limit_max"),
requestCount: integer("request_count").default(0).notNull(),
remaining: integer("remaining"),
lastRequest: timestamp("last_request"),
expiresAt: timestamp("expires_at"),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at")
.defaultNow()
.$onUpdate(() => new Date())
.notNull(),
permissions: text("permissions"),
metadata: text("metadata"),
},
(table) => [
index("apikey_config_id_idx").on(table.configId),
index("apikey_reference_id_idx").on(table.referenceId),
index("apikey_key_idx").on(table.key),
],
)
export const apikeyRelations = relations(apikey, ({ one }) => ({
user: one(user, {
fields: [apikey.referenceId],
references: [user.id],
}),
}))
@@ -0,0 +1,54 @@
import {
index,
pgEnum,
pgTable,
text,
timestamp,
uniqueIndex,
} from "drizzle-orm/pg-core"
import { games } from "./games"
import { user } from "./auth"
export const suggestionStatusEnum = pgEnum("suggestion_status", [
"pending",
"approved",
"rejected",
])
export const communitySuggestions = pgTable(
"community_suggestions",
{
id: text("id")
.primaryKey()
.$defaultFn(() => crypto.randomUUID()),
gameId: text("game_id")
.notNull()
.references(() => games.id, { onDelete: "cascade" }),
userId: text("user_id")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
fieldName: text("field_name").notNull(), // e.g. "title", "description", "developer"
currentValue: text("current_value"), // current value (snapshot)
proposedValue: text("proposed_value").notNull(), // proposed new value
reason: text("reason"), // optional explanation
status: suggestionStatusEnum("status").default("pending").notNull(),
reviewedBy: text("reviewed_by").references(() => user.id, {
onDelete: "set null",
}),
reviewedAt: timestamp("reviewed_at"),
reviewNote: text("review_note"), // reviewer's note
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at")
.defaultNow()
.$onUpdate(() => new Date())
.notNull(),
},
(table) => [
uniqueIndex("community_suggestions_game_field_user").on(
table.gameId,
table.fieldName,
table.userId,
),
index("suggestions_status_idx").on(table.status),
],
)
@@ -0,0 +1,30 @@
import {
integer,
pgTable,
text,
timestamp,
index,
} from "drizzle-orm/pg-core"
import { performanceEntries } from "./performanceEntries"
export const entryScreenshots = pgTable(
"entry_screenshots",
{
id: text("id")
.primaryKey()
.$defaultFn(() => crypto.randomUUID()),
entryId: text("entry_id")
.notNull()
.references(() => performanceEntries.id, { onDelete: "cascade" }),
storageKey: text("storage_key").notNull(),
orderIndex: integer("order_index").notNull().default(0),
mimeType: text("mime_type").notNull(),
width: integer("width").notNull(),
height: integer("height").notNull(),
originalName: text("original_name"),
createdAt: timestamp("created_at").defaultNow().notNull(),
},
(table) => [
index("entry_screenshots_entry_idx").on(table.entryId),
],
)
+39
View File
@@ -0,0 +1,39 @@
import {
boolean,
integer,
jsonb,
pgTable,
text,
timestamp,
index,
} from "drizzle-orm/pg-core"
import { games } from "./games"
import { user } from "./auth"
export const gameComments = pgTable(
"game_comments",
{
id: text("id")
.primaryKey()
.$defaultFn(() => crypto.randomUUID()),
gameId: text("game_id")
.notNull()
.references(() => games.id, { onDelete: "cascade" }),
userId: text("user_id")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
parentId: text("parent_id").references((): any => gameComments.id, { // eslint-disable-line @typescript-eslint/no-explicit-any
onDelete: "cascade",
}),
// Tiptap JSON document
content: jsonb("content").notNull().$type<Record<string, unknown>>(),
upvotes: integer("upvotes").default(0).notNull(),
isRemoved: boolean("is_removed").default(false).notNull(),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at").defaultNow().notNull(),
},
(table) => [
index("comments_game_created_idx").on(table.gameId, table.createdAt),
index("comments_parent_idx").on(table.parentId),
],
)
@@ -0,0 +1,66 @@
import {
boolean,
pgEnum,
pgTable,
text,
timestamp,
unique,
} from "drizzle-orm/pg-core"
import { games, playabilityStatusEnum } from "./games"
import { hardware } from "./hardware"
export const protonStatusEnum = pgEnum("proton_status", [
"native",
"proton",
"unsupported",
"unknown",
])
export const antiCheatStatusEnum = pgEnum("anti_cheat_status", [
"none",
"supported",
"unsupported",
"unknown",
])
export const gamePlatformSupport = pgTable(
"game_platform_support",
{
id: text("id")
.primaryKey()
.$defaultFn(() => crypto.randomUUID()),
gameId: text("game_id")
.notNull()
.references(() => games.id, { onDelete: "cascade" }),
hardwareSlug: text("hardware_slug")
.notNull()
.references(() => hardware.slug, { onDelete: "restrict" }),
// Compatibility
isSupported: boolean("is_supported").default(false).notNull(),
protonStatus: protonStatusEnum("proton_status")
.default("unknown")
.notNull(),
// Anti-cheat details
antiCheatRelevant: boolean("anti_cheat_relevant")
.default(false)
.notNull(),
antiCheatName: text("anti_cheat_name"),
antiCheatVersion: text("anti_cheat_version"),
antiCheatStatus: antiCheatStatusEnum("anti_cheat_status")
.default("unknown")
.notNull(),
// Per-device playability
playabilityStatus: playabilityStatusEnum("playability_status").default("unknown").notNull(),
playabilityOverride: boolean("playability_override").default(false).notNull(),
playabilityCalculatedAt: timestamp("playability_calculated_at"),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at").defaultNow().notNull(),
},
(table) => [
unique("game_hardware_unique").on(table.gameId, table.hardwareSlug),
]
)
+29
View File
@@ -0,0 +1,29 @@
import {
boolean,
index,
pgTable,
text,
timestamp,
unique,
} from "drizzle-orm/pg-core"
import { games } from "./games"
export const gameVersions = pgTable(
"game_versions",
{
id: text("id")
.primaryKey()
.$defaultFn(() => crypto.randomUUID()),
gameId: text("game_id")
.notNull()
.references(() => games.id, { onDelete: "cascade" }),
buildId: text("build_id"),
versionString: text("version_string"),
isLatest: boolean("is_latest").default(false).notNull(),
createdAt: timestamp("created_at").defaultNow().notNull(),
},
(table) => [
unique("game_build_unique").on(table.gameId, table.buildId),
index("perf_game_lookup_idx").on(table.gameId),
]
)
+105
View File
@@ -0,0 +1,105 @@
import {
boolean,
integer,
jsonb,
pgEnum,
pgTable,
text,
timestamp,
index,
} from "drizzle-orm/pg-core"
export const gameSourceEnum = pgEnum("game_source", [
"steam",
"manual",
"gog",
"epic",
])
export const onlineMultiplayerStatusEnum = pgEnum(
"online_multiplayer_status",
["none", "supported", "unknown"],
)
export const steamReviewSentimentEnum = pgEnum("steam_review_sentiment", [
"overwhelmingly_positive",
"very_positive",
"positive",
"mostly_positive",
"mixed",
"mostly_negative",
"negative",
"very_negative",
"overwhelmingly_negative",
])
export const playabilityStatusEnum = pgEnum("playability_status", [
"great",
"playable",
"needs_tweaks",
"unplayable",
"unknown",
])
export const games = pgTable(
"games",
{
id: text("id")
.primaryKey()
.$defaultFn(() => crypto.randomUUID()),
steamAppId: integer("steam_app_id").unique(),
source: gameSourceEnum("source").default("steam").notNull(),
slug: text("slug").unique(),
title: text("title").notNull(),
description: text("description"),
publisher: text("publisher"),
developer: text("developer"),
genres: jsonb("genres").$type<string[]>(),
headerImage: text("header_image"),
capsuleImage: text("capsule_image"),
storeUrl: text("store_url"),
onlineMultiplayerStatus: onlineMultiplayerStatusEnum(
"online_multiplayer_status",
)
.default("unknown")
.notNull(),
systemRequirements: jsonb("system_requirements").$type<{
minimum: string | null
recommended: string | null
}>(),
metacriticScore: integer("metacritic_score"),
metacriticUrl: text("metacritic_url"),
recommendationsTotal: integer("recommendations_total"),
steamReviewScore: integer("steam_review_score"), // 0-100 normalized score
steamReviewSentiment: steamReviewSentimentEnum("steam_review_sentiment"),
steamReviewCount: integer("steam_review_count"), // Total review count from Steam
// Playability (aggregate from all devices)
playabilityStatus: playabilityStatusEnum("playability_status").default("unknown").notNull(),
playabilityOverride: boolean("playability_override").default(false).notNull(), // true = manually set
playabilityCalculatedAt: timestamp("playability_calculated_at"), // when auto-calculated
priceCurrent: integer("price_current"),
priceInitial: integer("price_initial"),
priceCurrency: text("price_currency"),
isFree: boolean("is_free").default(false).notNull(),
releaseDate: text("release_date"),
categories: jsonb("categories").$type<string[]>(),
platforms: jsonb("platforms").$type<{
windows: boolean
mac: boolean
linux: boolean
}>(),
lastSync: timestamp("last_sync"),
syncStatus: text("sync_status").default("pending"),
syncError: text("sync_error"),
syncRetryCount: integer("sync_retry_count").default(0),
syncNextRetry: timestamp("sync_next_retry"),
createdBy: text("created_by"),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at").defaultNow().notNull(),
},
(table) => [
index("games_source_idx").on(table.source),
index("games_sync_status_idx").on(table.syncStatus, table.steamAppId),
],
)
+14
View File
@@ -0,0 +1,14 @@
import { integer, pgEnum, pgTable, real, text, timestamp } from "drizzle-orm/pg-core"
export const deviceTypeEnum = pgEnum("device_type", ["handheld", "console"])
export const hardware = pgTable("hardware", {
slug: text("slug").primaryKey(),
name: text("name").notNull(),
deviceType: deviceTypeEnum("device_type").notNull(),
image: text("image"),
wattHours: real("watt_hours"),
tdpMax: real("tdp_max"),
sortOrder: integer("sort_order").default(0).notNull(),
createdAt: timestamp("created_at").defaultNow().notNull(),
})
+14
View File
@@ -0,0 +1,14 @@
// Barrel export — domain + auth schemas
export * from "./auth"
export * from "./games"
export * from "./gameVersions"
export * from "./hardware"
export * from "./performanceEntries"
export * from "./gamePlatformSupport"
export * from "./gameComments"
export * from "./savedGames"
export * from "./reports"
export * from "./community-suggestions"
export * from "./saved-filters"
export * from "./storage"
export * from "./entryScreenshots"
@@ -0,0 +1,118 @@
import {
boolean,
integer,
jsonb,
pgEnum,
pgTable,
real,
text,
timestamp,
index,
} from "drizzle-orm/pg-core"
import { gameVersions } from "./gameVersions"
import { hardware } from "./hardware"
import { user } from "./auth"
export const upscalerTypeEnum = pgEnum("upscaler_type", [
"none",
"fsr",
"dlss",
"xess",
"lsfg",
"other",
])
export const frameGenMethodEnum = pgEnum("frame_gen_method", [
"none",
"fsr_fg",
"dlss_fg",
"lsfg",
"other",
])
export type GameSettingCategory = {
category: string
settings: { title: string; value: string | number | boolean }[]
}
export const performanceEntries = pgTable(
"performance_entries",
{
id: text("id")
.primaryKey()
.$defaultFn(() => crypto.randomUUID()),
versionId: text("version_id")
.notNull()
.references(() => gameVersions.id, { onDelete: "cascade" }),
hardwareSlug: text("hardware_slug")
.notNull()
.references(() => hardware.slug, { onDelete: "restrict" }),
userId: text("user_id")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
// Performance metrics
fpsAvg: real("fps_avg").notNull(),
fpsLow: real("fps_low"),
fpsOnePercentLow: real("fps_one_percent_low"),
fpsHigh: real("fps_high"),
// Environment
protonVersion: text("proton_version"),
osVersion: text("os_version"),
// Upscaler tracking
upscalerType: upscalerTypeEnum("upscaler_type").default("none").notNull(),
upscalerVersion: text("upscaler_version"),
frameGenMethod: frameGenMethodEnum("frame_gen_method")
.default("none")
.notNull(),
// Load times (seconds)
loadTimeSsd: real("load_time_ssd"),
loadTimeSd: real("load_time_sd"),
// Launch options (Steam launch options string)
launchOptions: text("launch_options"),
// Settings & notes
settingsJson: jsonb("settings_json").$type<GameSettingCategory[]>(),
userNotes: text("user_notes"),
// TDP tracking (watts) — user-set TDP cap during benchmark
tdpWatts: real("tdp_watts"),
// YouTube video linking
youtubeVideoId: text("youtube_video_id"),
// Custom system flag
customSystem: boolean("custom_system").default(false).notNull(),
// Moderation
isRemoved: boolean("is_removed").default(false).notNull(),
isPinned: boolean("is_pinned").default(false).notNull(),
pinnedAt: timestamp("pinned_at"),
removedReason: text("removed_reason"),
// Community rating
upvotes: integer("upvotes").default(0).notNull(),
downvotes: integer("downvotes").default(0).notNull(),
// Verification (admin/mod workflow)
verifiedAt: timestamp("verified_at"),
verifiedBy: text("verified_by").references(() => user.id, {
onDelete: "set null",
}),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at").defaultNow().notNull(),
},
(table) => [
index("perf_hardware_upscaler_idx").on(table.hardwareSlug, table.upscalerType),
index("perf_version_idx").on(table.versionId),
index("perf_user_idx").on(table.userId),
index("perf_removed_created_idx").on(table.isRemoved, table.createdAt.desc()),
index("perf_upvotes_idx").on(table.upvotes.desc()),
],
)
+42
View File
@@ -0,0 +1,42 @@
import {
index,
text,
pgEnum,
pgTable,
timestamp,
uniqueIndex,
} from "drizzle-orm/pg-core"
import { performanceEntries } from "./performanceEntries"
import { user } from "./auth"
export const reportReasonEnum = pgEnum("report_reason", [
"inaccurate",
"spam",
"inappropriate",
"other",
])
export const reportStatusEnum = pgEnum("report_status", [
"open",
"reviewed",
"dismissed",
])
export const reports = pgTable("reports", {
id: text("id")
.primaryKey()
.$defaultFn(() => crypto.randomUUID()),
entryId: text("entry_id")
.notNull()
.references(() => performanceEntries.id, { onDelete: "cascade" }),
reporterId: text("reporter_id")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
reason: reportReasonEnum("reason").notNull(),
details: text("details"),
status: reportStatusEnum("status").default("open").notNull(),
createdAt: timestamp("created_at").defaultNow().notNull(),
}, (table) => [
uniqueIndex("reports_entry_reporter_unique").on(table.entryId, table.reporterId),
index("reports_status_idx").on(table.status),
])
+30
View File
@@ -0,0 +1,30 @@
import {
pgTable,
text,
timestamp,
jsonb,
unique,
} from "drizzle-orm/pg-core"
import { user } from "./auth"
export const savedFilters = pgTable(
"saved_filters",
{
id: text("id")
.primaryKey()
.$defaultFn(() => crypto.randomUUID()),
userId: text("user_id")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
name: text("name").notNull(),
filters: jsonb("filters").notNull(),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at")
.defaultNow()
.$onUpdate(() => new Date())
.notNull(),
},
(table) => [
unique("saved_filters_user_name").on(table.userId, table.name),
],
)
+27
View File
@@ -0,0 +1,27 @@
import {
pgTable,
text,
timestamp,
unique,
} from "drizzle-orm/pg-core"
import { user } from "./auth"
import { games } from "./games"
export const savedGames = pgTable(
"saved_games",
{
id: text("id")
.primaryKey()
.$defaultFn(() => crypto.randomUUID()),
userId: text("user_id")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
gameId: text("game_id")
.notNull()
.references(() => games.id, { onDelete: "cascade" }),
createdAt: timestamp("created_at").defaultNow().notNull(),
},
(table) => [
unique("saved_games_user_game_unique").on(table.userId, table.gameId),
],
)
+34
View File
@@ -0,0 +1,34 @@
import {
boolean,
integer,
pgTable,
text,
timestamp,
index,
} from "drizzle-orm/pg-core"
import { user } from "./auth"
export const storageObjects = pgTable(
"storage_objects",
{
id: text("id")
.primaryKey()
.$defaultFn(() => crypto.randomUUID()),
key: text("key").notNull(),
bucket: text("bucket").notNull(),
size: integer("size").notNull(),
mimeType: text("mime_type").notNull(),
entityType: text("entity_type").notNull(), // "avatar" | "game_cover" | "hardware_image"
entityId: text("entity_id"), // user ID, game ID, or hardware slug
uploadedBy: text("uploaded_by")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
createdAt: timestamp("created_at").defaultNow().notNull(),
lastAccessedAt: timestamp("last_accessed_at"),
isOrphaned: boolean("is_orphaned").default(false).notNull(),
},
(table) => [
index("storage_entity_idx").on(table.entityType, table.entityId),
index("storage_key_idx").on(table.key),
],
)
+17
View File
@@ -0,0 +1,17 @@
/**
* Normalizes a raw search query so that common word separators
* (spaces, hyphens, underscores, colons, dots) are treated equivalently.
*
* Replaces any sequence of separator chars with a single `%` ILIKE wildcard,
* then wraps the whole pattern in `%…%`.
*
* Example:
* fuzzySearchTerm("counter strike") → "%counter%strike%"
* fuzzySearchTerm("counter-strike") → "%counter%strike%"
*
* Both will match "Counter-Strike", "Counter Strike", "Counter_Strike", etc.
*/
export function fuzzySearchTerm(rawQuery: string): string {
const normalized = rawQuery.replace(/[-_\s:.]+/g, "%")
return `%${normalized}%`
}
+51
View File
@@ -0,0 +1,51 @@
import "dotenv/config"
import { drizzle } from "drizzle-orm/node-postgres"
import { Pool } from "pg"
import { hardware } from "./schema/hardware"
const pool = new Pool({
connectionString: process.env.DATABASE_URL,
})
const db = drizzle(pool)
async function seed() {
// ── Hardware ──────────────────────────────────────────────────────
console.log("Seeding hardware table...")
const devices = [
{
slug: "steamdeck-oled",
name: "Steam Deck OLED",
deviceType: "handheld" as const,
sortOrder: 0,
},
{
slug: "steamdeck-lcd",
name: "Steam Deck LCD",
deviceType: "handheld" as const,
sortOrder: 1,
},
{
slug: "steam-machine",
name: "Steam Machine",
deviceType: "console" as const,
sortOrder: 2,
},
]
for (const device of devices) {
await db
.insert(hardware)
.values(device)
.onConflictDoNothing({ target: hardware.slug })
}
console.log(`Seeded ${devices.length} hardware devices.`)
await pool.end()
}
seed().catch((err) => {
console.error("Seed failed:", err)
process.exit(1)
})
+11
View File
@@ -0,0 +1,11 @@
import { treaty } from "@elysia/eden"
import type { App } from "@/lib/api/app"
// Use require() on the server branch to prevent the client bundler from
// pulling in Elysia and the route module. The typeof window check is the
// standard Next.js server-vs-client guard.
export const api =
typeof window === "undefined"
? // eslint-disable-next-line @typescript-eslint/no-require-imports
treaty((require("@/lib/api/app") as { app: App }).app).api
: treaty<App>(process.env.NEXT_PUBLIC_SITE_URL || "http://localhost:3000").api
@@ -0,0 +1,211 @@
"use client"
import { useEffect, useRef, useState } from "react"
const AXIS_THRESHOLD = 0.5
const DEBOUNCE_MS = 150
interface GamepadNavigationOptions {
/** CSS selector for focusable elements within the container */
focusSelector?: string
/** Callback when X button is pressed (typically opens search) */
onXButton?: () => void
/** Callback when Y button is pressed (typically toggles filters) */
onYButton?: () => void
}
/**
* Hook for gamepad (Steam Deck controller) navigation.
*
* Activates only when a gamepad button press is detected.
* Deactivates on mouse movement or keyboard input.
* Uses roving tabindex pattern for D-pad and left stick navigation.
* A button = activate, B button = back, X/Y = context-specific actions.
* L1/R1 = previous/next tab (if applicable).
*/
export function useGamepadNavigation(
containerRef: React.RefObject<HTMLElement | null>,
options: GamepadNavigationOptions = {},
) {
const {
focusSelector = 'a, button, [role="button"], input, select, textarea, [tabindex]:not([tabindex="-1"])',
onXButton,
onYButton,
} = options
const [isGamepadActive, setIsGamepadActive] = useState(false)
const currentIndexRef = useRef(-1)
const lastInputTimeRef = useRef(0)
const rafRef = useRef<number>(0)
// Deactivate gamepad mode on mouse or keyboard input
useEffect(() => {
if (!isGamepadActive) return
const handleMouseMovement = () => {
setIsGamepadActive(false)
}
const handleKeyboardInput = (e: KeyboardEvent) => {
// Allow Tab key to coexist with gamepad navigation
if (e.key !== "Tab") {
setIsGamepadActive(false)
}
}
window.addEventListener("mousemove", handleMouseMovement)
window.addEventListener("keydown", handleKeyboardInput)
return () => {
window.removeEventListener("mousemove", handleMouseMovement)
window.removeEventListener("keydown", handleKeyboardInput)
}
}, [isGamepadActive])
// Main gamepad polling loop
useEffect(() => {
let activated = false
const poll = () => {
const gamepads = navigator.getGamepads?.()
if (!gamepads) {
rafRef.current = requestAnimationFrame(poll)
return
}
// Find the first connected gamepad
let gamepad: Gamepad | null = null
for (const gp of gamepads) {
if (gp) {
gamepad = gp
break
}
}
if (!gamepad) {
rafRef.current = requestAnimationFrame(poll)
return
}
const now = performance.now()
if (now - lastInputTimeRef.current < DEBOUNCE_MS) {
rafRef.current = requestAnimationFrame(poll)
return
}
const container = containerRef.current
if (!container) {
rafRef.current = requestAnimationFrame(poll)
return
}
// Auto-activate on first gamepad input
if (!isGamepadActive && !activated) {
for (const button of gamepad.buttons) {
if (button.pressed) {
setIsGamepadActive(true)
activated = true
break
}
}
}
if (!isGamepadActive) {
rafRef.current = requestAnimationFrame(poll)
return
}
const focusable = Array.from(
container.querySelectorAll<HTMLElement>(focusSelector),
).filter((el) => {
// Skip hidden or disabled elements
return el.offsetParent !== null && !el.hasAttribute("disabled")
})
if (focusable.length === 0) {
rafRef.current = requestAnimationFrame(poll)
return
}
// D-pad navigation
const upPressed = gamepad.buttons[12]?.pressed // D-pad up
const downPressed = gamepad.buttons[13]?.pressed // D-pad down
// Left stick navigation
const axisY = gamepad.axes[1] ?? 0
const stickUp = axisY < -AXIS_THRESHOLD
const stickDown = axisY > AXIS_THRESHOLD
// Vertical navigation (primary)
if (upPressed || stickUp) {
currentIndexRef.current = Math.max(0, currentIndexRef.current - 1)
lastInputTimeRef.current = now
} else if (downPressed || stickDown) {
currentIndexRef.current = Math.min(
focusable.length - 1,
currentIndexRef.current + 1,
)
lastInputTimeRef.current = now
}
// Ensure index is valid
currentIndexRef.current = Math.max(
0,
Math.min(currentIndexRef.current, focusable.length - 1),
)
// Focus the current element
if (
currentIndexRef.current >= 0 &&
currentIndexRef.current < focusable.length
) {
focusable[currentIndexRef.current].focus()
}
// A button = activate (click)
if (gamepad.buttons[0]?.pressed) {
if (currentIndexRef.current >= 0 && currentIndexRef.current < focusable.length) {
focusable[currentIndexRef.current].click()
lastInputTimeRef.current = now
}
}
// B button = back
if (gamepad.buttons[1]?.pressed) {
window.history.back()
lastInputTimeRef.current = now
}
// X button = search
if (gamepad.buttons[2]?.pressed && onXButton) {
onXButton()
lastInputTimeRef.current = now
}
// Y button = toggle filters
if (gamepad.buttons[3]?.pressed && onYButton) {
onYButton()
lastInputTimeRef.current = now
}
rafRef.current = requestAnimationFrame(poll)
}
rafRef.current = requestAnimationFrame(poll)
return () => {
if (rafRef.current) {
cancelAnimationFrame(rafRef.current)
}
}
}, [
isGamepadActive,
containerRef,
focusSelector,
onXButton,
onYButton,
])
return { isGamepadActive }
}
+12
View File
@@ -0,0 +1,12 @@
import { useState, useEffect } from "react"
export function useDebounce<T>(value: T, delay: number): T {
const [debouncedValue, setDebouncedValue] = useState<T>(value)
useEffect(() => {
const timer = setTimeout(() => setDebouncedValue(value), delay)
return () => clearTimeout(timer)
}, [value, delay])
return debouncedValue
}
+98
View File
@@ -0,0 +1,98 @@
import sharp from "sharp"
const MAX_WIDTH = 1920
const MAX_HEIGHT = 1080
const QUALITY_HIGH = 80
const QUALITY_FLOOR = 75
const MAX_SIZE_BYTES = 400 * 1024 // 400 KB
const FALLBACK_WIDTH = 1280
const FALLBACK_HEIGHT = 720
const ALLOWED_MIME_TYPES = ["image/jpeg", "image/png", "image/webp"]
// 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)
}
export 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 function isAllowedMimeType(mimeType: string): boolean {
return ALLOWED_MIME_TYPES.includes(mimeType)
}
export interface ProcessedImage {
buffer: Buffer
width: number
height: number
mimeType: string // Always "image/jpeg" after processing
size: number
}
/**
* Process a screenshot for storage:
* 1. Validate format via magic bytes
* 2. Resize to max 1920×1080, maintain aspect ratio
* 3. Convert to progressive JPEG
* 4. If size > 400KB, reduce quality to 75
* 5. If still > 400KB at quality 75, resize to 1280×720 and try again
* 6. Strip ALL EXIF data
*/
export async function processScreenshot(
inputBuffer: Buffer,
declaredMime: string,
): Promise<ProcessedImage> {
if (!isAllowedMimeType(declaredMime)) {
throw new Error(`Invalid MIME type: ${declaredMime}`)
}
if (!validateMagicBytes(inputBuffer, declaredMime)) {
throw new Error("File content does not match declared type")
}
let pipeline = sharp(inputBuffer, { animated: false })
.rotate() // Auto-rotate based on EXIF orientation
.resize(MAX_WIDTH, MAX_HEIGHT, { fit: "inside", withoutEnlargement: true })
.jpeg({ quality: QUALITY_HIGH, progressive: true, mozjpeg: true })
// Metadata is stripped by default (no .withMetadata() / .keepMetadata() call)
let outputBuffer = await pipeline.toBuffer()
let metadata = await sharp(outputBuffer).metadata()
// If still over 400KB, reduce quality to floor
if (outputBuffer.length > MAX_SIZE_BYTES) {
pipeline = sharp(inputBuffer, { animated: false })
.rotate()
.resize(MAX_WIDTH, MAX_HEIGHT, { fit: "inside", withoutEnlargement: true })
.jpeg({ quality: QUALITY_FLOOR, progressive: true, mozjpeg: true })
outputBuffer = await pipeline.toBuffer()
metadata = await sharp(outputBuffer).metadata()
}
// If STILL over 400KB, downscale to 1280×720
if (outputBuffer.length > MAX_SIZE_BYTES) {
pipeline = sharp(inputBuffer, { animated: false })
.rotate()
.resize(FALLBACK_WIDTH, FALLBACK_HEIGHT, { fit: "inside", withoutEnlargement: true })
.jpeg({ quality: QUALITY_FLOOR, progressive: true, mozjpeg: true })
outputBuffer = await pipeline.toBuffer()
metadata = await sharp(outputBuffer).metadata()
}
return {
buffer: outputBuffer,
width: metadata.width ?? FALLBACK_WIDTH,
height: metadata.height ?? FALLBACK_HEIGHT,
mimeType: "image/jpeg",
size: outputBuffer.length,
}
}
+62
View File
@@ -0,0 +1,62 @@
export const routes = [
{
title: "Home",
href: "/",
},
{
title: "Search",
href: "/search",
},
{
title: "Games",
href: "/games",
},
{
title: "Compare",
href: "/compare",
},
{
title: "Devices",
href: "/devices",
},
{
title: "Contact",
href: "/contact",
},
// TODO: Add back when about page is implemented
// {
// title: "About",
// href: "/about",
// }
]
export const authRoutes = [
{
title: "Profile",
href: "/profile",
icon: "User",
},
{
title: "Saved Games",
href: "/profile?tab=saved",
icon: "Bookmark",
},
]
export const adminRoutes = [
{
title: "Users",
href: "/admin/users",
icon: "Users",
},
{
title: "Hardware",
href: "/admin/hardware",
icon: "Cpu",
},
{
title: "Games",
href: "/admin/games",
icon: "Gamepad2",
},
]
+88
View File
@@ -0,0 +1,88 @@
import type { MetadataRoute } from "next"
// ─── Configuration ─────────────────────────────────────────────────────
export const PRODUCTION_URL = "https://deckyvault.xyz"
// ─── Helpers ───────────────────────────────────────────────────────────
export function getBaseUrl(): string {
const envUrl = process.env.NEXT_PUBLIC_SITE_URL
if (envUrl && !envUrl.includes("localhost") && !envUrl.includes("127.0.0.1")) {
return envUrl.replace(/\/$/, "")
}
return PRODUCTION_URL
}
/** Build a valid image sitemap entry from a capsule image URL */
export function imageEntry(
capsuleImage: unknown,
): { images: string[] } | Record<string, never> {
if (
typeof capsuleImage === "string" &&
capsuleImage.trim().startsWith("https://") &&
capsuleImage.trim().length <= 2048
) {
return { images: [capsuleImage.trim()] }
}
return {}
}
/** Safely extract a Date from a value that could be Date, string, or nullish */
export function toDate(value: unknown): Date | undefined {
if (value instanceof Date && !Number.isNaN(value.getTime())) return value
if (typeof value === "string" || typeof value === "number") {
const d = new Date(value)
if (!Number.isNaN(d.getTime())) return d
}
return undefined
}
/**
* Run a DB query with a safety net.
* Returns rows on success, undefined on failure the sitemap still
* renders with whatever data is available.
*/
export async function querySafe<T>(
label: string,
query: () => Promise<T>,
timeoutMs = 15_000,
): Promise<T | undefined> {
try {
const result = await Promise.race([
query(),
new Promise<never>((_, reject) =>
setTimeout(
() => reject(new Error(`[Sitemap] ${label} query timed out after ${timeoutMs}ms`)),
timeoutMs,
),
),
])
return result
} catch (err) {
console.error(`[Sitemap] ${label} query failed:`, err)
return undefined
}
}
// ─── Static page definitions ───────────────────────────────────────────
export interface StaticPageDef {
urlPath: string
changeFrequency: MetadataRoute.Sitemap[number]["changeFrequency"]
priority: number
}
export const STATIC_PAGES: StaticPageDef[] = [
// Homepage
{ urlPath: "", changeFrequency: "weekly", priority: 1.0 },
// Core browse pages
{ urlPath: "/games", changeFrequency: "daily", priority: 0.9 },
{ urlPath: "/devices", changeFrequency: "weekly", priority: 0.7 },
{ urlPath: "/updates", changeFrequency: "weekly", priority: 0.6 },
// Utility pages
{ urlPath: "/compare", changeFrequency: "weekly", priority: 0.5 },
{ urlPath: "/search", changeFrequency: "monthly", priority: 0.3 },
// Static content
{ urlPath: "/contact", changeFrequency: "yearly", priority: 0.3 },
]
@@ -0,0 +1,250 @@
import { describe, it, expect, vi, beforeEach } from "vitest"
// 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 global fetch so syncSteamGame doesn't hit real APIs ──
const mockFetch = vi.fn()
Object.assign(globalThis, { fetch: mockFetch as unknown as typeof fetch })
// ── Mock db with a queue of select results ──
function createDbMock() {
const state = {
selectQueue: [] as unknown[][],
insertResults: [] as unknown[],
selectIdx: 0,
}
return {
setSelectQueue: (q: unknown[][]) => { state.selectQueue = q; state.selectIdx = 0 },
setInsertResults: (r: unknown[]) => { state.insertResults = r },
select: vi.fn().mockImplementation(() => ({
from: vi.fn().mockImplementation(() => ({
where: vi.fn().mockImplementation(() => ({
limit: vi.fn().mockImplementation((n: number) => {
const result = state.selectQueue[state.selectIdx] ?? []
state.selectIdx++
return Promise.resolve(result.slice(0, n))
}),
})),
})),
})),
insert: vi.fn().mockImplementation(() => ({
values: vi.fn().mockImplementation(() => ({
returning: vi.fn().mockImplementation(() =>
Promise.resolve(state.insertResults)
),
})),
})),
update: vi.fn().mockImplementation(() => ({
set: vi.fn().mockImplementation(() => ({
where: vi.fn().mockImplementation(() => Promise.resolve(undefined)),
})),
})),
}
}
const mockDb = createDbMock()
vi.mock("@/lib/db/index", () => ({ db: mockDb }))
vi.mock("@/lib/db/schema", () => ({
games: {
steamAppId: "steam_app_id",
id: "id",
source: "source",
title: "title",
syncRetryCount: "sync_retry_count",
},
steamReviewSentimentEnum: {
enumValues: [
"overwhelmingly_positive",
"very_positive",
"positive",
"mostly_positive",
"mixed",
"mostly_negative",
"negative",
"very_negative",
"overwhelmingly_negative",
],
},
}))
vi.mock("@/lib/api/playability", () => ({
recalculatePlayability: vi.fn().mockResolvedValue({
gamePlayability: "unknown",
deviceResults: [],
}),
}))
// Import AFTER mocks are established
const { ensureSteamGame } = await import("@/lib/steam/sync")
describe("ensureSteamGame", () => {
beforeEach(() => {
vi.clearAllMocks()
mockFetch.mockReset()
mockDb.setSelectQueue([])
mockDb.setInsertResults([])
})
it("returns existing game without re-syncing when game already exists", async () => {
const existingGame = {
id: "existing-id",
steamAppId: 12345,
title: "Existing Game",
source: "steam",
}
mockDb.setSelectQueue([[existingGame]])
const result = await ensureSteamGame(12345)
expect(result.created).toBe(false)
expect(result.game).toEqual(existingGame)
expect(mockFetch).not.toHaveBeenCalled()
})
it("creates stub and syncs when game does not exist", async () => {
const stubGame = {
id: "new-id",
steamAppId: 67890,
title: "Steam App 67890",
source: "steam",
syncStatus: "pending",
}
const syncedGame = {
...stubGame,
title: "Real Game Name",
steamReviewScore: 95,
syncStatus: "synced",
}
// Queue: [existence check, recalc-select, final-select]
mockDb.setSelectQueue([[], [{ id: "new-id" }], [syncedGame]])
mockDb.setInsertResults([stubGame])
// Mock Steam API responses
mockFetch.mockImplementation((url: string | URL, init?: RequestInit) => {
const urlStr = url.toString()
if (urlStr.includes("store.steampowered.com/api/appdetails")) {
return Promise.resolve({
ok: true,
status: 200,
headers: new Headers(),
json: () =>
Promise.resolve({
"67890": {
success: true,
data: {
type: "game",
name: "Real Game Name",
developers: ["Dev Studios"],
publishers: ["Publisher"],
genres: [{ id: "1", description: "Action" }],
header_image: "https://example.com/header.jpg",
short_description: "An action game",
pc_requirements: { minimum: "min", recommended: "rec" },
categories: [{ id: "1", description: "Single-player" }],
platforms: { windows: true, mac: false, linux: true },
is_free: false,
release_date: { coming_soon: false, date: "2023-01-01" },
},
},
}),
} as unknown as Response)
}
if (urlStr.includes("store.steampowered.com/appreviews")) {
return Promise.resolve({
ok: true,
status: 200,
headers: new Headers(),
json: () =>
Promise.resolve({
query_summary: {
total_reviews: 1000,
total_positive: 950,
review_score_desc: "Overwhelmingly Positive",
},
}),
} as unknown as Response)
}
// Capsule image HEAD check
if (urlStr.includes("library_600x900.jpg") && init?.method === "HEAD") {
return Promise.resolve({
ok: true,
status: 200,
headers: new Headers(),
} as unknown as Response)
}
return Promise.resolve({
ok: false,
status: 404,
} as unknown as Response)
})
const result = await ensureSteamGame(67890)
expect(result.created).toBe(true)
expect(result.game?.title).toBe("Real Game Name")
expect(result.game?.steamReviewScore).toBe(95)
})
it("handles sync failure gracefully — returns stub with error info", async () => {
const stubGame = {
id: "fail-id",
steamAppId: 99999,
title: "Steam App 99999",
source: "steam",
syncStatus: "pending",
}
const failedGame = {
...stubGame,
syncStatus: "error",
syncError: "Steam API returned 503",
}
// Queue: [existence check, recalc-select (for recordSyncFailure gets retryCount), final-select]
mockDb.setSelectQueue([[], [{ syncRetryCount: 0 }], [failedGame]])
mockDb.setInsertResults([stubGame])
// Mock Steam API to fail
mockFetch.mockImplementation((url: string | URL) => {
const urlStr = url.toString()
if (urlStr.includes("store.steampowered.com/api/appdetails")) {
return Promise.resolve({
ok: false,
status: 503,
headers: new Headers(),
} as unknown as Response)
}
return Promise.resolve({
ok: false,
status: 404,
} as unknown as Response)
})
const result = await ensureSteamGame(99999)
expect(result.created).toBe(true)
expect(result.error).toBe("Steam API returned 503")
expect(result.game?.syncStatus).toBe("error")
})
})
+359
View File
@@ -0,0 +1,359 @@
import { db } from "@/lib/db/index"
import { games, steamReviewSentimentEnum } from "@/lib/db/schema"
import { eq } from "drizzle-orm"
import { recalculatePlayability } from "@/lib/api/playability"
interface SteamReviewData {
reviewScore: number | null;
reviewSentiment: string | null;
reviewCount: number | null;
}
async function fetchSteamReviews(steamAppId: number): Promise<SteamReviewData> {
try {
const response = await fetch(
`https://store.steampowered.com/appreviews/${steamAppId}?json=1&language=all&purchase_type=all`,
{ signal: AbortSignal.timeout(10000) }
);
if (!response.ok) return { reviewScore: null, reviewSentiment: null, reviewCount: null };
const data = await response.json();
if (!data.query_summary) return { reviewScore: null, reviewSentiment: null, reviewCount: null };
const summary = data.query_summary;
const totalReviews = summary.total_reviews;
const positiveReviews = summary.total_positive;
const reviewScore = totalReviews > 0 ? Math.round((positiveReviews / totalReviews) * 100) : null;
// Map Steam's review_desc to our enum values
const sentimentMap: Record<string, string> = {
"Overwhelmingly Positive": "overwhelmingly_positive",
"Very Positive": "very_positive",
"Positive": "positive",
"Mostly Positive": "mostly_positive",
"Mixed": "mixed",
"Mostly Negative": "mostly_negative",
"Negative": "negative",
"Very Negative": "very_negative",
"Overwhelmingly Negative": "overwhelmingly_negative",
};
return {
reviewScore,
reviewSentiment: sentimentMap[summary.review_score_desc] ?? null,
reviewCount: totalReviews,
};
} catch {
return { reviewScore: null, reviewSentiment: null, reviewCount: null };
}
}
interface SteamAppDetails {
steam_appid: number
name: string
developers?: string[]
publishers?: string[]
header_image?: string
genres?: { id: string; description: string }[]
website?: string
short_description?: string
pc_requirements?: { minimum?: string; recommended?: string }
metacritic?: { score: number; url: string }
recommendations?: { total: number }
price_overview?: { currency: string; initial: number; final: number }
is_free?: boolean
type?: string
release_date?: { coming_soon: boolean; date: string }
categories?: { id: string; description: string }[]
platforms?: { windows: boolean; mac: boolean; linux: boolean }
}
const SEVEN_DAYS_MS = 7 * 24 * 60 * 60 * 1000
const ONE_HOUR_MS = 60 * 60 * 1000
const MAX_RETRY_DELAY_MS = SEVEN_DAYS_MS
async function recordSyncFailure(steamAppId: number, errorMsg: string): Promise<void> {
const [currentGame] = await db
.select({ retryCount: games.syncRetryCount })
.from(games)
.where(eq(games.steamAppId, steamAppId))
.limit(1)
const retryCount = (currentGame?.retryCount ?? 0) + 1
const backoffMs = Math.min(ONE_HOUR_MS * Math.pow(2, retryCount - 1), MAX_RETRY_DELAY_MS)
await db
.update(games)
.set({
syncStatus: "error",
syncError: errorMsg,
syncRetryCount: retryCount,
syncNextRetry: new Date(Date.now() + backoffMs),
updatedAt: new Date(),
})
.where(eq(games.steamAppId, steamAppId))
}
export function isSyncStale(lastSync: Date | null): boolean {
if (!lastSync) return true
return Date.now() - new Date(lastSync).getTime() > SEVEN_DAYS_MS
}
export async function validateImageUrl(url: string): Promise<boolean> {
try {
const res = await fetch(url, { method: "HEAD", signal: AbortSignal.timeout(5000) });
return res.ok;
} catch {
return false;
}
}
export async function fetchSteamGridDBCover(gameTitle: string): Promise<string | null> {
try {
const apiKey = process.env.STEAMGRIDDB_API_KEY;
if (!apiKey) return null;
// Search for game
const searchRes = await fetch(
`https://www.steamgriddb.com/api/v2/search/autocomplete/${encodeURIComponent(gameTitle)}`,
{ headers: { Authorization: `Bearer ${apiKey}` }, signal: AbortSignal.timeout(5000) }
);
if (!searchRes.ok) return null;
const searchData = await searchRes.json();
if (!searchData.data || searchData.data.length === 0) return null;
const gameId = searchData.data[0].id;
// Get grids
const gridsRes = await fetch(
`https://www.steamgriddb.com/api/v2/grids/game/${gameId}?dimensions=600x900,342x482`,
{ headers: { Authorization: `Bearer ${apiKey}` }, signal: AbortSignal.timeout(5000) }
);
if (!gridsRes.ok) return null;
const gridsData = await gridsRes.json();
if (!gridsData.data || gridsData.data.length === 0) return null;
return gridsData.data[0].url;
} catch {
return null;
}
}
export async function syncSteamGame(
steamAppId: number,
options?: { forceRetry?: boolean }
): Promise<{ success: boolean; error?: string }> {
try {
// Check if recently synced (unless forceRetry)
if (!options?.forceRetry) {
const existing = await db
.select({ lastSync: games.lastSync, syncStatus: games.syncStatus, syncNextRetry: games.syncNextRetry })
.from(games)
.where(eq(games.steamAppId, steamAppId))
.limit(1);
if (existing.length > 0 && existing[0].lastSync) {
const nextRetry = existing[0].syncNextRetry ? new Date(existing[0].syncNextRetry) : null;
if (nextRetry && nextRetry.getTime() > Date.now()) {
return { success: false, error: "Sync skipped: next retry not yet reached" };
}
if (!isSyncStale(existing[0].lastSync)) {
return { success: false, error: "Sync skipped: recently synced" };
}
}
}
// Fetch from Steam API with timeout
const url = new URL("https://store.steampowered.com/api/appdetails/")
url.searchParams.set("appids", String(steamAppId))
url.searchParams.set("cc", "US")
url.searchParams.set("l", "en")
const res = await fetch(url.toString(), {
headers: {
Accept: "application/json",
"User-Agent": "DeckyVault/1.0",
"Accept-Language": "en-US,en;q=0.9",
},
signal: AbortSignal.timeout(15000),
})
if (res.status === 429) {
const retryAfterHeader = res.headers.get("Retry-After");
const retryAfterSec = retryAfterHeader ? parseInt(retryAfterHeader, 10) : 60;
const errorMsg = `Rate limited by Steam (429). Retry after ${retryAfterSec}s`;
await recordSyncFailure(steamAppId, errorMsg);
return { success: false, error: errorMsg };
}
if (!res.ok) {
const errorMsg = `Steam API returned ${res.status}`
await recordSyncFailure(steamAppId, errorMsg)
return { success: false, error: errorMsg }
}
const data = (await res.json()) as Record<
string,
{ success: boolean; data: SteamAppDetails }
>
const entry = data[String(steamAppId)]
if (!entry?.success || !entry.data) {
const errorMsg = `No data returned from Steam for app ${steamAppId}`
await recordSyncFailure(steamAppId, errorMsg)
return { success: false, error: errorMsg }
}
const d = entry.data
// Reject non-game types (DLC, soundtrack, demo, etc.)
if (d.type && d.type !== "game") {
const errorMsg = `Steam app ${steamAppId} is not a game (type: ${d.type})`;
await recordSyncFailure(steamAppId, errorMsg);
return { success: false, error: errorMsg };
}
// Fetch review data
const reviewData = await fetchSteamReviews(steamAppId);
// Build capsule image URL and validate it
const capsuleUrl = `https://cdn.akamai.steamstatic.com/steam/apps/${steamAppId}/library_600x900.jpg`
let finalCapsuleUrl: string | null = capsuleUrl
const imageValid = await validateImageUrl(capsuleUrl)
if (!imageValid) {
// Fall back to SteamGridDB
const fallbackUrl = await fetchSteamGridDBCover(d.name)
finalCapsuleUrl = fallbackUrl || null
}
// Update database with all fields including error tracking
await db
.update(games)
.set({
title: d.name,
developer: d.developers?.[0] || null,
publisher: d.publishers?.[0] || null,
description: d.short_description || null,
genres: d.genres?.map((g) => g.description) || [],
headerImage: d.header_image || null,
capsuleImage: finalCapsuleUrl,
storeUrl: `https://store.steampowered.com/app/${steamAppId}`,
systemRequirements: d.pc_requirements
? { minimum: d.pc_requirements.minimum || null, recommended: d.pc_requirements.recommended || null }
: null,
metacriticScore: d.metacritic?.score ?? null,
metacriticUrl: d.metacritic?.url ?? null,
recommendationsTotal: d.recommendations?.total ?? null,
steamReviewScore: reviewData.reviewScore,
steamReviewSentiment: reviewData.reviewSentiment as typeof steamReviewSentimentEnum.enumValues[number] | null,
steamReviewCount: reviewData.reviewCount,
priceCurrent: d.price_overview?.final ?? null,
priceInitial: d.price_overview?.initial ?? null,
priceCurrency: d.price_overview?.currency ?? null,
isFree: d.is_free ?? false,
releaseDate: d.release_date?.date ?? null,
categories: d.categories?.map((c) => c.description) ?? null,
platforms: d.platforms ?? null,
lastSync: new Date(),
syncStatus: "synced",
syncError: null,
syncRetryCount: 0,
syncNextRetry: null,
updatedAt: new Date(),
})
.where(eq(games.steamAppId, steamAppId))
// Recalculate playability after sync (fire and forget)
const [game] = await db
.select({ id: games.id })
.from(games)
.where(eq(games.steamAppId, steamAppId))
.limit(1)
if (game) {
recalculatePlayability(game.id).catch((err) =>
console.error("Failed to recalculate playability after sync:", err),
)
}
return { success: true }
} catch (err) {
const errorMsg = err instanceof Error ? err.message : String(err)
try {
await recordSyncFailure(steamAppId, errorMsg)
} catch {
console.error(`Failed to update error tracking for ${steamAppId}`)
}
return { success: false, error: errorMsg }
}
}
/**
* Ensure a Steam game exists in the database with full data.
* If the game doesn't exist, inserts a minimal stub then runs a full sync.
* If the game already exists, returns it without re-syncing.
*
* This is the SINGLE ENTRY POINT for creating new Steam game records.
* Use this instead of direct Steam API fetches + manual inserts.
*/
export async function ensureSteamGame(
steamAppId: number
): Promise<{ game: typeof games.$inferSelect | null; created: boolean; error?: string }> {
// 1. Check if game already exists
const [existing] = await db
.select()
.from(games)
.where(eq(games.steamAppId, steamAppId))
.limit(1)
if (existing) {
return { game: existing, created: false }
}
// 2. Insert minimal stub
const [stub] = await db
.insert(games)
.values({
steamAppId,
source: "steam",
title: `Steam App ${steamAppId}`,
storeUrl: `https://store.steampowered.com/app/${steamAppId}`,
syncStatus: "pending",
})
.returning()
// Handle race condition: if another request inserted first, UNIQUE constraint
// on steamAppId will throw. Catch and return the existing record.
if (!stub) {
const [raceWinner] = await db
.select()
.from(games)
.where(eq(games.steamAppId, steamAppId))
.limit(1)
return { game: raceWinner ?? null, created: false }
}
// 3. Run full sync (forceRetry=true to bypass staleness check on a brand-new record)
const syncResult = await syncSteamGame(steamAppId, { forceRetry: true })
if (!syncResult.success) {
// Sync failed — return the stub (it has error tracking fields populated by
// recordSyncFailure inside syncSteamGame). Re-fetch to get updated fields.
const [failedGame] = await db
.select()
.from(games)
.where(eq(games.steamAppId, steamAppId))
.limit(1)
return { game: failedGame ?? null, created: true, error: syncResult.error }
}
// 4. Return fully populated game
const [fullGame] = await db
.select()
.from(games)
.where(eq(games.steamAppId, steamAppId))
.limit(1)
return { game: fullGame ?? null, created: true }
}
@@ -0,0 +1,65 @@
import { describe, it, expect } from "vitest"
describe("SteamDB HTML Parser", () => {
it("parses version and build from typical SteamDB HTML", async () => {
const { _parseSteamDBHtml } = await import("../scrape")
// Version regex: /Last known name[^<]*<[^>]*>([^<]+)</i
// Build ID regex: /Build\s*ID[^<]*<\/td>\s*<td[^>]*>(\d+)/i
const html = `
<html>
<body>
<table>
<tr><td>Last known name <span>v1.2.3</span></td></tr>
<tr><td>Build ID</td><td>12345678</td></tr>
</table>
</body>
</html>
`
const result = _parseSteamDBHtml(html)
expect(result.versionString).toBe("v1.2.3")
expect(result.buildId).toBe("12345678")
})
it("returns nulls for unrecognized HTML", async () => {
const { _parseSteamDBHtml } = await import("../scrape")
const result = _parseSteamDBHtml("<html><body>Nothing here</body></html>")
expect(result.versionString).toBeNull()
expect(result.buildId).toBeNull()
})
it("extracts build ID via buildid attribute even without version string", async () => {
const { _parseSteamDBHtml } = await import("../scrape")
// Build ID regex fallback: /buildid[^>]*>(\d+)/i
// buildid[^>]*>(\d+) — expects digits right after the closing tag
const html = `<span buildid>99999</span>`
const result = _parseSteamDBHtml(html)
expect(result.buildId).toBe("99999")
expect(result.versionString).toBeNull()
})
it("extracts build ID via Build ID table row pattern", async () => {
const { _parseSteamDBHtml } = await import("../scrape")
const html = `<tr><td>Build ID</td><td>55555</td></tr>`
const result = _parseSteamDBHtml(html)
expect(result.buildId).toBe("55555")
expect(result.versionString).toBeNull()
})
it("extracts version from JSON-LD when available", async () => {
const { _parseSteamDBHtml } = await import("../scrape")
const html = `
<html>
<head>
<script type="application/ld+json">
{"name":"Test Game","version":"2.0.0","datePublished":"2024-01-01"}
</script>
</head>
<body></body>
</html>
`
const result = _parseSteamDBHtml(html)
expect(result.versionString).toBe("2.0.0")
expect(result.buildId).toBeNull()
})
})
+51
View File
@@ -0,0 +1,51 @@
interface CachedVersion {
versionString: string | null
buildId: string | null
fetchedAt: number
}
const cache = new Map<number, CachedVersion>()
const TTL_MS = 6 * 60 * 60 * 1000 // 6 hours
export function getCachedVersion(steamAppId: number): CachedVersion | null {
const entry = cache.get(steamAppId)
if (!entry) return null
if (Date.now() - entry.fetchedAt > TTL_MS) {
cache.delete(steamAppId)
return null
}
return entry
}
export function setCachedVersion(
steamAppId: number,
versionString: string | null,
buildId: string | null,
): void {
cache.set(steamAppId, {
versionString,
buildId,
fetchedAt: Date.now(),
})
}
// Cooldown tracking for failed fetches
const cooldowns = new Map<number, number>()
export function isOnCooldown(steamAppId: number): boolean {
const until = cooldowns.get(steamAppId)
if (!until) return false
if (Date.now() > until) {
cooldowns.delete(steamAppId)
return false
}
return true
}
export function setCooldown(steamAppId: number, minutes: number = 30): void {
cooldowns.set(steamAppId, Date.now() + minutes * 60 * 1000)
}
export function setExtendedCooldown(steamAppId: number): void {
cooldowns.set(steamAppId, Date.now() + 24 * 60 * 60 * 1000) // 24 hours
}
+398
View File
@@ -0,0 +1,398 @@
import { getCachedVersion, setCachedVersion, isOnCooldown, setCooldown, setExtendedCooldown } from "./cache"
const STEAMDB_APP_URL = "https://steamdb.info/app"
interface ScrapeResult {
versionString: string | null
buildId: string | null
}
/**
* Scrape version/build info from SteamDB for a given Steam App ID.
*
* SteamDB is a client-side rendered app the raw HTML from a fetch() is a
* skeleton that gets populated by JavaScript. To work around this we:
* 1. Search ALL <script> tags for embedded JSON/JS objects (hydration data)
* 2. Parse table rows with flexible regex (catches server-rendered fragments)
* 3. Try JSON-LD structured data
* 4. Extract build IDs from multiple known patterns
*
* All failures are non-fatal the submit wizard gracefully degrades.
*/
export async function scrapeSteamDBVersion(steamAppId: number): Promise<ScrapeResult & { cached: boolean }> {
// Check cache first
const cached = getCachedVersion(steamAppId)
if (cached) {
return { versionString: cached.versionString, buildId: cached.buildId, cached: true }
}
// Check cooldown
if (isOnCooldown(steamAppId)) {
return { versionString: null, buildId: null, cached: true }
}
// If scraping is disabled via env, skip
if (process.env.STEAMDB_SCRAPING_ENABLED === "false") {
return { versionString: null, buildId: null, cached: true }
}
// ── Primary: fetch the main SteamDB app page ─────────────────────
let html: string | null = null
try {
const res = await fetch(`${STEAMDB_APP_URL}/${steamAppId}/`, {
headers: {
"User-Agent": "DeckyVault/1.0 (deckyvault.xyz; game version lookup)",
"Accept": "text/html",
},
signal: AbortSignal.timeout(10000),
})
if (res.status === 429 || res.status === 403) {
console.warn(`[steamdb] Rate-limited or blocked for app ${steamAppId} (HTTP ${res.status})`)
setExtendedCooldown(steamAppId)
return { versionString: null, buildId: null, cached: true }
}
if (!res.ok) {
console.warn(`[steamdb] Non-OK response for app ${steamAppId}: HTTP ${res.status}`)
setCooldown(steamAppId, 30)
return { versionString: null, buildId: null, cached: true }
}
html = await res.text()
} catch (err) {
console.warn(`[steamdb] Network error fetching app ${steamAppId}:`, err instanceof Error ? err.message : err)
setCooldown(steamAppId, 30)
return { versionString: null, buildId: null, cached: true }
}
const result = parseSteamDBHtml(html, steamAppId)
// ── Log diagnostic info for debugging ────────────────────────────
if (result.versionString || result.buildId) {
console.log(`[steamdb] Found data for app ${steamAppId}: version="${result.versionString ?? "?"}", build="${result.buildId ?? "?"}"`)
} else {
// Log a snippet of the HTML to help debug parsing failures
const snippet = html.slice(0, 300).replace(/\s+/g, " ").trim()
console.warn(`[steamdb] No version/build found for app ${steamAppId}. HTML preview: ${snippet}...`)
}
setCachedVersion(steamAppId, result.versionString, result.buildId)
return { ...result, cached: false }
}
// ── Parsing ────────────────────────────────────────────────────────
function parseSteamDBHtml(html: string, steamAppId: number): ScrapeResult {
let versionString: string | null = null
let buildId: string | null = null
// ── Strategy 1 (PRIMARY): Embedded JSON/JS data in <script> tags ──
// SPAs often embed initial state for hydration. Search ALL script tags
// for JSON-like objects containing known version/build keys.
const scriptMatches = html.match(/<script[^>]*>([\s\S]*?)<\/script>/gi)
if (scriptMatches) {
for (const scriptTag of scriptMatches) {
// Skip JSON-LD (handled separately below)
if (scriptTag.includes('application/ld+json')) continue
const inner = scriptTag.replace(/<script[^>]*>/i, "").replace(/<\/script>/i, "")
// Try to find JSON objects within the script content
const objects = extractTopLevelObjects(inner)
for (const obj of objects) {
if (!versionString) versionString = findVersionInObject(obj)
if (!buildId) buildId = findBuildIdInObject(obj)
if (versionString && buildId) break
}
if (versionString && buildId) break
}
}
// ── Strategy 2: <script type="application/json"> tags ──────────────
if (!versionString || !buildId) {
const jsonScripts = html.match(/<script[^>]*type="application\/json"[^>]*>([\s\S]*?)<\/script>/gi)
if (jsonScripts) {
for (const tag of jsonScripts) {
const inner = tag.replace(/<script[^>]*>/i, "").replace(/<\/script>/i, "")
try {
const obj = JSON.parse(inner)
if (!versionString) versionString = findVersionInObject(obj)
if (!buildId) buildId = findBuildIdInObject(obj)
if (versionString && buildId) break
} catch {
// Not valid JSON — continue
}
}
}
}
// ── Strategy 3: Table rows — more flexible patterns ──────────────
if (!versionString) {
versionString = extractVersionFromTable(html)
}
// ── Strategy 4: JSON-LD structured data ──────────────────────────
if (!versionString) {
versionString = extractVersionFromJsonLD(html)
}
// ── Strategy 5: Build ID from multiple patterns ──────────────────
if (!buildId) {
buildId = extractBuildIdFromHtml(html)
}
return { versionString, buildId }
}
// ── Helpers: JSON object extraction ────────────────────────────────
/**
* Extract top-level JSON-like objects from JavaScript code.
* Handles patterns like:
* window.__DATA__ = { ... }
* var appData = { ... }
* JSON.parse('{ ... }')
* __NEXT_DATA__ = { ... }
*/
function extractTopLevelObjects(js: string): Record<string, unknown>[] {
const objects: Record<string, unknown>[] = []
// Pattern 1: JSON.parse('...') or JSON.parse("{...}")
const parseRegex = /JSON\.parse\((["'])((?:\\.|(?!\1)[^\\])*?)\1\)/g
let match
while ((match = parseRegex.exec(js)) !== null) {
try {
const obj = JSON.parse(match[2])
if (typeof obj === "object" && obj !== null && !Array.isArray(obj)) {
objects.push(obj as Record<string, unknown>)
}
} catch {
// JSON parse failure — ignore
}
}
// Pattern 2: Assignment of object literals
// Matches: var/let/const/window.NAME = { ... }
// We look for balanced braces after `=`
const assignRegex = /(?:var|let|const|window\.\w+|self\.\w+|this\.\w+)\s*\w*\s*=\s*(\{)/g
while ((match = assignRegex.exec(js)) !== null) {
const startIdx = match.index + match[0].length - 1 // position of opening {
const objStr = extractBalancedBraces(js, startIdx)
if (objStr) {
try {
// Try as JSON first, then as JS object
const obj = safeParseJSObject(objStr)
if (obj && typeof obj === "object" && !Array.isArray(obj)) {
objects.push(obj as Record<string, unknown>)
}
} catch {
// Parse failure — ignore
}
}
}
return objects
}
/** Extract text between balanced { } braces */
function extractBalancedBraces(str: string, startIdx: number): string | null {
if (str[startIdx] !== "{") return null
let depth = 0
let inString = false
let stringChar = ""
for (let i = startIdx; i < str.length; i++) {
const ch = str[i]
if (inString) {
if (ch === "\\") { i++; continue }
if (ch === stringChar) { inString = false }
continue
}
if (ch === '"' || ch === "'") { inString = true; stringChar = ch; continue }
if (ch === "{") { depth++ }
else if (ch === "}") {
depth--
if (depth === 0) return str.slice(startIdx, i + 1)
}
}
return null
}
/** Parse a JS object literal string to a plain object (handles unquoted keys) */
function safeParseJSObject(jsObj: string): unknown {
// First try direct JSON.parse
try { return JSON.parse(jsObj) } catch { /* fall through */ }
// Try converting JS object to valid JSON (quote unquoted keys)
try {
const jsonLike = jsObj
.replace(/([{,]\s*)([a-zA-Z_$][\w$]*)\s*:/g, '$1"$2":') // quote keys
.replace(/'/g, '"') // single → double quotes
.replace(/,\s*}/g, "}") // trailing commas
.replace(/,\s*]/g, "]")
return JSON.parse(jsonLike)
} catch {
return null
}
}
// ── Helpers: value extraction from objects ─────────────────────────
const VERSION_KEYS = [
"version", "versionString", "version_string", "displayVersion",
"latestVersion", "appVersion", "gameVersion", "name",
"lastKnownName", "last_known_name", "Last Known Name",
]
const BUILD_KEYS = [
"buildid", "buildId", "build_id", "build", "latestBuild",
"appBuild", "publicBuild", "buildNumber", "build_number",
]
function findVersionInObject(obj: Record<string, unknown>, depth = 0): string | null {
if (depth > 3 || !obj) return null
for (const key of VERSION_KEYS) {
const val = obj[key]
if (typeof val === "string" && val.length > 0 && val.length < 200) {
// Filter out non-version strings (URLs, descriptions, etc.)
if (!val.startsWith("http") && !val.includes("<") && val.length > 1) {
return val.trim()
}
}
}
// Recurse into nested objects
for (const val of Object.values(obj)) {
if (val && typeof val === "object" && !Array.isArray(val)) {
const found = findVersionInObject(val as Record<string, unknown>, depth + 1)
if (found) return found
}
}
return null
}
function findBuildIdInObject(obj: Record<string, unknown>, depth = 0): string | null {
if (depth > 3 || !obj) return null
for (const key of BUILD_KEYS) {
const val = obj[key]
if (typeof val === "number" && val > 0 && val < 99999999) {
return String(val)
}
if (typeof val === "string" && /^\d{3,10}$/.test(val)) {
return val
}
}
// Recurse into nested objects
for (const val of Object.values(obj)) {
if (val && typeof val === "object" && !Array.isArray(val)) {
const found = findBuildIdInObject(val as Record<string, unknown>, depth + 1)
if (found) return found
}
}
return null
}
// ── Helpers: table-based extraction ────────────────────────────────
function extractVersionFromTable(html: string): string | null {
const patterns = [
// "Last known name" / "Last recorded name" in a table
/(?:Last\s*(?:known|recorded)\s*name)\s*<\/t[hd]>\s*<t[hd][^>]*>([^<]+)</i,
// "Version" label in a definition list or table
/<t[hd][^>]*>\s*Version\s*<\/t[hd]>\s*<t[hd][^>]*>([^<]+)</i,
// "Current version" label
/(?:Current|Latest)\s+version[^<]*<\/t[hd]>\s*<t[hd][^>]*>([^<]+)</i,
// Generic: any table row with "version" as label
/<td[^>]*>([^<]*[Vv]ersion[^<]*)<\/td>\s*<td[^>]*>([^<]+)</i,
]
for (const pattern of patterns) {
const match = html.match(pattern)
if (match) {
const val = (match[2] || match[1]).trim()
if (val && val.length > 1 && !val.startsWith("http") && !val.includes("<")) {
return val
}
}
}
// Inline element pattern: "Last known name" followed by a <span> or other
// inline tag inside the same cell (e.g. <td>Last known name <span>v1.2.3</span></td>)
const inlineMatch = html.match(/Last known name[^<]*<[^>]*>([^<]+)</i)
if (inlineMatch) {
const val = inlineMatch[1].trim()
if (val.length > 1 && !val.startsWith("http")) return val
}
// Broader: look for "Last known name" anywhere nearby a <td> with content
const looseMatch = html.match(/Last known name[^<]*(?:<[^>]+>)*?\s*<t[hd][^>]*>([^<]+)</i)
if (looseMatch) {
const val = looseMatch[1].trim()
if (val.length > 1 && !val.startsWith("http")) return val
}
return null
}
// ── Helpers: JSON-LD extraction ────────────────────────────────────
function extractVersionFromJsonLD(html: string): string | null {
const ldMatches = html.match(/<script[^>]*type="application\/ld\+json"[^>]*>([\s\S]*?)<\/script>/gi)
if (!ldMatches) return null
for (const tag of ldMatches) {
const inner = tag.replace(/<script[^>]*>/i, "").replace(/<\/script>/i, "")
try {
const parsed = JSON.parse(inner)
// Walk the JSON-LD graph
const version = findVersionInObject(parsed)
if (version) return version
} catch {
// Non-fatal
}
}
return null
}
// ── Helpers: build ID extraction ───────────────────────────────────
function extractBuildIdFromHtml(html: string): string | null {
const patterns = [
// Table: Build ID cell
/Build\s*ID\s*<\/t[hd]>\s*<t[hd][^>]*>(\d{3,10})/i,
// Inline buildid attribute
/buildid[^>]*>(\d{3,10})/i,
// "Build" label in table
/<t[hd][^>]*>\s*Build\s*<\/t[hd]>\s*<t[hd][^>]*>(\d{3,10})/i,
// data-build-id attribute
/data-build-?id\s*=\s*["'](\d{3,10})["']/i,
// "public" branch build in a table
/public\s*<\/t[hd]>\s*<t[hd][^>]*>(\d{3,10})/i,
// Generic: any bare build ID near "build" text
/build[^<]*<\/t[hd]>\s*<t[hd][^>]*>(\d{3,10})/i,
// Numeric build ID in JSON-like context
/"buildid"\s*:\s*(\d{3,10})/i,
]
for (const pattern of patterns) {
const match = html.match(pattern)
if (match) {
const val = match[1].trim()
const num = parseInt(val, 10)
if (num > 0 && num < 99999999) return val
}
}
return null
}
// Export for testing
export const _parseSteamDBHtml = parseSteamDBHtml
+11
View File
@@ -0,0 +1,11 @@
export {
uploadObject,
deleteObject,
listObjects,
getObjectMetadata,
isR2Configured,
getR2PublicUrl,
isR2Url,
getR2ConfigStatus,
} from "./r2-client"
export type { ObjectInfo, ObjectMetadata } from "./r2-client"
+136
View File
@@ -0,0 +1,136 @@
import {
S3Client,
PutObjectCommand,
DeleteObjectCommand,
ListObjectsV2Command,
HeadObjectCommand,
} from "@aws-sdk/client-s3"
// ── Configuration ──────────────────────────────────────────────────
const R2_ACCOUNT_ID = process.env.R2_ACCOUNT_ID
const R2_ACCESS_KEY_ID = process.env.R2_ACCESS_KEY_ID
const R2_SECRET_ACCESS_KEY = process.env.R2_SECRET_ACCESS_KEY
const R2_BUCKET_NAME = process.env.R2_BUCKET_NAME ?? "deckyvault"
const R2_PUBLIC_URL = process.env.R2_PUBLIC_URL ?? ""
let _client: S3Client | null = null
function getR2ConfigStatus(): { configured: boolean; reason?: string } {
if (!R2_ACCOUNT_ID || !R2_ACCESS_KEY_ID || !R2_SECRET_ACCESS_KEY) {
return { configured: false, reason: "Missing R2_ACCOUNT_ID, R2_ACCESS_KEY_ID, or R2_SECRET_ACCESS_KEY" }
}
return { configured: true }
}
function getClient(): S3Client {
if (_client) return _client
const status = getR2ConfigStatus()
if (!status.configured) {
throw new Error(`R2 not configured: ${status.reason}`)
}
_client = new S3Client({
region: "auto",
endpoint: `https://${R2_ACCOUNT_ID}.r2.cloudflarestorage.com`,
credentials: {
accessKeyId: R2_ACCESS_KEY_ID!,
secretAccessKey: R2_SECRET_ACCESS_KEY!,
},
})
return _client
}
// ── Upload ──────────────────────────────────────────────────────────
export async function uploadObject(
key: string,
body: Buffer | Uint8Array,
contentType: string,
metadata?: Record<string, string>,
): Promise<string> {
const client = getClient()
await client.send(
new PutObjectCommand({
Bucket: R2_BUCKET_NAME,
Key: key,
Body: body,
ContentType: contentType,
Metadata: metadata,
}),
)
return `${R2_PUBLIC_URL}/${key}`
}
// ── Delete ──────────────────────────────────────────────────────────
export async function deleteObject(key: string): Promise<void> {
const client = getClient()
await client.send(
new DeleteObjectCommand({
Bucket: R2_BUCKET_NAME,
Key: key,
}),
)
}
// ── List ────────────────────────────────────────────────────────────
export interface ObjectInfo {
key: string
size: number
lastModified: Date | undefined
}
export async function listObjects(prefix?: string): Promise<ObjectInfo[]> {
const client = getClient()
const result = await client.send(
new ListObjectsV2Command({
Bucket: R2_BUCKET_NAME,
Prefix: prefix,
MaxKeys: 1000,
}),
)
return (result.Contents ?? []).map((obj) => ({
key: obj.Key!,
size: obj.Size ?? 0,
lastModified: obj.LastModified,
}))
}
// ── Metadata ────────────────────────────────────────────────────────
export interface ObjectMetadata {
contentType: string
size: number
lastModified: Date | undefined
}
export async function getObjectMetadata(key: string): Promise<ObjectMetadata | null> {
const client = getClient()
try {
const result = await client.send(
new HeadObjectCommand({
Bucket: R2_BUCKET_NAME,
Key: key,
}),
)
return {
contentType: result.ContentType ?? "application/octet-stream",
size: result.ContentLength ?? 0,
lastModified: result.LastModified,
}
} catch {
return null
}
}
// ── Helpers ─────────────────────────────────────────────────────────
export function isR2Configured(): boolean {
return getR2ConfigStatus().configured
}
export function getR2PublicUrl(): string {
return R2_PUBLIC_URL
}
export function isR2Url(url: string | null): boolean {
if (!url || !R2_PUBLIC_URL) return false
return url.startsWith(R2_PUBLIC_URL)
}
export { getR2ConfigStatus }
+123
View File
@@ -0,0 +1,123 @@
import fs from "fs"
import path from "path"
import matter from "gray-matter"
import { remark } from "remark"
import remarkRehype from "remark-rehype"
import rehypeSlug from "rehype-slug"
import rehypeAutolinkHeadings from "rehype-autolink-headings"
import rehypeStringify from "rehype-stringify"
export interface UpdateMeta {
slug: string
title: string
date: string
version: string
summary: string
}
export interface UpdateHeading {
id: string
text: string
level: number
}
export interface UpdateContent {
meta: UpdateMeta
html: string
headings: UpdateHeading[]
}
const UPDATES_DIR = path.join(process.cwd(), "content", "updates")
function getSlugs(): string[] {
if (!fs.existsSync(UPDATES_DIR)) return []
return fs
.readdirSync(UPDATES_DIR)
.filter((file) => file.endsWith(".md"))
.map((file) => file.replace(/\.md$/, ""))
}
function compareVersion(a: string, b: string): number {
const pa = a.split(".").map(Number)
const pb = b.split(".").map(Number)
const len = Math.max(pa.length, pb.length)
for (let i = 0; i < len; i++) {
const na = pa[i] ?? 0
const nb = pb[i] ?? 0
if (na !== nb) return nb - na // descending
}
return 0
}
export function getAllUpdates(): UpdateMeta[] {
const slugs = getSlugs()
const updates = slugs.map((slug) => {
const { meta } = getUpdateMeta(slug)
return meta
})
// Sort by date descending, then by version descending (newest first)
return updates.sort((a, b) => {
const dateDiff = new Date(b.date).getTime() - new Date(a.date).getTime()
if (dateDiff !== 0) return dateDiff
return compareVersion(a.version, b.version)
})
}
function getUpdateMeta(slug: string): { meta: UpdateMeta } {
const filePath = path.join(UPDATES_DIR, `${slug}.md`)
const fileContents = fs.readFileSync(filePath, "utf8")
const { data } = matter(fileContents)
return {
meta: {
slug,
title: data.title ?? "",
date: data.date ?? "",
version: data.version ?? "",
summary: data.summary ?? "",
},
}
}
export async function getUpdateBySlug(slug: string): Promise<UpdateContent> {
const filePath = path.join(UPDATES_DIR, `${slug}.md`)
const fileContents = fs.readFileSync(filePath, "utf8")
const { data, content } = matter(fileContents)
const processedContent = await remark()
.use(remarkRehype)
.use(rehypeSlug)
.use(rehypeAutolinkHeadings)
.use(rehypeStringify)
.process(content)
const html = processedContent.toString()
// Extract headings from the rendered HTML
const headingRegex = /<h([1-6])[^>]*id=["']([^"']+)["'][^>]*>(.*?)<\/h[1-6]>/g
const headings: UpdateHeading[] = []
let match: RegExpExecArray | null
while ((match = headingRegex.exec(html)) !== null) {
headings.push({
level: parseInt(match[1], 10),
text: match[3].replace(/<[^>]*>/g, ""), // Strip any inner HTML tags
id: match[2],
})
}
return {
meta: {
slug,
title: data.title ?? "",
date: data.date ?? "",
version: data.version ?? "",
summary: data.summary ?? "",
},
html,
headings,
}
}
export function getAllUpdateSlugs(): string[] {
return getSlugs()
}
+8
View File
@@ -0,0 +1,8 @@
/**
* Simple class name utility that filters out falsy values and joins with spaces.
* This is a lightweight alternative to clsx/tailwind-merge for projects that
* don't need the full complexity of those libraries.
*/
export function cn(...inputs: (string | boolean | undefined | null)[]): string {
return inputs.filter(Boolean).join(" ");
}
+53
View File
@@ -0,0 +1,53 @@
import { describe, it, expect } from "vitest"
import { smartTruncate, buildBreadcrumbList, BreadcrumbSegment } from "@/lib/utils/seo"
describe("smartTruncate", () => {
it("returns full text when shorter than maxLen", () => {
expect(smartTruncate("Short text", 100)).toBe("Short text")
})
it("truncates at word boundary and appends ellipsis", () => {
const result = smartTruncate("This is a longer sentence that should be truncated properly.", 30)
expect(result).toBe("This is a longer sentence...")
expect(result.length).toBeLessThanOrEqual(30 + 3) // + "..."
})
it("handles text with no spaces gracefully", () => {
const result = smartTruncate("SuperLongWordThatHasNoSpaces", 10)
expect(result).toBe("SuperLongW...")
})
it("does not append ellipsis when text fits exactly", () => {
expect(smartTruncate("abc", 3)).toBe("abc")
})
it("handles empty string", () => {
expect(smartTruncate("", 10)).toBe("")
})
})
describe("buildBreadcrumbList", () => {
it("builds a valid BreadcrumbList from segments", () => {
const segments = [
{ name: "Home", url: "https://deckyvault.xyz" },
{ name: "Games", url: "https://deckyvault.xyz/games" },
{ name: "Elden Ring", url: "https://deckyvault.xyz/game/123" },
]
const result = buildBreadcrumbList(segments)
expect(result["@context"]).toBe("https://schema.org")
expect(result["@type"]).toBe("BreadcrumbList")
expect(result.itemListElement).toHaveLength(3)
expect(result.itemListElement[0]).toEqual({
"@type": "ListItem",
position: 1,
name: "Home",
item: "https://deckyvault.xyz",
})
})
it("handles a single segment", () => {
const segments = [{ name: "Home", url: "https://deckyvault.xyz" }]
const result = buildBreadcrumbList(segments)
expect(result.itemListElement).toHaveLength(1)
})
})
+28
View File
@@ -0,0 +1,28 @@
import { describe, it, expect } from "vitest"
import { generateSlug } from "@/lib/utils/slug"
describe("generateSlug", () => {
it("converts a simple title to lowercase hyphenated slug", () => {
expect(generateSlug("The Witcher 3")).toBe("the-witcher-3")
})
it("replaces special characters with hyphens", () => {
expect(generateSlug("Hades II: The Sequel")).toBe("hades-ii-the-sequel")
})
it("collapses multiple consecutive hyphens", () => {
expect(generateSlug("Game!!! -- Test")).toBe("game-test")
})
it("trims leading and trailing hyphens", () => {
expect(generateSlug(" -- My Game -- ")).toBe("my-game")
})
it("truncates to 80 characters", () => {
const longTitle = "A".repeat(100)
expect(generateSlug(longTitle).length).toBeLessThanOrEqual(80)
})
it("does not leave trailing hyphen after truncation", () => {
const title = "A".repeat(79) + " -"
expect(generateSlug(title)).not.toMatch(/-$/)
})
it("handles empty string", () => {
expect(generateSlug("")).toBe("")
})
})

Some files were not shown because too many files have changed in this diff Show More