refactor: convert to bun workspaces monorepo
- Move web app into apps/web/ - Create packages/shared/ with shared types - Create plugins/decky-vault/ scaffold - Root package.json manages workspaces only
This commit is contained in:
@@ -0,0 +1,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:')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,137 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest"
|
||||
import { db } from "@/lib/db/index"
|
||||
import { checkAndAutoPin } from "@/lib/api/auto-pin"
|
||||
|
||||
// Mock drizzle-orm to avoid ESM named-export resolution errors between test files
|
||||
vi.mock("drizzle-orm", () => ({
|
||||
eq: vi.fn((col, val) => ({ col, val })),
|
||||
}))
|
||||
vi.mock("drizzle-orm/pg-core", () => ({
|
||||
pgTable: vi.fn((name, columns, indexes) => ({ name, columns, indexes })),
|
||||
pgEnum: vi.fn((name, values) => ({ name, values })),
|
||||
text: vi.fn((name) => name),
|
||||
integer: vi.fn((name) => name),
|
||||
real: vi.fn((name) => name),
|
||||
boolean: vi.fn((name) => name),
|
||||
timestamp: vi.fn((name) => name),
|
||||
jsonb: vi.fn((name) => name),
|
||||
index: vi.fn((name) => ({ on: vi.fn() })),
|
||||
}))
|
||||
|
||||
// Mock db schema — only the symbols the module under test references
|
||||
vi.mock("@/lib/db/schema", () => ({
|
||||
performanceEntries: {
|
||||
id: "id",
|
||||
isPinned: "is_pinned",
|
||||
isRemoved: "is_removed",
|
||||
upvotes: "upvotes",
|
||||
downvotes: "downvotes",
|
||||
},
|
||||
}))
|
||||
|
||||
// Mock the database
|
||||
vi.mock("@/lib/db/index", () => ({
|
||||
db: {
|
||||
select: vi.fn(),
|
||||
update: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
function setupMockEntry(overrides: {
|
||||
id?: string
|
||||
isPinned?: boolean
|
||||
isRemoved?: boolean
|
||||
upvotes?: number
|
||||
downvotes?: number
|
||||
}) {
|
||||
const entry = {
|
||||
id: overrides.id ?? "test-entry-1",
|
||||
isPinned: overrides.isPinned ?? false,
|
||||
isRemoved: overrides.isRemoved ?? false,
|
||||
upvotes: overrides.upvotes ?? 0,
|
||||
downvotes: overrides.downvotes ?? 0,
|
||||
}
|
||||
|
||||
// Mock select chain: .select(...).from(...).where(...).limit(...)
|
||||
const limitMock = vi.fn().mockResolvedValue([entry])
|
||||
const whereMock = vi.fn().mockReturnValue({ limit: limitMock })
|
||||
const fromMock = vi.fn().mockReturnValue({ where: whereMock })
|
||||
const selectMock = vi.fn().mockReturnValue({ from: fromMock })
|
||||
;(db.select as any).mockReturnValue({ from: fromMock })
|
||||
|
||||
// Mock update chain: .update(...).set(...).where(...)
|
||||
const updateWhereMock = vi.fn().mockResolvedValue(undefined)
|
||||
const updateSetMock = vi.fn().mockReturnValue({ where: updateWhereMock })
|
||||
const updateMock = vi.fn().mockReturnValue({ set: updateSetMock })
|
||||
;(db.update as any).mockImplementation(updateMock)
|
||||
|
||||
return entry
|
||||
}
|
||||
|
||||
describe("checkAndAutoPin", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it("pins an entry with 8 upvotes and 2 downvotes (80% approval, 10 total)", async () => {
|
||||
setupMockEntry({ upvotes: 8, downvotes: 2 })
|
||||
|
||||
const result = await checkAndAutoPin("test-entry-1")
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
|
||||
it("does NOT pin an entry with 7 upvotes and 1 downvote (< 8 absolute upvotes)", async () => {
|
||||
setupMockEntry({ upvotes: 7, downvotes: 1 })
|
||||
|
||||
const result = await checkAndAutoPin("test-entry-1")
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
|
||||
it("does NOT pin an entry with 8 upvotes and 0 downvotes (< 10 total votes)", async () => {
|
||||
setupMockEntry({ upvotes: 8, downvotes: 0 })
|
||||
|
||||
const result = await checkAndAutoPin("test-entry-1")
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
|
||||
it("does NOT pin an entry with 6 upvotes and 4 downvotes (60% approval, below 80%)", async () => {
|
||||
setupMockEntry({ upvotes: 6, downvotes: 4 })
|
||||
|
||||
const result = await checkAndAutoPin("test-entry-1")
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
|
||||
it("does NOT pin an already-pinned entry", async () => {
|
||||
setupMockEntry({ isPinned: true, upvotes: 20, downvotes: 0 })
|
||||
|
||||
const result = await checkAndAutoPin("test-entry-1")
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
|
||||
it("does NOT pin a removed entry", async () => {
|
||||
setupMockEntry({ isRemoved: true, upvotes: 20, downvotes: 0 })
|
||||
|
||||
const result = await checkAndAutoPin("test-entry-1")
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
|
||||
it("pins an entry with 20 upvotes and 5 downvotes (80% approval exactly)", async () => {
|
||||
setupMockEntry({ upvotes: 20, downvotes: 5 })
|
||||
|
||||
const result = await checkAndAutoPin("test-entry-1")
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
|
||||
it("does NOT pin when entry is not found", async () => {
|
||||
// Mock select returning empty array
|
||||
const limitMock = vi.fn().mockResolvedValue([])
|
||||
const whereMock = vi.fn().mockReturnValue({ limit: limitMock })
|
||||
const fromMock = vi.fn().mockReturnValue({ where: whereMock })
|
||||
const selectMock = vi.fn().mockReturnValue({ from: fromMock })
|
||||
;(db.select as any).mockReturnValue({ from: fromMock })
|
||||
;(db.update as any) = vi.fn()
|
||||
|
||||
const result = await checkAndAutoPin("nonexistent")
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,64 @@
|
||||
import { describe, it, expect } from "vitest"
|
||||
import { loginEmailSchema } from "@/lib/auth/validation"
|
||||
|
||||
describe("check-email validation", () => {
|
||||
it("accepts valid email", () => {
|
||||
const result = loginEmailSchema.safeParse({ email: "user@example.com" })
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it("rejects missing email", () => {
|
||||
const result = loginEmailSchema.safeParse({})
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it("rejects invalid email format", () => {
|
||||
const result = loginEmailSchema.safeParse({ email: "not-email" })
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it("rejects empty string email", () => {
|
||||
const result = loginEmailSchema.safeParse({ email: "" })
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("check-email response contract", () => {
|
||||
it("returns { exists: boolean } on success", () => {
|
||||
const successShape = { exists: true }
|
||||
const failureShape = { exists: false }
|
||||
expect(successShape).toHaveProperty("exists")
|
||||
expect(failureShape).toHaveProperty("exists")
|
||||
expect(typeof successShape.exists).toBe("boolean")
|
||||
expect(typeof failureShape.exists).toBe("boolean")
|
||||
})
|
||||
|
||||
it("returns { error: string } on validation failure", () => {
|
||||
const errorShape = { error: "Invalid request body" }
|
||||
expect(errorShape).toHaveProperty("error")
|
||||
expect(typeof errorShape.error).toBe("string")
|
||||
})
|
||||
})
|
||||
|
||||
describe("check-email does NOT block deckyvault.xyz for login", () => {
|
||||
it("accepts a @deckyvault.xyz email through loginEmailSchema", () => {
|
||||
const result = loginEmailSchema.safeParse({
|
||||
email: "admin@deckyvault.xyz",
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it("accepts a @DECKYVAULT.XYZ email (uppercase) through loginEmailSchema", () => {
|
||||
const result = loginEmailSchema.safeParse({
|
||||
email: "admin@DECKYVAULT.XYZ",
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it("the response contract still matches { exists: boolean }", () => {
|
||||
const successShape = { exists: true }
|
||||
const failureShape = { exists: false }
|
||||
expect(typeof successShape.exists).toBe("boolean")
|
||||
expect(typeof failureShape.exists).toBe("boolean")
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,117 @@
|
||||
import { describe, it, expect } from "vitest"
|
||||
import { Elysia } from "elysia"
|
||||
import { isDeckyVaultEmail, DOMAIN_BLOCK_ERROR } from "@/lib/auth/domain-block"
|
||||
|
||||
/**
|
||||
* The domain-block onBeforeHandle handler – mirrors the logic
|
||||
* wired into the auth group in lib/api/app.ts. Tested in isolation
|
||||
* here so we don't need to stand up the full app / DB.
|
||||
*
|
||||
* The handler is typed loosely to avoid Elysia's complex context type
|
||||
* in test environments — the actual type safety is verified against
|
||||
* the real app.ts implementation at integration test time.
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const domainBlockOnBeforeHandle = async (context: any) => {
|
||||
const { request, set } = context as {
|
||||
request: Request
|
||||
set: { status: number }
|
||||
}
|
||||
const url = new URL(request.url)
|
||||
const isSignUp =
|
||||
url.pathname === "/api/auth/sign-up/email" &&
|
||||
request.method === "POST"
|
||||
|
||||
if (!isSignUp) return
|
||||
|
||||
if (process.env.NODE_ENV !== "development") {
|
||||
try {
|
||||
const cloned = request.clone()
|
||||
const body = await cloned.json()
|
||||
if (isDeckyVaultEmail(body.email)) {
|
||||
set.status = 400
|
||||
return { error: DOMAIN_BLOCK_ERROR }
|
||||
}
|
||||
} catch {
|
||||
// Malformed body — let Better Auth reject it downstream
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe("domain block middleware", () => {
|
||||
it("blocks POST /api/auth/sign-up/email with @deckyvault.xyz email", async () => {
|
||||
const app = new Elysia()
|
||||
.onBeforeHandle(domainBlockOnBeforeHandle)
|
||||
.post("/api/auth/sign-up/email", () => ({
|
||||
success: "should not reach this",
|
||||
}))
|
||||
|
||||
const res = await app.handle(
|
||||
new Request("http://localhost:3000/api/auth/sign-up/email", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
email: "bad@deckyvault.xyz",
|
||||
password: "abcdefghij",
|
||||
name: "Test",
|
||||
}),
|
||||
}),
|
||||
)
|
||||
|
||||
expect(res.status).toBe(400)
|
||||
const body = await res.json()
|
||||
expect(body.error).toBe(DOMAIN_BLOCK_ERROR)
|
||||
})
|
||||
|
||||
it("allows POST /api/auth/sign-up/email with non-deckyvault email", async () => {
|
||||
const app = new Elysia()
|
||||
.onBeforeHandle(domainBlockOnBeforeHandle)
|
||||
.post("/api/auth/sign-up/email", () => ({
|
||||
success: true,
|
||||
}))
|
||||
|
||||
const res = await app.handle(
|
||||
new Request("http://localhost:3000/api/auth/sign-up/email", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
email: "good@gmail.com",
|
||||
password: "abcdefghij",
|
||||
name: "Test",
|
||||
}),
|
||||
}),
|
||||
)
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
})
|
||||
|
||||
it("ignores non-sign-up routes (GET /api/auth/something)", async () => {
|
||||
const app = new Elysia()
|
||||
.onBeforeHandle(domainBlockOnBeforeHandle)
|
||||
.get("/api/auth/something", () => ({ ok: true }))
|
||||
|
||||
const res = await app.handle(
|
||||
new Request("http://localhost:3000/api/auth/something"),
|
||||
)
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
const body = await res.json()
|
||||
expect(body.ok).toBe(true)
|
||||
})
|
||||
|
||||
it("handles malformed JSON body gracefully (passes through)", async () => {
|
||||
const app = new Elysia()
|
||||
.onBeforeHandle(domainBlockOnBeforeHandle)
|
||||
.post("/api/auth/sign-up/email", () => ({ success: true }))
|
||||
|
||||
const res = await app.handle(
|
||||
new Request("http://localhost:3000/api/auth/sign-up/email", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: "not-valid-json",
|
||||
}),
|
||||
)
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,23 @@
|
||||
import { describe, it, expect } from "vitest"
|
||||
|
||||
describe("API version", () => {
|
||||
it("matches package.json version", () => {
|
||||
const pkg = require("../../../package.json")
|
||||
const expectedVersion = pkg.version
|
||||
expect(expectedVersion).toBe("2026.2.2")
|
||||
})
|
||||
})
|
||||
|
||||
describe("set-password endpoint security", () => {
|
||||
it("only accepts JSON body for newPassword (not URL-encoded or form-data)", () => {
|
||||
const allowedContentTypes = ["application/json"]
|
||||
const forbiddenContentTypes = [
|
||||
"application/x-www-form-urlencoded",
|
||||
"multipart/form-data",
|
||||
]
|
||||
expect(allowedContentTypes).toContain("application/json")
|
||||
for (const ct of forbiddenContentTypes) {
|
||||
expect(allowedContentTypes).not.toContain(ct)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,59 @@
|
||||
import { describe, it, expect } from "vitest"
|
||||
import fs from "node:fs"
|
||||
import path from "node:path"
|
||||
|
||||
describe("rate limiting removal", () => {
|
||||
const cwd = process.cwd()
|
||||
|
||||
it("lib/auth/rate-limit.ts no longer exports the rateLimit function", () => {
|
||||
const filePath = path.join(cwd, "lib", "auth", "rate-limit.ts")
|
||||
expect(fs.existsSync(filePath)).toBe(false)
|
||||
})
|
||||
|
||||
it("lib/api/app.ts no longer imports or uses rateLimit", () => {
|
||||
const content = fs.readFileSync(
|
||||
path.join(cwd, "lib", "api", "app.ts"),
|
||||
"utf8",
|
||||
)
|
||||
expect(content).not.toContain('import { rateLimit }')
|
||||
expect(content).not.toContain('.use(rateLimit(')
|
||||
})
|
||||
|
||||
it("lib/auth.ts has Better Auth rateLimit disabled", () => {
|
||||
const content = fs.readFileSync(
|
||||
path.join(cwd, "lib", "auth.ts"),
|
||||
"utf8",
|
||||
)
|
||||
const hasRateLimitBlock = content.includes("rateLimit:")
|
||||
if (hasRateLimitBlock) {
|
||||
expect(content).toContain("enabled: false")
|
||||
}
|
||||
})
|
||||
|
||||
it("lib/api/contact.ts no longer calls checkContactRateLimit", () => {
|
||||
const content = fs.readFileSync(
|
||||
path.join(cwd, "lib", "api", "contact.ts"),
|
||||
"utf8",
|
||||
)
|
||||
expect(content).not.toContain("checkContactRateLimit")
|
||||
})
|
||||
|
||||
it("lib/api/comments.ts no longer enforces hourly cap", () => {
|
||||
const content = fs.readFileSync(
|
||||
path.join(cwd, "lib", "api", "comments.ts"),
|
||||
"utf8",
|
||||
)
|
||||
expect(content).not.toContain("hourly limit")
|
||||
expect(content).not.toContain('"Duplicate comment detected')
|
||||
})
|
||||
|
||||
it("lib/api/performance-submit.ts no longer enforces submission cooldown", () => {
|
||||
const content = fs.readFileSync(
|
||||
path.join(cwd, "lib", "api", "performance-submit.ts"),
|
||||
"utf8",
|
||||
)
|
||||
expect(content).not.toContain("Submission cooldown")
|
||||
expect(content).not.toContain("sixtySecondsAgo")
|
||||
expect(content).not.toContain("Please wait before submitting")
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,104 @@
|
||||
import { Elysia } from "elysia"
|
||||
import { db } from "@/lib/db/index"
|
||||
import {
|
||||
performanceEntries,
|
||||
gameVersions,
|
||||
games,
|
||||
hardware,
|
||||
user,
|
||||
gameComments,
|
||||
} from "@/lib/db/schema"
|
||||
import { eq, count, sql, desc } from "drizzle-orm"
|
||||
import { requireModeratorOrAdmin } from "@/lib/auth/guard"
|
||||
|
||||
export const adminAnalyticsRoutes = new Elysia({
|
||||
prefix: "/admin/analytics",
|
||||
detail: { tags: ["Admin"] },
|
||||
})
|
||||
|
||||
// ── Overview: time-series data for charts ────────────────────────
|
||||
.get(
|
||||
"/overview",
|
||||
async ({ request, set }) => {
|
||||
const guard = await requireModeratorOrAdmin(request.headers)
|
||||
if (!guard.ok) {
|
||||
set.status = guard.status
|
||||
return { error: guard.error }
|
||||
}
|
||||
|
||||
const ninetyDaysAgo = new Date()
|
||||
ninetyDaysAgo.setDate(ninetyDaysAgo.getDate() - 90)
|
||||
|
||||
// Benchmark submissions per day (last 90 days)
|
||||
const benchmarkTimeline = await db.execute(sql`
|
||||
SELECT
|
||||
DATE(pe.created_at) AS day,
|
||||
COUNT(*)::int AS count
|
||||
FROM performance_entries pe
|
||||
WHERE pe.created_at >= ${ninetyDaysAgo}
|
||||
AND pe.is_removed = false
|
||||
GROUP BY DATE(pe.created_at)
|
||||
ORDER BY day ASC
|
||||
`)
|
||||
|
||||
// User registrations per day (last 90 days)
|
||||
const userTimeline = await db.execute(sql`
|
||||
SELECT
|
||||
DATE(u.created_at) AS day,
|
||||
COUNT(*)::int AS count
|
||||
FROM "user" u
|
||||
WHERE u.created_at >= ${ninetyDaysAgo}
|
||||
GROUP BY DATE(u.created_at)
|
||||
ORDER BY day ASC
|
||||
`)
|
||||
|
||||
// Device distribution (total benchmarks per hardware)
|
||||
const deviceDistribution = await db
|
||||
.select({
|
||||
hardwareSlug: performanceEntries.hardwareSlug,
|
||||
hardwareName: hardware.name,
|
||||
count: count(),
|
||||
})
|
||||
.from(performanceEntries)
|
||||
.innerJoin(hardware, eq(performanceEntries.hardwareSlug, hardware.slug))
|
||||
.where(eq(performanceEntries.isRemoved, false))
|
||||
.groupBy(performanceEntries.hardwareSlug, hardware.name)
|
||||
.orderBy(desc(count()))
|
||||
|
||||
// Genre popularity (top 10 genres by benchmark count)
|
||||
const genrePopularity = await db.execute(sql`
|
||||
SELECT
|
||||
genre,
|
||||
COUNT(*)::int AS count
|
||||
FROM games g
|
||||
CROSS JOIN LATERAL jsonb_array_elements_text(g.genres) AS genre
|
||||
JOIN game_versions gv ON gv.game_id = g.id
|
||||
JOIN performance_entries pe ON pe.version_id = gv.id
|
||||
WHERE pe.is_removed = false
|
||||
AND g.genres IS NOT NULL
|
||||
GROUP BY genre
|
||||
ORDER BY count DESC
|
||||
LIMIT 10
|
||||
`)
|
||||
|
||||
// Comment activity per day (last 90 days)
|
||||
const commentTimeline = await db.execute(sql`
|
||||
SELECT
|
||||
DATE(gc.created_at) AS day,
|
||||
COUNT(*)::int AS count
|
||||
FROM game_comments gc
|
||||
WHERE gc.created_at >= ${ninetyDaysAgo}
|
||||
AND gc.is_removed = false
|
||||
GROUP BY DATE(gc.created_at)
|
||||
ORDER BY day ASC
|
||||
`)
|
||||
|
||||
return {
|
||||
benchmarkTimeline: benchmarkTimeline.rows,
|
||||
userTimeline: userTimeline.rows,
|
||||
deviceDistribution,
|
||||
genrePopularity: genrePopularity.rows,
|
||||
commentTimeline: commentTimeline.rows,
|
||||
}
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,161 @@
|
||||
import { Elysia, t } from "elysia"
|
||||
import { db } from "@/lib/db/index"
|
||||
import {
|
||||
gameComments,
|
||||
games,
|
||||
user,
|
||||
} from "@/lib/db/schema"
|
||||
import { eq, desc, sql, and, ilike } from "drizzle-orm"
|
||||
import { requireModeratorOrAdmin } from "@/lib/auth/guard"
|
||||
|
||||
export const adminCommentRoutes = new Elysia({ prefix: "/admin", detail: { tags: ["Admin"] } })
|
||||
.get(
|
||||
"/comments",
|
||||
async ({ query, request, set }) => {
|
||||
const guard = await requireModeratorOrAdmin(request.headers)
|
||||
if (!guard.ok) {
|
||||
set.status = guard.status
|
||||
return { error: guard.error }
|
||||
}
|
||||
|
||||
const limit = Math.min(Number(query.limit) || 20, 100)
|
||||
const offset = Number(query.offset) || 0
|
||||
const removedFilter = query.removed
|
||||
const searchTerm = query.search
|
||||
|
||||
const conditions: (ReturnType<typeof eq> | ReturnType<typeof ilike> | ReturnType<typeof and>)[] = []
|
||||
|
||||
if (removedFilter === "true") {
|
||||
conditions.push(eq(gameComments.isRemoved, true))
|
||||
} else if (removedFilter === "false") {
|
||||
conditions.push(eq(gameComments.isRemoved, false))
|
||||
}
|
||||
|
||||
if (searchTerm) {
|
||||
conditions.push(ilike(user.name, `%${searchTerm}%`))
|
||||
}
|
||||
|
||||
const whereClause = conditions.length > 0 ? and(...conditions) : undefined
|
||||
|
||||
const baseQuery = db
|
||||
.select({
|
||||
id: gameComments.id,
|
||||
gameId: gameComments.gameId,
|
||||
content: gameComments.content,
|
||||
upvotes: gameComments.upvotes,
|
||||
isRemoved: gameComments.isRemoved,
|
||||
createdAt: gameComments.createdAt,
|
||||
updatedAt: gameComments.updatedAt,
|
||||
userId: gameComments.userId,
|
||||
userName: user.name,
|
||||
userImage: user.image,
|
||||
gameTitle: games.title,
|
||||
parentId: gameComments.parentId,
|
||||
})
|
||||
.from(gameComments)
|
||||
.innerJoin(games, eq(gameComments.gameId, games.id))
|
||||
.innerJoin(user, eq(gameComments.userId, user.id))
|
||||
.orderBy(desc(gameComments.createdAt))
|
||||
|
||||
const items = whereClause
|
||||
? await baseQuery.where(whereClause).limit(limit).offset(offset)
|
||||
: await baseQuery.limit(limit).offset(offset)
|
||||
|
||||
const countQuery = db
|
||||
.select({ count: sql<number>`count(*)::int` })
|
||||
.from(gameComments)
|
||||
.innerJoin(games, eq(gameComments.gameId, games.id))
|
||||
.innerJoin(user, eq(gameComments.userId, user.id))
|
||||
|
||||
const countResult = whereClause
|
||||
? await countQuery.where(whereClause)
|
||||
: await countQuery
|
||||
|
||||
const total = countResult[0]?.count ?? 0
|
||||
|
||||
return {
|
||||
data: items,
|
||||
total,
|
||||
limit,
|
||||
offset,
|
||||
}
|
||||
},
|
||||
{
|
||||
query: t.Object({
|
||||
removed: t.Optional(t.Union([t.Literal("true"), t.Literal("false")])),
|
||||
search: t.Optional(t.String()),
|
||||
limit: t.Optional(t.String()),
|
||||
offset: t.Optional(t.String()),
|
||||
}),
|
||||
},
|
||||
)
|
||||
.patch(
|
||||
"/comments/:id/remove",
|
||||
async ({ params, request, set }) => {
|
||||
const guard = await requireModeratorOrAdmin(request.headers)
|
||||
if (!guard.ok) {
|
||||
set.status = guard.status
|
||||
return { error: guard.error }
|
||||
}
|
||||
|
||||
const [comment] = await db
|
||||
.select()
|
||||
.from(gameComments)
|
||||
.where(eq(gameComments.id, params.id))
|
||||
.limit(1)
|
||||
|
||||
if (!comment) {
|
||||
set.status = 404
|
||||
return { error: "Comment not found" }
|
||||
}
|
||||
|
||||
const [updated] = await db
|
||||
.update(gameComments)
|
||||
.set({
|
||||
isRemoved: true,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(gameComments.id, params.id))
|
||||
.returning()
|
||||
|
||||
return updated
|
||||
},
|
||||
{
|
||||
params: t.Object({ id: t.String() }),
|
||||
},
|
||||
)
|
||||
.patch(
|
||||
"/comments/:id/restore",
|
||||
async ({ params, request, set }) => {
|
||||
const guard = await requireModeratorOrAdmin(request.headers)
|
||||
if (!guard.ok) {
|
||||
set.status = guard.status
|
||||
return { error: guard.error }
|
||||
}
|
||||
|
||||
const [comment] = await db
|
||||
.select()
|
||||
.from(gameComments)
|
||||
.where(eq(gameComments.id, params.id))
|
||||
.limit(1)
|
||||
|
||||
if (!comment) {
|
||||
set.status = 404
|
||||
return { error: "Comment not found" }
|
||||
}
|
||||
|
||||
const [updated] = await db
|
||||
.update(gameComments)
|
||||
.set({
|
||||
isRemoved: false,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(gameComments.id, params.id))
|
||||
.returning()
|
||||
|
||||
return updated
|
||||
},
|
||||
{
|
||||
params: t.Object({ id: t.String() }),
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,295 @@
|
||||
import { Elysia, t } from "elysia"
|
||||
import { db } from "@/lib/db/index"
|
||||
import {
|
||||
performanceEntries,
|
||||
gameVersions,
|
||||
games,
|
||||
hardware,
|
||||
user,
|
||||
} from "@/lib/db/schema"
|
||||
import { eq, desc, sql, and, ilike, isNull, isNotNull, or } from "drizzle-orm"
|
||||
import { fuzzySearchTerm } from "@/lib/db/search"
|
||||
import {
|
||||
requireContributorOrAdmin,
|
||||
requireAdmin,
|
||||
requireModeratorOrAdmin,
|
||||
} from "@/lib/auth/guard"
|
||||
|
||||
export const adminPerformanceRoutes = new Elysia({ prefix: "/admin", detail: { tags: ["Admin"] } })
|
||||
.get(
|
||||
"/performance",
|
||||
async ({ query, request, set }) => {
|
||||
const guard = await requireContributorOrAdmin(request.headers)
|
||||
if (!guard.ok) {
|
||||
set.status = guard.status
|
||||
return { error: guard.error }
|
||||
}
|
||||
|
||||
const limit = Math.min(Number(query.limit) || 20, 100)
|
||||
const offset = Number(query.offset) || 0
|
||||
const removedFilter = query.removed
|
||||
const verifiedFilter = query.verified
|
||||
const searchTerm = query.search
|
||||
|
||||
const conditions: (ReturnType<typeof eq> | ReturnType<typeof ilike> | ReturnType<typeof isNull> | ReturnType<typeof isNotNull> | ReturnType<typeof and>)[] = []
|
||||
|
||||
if (removedFilter === "true") {
|
||||
conditions.push(eq(performanceEntries.isRemoved, true))
|
||||
} else if (removedFilter === "false") {
|
||||
conditions.push(eq(performanceEntries.isRemoved, false))
|
||||
}
|
||||
|
||||
if (verifiedFilter === "true") {
|
||||
conditions.push(isNotNull(performanceEntries.verifiedAt))
|
||||
} else if (verifiedFilter === "false") {
|
||||
conditions.push(isNull(performanceEntries.verifiedAt))
|
||||
}
|
||||
|
||||
if (searchTerm) {
|
||||
conditions.push(
|
||||
or(
|
||||
ilike(user.name, `%${searchTerm}%`),
|
||||
ilike(games.title, fuzzySearchTerm(searchTerm)),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
const whereClause = conditions.length > 0 ? and(...conditions) : undefined
|
||||
|
||||
const baseQuery = db
|
||||
.select({
|
||||
id: performanceEntries.id,
|
||||
versionId: performanceEntries.versionId,
|
||||
hardwareSlug: performanceEntries.hardwareSlug,
|
||||
userId: performanceEntries.userId,
|
||||
fpsAvg: performanceEntries.fpsAvg,
|
||||
fpsLow: performanceEntries.fpsLow,
|
||||
fpsHigh: performanceEntries.fpsHigh,
|
||||
protonVersion: performanceEntries.protonVersion,
|
||||
osVersion: performanceEntries.osVersion,
|
||||
upscalerType: performanceEntries.upscalerType,
|
||||
upscalerVersion: performanceEntries.upscalerVersion,
|
||||
frameGenMethod: performanceEntries.frameGenMethod,
|
||||
loadTimeSsd: performanceEntries.loadTimeSsd,
|
||||
loadTimeSd: performanceEntries.loadTimeSd,
|
||||
launchOptions: performanceEntries.launchOptions,
|
||||
settingsJson: performanceEntries.settingsJson,
|
||||
userNotes: performanceEntries.userNotes,
|
||||
customSystem: performanceEntries.customSystem,
|
||||
isRemoved: performanceEntries.isRemoved,
|
||||
removedReason: performanceEntries.removedReason,
|
||||
upvotes: performanceEntries.upvotes,
|
||||
downvotes: performanceEntries.downvotes,
|
||||
verifiedAt: performanceEntries.verifiedAt,
|
||||
verifiedBy: performanceEntries.verifiedBy,
|
||||
createdAt: performanceEntries.createdAt,
|
||||
updatedAt: performanceEntries.updatedAt,
|
||||
gameId: games.id,
|
||||
gameTitle: games.title,
|
||||
versionString: gameVersions.versionString,
|
||||
hardwareName: hardware.name,
|
||||
authorName: user.name,
|
||||
authorImage: user.image,
|
||||
})
|
||||
.from(performanceEntries)
|
||||
.innerJoin(
|
||||
gameVersions,
|
||||
eq(performanceEntries.versionId, gameVersions.id),
|
||||
)
|
||||
.innerJoin(games, eq(gameVersions.gameId, games.id))
|
||||
.innerJoin(
|
||||
hardware,
|
||||
eq(performanceEntries.hardwareSlug, hardware.slug),
|
||||
)
|
||||
.innerJoin(user, eq(performanceEntries.userId, user.id))
|
||||
.orderBy(desc(performanceEntries.createdAt))
|
||||
|
||||
const items = whereClause
|
||||
? await baseQuery.where(whereClause).limit(limit).offset(offset)
|
||||
: await baseQuery.limit(limit).offset(offset)
|
||||
|
||||
const countQuery = db
|
||||
.select({ count: sql<number>`count(*)::int` })
|
||||
.from(performanceEntries)
|
||||
.innerJoin(
|
||||
gameVersions,
|
||||
eq(performanceEntries.versionId, gameVersions.id),
|
||||
)
|
||||
.innerJoin(games, eq(gameVersions.gameId, games.id))
|
||||
.innerJoin(
|
||||
hardware,
|
||||
eq(performanceEntries.hardwareSlug, hardware.slug),
|
||||
)
|
||||
.innerJoin(user, eq(performanceEntries.userId, user.id))
|
||||
|
||||
const countResult = whereClause
|
||||
? await countQuery.where(whereClause)
|
||||
: await countQuery
|
||||
|
||||
const total = countResult[0]?.count ?? 0
|
||||
|
||||
return {
|
||||
data: items,
|
||||
total,
|
||||
limit,
|
||||
offset,
|
||||
}
|
||||
},
|
||||
{
|
||||
query: t.Object({
|
||||
removed: t.Optional(t.Union([t.Literal("true"), t.Literal("false")])),
|
||||
verified: t.Optional(t.Union([t.Literal("true"), t.Literal("false")])),
|
||||
search: t.Optional(t.String()),
|
||||
limit: t.Optional(t.String()),
|
||||
offset: t.Optional(t.String()),
|
||||
}),
|
||||
},
|
||||
)
|
||||
.patch(
|
||||
"/performance/:id/verify",
|
||||
async ({ params, request, set }) => {
|
||||
const guard = await requireContributorOrAdmin(request.headers)
|
||||
if (!guard.ok) {
|
||||
set.status = guard.status
|
||||
return { error: guard.error }
|
||||
}
|
||||
|
||||
const [entry] = await db
|
||||
.select()
|
||||
.from(performanceEntries)
|
||||
.where(eq(performanceEntries.id, params.id))
|
||||
.limit(1)
|
||||
|
||||
if (!entry) {
|
||||
set.status = 404
|
||||
return { error: "Performance entry not found" }
|
||||
}
|
||||
|
||||
if (entry.verifiedAt) {
|
||||
set.status = 409
|
||||
return { error: "Entry already verified" }
|
||||
}
|
||||
|
||||
const [updated] = await db
|
||||
.update(performanceEntries)
|
||||
.set({
|
||||
verifiedAt: new Date(),
|
||||
verifiedBy: guard.user.id,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(performanceEntries.id, params.id))
|
||||
.returning()
|
||||
|
||||
return updated
|
||||
},
|
||||
{
|
||||
params: t.Object({ id: t.String() }),
|
||||
},
|
||||
)
|
||||
.patch(
|
||||
"/performance/:id/remove",
|
||||
async ({ params, body, request, set }) => {
|
||||
const guard = await requireModeratorOrAdmin(request.headers)
|
||||
if (!guard.ok) {
|
||||
set.status = guard.status
|
||||
return { error: guard.error }
|
||||
}
|
||||
|
||||
const [entry] = await db
|
||||
.select()
|
||||
.from(performanceEntries)
|
||||
.where(eq(performanceEntries.id, params.id))
|
||||
.limit(1)
|
||||
|
||||
if (!entry) {
|
||||
set.status = 404
|
||||
return { error: "Performance entry not found" }
|
||||
}
|
||||
|
||||
const [updated] = await db
|
||||
.update(performanceEntries)
|
||||
.set({
|
||||
isRemoved: true,
|
||||
removedReason: body?.reason ?? "Admin removed",
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(performanceEntries.id, params.id))
|
||||
.returning()
|
||||
|
||||
return updated
|
||||
},
|
||||
{
|
||||
params: t.Object({ id: t.String() }),
|
||||
body: t.Optional(
|
||||
t.Object({
|
||||
reason: t.Optional(t.String()),
|
||||
}),
|
||||
),
|
||||
},
|
||||
)
|
||||
.delete(
|
||||
"/performance/:id/hard-delete",
|
||||
async ({ params, request, set }) => {
|
||||
const guard = await requireModeratorOrAdmin(request.headers)
|
||||
if (!guard.ok) {
|
||||
set.status = guard.status
|
||||
return { error: guard.error }
|
||||
}
|
||||
|
||||
const [entry] = await db
|
||||
.select()
|
||||
.from(performanceEntries)
|
||||
.where(eq(performanceEntries.id, params.id))
|
||||
.limit(1)
|
||||
|
||||
if (!entry) {
|
||||
set.status = 404
|
||||
return { error: "Performance entry not found" }
|
||||
}
|
||||
|
||||
await db
|
||||
.delete(performanceEntries)
|
||||
.where(eq(performanceEntries.id, params.id))
|
||||
|
||||
return { success: true }
|
||||
},
|
||||
{
|
||||
params: t.Object({ id: t.String() }),
|
||||
},
|
||||
)
|
||||
.patch(
|
||||
"/performance/:id/restore",
|
||||
async ({ params, request, set }) => {
|
||||
const guard = await requireModeratorOrAdmin(request.headers)
|
||||
if (!guard.ok) {
|
||||
set.status = guard.status
|
||||
return { error: guard.error }
|
||||
}
|
||||
|
||||
const [entry] = await db
|
||||
.select()
|
||||
.from(performanceEntries)
|
||||
.where(eq(performanceEntries.id, params.id))
|
||||
.limit(1)
|
||||
|
||||
if (!entry) {
|
||||
set.status = 404
|
||||
return { error: "Performance entry not found" }
|
||||
}
|
||||
|
||||
const [updated] = await db
|
||||
.update(performanceEntries)
|
||||
.set({
|
||||
isRemoved: false,
|
||||
removedReason: null,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(performanceEntries.id, params.id))
|
||||
.returning()
|
||||
|
||||
return updated
|
||||
},
|
||||
{
|
||||
params: t.Object({ id: t.String() }),
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,194 @@
|
||||
import { Elysia, t } from "elysia"
|
||||
import { db } from "@/lib/db/index"
|
||||
import {
|
||||
reports,
|
||||
performanceEntries,
|
||||
gameVersions,
|
||||
games,
|
||||
user,
|
||||
} from "@/lib/db/schema"
|
||||
import { eq, desc, sql, and, inArray } from "drizzle-orm"
|
||||
import { requireModeratorOrAdmin } from "@/lib/auth/guard"
|
||||
|
||||
export const adminReportRoutes = new Elysia({ prefix: "/admin", detail: { tags: ["Admin"] } })
|
||||
.get(
|
||||
"/reports",
|
||||
async ({ query, request, set }) => {
|
||||
const guard = await requireModeratorOrAdmin(request.headers)
|
||||
if (!guard.ok) {
|
||||
set.status = guard.status
|
||||
return { error: guard.error }
|
||||
}
|
||||
|
||||
const limit = Math.min(Number(query.limit) || 20, 100)
|
||||
const offset = Number(query.offset) || 0
|
||||
const statusFilter = query.status
|
||||
|
||||
const conditions = []
|
||||
if (
|
||||
statusFilter &&
|
||||
["open", "reviewed", "dismissed"].includes(statusFilter)
|
||||
) {
|
||||
conditions.push(
|
||||
eq(reports.status, statusFilter as "open" | "reviewed" | "dismissed"),
|
||||
)
|
||||
}
|
||||
|
||||
const whereClause =
|
||||
conditions.length > 0 ? and(...conditions) : undefined
|
||||
|
||||
const baseQuery = db
|
||||
.select({
|
||||
report: {
|
||||
id: reports.id,
|
||||
entryId: reports.entryId,
|
||||
reporterId: reports.reporterId,
|
||||
reason: reports.reason,
|
||||
details: reports.details,
|
||||
status: reports.status,
|
||||
createdAt: reports.createdAt,
|
||||
},
|
||||
reporterName: user.name,
|
||||
entry: {
|
||||
id: performanceEntries.id,
|
||||
userId: performanceEntries.userId,
|
||||
fpsAvg: performanceEntries.fpsAvg,
|
||||
fpsLow: performanceEntries.fpsLow,
|
||||
fpsHigh: performanceEntries.fpsHigh,
|
||||
upscalerType: performanceEntries.upscalerType,
|
||||
userNotes: performanceEntries.userNotes,
|
||||
isRemoved: performanceEntries.isRemoved,
|
||||
},
|
||||
gameVersion: {
|
||||
id: gameVersions.id,
|
||||
versionString: gameVersions.versionString,
|
||||
},
|
||||
game: {
|
||||
id: games.id,
|
||||
title: games.title,
|
||||
headerImage: games.headerImage,
|
||||
},
|
||||
})
|
||||
.from(reports)
|
||||
.innerJoin(
|
||||
performanceEntries,
|
||||
eq(reports.entryId, performanceEntries.id),
|
||||
)
|
||||
.innerJoin(
|
||||
gameVersions,
|
||||
eq(performanceEntries.versionId, gameVersions.id),
|
||||
)
|
||||
.innerJoin(games, eq(gameVersions.gameId, games.id))
|
||||
.innerJoin(user, eq(reports.reporterId, user.id))
|
||||
.orderBy(desc(reports.createdAt))
|
||||
|
||||
const items = whereClause
|
||||
? await baseQuery.where(whereClause).limit(limit).offset(offset)
|
||||
: await baseQuery.limit(limit).offset(offset)
|
||||
|
||||
const countResult = whereClause
|
||||
? await db
|
||||
.select({ count: sql<number>`count(*)::int` })
|
||||
.from(reports)
|
||||
.where(whereClause)
|
||||
: await db.select({ count: sql<number>`count(*)::int` }).from(reports)
|
||||
|
||||
const total = countResult[0]?.count ?? 0
|
||||
|
||||
// Batch-fetch entry author names
|
||||
const authorIds = [...new Set(items.map((i) => i.entry.userId))]
|
||||
const authorNames: Record<string, string | null> = {}
|
||||
|
||||
if (authorIds.length > 0) {
|
||||
const authors = await db
|
||||
.select({ id: user.id, name: user.name })
|
||||
.from(user)
|
||||
.where(inArray(user.id, authorIds))
|
||||
|
||||
for (const a of authors) {
|
||||
authorNames[a.id] = a.name
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
data: items.map((item) => ({
|
||||
...item.report,
|
||||
createdAt: item.report.createdAt.toISOString(),
|
||||
reporterName: item.reporterName,
|
||||
entry: {
|
||||
...item.entry,
|
||||
authorName: authorNames[item.entry.userId] ?? null,
|
||||
},
|
||||
gameVersion: item.gameVersion,
|
||||
game: item.game,
|
||||
})),
|
||||
total,
|
||||
limit,
|
||||
offset,
|
||||
}
|
||||
},
|
||||
{
|
||||
query: t.Object({
|
||||
status: t.Optional(
|
||||
t.Union([
|
||||
t.Literal("open"),
|
||||
t.Literal("reviewed"),
|
||||
t.Literal("dismissed"),
|
||||
]),
|
||||
),
|
||||
limit: t.Optional(t.String()),
|
||||
offset: t.Optional(t.String()),
|
||||
}),
|
||||
},
|
||||
)
|
||||
.patch(
|
||||
"/reports/:id/status",
|
||||
async ({ params, body, request, set }) => {
|
||||
const guard = await requireModeratorOrAdmin(request.headers)
|
||||
if (!guard.ok) {
|
||||
set.status = guard.status
|
||||
return { error: guard.error }
|
||||
}
|
||||
|
||||
const [existing] = await db
|
||||
.select()
|
||||
.from(reports)
|
||||
.where(eq(reports.id, params.id))
|
||||
.limit(1)
|
||||
|
||||
if (!existing) {
|
||||
set.status = 404
|
||||
return { error: "Report not found" }
|
||||
}
|
||||
|
||||
if (existing.status === body.status) {
|
||||
set.status = 409
|
||||
return { error: "Report already has this status" }
|
||||
}
|
||||
|
||||
if (body.status === "reviewed") {
|
||||
await db
|
||||
.update(performanceEntries)
|
||||
.set({
|
||||
isRemoved: true,
|
||||
removedReason: `Reported: ${existing.reason}${existing.details ? ` — ${existing.details}` : ""}`,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(performanceEntries.id, existing.entryId))
|
||||
}
|
||||
|
||||
const [updated] = await db
|
||||
.update(reports)
|
||||
.set({ status: body.status })
|
||||
.where(eq(reports.id, params.id))
|
||||
.returning()
|
||||
|
||||
return updated
|
||||
},
|
||||
{
|
||||
params: t.Object({ id: t.String() }),
|
||||
body: t.Object({
|
||||
status: t.Union([t.Literal("reviewed"), t.Literal("dismissed")]),
|
||||
}),
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,321 @@
|
||||
import { Elysia, t } from "elysia"
|
||||
import { db } from "@/lib/db/index"
|
||||
import { storageObjects, user, games } from "@/lib/db/schema"
|
||||
import { eq, sql, and, ilike, desc } from "drizzle-orm"
|
||||
import { requireAdmin } from "@/lib/auth/guard"
|
||||
import { deleteObject, isR2Configured } from "@/lib/storage"
|
||||
|
||||
export const adminStorageRoutes = new Elysia({ prefix: "/admin/storage", detail: { tags: ["Admin"] } })
|
||||
|
||||
// ── GET /stats ──────────────────────────────────────────────────
|
||||
.get(
|
||||
"/stats",
|
||||
async ({ request, set }) => {
|
||||
const guard = await requireAdmin(request.headers)
|
||||
if (!guard.ok) {
|
||||
set.status = guard.status
|
||||
return { error: guard.error }
|
||||
}
|
||||
|
||||
const [totalResult] = await db
|
||||
.select({ count: sql<number>`count(*)::int` })
|
||||
.from(storageObjects)
|
||||
|
||||
const [orphanedResult] = await db
|
||||
.select({ count: sql<number>`count(*)::int` })
|
||||
.from(storageObjects)
|
||||
.where(eq(storageObjects.isOrphaned, true))
|
||||
|
||||
const byEntityType = await db
|
||||
.select({
|
||||
entityType: storageObjects.entityType,
|
||||
count: sql<number>`count(*)::int`,
|
||||
})
|
||||
.from(storageObjects)
|
||||
.groupBy(storageObjects.entityType)
|
||||
|
||||
const [totalSizeResult] = await db
|
||||
.select({ total: sql<number>`coalesce(sum(${storageObjects.size}), 0)::int` })
|
||||
.from(storageObjects)
|
||||
|
||||
return {
|
||||
total: totalResult?.count ?? 0,
|
||||
orphaned: orphanedResult?.count ?? 0,
|
||||
totalSizeBytes: totalSizeResult?.total ?? 0,
|
||||
byEntityType: byEntityType.reduce<Record<string, number>>(
|
||||
(acc, row) => {
|
||||
acc[row.entityType] = row.count
|
||||
return acc
|
||||
},
|
||||
{},
|
||||
),
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
// ── GET /objects ─────────────────────────────────────────────────
|
||||
.get(
|
||||
"/objects",
|
||||
async ({ query, request, set }) => {
|
||||
const guard = await requireAdmin(request.headers)
|
||||
if (!guard.ok) {
|
||||
set.status = guard.status
|
||||
return { error: guard.error }
|
||||
}
|
||||
|
||||
const limit = Math.min(Number(query.limit) || 20, 100)
|
||||
const offset = Number(query.offset) || 0
|
||||
|
||||
const conditions: (ReturnType<typeof eq> | ReturnType<typeof ilike> | ReturnType<typeof and>)[] = []
|
||||
|
||||
if (query.entityType) {
|
||||
conditions.push(eq(storageObjects.entityType, query.entityType))
|
||||
}
|
||||
|
||||
if (query.orphaned === "true") {
|
||||
conditions.push(eq(storageObjects.isOrphaned, true))
|
||||
} else if (query.orphaned === "false") {
|
||||
conditions.push(eq(storageObjects.isOrphaned, false))
|
||||
}
|
||||
|
||||
if (query.search) {
|
||||
conditions.push(ilike(storageObjects.key, `%${query.search}%`))
|
||||
}
|
||||
|
||||
const whereClause = conditions.length > 0 ? and(...conditions) : undefined
|
||||
|
||||
const baseQuery = db
|
||||
.select({
|
||||
id: storageObjects.id,
|
||||
key: storageObjects.key,
|
||||
bucket: storageObjects.bucket,
|
||||
size: storageObjects.size,
|
||||
mimeType: storageObjects.mimeType,
|
||||
entityType: storageObjects.entityType,
|
||||
entityId: storageObjects.entityId,
|
||||
uploadedBy: storageObjects.uploadedBy,
|
||||
createdAt: storageObjects.createdAt,
|
||||
lastAccessedAt: storageObjects.lastAccessedAt,
|
||||
isOrphaned: storageObjects.isOrphaned,
|
||||
uploaderName: user.name,
|
||||
})
|
||||
.from(storageObjects)
|
||||
.innerJoin(user, eq(storageObjects.uploadedBy, user.id))
|
||||
.orderBy(desc(storageObjects.createdAt))
|
||||
|
||||
const items = whereClause
|
||||
? await baseQuery.where(whereClause).limit(limit).offset(offset)
|
||||
: await baseQuery.limit(limit).offset(offset)
|
||||
|
||||
const countQuery = db
|
||||
.select({ count: sql<number>`count(*)::int` })
|
||||
.from(storageObjects)
|
||||
.innerJoin(user, eq(storageObjects.uploadedBy, user.id))
|
||||
|
||||
const countResult = whereClause
|
||||
? await countQuery.where(whereClause)
|
||||
: await countQuery
|
||||
|
||||
const total = countResult[0]?.count ?? 0
|
||||
|
||||
return {
|
||||
data: items.map((item) => ({
|
||||
...item,
|
||||
createdAt: item.createdAt.toISOString(),
|
||||
lastAccessedAt: item.lastAccessedAt?.toISOString() ?? null,
|
||||
})),
|
||||
total,
|
||||
limit,
|
||||
offset,
|
||||
}
|
||||
},
|
||||
{
|
||||
query: t.Object({
|
||||
limit: t.Optional(t.String()),
|
||||
offset: t.Optional(t.String()),
|
||||
entityType: t.Optional(t.String()),
|
||||
orphaned: t.Optional(t.Union([t.Literal("true"), t.Literal("false")])),
|
||||
search: t.Optional(t.String()),
|
||||
}),
|
||||
},
|
||||
)
|
||||
|
||||
// ── DELETE /objects/:id ──────────────────────────────────────────
|
||||
.delete(
|
||||
"/objects/:id",
|
||||
async ({ params, request, set }) => {
|
||||
const guard = await requireAdmin(request.headers)
|
||||
if (!guard.ok) {
|
||||
set.status = guard.status
|
||||
return { error: guard.error }
|
||||
}
|
||||
|
||||
const [obj] = await db
|
||||
.select()
|
||||
.from(storageObjects)
|
||||
.where(eq(storageObjects.id, params.id))
|
||||
.limit(1)
|
||||
|
||||
if (!obj) {
|
||||
set.status = 404
|
||||
return { error: "Storage object not found" }
|
||||
}
|
||||
|
||||
// Delete from R2 (best-effort; don't block on R2 failure)
|
||||
if (isR2Configured()) {
|
||||
try {
|
||||
await deleteObject(obj.key)
|
||||
} catch {
|
||||
console.warn(`Failed to delete object from R2: ${obj.key}`)
|
||||
}
|
||||
}
|
||||
|
||||
// Clear entity references for known entity types
|
||||
if (obj.entityType === "avatar" && obj.entityId) {
|
||||
try {
|
||||
await db
|
||||
.update(user)
|
||||
.set({ image: null, updatedAt: new Date() })
|
||||
.where(eq(user.id, obj.entityId))
|
||||
} catch {
|
||||
// User may have been deleted already; skip silently
|
||||
}
|
||||
} else if (obj.entityType === "game_cover" && obj.entityId) {
|
||||
try {
|
||||
await db
|
||||
.update(games)
|
||||
.set({ headerImage: null, updatedAt: new Date() })
|
||||
.where(eq(games.id, obj.entityId))
|
||||
} catch {
|
||||
// Game may have been deleted already; skip silently
|
||||
}
|
||||
}
|
||||
|
||||
// Delete the DB record
|
||||
await db
|
||||
.delete(storageObjects)
|
||||
.where(eq(storageObjects.id, params.id))
|
||||
|
||||
return { success: true, deleted: { id: obj.id, key: obj.key } }
|
||||
},
|
||||
{
|
||||
params: t.Object({ id: t.String() }),
|
||||
},
|
||||
)
|
||||
|
||||
// ── POST /cleanup ───────────────────────────────────────────────
|
||||
.post(
|
||||
"/cleanup",
|
||||
async ({ request, set }) => {
|
||||
const guard = await requireAdmin(request.headers)
|
||||
if (!guard.ok) {
|
||||
set.status = guard.status
|
||||
return { error: guard.error }
|
||||
}
|
||||
|
||||
if (!isR2Configured()) {
|
||||
set.status = 503
|
||||
return { error: "Storage (R2) not configured" }
|
||||
}
|
||||
|
||||
// 1. Mark orphaned avatars (entityId not in user table)
|
||||
const avatarOrphans = await db.execute(sql`
|
||||
UPDATE storage_objects
|
||||
SET is_orphaned = true
|
||||
WHERE entity_type = 'avatar'
|
||||
AND is_orphaned = false
|
||||
AND entity_id NOT IN (SELECT id FROM "user")
|
||||
`)
|
||||
|
||||
// 2. Mark orphaned game covers (entityId not in games table)
|
||||
const gameCoverOrphans = await db.execute(sql`
|
||||
UPDATE storage_objects
|
||||
SET is_orphaned = true
|
||||
WHERE entity_type = 'game_cover'
|
||||
AND is_orphaned = false
|
||||
AND entity_id NOT IN (SELECT id FROM games)
|
||||
`)
|
||||
|
||||
// 3. Fetch all now-orphaned objects for deletion
|
||||
const orphaned = await db
|
||||
.select()
|
||||
.from(storageObjects)
|
||||
.where(eq(storageObjects.isOrphaned, true))
|
||||
|
||||
let deletedFromR2 = 0
|
||||
let r2Errors = 0
|
||||
|
||||
// 4. Delete from R2 in batches of 50
|
||||
for (let i = 0; i < orphaned.length; i += 50) {
|
||||
const batch = orphaned.slice(i, i + 50)
|
||||
const results = await Promise.allSettled(
|
||||
batch.map(async (obj) => {
|
||||
await deleteObject(obj.key)
|
||||
}),
|
||||
)
|
||||
for (const result of results) {
|
||||
if (result.status === "fulfilled") {
|
||||
deletedFromR2++
|
||||
} else {
|
||||
r2Errors++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Clear entity references for orphaned objects that still reference entities
|
||||
// (These are orphans where the entity was deleted but reference wasn't cleared)
|
||||
const orphanedAvatars = orphaned.filter((o) => o.entityType === "avatar" && o.entityId)
|
||||
const orphanedGameCovers = orphaned.filter((o) => o.entityType === "game_cover" && o.entityId)
|
||||
|
||||
// Clear avatar references
|
||||
if (orphanedAvatars.length > 0) {
|
||||
const avatarEntityIds = [...new Set(orphanedAvatars.map((o) => o.entityId!))]
|
||||
// Only clear for users that still exist
|
||||
for (const userId of avatarEntityIds) {
|
||||
try {
|
||||
await db
|
||||
.update(user)
|
||||
.set({ image: null, updatedAt: new Date() })
|
||||
.where(eq(user.id, userId))
|
||||
} catch {
|
||||
// User may not exist; skip
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Clear game cover references
|
||||
if (orphanedGameCovers.length > 0) {
|
||||
const gameIds = [...new Set(orphanedGameCovers.map((o) => o.entityId!))]
|
||||
for (const gameId of gameIds) {
|
||||
try {
|
||||
await db
|
||||
.update(games)
|
||||
.set({ headerImage: null, updatedAt: new Date() })
|
||||
.where(eq(games.id, gameId))
|
||||
} catch {
|
||||
// Game may not exist; skip
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 6. Delete DB records
|
||||
if (orphaned.length > 0) {
|
||||
await db
|
||||
.delete(storageObjects)
|
||||
.where(eq(storageObjects.isOrphaned, true))
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
marked: {
|
||||
avatars: avatarOrphans.rowCount ?? 0,
|
||||
gameCovers: gameCoverOrphans.rowCount ?? 0,
|
||||
},
|
||||
deleted: {
|
||||
fromR2: deletedFromR2,
|
||||
r2Errors,
|
||||
fromDb: orphaned.length,
|
||||
},
|
||||
}
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,275 @@
|
||||
import { Elysia, t } from "elysia"
|
||||
import { openapi } from "@elysia/openapi"
|
||||
import { cron, Patterns } from "@elysia/cron"
|
||||
import { auth } from "@/lib/auth"
|
||||
import { isDeckyVaultEmail, DOMAIN_BLOCK_ERROR } from "@/lib/auth/domain-block"
|
||||
import { taskRegistry } from "./cron"
|
||||
import {
|
||||
healthRoutes,
|
||||
userRoutes,
|
||||
gamesRoutes,
|
||||
gameVersionsRoutes,
|
||||
gameSyncRoutes,
|
||||
hardwareRoutes,
|
||||
hardwareStatsRoutes,
|
||||
performanceRoutes,
|
||||
performanceVerifyRoutes,
|
||||
performanceSubmitRoutes,
|
||||
commentsRoutes,
|
||||
savedGamesRoutes,
|
||||
reportRoutes,
|
||||
contactRoutes,
|
||||
adminReportRoutes,
|
||||
adminPerformanceRoutes,
|
||||
adminCommentRoutes,
|
||||
} from "@/lib/api"
|
||||
import { adminStorageRoutes } from "@/lib/api/admin-storage"
|
||||
import { adminAnalyticsRoutes } from "@/lib/api/admin-analytics"
|
||||
import { steamSearchRoutes } from "@/lib/api/steam-search"
|
||||
import { steamdbVersionRoutes, clientVersionRoutes } from "@/lib/api/steamdb-version"
|
||||
import { versionTestRoutes, standaloneVersionTestRoutes } from "@/lib/api/version-test"
|
||||
import { searchUnifiedRoutes } from "@/lib/api/search-unified"
|
||||
import { gameStubRoutes } from "@/lib/api/game-stub"
|
||||
import { gameStatsRoutes } from "@/lib/api/game-stats"
|
||||
import { gamesPerformanceRoutes } from "@/lib/api/games-performance"
|
||||
import { gamesManualRoutes } from "@/lib/api/games-manual"
|
||||
import { compareRoutes } from "@/lib/api/compare"
|
||||
import { playabilityRoutes } from "@/lib/api/playability"
|
||||
import { steamReviewRoutes } from "@/lib/api/steam-reviews"
|
||||
import { communitySuggestionRoutes } from "@/lib/api/community-suggestions"
|
||||
import { gamesListingRoutes } from "@/lib/api/games-listing"
|
||||
import { steamgridProxyRoutes } from "@/lib/api/steamgrid-proxy"
|
||||
import { dashboardRoutes } from "@/lib/api/dashboard"
|
||||
import { dashboardPublicRoutes } from "@/lib/api/dashboard-public"
|
||||
import { savedFilterRoutes } from "@/lib/api/saved-filters"
|
||||
import { cronRoutes } from "@/lib/api/cron"
|
||||
import { profilePhotoRoutes } from "@/lib/api/profile-photo"
|
||||
import { screenshotRoutes } from "@/lib/api/screenshots"
|
||||
import { loginEmailSchema } from "@/lib/auth/validation"
|
||||
import { db } from "@/lib/db"
|
||||
import { user } from "@/lib/db/schema/auth"
|
||||
import { eq } from "drizzle-orm"
|
||||
import { mobileRoutes } from "@/lib/api/mobile"
|
||||
|
||||
const betterAuth = new Elysia({ name: "better-auth" })
|
||||
.mount(auth.handler)
|
||||
.macro({
|
||||
auth: {
|
||||
async resolve({ status, request: { headers } }) {
|
||||
const session = await auth.api.getSession({
|
||||
headers,
|
||||
})
|
||||
|
||||
if (!session) return status(401)
|
||||
|
||||
return {
|
||||
user: session.user,
|
||||
session: session.session,
|
||||
}
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
export const app = new Elysia({ prefix: "/api" })
|
||||
.use(
|
||||
openapi({
|
||||
path: "/openapi",
|
||||
embedSpec: true,
|
||||
documentation: {
|
||||
info: {
|
||||
title: "DeckyVault API",
|
||||
version: "2026.2.2",
|
||||
description:
|
||||
"API for DeckyVault — Steam Deck game compatibility, performance reports, and community features.",
|
||||
},
|
||||
tags: [
|
||||
{ name: "Health", description: "Health check endpoints" },
|
||||
{ name: "Auth", description: "Authentication endpoints" },
|
||||
{ name: "Users", description: "User management" },
|
||||
{ name: "Games", description: "Games listing and details" },
|
||||
{ name: "Hardware", description: "Hardware submission and stats" },
|
||||
{ name: "Performance", description: "Performance reports and verification" },
|
||||
{ name: "Comments", description: "Game comments" },
|
||||
{ name: "Reports", description: "User reports" },
|
||||
{ name: "Admin", description: "Admin-only endpoints" },
|
||||
{ name: "Steam", description: "Steam proxy endpoints" },
|
||||
{ name: "Search", description: "Search endpoints" },
|
||||
{ name: "Mobile", description: "Mobile-optimized consolidated endpoints" },
|
||||
{ name: "Contact", description: "Contact form" },
|
||||
{ name: "Dashboard", description: "Dashboard data" },
|
||||
{ name: "Cron", description: "Scheduled job triggers" },
|
||||
],
|
||||
components: {
|
||||
securitySchemes: {
|
||||
bearerAuth: {
|
||||
type: "http",
|
||||
scheme: "bearer",
|
||||
bearerFormat: "JWT",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
)
|
||||
.use(
|
||||
cron({
|
||||
name: "orphan_detection",
|
||||
pattern: Patterns.EVERY_DAY_AT_2AM,
|
||||
async run() {
|
||||
const task = taskRegistry.get("orphan_detection")
|
||||
if (!task) return
|
||||
try {
|
||||
const result = await task()
|
||||
console.log("[cron] orphan_detection:", result.status, result.details)
|
||||
} catch (err) {
|
||||
console.error("[cron] orphan_detection failed:", err)
|
||||
}
|
||||
},
|
||||
}),
|
||||
)
|
||||
.use(
|
||||
cron({
|
||||
name: "storage_cleanup",
|
||||
pattern: Patterns.EVERY_DAY_AT_3AM,
|
||||
async run() {
|
||||
const task = taskRegistry.get("storage_cleanup")
|
||||
if (!task) return
|
||||
try {
|
||||
const result = await task()
|
||||
console.log("[cron] storage_cleanup:", result.status, result.details)
|
||||
} catch (err) {
|
||||
console.error("[cron] storage_cleanup failed:", err)
|
||||
}
|
||||
},
|
||||
}),
|
||||
)
|
||||
.onError(({ code, error, set, request }) => {
|
||||
console.error(
|
||||
`[API Error] ${code} ${request.url}`,
|
||||
error instanceof Error ? error.message : error,
|
||||
)
|
||||
set.status = code === "NOT_FOUND" ? 404 : 500
|
||||
return {
|
||||
error: code === "NOT_FOUND" ? "Not found" : "Internal server error",
|
||||
}
|
||||
})
|
||||
// ── Auth routes ─────────────────────────────────────────────
|
||||
.group("", (app) =>
|
||||
app
|
||||
.onBeforeHandle(async ({ request, set }) => {
|
||||
const url = new URL(request.url)
|
||||
const isSignUp =
|
||||
url.pathname === "/api/auth/sign-up/email" &&
|
||||
request.method === "POST"
|
||||
|
||||
if (!isSignUp) return
|
||||
|
||||
if (process.env.NODE_ENV !== "development") {
|
||||
try {
|
||||
const cloned = request.clone()
|
||||
const body = await cloned.json()
|
||||
if (isDeckyVaultEmail(body.email)) {
|
||||
console.warn(
|
||||
"[AUTH] Blocked sign-up attempt with deckyvault.xyz email",
|
||||
body.email,
|
||||
)
|
||||
set.status = 400
|
||||
return { error: DOMAIN_BLOCK_ERROR }
|
||||
}
|
||||
} catch {
|
||||
// Malformed body — let Better Auth reject downstream
|
||||
}
|
||||
}
|
||||
})
|
||||
.use(betterAuth)
|
||||
.use(userRoutes)
|
||||
.use(profilePhotoRoutes)
|
||||
.post(
|
||||
"/auth/check-email",
|
||||
async ({ body, set }) => {
|
||||
const result = loginEmailSchema.safeParse(body)
|
||||
if (!result.success) {
|
||||
set.status = 400
|
||||
return { error: result.error.issues[0].message }
|
||||
}
|
||||
|
||||
const { email } = result.data
|
||||
const existingUser = await db
|
||||
.select({ id: user.id })
|
||||
.from(user)
|
||||
.where(eq(user.email, email.toLowerCase()))
|
||||
.limit(1)
|
||||
|
||||
return { exists: existingUser.length > 0 }
|
||||
},
|
||||
{
|
||||
body: t.Object({ email: t.String() }),
|
||||
response: t.Union([
|
||||
t.Object({ exists: t.Boolean() }),
|
||||
t.Object({ error: t.String() }),
|
||||
]),
|
||||
detail: { hide: true },
|
||||
},
|
||||
)
|
||||
)
|
||||
// ── Read-heavy public routes ──────────────────────────────
|
||||
.group("", (app) =>
|
||||
app
|
||||
.use(healthRoutes)
|
||||
.use(gamesRoutes)
|
||||
.use(gameVersionsRoutes)
|
||||
.use(gameSyncRoutes)
|
||||
.use(gamesListingRoutes)
|
||||
.use(hardwareRoutes)
|
||||
.use(hardwareStatsRoutes)
|
||||
.use(performanceRoutes)
|
||||
.use(gameStatsRoutes)
|
||||
.use(gamesPerformanceRoutes)
|
||||
.use(dashboardRoutes)
|
||||
.use(dashboardPublicRoutes)
|
||||
.use(playabilityRoutes)
|
||||
.use(steamReviewRoutes)
|
||||
.use(compareRoutes)
|
||||
.use(savedGamesRoutes)
|
||||
.use(savedFilterRoutes)
|
||||
.use(steamSearchRoutes)
|
||||
.use(searchUnifiedRoutes)
|
||||
.use(gameStubRoutes)
|
||||
.use(steamgridProxyRoutes)
|
||||
.use(steamdbVersionRoutes)
|
||||
.use(versionTestRoutes)
|
||||
.use(standaloneVersionTestRoutes)
|
||||
.use(gamesManualRoutes)
|
||||
.use(screenshotRoutes)
|
||||
.use(mobileRoutes)
|
||||
)
|
||||
// ── Write routes ───────────────────────────────────────────
|
||||
.group("", (app) =>
|
||||
app
|
||||
.use(betterAuth)
|
||||
.use(clientVersionRoutes)
|
||||
.use(performanceVerifyRoutes)
|
||||
.use(performanceSubmitRoutes)
|
||||
.use(commentsRoutes)
|
||||
.use(reportRoutes)
|
||||
.use(adminReportRoutes)
|
||||
.use(adminPerformanceRoutes)
|
||||
.use(adminCommentRoutes)
|
||||
.use(adminStorageRoutes)
|
||||
.use(adminAnalyticsRoutes)
|
||||
)
|
||||
// ── Public forms (no auth) ─────────────────────────────────
|
||||
.group("", (app) =>
|
||||
app
|
||||
.use(contactRoutes)
|
||||
.use(communitySuggestionRoutes)
|
||||
)
|
||||
// ── Cron ─────────────────────────────────────────────────────
|
||||
.use(cronRoutes)
|
||||
// ── Root ────────────────────────────────────────────────────
|
||||
.get("/", () => ({
|
||||
name: "DeckyVault API",
|
||||
version: "2026.2.2",
|
||||
}))
|
||||
|
||||
export type App = typeof app
|
||||
@@ -0,0 +1,51 @@
|
||||
import { db } from "@/lib/db/index"
|
||||
import { performanceEntries } from "@/lib/db/schema"
|
||||
import { eq } from "drizzle-orm"
|
||||
|
||||
/**
|
||||
* Auto-pin check: an entry is eligible for auto-pinning when:
|
||||
* 1. NOT already pinned (isPinned = false)
|
||||
* 2. NOT removed (isRemoved = false)
|
||||
* 3. Total votes >= 10 (upvotes + downvotes)
|
||||
* 4. Approval ratio >= 0.80 (upvotes / total_votes)
|
||||
* 5. Absolute upvotes >= 8
|
||||
*/
|
||||
const AUTO_PIN_MIN_TOTAL_VOTES = 10
|
||||
const AUTO_PIN_MIN_APPROVAL_RATIO = 0.80
|
||||
const AUTO_PIN_MIN_UPVOTES = 8
|
||||
|
||||
export async function checkAndAutoPin(entryId: string): Promise<boolean> {
|
||||
const [entry] = await db
|
||||
.select({
|
||||
id: performanceEntries.id,
|
||||
isPinned: performanceEntries.isPinned,
|
||||
isRemoved: performanceEntries.isRemoved,
|
||||
upvotes: performanceEntries.upvotes,
|
||||
downvotes: performanceEntries.downvotes,
|
||||
})
|
||||
.from(performanceEntries)
|
||||
.where(eq(performanceEntries.id, entryId))
|
||||
.limit(1)
|
||||
|
||||
if (!entry) return false
|
||||
if (entry.isPinned) return false
|
||||
if (entry.isRemoved) return false
|
||||
|
||||
const totalVotes = entry.upvotes + entry.downvotes
|
||||
if (totalVotes < AUTO_PIN_MIN_TOTAL_VOTES) return false
|
||||
if (entry.upvotes < AUTO_PIN_MIN_UPVOTES) return false
|
||||
|
||||
const approvalRatio = entry.upvotes / totalVotes
|
||||
if (approvalRatio < AUTO_PIN_MIN_APPROVAL_RATIO) return false
|
||||
|
||||
// Conditions met — auto-pin
|
||||
await db
|
||||
.update(performanceEntries)
|
||||
.set({
|
||||
isPinned: true,
|
||||
pinnedAt: new Date(),
|
||||
})
|
||||
.where(eq(performanceEntries.id, entryId))
|
||||
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,316 @@
|
||||
import { Elysia, t } from "elysia"
|
||||
import { gameComments, user } from "@/lib/db/schema"
|
||||
import { db } from "@/lib/db/index"
|
||||
import { eq, and, desc, sql, isNull } from "drizzle-orm"
|
||||
import { requireRole } from "@/lib/auth/guard"
|
||||
|
||||
export const commentsRoutes = new Elysia({
|
||||
prefix: "/games/:gameId/comments",
|
||||
detail: { tags: ["Comments"] },
|
||||
})
|
||||
// LIST top-level comments for a game (paginated)
|
||||
.get(
|
||||
"/",
|
||||
async ({ params, query }) => {
|
||||
const limit = Math.min(Number(query.limit) || 20, 100)
|
||||
const offset = Number(query.offset) || 0
|
||||
|
||||
const conditions = [
|
||||
eq(gameComments.gameId, params.gameId),
|
||||
eq(gameComments.isRemoved, false),
|
||||
isNull(gameComments.parentId), // top-level only
|
||||
]
|
||||
|
||||
const data = await db
|
||||
.select({
|
||||
id: gameComments.id,
|
||||
gameId: gameComments.gameId,
|
||||
userId: gameComments.userId,
|
||||
parentId: gameComments.parentId,
|
||||
content: gameComments.content,
|
||||
upvotes: gameComments.upvotes,
|
||||
createdAt: gameComments.createdAt,
|
||||
updatedAt: gameComments.updatedAt,
|
||||
userName: user.name,
|
||||
userImage: user.image,
|
||||
})
|
||||
.from(gameComments)
|
||||
.innerJoin(user, eq(gameComments.userId, user.id))
|
||||
.where(and(...conditions))
|
||||
.orderBy(desc(gameComments.createdAt))
|
||||
.limit(limit)
|
||||
.offset(offset)
|
||||
|
||||
const [{ count }] = await db
|
||||
.select({ count: sql<number>`count(*)::int` })
|
||||
.from(gameComments)
|
||||
.where(and(...conditions))
|
||||
|
||||
return { data, total: count, limit, offset }
|
||||
},
|
||||
{
|
||||
params: t.Object({ gameId: t.String() }),
|
||||
query: t.Object({
|
||||
limit: t.Optional(t.String()),
|
||||
offset: t.Optional(t.String()),
|
||||
}),
|
||||
},
|
||||
)
|
||||
// GET replies for a specific comment
|
||||
.get(
|
||||
"/:id/replies",
|
||||
async ({ params, query }) => {
|
||||
const limit = Math.min(Number(query.limit) || 20, 100)
|
||||
const offset = Number(query.offset) || 0
|
||||
|
||||
const data = await db
|
||||
.select({
|
||||
id: gameComments.id,
|
||||
gameId: gameComments.gameId,
|
||||
userId: gameComments.userId,
|
||||
parentId: gameComments.parentId,
|
||||
content: gameComments.content,
|
||||
upvotes: gameComments.upvotes,
|
||||
createdAt: gameComments.createdAt,
|
||||
updatedAt: gameComments.updatedAt,
|
||||
userName: user.name,
|
||||
userImage: user.image,
|
||||
})
|
||||
.from(gameComments)
|
||||
.innerJoin(user, eq(gameComments.userId, user.id))
|
||||
.where(
|
||||
and(
|
||||
eq(gameComments.parentId, params.id),
|
||||
eq(gameComments.isRemoved, false),
|
||||
),
|
||||
)
|
||||
.orderBy(desc(gameComments.createdAt))
|
||||
.limit(limit)
|
||||
.offset(offset)
|
||||
|
||||
return data
|
||||
},
|
||||
{
|
||||
params: t.Object({ gameId: t.String(), id: t.String() }),
|
||||
query: t.Object({
|
||||
limit: t.Optional(t.String()),
|
||||
offset: t.Optional(t.String()),
|
||||
}),
|
||||
},
|
||||
)
|
||||
// CREATE comment
|
||||
.post(
|
||||
"/",
|
||||
async ({ params, body, request, set }) => {
|
||||
const guard = await requireRole(request.headers, [
|
||||
"user",
|
||||
"contributor",
|
||||
"admin",
|
||||
])
|
||||
if (!guard.ok) {
|
||||
set.status = guard.status
|
||||
return { error: guard.error }
|
||||
}
|
||||
|
||||
// ── Anti-spam: content length cap ──────────────────────────
|
||||
const contentStr = JSON.stringify(body.content)
|
||||
if (contentStr.length > 50000) {
|
||||
set.status = 413
|
||||
return { error: "Comment content exceeds maximum size (50KB)" }
|
||||
}
|
||||
|
||||
// If parentId is provided, verify it exists and belongs to the same game
|
||||
if (body.parentId) {
|
||||
const [parent] = await db
|
||||
.select({ id: gameComments.id })
|
||||
.from(gameComments)
|
||||
.where(
|
||||
and(
|
||||
eq(gameComments.id, body.parentId),
|
||||
eq(gameComments.gameId, params.gameId),
|
||||
),
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
if (!parent) {
|
||||
set.status = 404
|
||||
return { error: "Parent comment not found" }
|
||||
}
|
||||
}
|
||||
|
||||
// ── Sanitize: strip <script> tags and javascript: URLs from content ──
|
||||
const sanitizeValue = (val: unknown): unknown => {
|
||||
if (typeof val === "string") {
|
||||
return val
|
||||
.replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, "")
|
||||
.replace(/javascript\s*:/gi, "blocked:")
|
||||
}
|
||||
if (Array.isArray(val)) return val.map(sanitizeValue)
|
||||
if (val !== null && typeof val === "object") {
|
||||
const cleaned: Record<string, unknown> = {}
|
||||
for (const [k, v] of Object.entries(val as Record<string, unknown>)) {
|
||||
cleaned[k] = sanitizeValue(v)
|
||||
}
|
||||
return cleaned
|
||||
}
|
||||
return val
|
||||
}
|
||||
const sanitizedContent = sanitizeValue(body.content) as Record<string, unknown>
|
||||
|
||||
const [created] = await db
|
||||
.insert(gameComments)
|
||||
.values({
|
||||
gameId: params.gameId,
|
||||
userId: guard.user.id,
|
||||
parentId: body.parentId ?? null,
|
||||
content: sanitizedContent,
|
||||
})
|
||||
.returning()
|
||||
|
||||
set.status = 201
|
||||
return created
|
||||
},
|
||||
{
|
||||
params: t.Object({ gameId: t.String() }),
|
||||
body: t.Object({
|
||||
parentId: t.Optional(t.String()),
|
||||
content: t.Record(t.String(), t.Any()), // Tiptap JSON
|
||||
}),
|
||||
},
|
||||
)
|
||||
// UPDATE comment (owner or admin)
|
||||
.patch(
|
||||
"/:id",
|
||||
async ({ params, body, request, set }) => {
|
||||
const guard = await requireRole(request.headers, [
|
||||
"user",
|
||||
"contributor",
|
||||
"admin",
|
||||
])
|
||||
if (!guard.ok) {
|
||||
set.status = guard.status
|
||||
return { error: guard.error }
|
||||
}
|
||||
|
||||
const [comment] = await db
|
||||
.select()
|
||||
.from(gameComments)
|
||||
.where(
|
||||
and(
|
||||
eq(gameComments.id, params.id),
|
||||
eq(gameComments.gameId, params.gameId),
|
||||
),
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
if (!comment) {
|
||||
set.status = 404
|
||||
return { error: "Comment not found" }
|
||||
}
|
||||
|
||||
if (comment.userId !== guard.user.id && guard.user.role !== "admin") {
|
||||
set.status = 403
|
||||
return { error: "Not authorized" }
|
||||
}
|
||||
|
||||
const [updated] = await db
|
||||
.update(gameComments)
|
||||
.set({ content: body.content, updatedAt: new Date() })
|
||||
.where(eq(gameComments.id, params.id))
|
||||
.returning()
|
||||
|
||||
return updated
|
||||
},
|
||||
{
|
||||
params: t.Object({ gameId: t.String(), id: t.String() }),
|
||||
body: t.Object({
|
||||
content: t.Record(t.String(), t.Any()),
|
||||
}),
|
||||
},
|
||||
)
|
||||
// DELETE comment (soft delete — owner or admin)
|
||||
.delete(
|
||||
"/:id",
|
||||
async ({ params, request, set }) => {
|
||||
const guard = await requireRole(request.headers, [
|
||||
"user",
|
||||
"contributor",
|
||||
"admin",
|
||||
])
|
||||
if (!guard.ok) {
|
||||
set.status = guard.status
|
||||
return { error: guard.error }
|
||||
}
|
||||
|
||||
const [comment] = await db
|
||||
.select()
|
||||
.from(gameComments)
|
||||
.where(
|
||||
and(
|
||||
eq(gameComments.id, params.id),
|
||||
eq(gameComments.gameId, params.gameId),
|
||||
),
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
if (!comment) {
|
||||
set.status = 404
|
||||
return { error: "Comment not found" }
|
||||
}
|
||||
|
||||
if (comment.userId !== guard.user.id && guard.user.role !== "admin") {
|
||||
set.status = 403
|
||||
return { error: "Not authorized" }
|
||||
}
|
||||
|
||||
await db
|
||||
.update(gameComments)
|
||||
.set({ isRemoved: true, updatedAt: new Date() })
|
||||
.where(eq(gameComments.id, params.id))
|
||||
|
||||
return { success: true }
|
||||
},
|
||||
{
|
||||
params: t.Object({ gameId: t.String(), id: t.String() }),
|
||||
},
|
||||
)
|
||||
// UPVOTE comment
|
||||
.post(
|
||||
"/:id/upvote",
|
||||
async ({ params, request, set }) => {
|
||||
const guard = await requireRole(request.headers, [
|
||||
"user",
|
||||
"contributor",
|
||||
"admin",
|
||||
])
|
||||
if (!guard.ok) {
|
||||
set.status = guard.status
|
||||
return { error: guard.error }
|
||||
}
|
||||
|
||||
const [updated] = await db
|
||||
.update(gameComments)
|
||||
.set({
|
||||
upvotes: sql`${gameComments.upvotes} + 1`,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(gameComments.id, params.id),
|
||||
eq(gameComments.gameId, params.gameId),
|
||||
eq(gameComments.isRemoved, false),
|
||||
),
|
||||
)
|
||||
.returning()
|
||||
|
||||
if (!updated) {
|
||||
set.status = 404
|
||||
return { error: "Comment not found" }
|
||||
}
|
||||
|
||||
return updated
|
||||
},
|
||||
{
|
||||
params: t.Object({ gameId: t.String(), id: t.String() }),
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,345 @@
|
||||
import { Elysia, t } from "elysia"
|
||||
import { db } from "@/lib/db/index"
|
||||
import { communitySuggestions, games, user, suggestionStatusEnum } from "@/lib/db/schema"
|
||||
import { eq, and, desc, sql, ilike } from "drizzle-orm"
|
||||
import { requireAuth, requireModeratorOrAdmin } from "@/lib/auth/guard"
|
||||
|
||||
const DISCORD_WEBHOOK_URL = process.env.DISCORD_WEBHOOK_URL
|
||||
|
||||
async function sendDiscordNotification(suggestion: {
|
||||
gameTitle: string
|
||||
fieldName: string
|
||||
proposedValue: string
|
||||
userName: string
|
||||
}) {
|
||||
if (!DISCORD_WEBHOOK_URL) return
|
||||
|
||||
try {
|
||||
await fetch(DISCORD_WEBHOOK_URL, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
embeds: [
|
||||
{
|
||||
title: "📝 New Community Suggestion",
|
||||
color: 0x5865f2,
|
||||
fields: [
|
||||
{ name: "Game", value: suggestion.gameTitle, inline: true },
|
||||
{ name: "Field", value: suggestion.fieldName, inline: true },
|
||||
{
|
||||
name: "Suggested By",
|
||||
value: suggestion.userName,
|
||||
inline: true,
|
||||
},
|
||||
{
|
||||
name: "Proposed Value",
|
||||
value: suggestion.proposedValue.slice(0, 1000),
|
||||
},
|
||||
],
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
],
|
||||
}),
|
||||
})
|
||||
} catch (err) {
|
||||
console.error("Discord webhook failed:", err)
|
||||
}
|
||||
}
|
||||
|
||||
const allowedFields = [
|
||||
"title",
|
||||
"description",
|
||||
"developer",
|
||||
"publisher",
|
||||
"genres",
|
||||
"storeUrl",
|
||||
"releaseDate",
|
||||
]
|
||||
|
||||
export const communitySuggestionRoutes = new Elysia({
|
||||
prefix: "/community-suggestions",
|
||||
detail: { tags: ["Games"] },
|
||||
})
|
||||
|
||||
// Admin list with pagination and filtering
|
||||
.get(
|
||||
"/admin",
|
||||
async ({ query, request, set }) => {
|
||||
const guard = await requireModeratorOrAdmin(request.headers)
|
||||
if (!guard.ok) {
|
||||
set.status = guard.status
|
||||
return { error: guard.error }
|
||||
}
|
||||
|
||||
const { limit, offset, status } = query
|
||||
|
||||
const conditions = []
|
||||
if (status) {
|
||||
conditions.push(eq(communitySuggestions.status, status as typeof suggestionStatusEnum.enumValues[number]))
|
||||
}
|
||||
|
||||
const whereClause = conditions.length > 0 ? and(...conditions) : undefined
|
||||
|
||||
const [countResult] = await db
|
||||
.select({ count: sql<number>`count(*)::int` })
|
||||
.from(communitySuggestions)
|
||||
.leftJoin(games, eq(communitySuggestions.gameId, games.id))
|
||||
.leftJoin(user, eq(communitySuggestions.userId, user.id))
|
||||
.where(whereClause)
|
||||
|
||||
const data = await db
|
||||
.select({
|
||||
id: communitySuggestions.id,
|
||||
gameId: communitySuggestions.gameId,
|
||||
gameTitle: games.title,
|
||||
fieldName: communitySuggestions.fieldName,
|
||||
currentValue: communitySuggestions.currentValue,
|
||||
proposedValue: communitySuggestions.proposedValue,
|
||||
reason: communitySuggestions.reason,
|
||||
status: communitySuggestions.status,
|
||||
createdAt: communitySuggestions.createdAt,
|
||||
reviewedAt: communitySuggestions.reviewedAt,
|
||||
reviewNote: communitySuggestions.reviewNote,
|
||||
userName: user.name,
|
||||
})
|
||||
.from(communitySuggestions)
|
||||
.leftJoin(games, eq(communitySuggestions.gameId, games.id))
|
||||
.leftJoin(user, eq(communitySuggestions.userId, user.id))
|
||||
.where(whereClause)
|
||||
.orderBy(desc(communitySuggestions.createdAt))
|
||||
.limit(limit)
|
||||
.offset(offset)
|
||||
|
||||
return { data, total: countResult.count, limit, offset }
|
||||
},
|
||||
{
|
||||
query: t.Object({
|
||||
limit: t.Number({ default: 20 }),
|
||||
offset: t.Number({ default: 0 }),
|
||||
status: t.Optional(t.String()),
|
||||
}),
|
||||
},
|
||||
)
|
||||
|
||||
// Submit a suggestion
|
||||
.post(
|
||||
"/",
|
||||
async ({ body, request, set }) => {
|
||||
const guard = await requireAuth(request.headers)
|
||||
if (!guard.ok) {
|
||||
set.status = guard.status
|
||||
return { error: guard.error }
|
||||
}
|
||||
|
||||
const { gameId, fieldName, proposedValue, reason } = body
|
||||
|
||||
// Validate field name is editable
|
||||
if (!allowedFields.includes(fieldName)) {
|
||||
set.status = 400
|
||||
return { error: `Field '${fieldName}' cannot be suggested` }
|
||||
}
|
||||
|
||||
// Get current game value
|
||||
const [game] = await db
|
||||
.select()
|
||||
.from(games)
|
||||
.where(eq(games.id, gameId))
|
||||
.limit(1)
|
||||
|
||||
if (!game) {
|
||||
set.status = 404
|
||||
return { error: "Game not found" }
|
||||
}
|
||||
|
||||
// Check for existing pending suggestion on same field
|
||||
const [existing] = await db
|
||||
.select()
|
||||
.from(communitySuggestions)
|
||||
.where(
|
||||
and(
|
||||
eq(communitySuggestions.gameId, gameId),
|
||||
eq(communitySuggestions.fieldName, fieldName),
|
||||
eq(communitySuggestions.userId, guard.user.id),
|
||||
eq(communitySuggestions.status, "pending"),
|
||||
),
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
if (existing) {
|
||||
set.status = 409
|
||||
return { error: "You already have a pending suggestion for this field" }
|
||||
}
|
||||
|
||||
const currentValue = String((game as Record<string, unknown>)[fieldName] ?? "")
|
||||
|
||||
const [suggestion] = await db
|
||||
.insert(communitySuggestions)
|
||||
.values({
|
||||
gameId,
|
||||
userId: guard.user.id,
|
||||
fieldName,
|
||||
currentValue,
|
||||
proposedValue,
|
||||
reason,
|
||||
})
|
||||
.returning()
|
||||
|
||||
// Send Discord notification
|
||||
await sendDiscordNotification({
|
||||
gameTitle: game.title,
|
||||
fieldName,
|
||||
proposedValue,
|
||||
userName: guard.user.name || "Anonymous",
|
||||
})
|
||||
|
||||
return suggestion
|
||||
},
|
||||
{
|
||||
body: t.Object({
|
||||
gameId: t.String(),
|
||||
fieldName: t.String(),
|
||||
proposedValue: t.String(),
|
||||
reason: t.Optional(t.String()),
|
||||
}),
|
||||
},
|
||||
)
|
||||
|
||||
// Get suggestions for a game
|
||||
.get(
|
||||
"/game/:gameId",
|
||||
async ({ params, query }) => {
|
||||
const status = query.status // optional filter
|
||||
|
||||
const conditions = [eq(communitySuggestions.gameId, params.gameId)]
|
||||
if (status) {
|
||||
conditions.push(eq(communitySuggestions.status, status as typeof suggestionStatusEnum.enumValues[number]))
|
||||
}
|
||||
|
||||
const suggestions = await db
|
||||
.select({
|
||||
id: communitySuggestions.id,
|
||||
fieldName: communitySuggestions.fieldName,
|
||||
currentValue: communitySuggestions.currentValue,
|
||||
proposedValue: communitySuggestions.proposedValue,
|
||||
reason: communitySuggestions.reason,
|
||||
status: communitySuggestions.status,
|
||||
createdAt: communitySuggestions.createdAt,
|
||||
reviewedAt: communitySuggestions.reviewedAt,
|
||||
reviewNote: communitySuggestions.reviewNote,
|
||||
userName: user.name,
|
||||
})
|
||||
.from(communitySuggestions)
|
||||
.leftJoin(user, eq(communitySuggestions.userId, user.id))
|
||||
.where(and(...conditions))
|
||||
.orderBy(desc(communitySuggestions.createdAt))
|
||||
|
||||
return suggestions
|
||||
},
|
||||
{
|
||||
params: t.Object({ gameId: t.String() }),
|
||||
query: t.Object({
|
||||
status: t.Optional(t.String()),
|
||||
}),
|
||||
},
|
||||
)
|
||||
|
||||
// Get all pending suggestions (admin/moderation view)
|
||||
.get(
|
||||
"/pending",
|
||||
async ({ request, set }) => {
|
||||
const guard = await requireModeratorOrAdmin(request.headers)
|
||||
if (!guard.ok) {
|
||||
set.status = guard.status
|
||||
return { error: guard.error }
|
||||
}
|
||||
|
||||
const suggestions = await db
|
||||
.select({
|
||||
id: communitySuggestions.id,
|
||||
gameId: communitySuggestions.gameId,
|
||||
gameTitle: games.title,
|
||||
fieldName: communitySuggestions.fieldName,
|
||||
currentValue: communitySuggestions.currentValue,
|
||||
proposedValue: communitySuggestions.proposedValue,
|
||||
reason: communitySuggestions.reason,
|
||||
status: communitySuggestions.status,
|
||||
createdAt: communitySuggestions.createdAt,
|
||||
userName: user.name,
|
||||
})
|
||||
.from(communitySuggestions)
|
||||
.leftJoin(games, eq(communitySuggestions.gameId, games.id))
|
||||
.leftJoin(user, eq(communitySuggestions.userId, user.id))
|
||||
.where(eq(communitySuggestions.status, "pending"))
|
||||
.orderBy(desc(communitySuggestions.createdAt))
|
||||
|
||||
return suggestions
|
||||
},
|
||||
)
|
||||
|
||||
// Approve/reject suggestion (admin/contributor)
|
||||
.patch(
|
||||
"/:suggestionId/review",
|
||||
async ({ params, body, request, set }) => {
|
||||
const guard = await requireModeratorOrAdmin(request.headers)
|
||||
if (!guard.ok) {
|
||||
set.status = guard.status
|
||||
return { error: guard.error }
|
||||
}
|
||||
|
||||
const { status: rawStatus, reviewNote } = body
|
||||
|
||||
if (!["approved", "rejected"].includes(rawStatus)) {
|
||||
set.status = 400
|
||||
return { error: "Status must be 'approved' or 'rejected'" }
|
||||
}
|
||||
|
||||
const status = rawStatus as "approved" | "rejected"
|
||||
|
||||
const [suggestion] = await db
|
||||
.select()
|
||||
.from(communitySuggestions)
|
||||
.where(eq(communitySuggestions.id, params.suggestionId))
|
||||
.limit(1)
|
||||
|
||||
if (!suggestion) {
|
||||
set.status = 404
|
||||
return { error: "Suggestion not found" }
|
||||
}
|
||||
|
||||
if (suggestion.status !== "pending") {
|
||||
set.status = 400
|
||||
return { error: "Suggestion already reviewed" }
|
||||
}
|
||||
|
||||
// Update suggestion status
|
||||
await db
|
||||
.update(communitySuggestions)
|
||||
.set({
|
||||
status: status as typeof communitySuggestions.$inferInsert.status,
|
||||
reviewedBy: guard.user.id,
|
||||
reviewedAt: new Date(),
|
||||
reviewNote,
|
||||
})
|
||||
.where(eq(communitySuggestions.id, params.suggestionId))
|
||||
|
||||
// If approved, apply the change to the game
|
||||
if (status === "approved") {
|
||||
const updateData: Record<string, string> = {}
|
||||
updateData[suggestion.fieldName] = suggestion.proposedValue
|
||||
|
||||
await db
|
||||
.update(games)
|
||||
.set(updateData)
|
||||
.where(eq(games.id, suggestion.gameId))
|
||||
}
|
||||
|
||||
return { success: true }
|
||||
},
|
||||
{
|
||||
params: t.Object({ suggestionId: t.String() }),
|
||||
body: t.Object({
|
||||
status: t.String(),
|
||||
reviewNote: t.Optional(t.String()),
|
||||
}),
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,187 @@
|
||||
import { Elysia, t } from "elysia"
|
||||
import { db } from "@/lib/db/index"
|
||||
import { games, gameVersions, performanceEntries, hardware } from "@/lib/db/schema"
|
||||
import { eq, and, inArray } from "drizzle-orm"
|
||||
|
||||
export const compareRoutes = new Elysia({ prefix: "/compare", detail: { tags: ["Games"] } })
|
||||
.get(
|
||||
"/games",
|
||||
async ({ query, set }) => {
|
||||
const gameIds = query.ids.split(",").filter(Boolean).slice(0, 4)
|
||||
if (gameIds.length < 2) {
|
||||
set.status = 400
|
||||
return { error: "At least 2 games required" }
|
||||
}
|
||||
|
||||
// Fetch games
|
||||
const gameRows = await db
|
||||
.select({
|
||||
id: games.id,
|
||||
title: games.title,
|
||||
steamAppId: games.steamAppId,
|
||||
source: games.source,
|
||||
capsuleImage: games.capsuleImage,
|
||||
headerImage: games.headerImage,
|
||||
})
|
||||
.from(games)
|
||||
.where(inArray(games.id, gameIds))
|
||||
|
||||
const results = await Promise.all(
|
||||
gameRows.map(async (game) => {
|
||||
// Fetch all non-removed entries for this game
|
||||
const entries = await db
|
||||
.select({
|
||||
fpsAvg: performanceEntries.fpsAvg,
|
||||
fpsLow: performanceEntries.fpsLow,
|
||||
fpsHigh: performanceEntries.fpsHigh,
|
||||
fpsOnePercentLow: performanceEntries.fpsOnePercentLow,
|
||||
upscalerType: performanceEntries.upscalerType,
|
||||
frameGenMethod: performanceEntries.frameGenMethod,
|
||||
hardwareSlug: performanceEntries.hardwareSlug,
|
||||
hardwareName: hardware.name,
|
||||
})
|
||||
.from(performanceEntries)
|
||||
.innerJoin(gameVersions, eq(performanceEntries.versionId, gameVersions.id))
|
||||
.innerJoin(hardware, eq(performanceEntries.hardwareSlug, hardware.slug))
|
||||
.where(
|
||||
and(
|
||||
eq(gameVersions.gameId, game.id),
|
||||
eq(performanceEntries.isRemoved, false)
|
||||
)
|
||||
)
|
||||
|
||||
if (entries.length === 0) {
|
||||
return {
|
||||
...game,
|
||||
stats: {
|
||||
totalEntries: 0,
|
||||
avgFps: null,
|
||||
medianFps: null,
|
||||
bestFps: null,
|
||||
avgOnePercentLow: null,
|
||||
avgStability: null,
|
||||
bestDevice: null,
|
||||
tierBreakdown: null,
|
||||
deviceBreakdown: [],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const fpsValues = entries.map((e) => e.fpsAvg ?? 0).sort((a, b) => a - b)
|
||||
const avgFps = fpsValues.reduce((a, b) => a + b, 0) / fpsValues.length
|
||||
const medianFps = fpsValues[Math.floor(fpsValues.length / 2)]
|
||||
|
||||
const onePercentLowValues = entries
|
||||
.filter((e) => e.fpsOnePercentLow != null)
|
||||
.map((e) => e.fpsOnePercentLow!)
|
||||
|
||||
const avgOnePercentLow =
|
||||
onePercentLowValues.length > 0
|
||||
? onePercentLowValues.reduce((a, b) => a + b, 0) / onePercentLowValues.length
|
||||
: null
|
||||
|
||||
const stabilityScores = entries
|
||||
.filter((e) => e.fpsOnePercentLow != null && e.fpsAvg > 0)
|
||||
.map((e) => Math.min(1, e.fpsOnePercentLow! / (e.fpsAvg ?? 1)))
|
||||
|
||||
const avgStability =
|
||||
stabilityScores.length > 0
|
||||
? stabilityScores.reduce((a, b) => a + b, 0) / stabilityScores.length
|
||||
: null
|
||||
|
||||
// Best device by avg fps
|
||||
const deviceFpsMap = new Map<string, number[]>()
|
||||
for (const e of entries) {
|
||||
const arr = deviceFpsMap.get(e.hardwareSlug) || []
|
||||
arr.push(e.fpsAvg ?? 0)
|
||||
deviceFpsMap.set(e.hardwareSlug, arr)
|
||||
}
|
||||
let bestDevice: string | null = null
|
||||
let bestDeviceAvg = 0
|
||||
for (const [slug, fpsArr] of deviceFpsMap) {
|
||||
const mean = fpsArr.reduce((a, b) => a + b, 0) / fpsArr.length
|
||||
if (mean > bestDeviceAvg) {
|
||||
bestDeviceAvg = mean
|
||||
bestDevice = slug
|
||||
}
|
||||
}
|
||||
|
||||
// Tier breakdown
|
||||
const tiers = { unplayable: 0, playable: 0, smooth: 0, excellent: 0 }
|
||||
for (const e of entries) {
|
||||
const fps = e.fpsAvg ?? 0
|
||||
if (fps < 30) tiers.unplayable++
|
||||
else if (fps < 60) tiers.playable++
|
||||
else if (fps < 120) tiers.smooth++
|
||||
else tiers.excellent++
|
||||
}
|
||||
|
||||
// Device breakdown
|
||||
const deviceBreakdown = Array.from(deviceFpsMap.entries()).map(([slug, fpsArr]) => ({
|
||||
hardwareSlug: slug,
|
||||
count: fpsArr.length,
|
||||
avgFps: Math.round((fpsArr.reduce((a, b) => a + b, 0) / fpsArr.length) * 10) / 10,
|
||||
}))
|
||||
|
||||
return {
|
||||
...game,
|
||||
stats: {
|
||||
totalEntries: entries.length,
|
||||
avgFps: Math.round(avgFps * 10) / 10,
|
||||
medianFps: Math.round(medianFps * 10) / 10,
|
||||
bestFps: Math.round(fpsValues[fpsValues.length - 1] * 10) / 10,
|
||||
avgOnePercentLow: avgOnePercentLow ? Math.round(avgOnePercentLow * 10) / 10 : null,
|
||||
avgStability: avgStability ? Math.round(avgStability * 100) : null,
|
||||
bestDevice,
|
||||
tierBreakdown: tiers,
|
||||
deviceBreakdown,
|
||||
},
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
return { games: results }
|
||||
},
|
||||
{
|
||||
query: t.Object({ ids: t.String() }),
|
||||
}
|
||||
)
|
||||
.get(
|
||||
"/presets",
|
||||
async ({ query, set }) => {
|
||||
const presetIds = query.ids.split(",").filter(Boolean).slice(0, 4)
|
||||
if (presetIds.length < 2) {
|
||||
set.status = 400
|
||||
return { error: "At least 2 presets required" }
|
||||
}
|
||||
|
||||
const presetRows = await db
|
||||
.select({
|
||||
id: performanceEntries.id,
|
||||
fpsAvg: performanceEntries.fpsAvg,
|
||||
fpsLow: performanceEntries.fpsLow,
|
||||
fpsHigh: performanceEntries.fpsHigh,
|
||||
fpsOnePercentLow: performanceEntries.fpsOnePercentLow,
|
||||
upscalerType: performanceEntries.upscalerType,
|
||||
upscalerVersion: performanceEntries.upscalerVersion,
|
||||
frameGenMethod: performanceEntries.frameGenMethod,
|
||||
protonVersion: performanceEntries.protonVersion,
|
||||
osVersion: performanceEntries.osVersion,
|
||||
loadTimeSsd: performanceEntries.loadTimeSsd,
|
||||
loadTimeSd: performanceEntries.loadTimeSd,
|
||||
settingsJson: performanceEntries.settingsJson,
|
||||
launchOptions: performanceEntries.launchOptions,
|
||||
hardwareSlug: performanceEntries.hardwareSlug,
|
||||
hardwareName: hardware.name,
|
||||
createdAt: performanceEntries.createdAt,
|
||||
})
|
||||
.from(performanceEntries)
|
||||
.innerJoin(hardware, eq(performanceEntries.hardwareSlug, hardware.slug))
|
||||
.where(inArray(performanceEntries.id, presetIds))
|
||||
|
||||
return { presets: presetRows }
|
||||
},
|
||||
{
|
||||
query: t.Object({ ids: t.String() }),
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,144 @@
|
||||
import { Elysia, t } from "elysia"
|
||||
|
||||
// ── Discord embed colors by category ─────────────────────────────
|
||||
const CATEGORY_COLORS: Record<string, number> = {
|
||||
bug: 0xe74c3c, // red
|
||||
game_data: 0xf1c40f, // yellow
|
||||
user_report: 0x3498db, // blue
|
||||
feature: 0x2ecc71, // green
|
||||
feedback: 0x95a5a6, // grey
|
||||
database: 0xe74c3c, // red
|
||||
}
|
||||
|
||||
const CATEGORY_LABELS: Record<string, string> = {
|
||||
bug: "Bug Report",
|
||||
game_data: "Game Data Issue",
|
||||
user_report: "User Report",
|
||||
feature: "Feature Request",
|
||||
feedback: "General Feedback",
|
||||
database: "Database Error",
|
||||
}
|
||||
|
||||
const VALID_CATEGORIES = ["bug", "game_data", "user_report", "feature", "feedback", "database"]
|
||||
|
||||
export const contactRoutes = new Elysia({ prefix: "/contact", detail: { tags: ["Contact"] } }).post(
|
||||
"/",
|
||||
async ({ body, request, set }) => {
|
||||
const payload = body as {
|
||||
category: string
|
||||
name?: string
|
||||
email?: string
|
||||
subject: string
|
||||
message: string
|
||||
gameUrl?: string
|
||||
honeypot?: string
|
||||
_timestamp?: string
|
||||
}
|
||||
|
||||
// ── Honeypot check ──────────────────────────────────────────
|
||||
if (payload.honeypot) {
|
||||
set.status = 200
|
||||
return { success: true }
|
||||
}
|
||||
|
||||
// ── Timing check (must take > 3 seconds) ─────────────────────
|
||||
if (payload._timestamp) {
|
||||
const start = Number(payload._timestamp)
|
||||
if (!isNaN(start) && Date.now() - start < 3000) {
|
||||
set.status = 200
|
||||
return { success: true }
|
||||
}
|
||||
}
|
||||
|
||||
// ── Validate category ──────────────────────────────────────
|
||||
if (!VALID_CATEGORIES.includes(payload.category)) {
|
||||
set.status = 400
|
||||
return { error: "Invalid category" }
|
||||
}
|
||||
|
||||
// ── Validate required fields ────────────────────────────────
|
||||
if (!payload.subject || payload.subject.trim().length === 0) {
|
||||
set.status = 400
|
||||
return { error: "Subject is required" }
|
||||
}
|
||||
if (payload.subject.length > 200) {
|
||||
set.status = 400
|
||||
return { error: "Subject must be 200 characters or less" }
|
||||
}
|
||||
if (!payload.message || payload.message.trim().length === 0) {
|
||||
set.status = 400
|
||||
return { error: "Message is required" }
|
||||
}
|
||||
if (payload.message.length > 2000) {
|
||||
set.status = 400
|
||||
return { error: "Message must be 2000 characters or less" }
|
||||
}
|
||||
|
||||
// ── Validate game URL for game_data category ────────────────
|
||||
if (payload.category === "game_data" && payload.gameUrl) {
|
||||
if (!payload.gameUrl.includes("/game/")) {
|
||||
set.status = 400
|
||||
return { error: "Game URL must be a valid DeckyVault game link" }
|
||||
}
|
||||
}
|
||||
|
||||
// ── Validate email format if provided ───────────────────────
|
||||
if (payload.email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(payload.email)) {
|
||||
set.status = 400
|
||||
return { error: "Invalid email format" }
|
||||
}
|
||||
|
||||
// ── Send to Discord webhook ─────────────────────────────────
|
||||
const webhookUrl = process.env.DISCORD_WEBHOOK_URL
|
||||
if (!webhookUrl) {
|
||||
console.error("[Contact] DISCORD_WEBHOOK_URL not configured")
|
||||
set.status = 500
|
||||
return { error: "Service not configured. Please try again later." }
|
||||
}
|
||||
|
||||
const embed = {
|
||||
title: `[${CATEGORY_LABELS[payload.category]}] ${payload.subject}`,
|
||||
description: payload.message.slice(0, 4096),
|
||||
color: CATEGORY_COLORS[payload.category] ?? 0x95a5a6,
|
||||
fields: [
|
||||
...(payload.name ? [{ name: "Name", value: payload.name, inline: true }] : []),
|
||||
...(payload.email ? [{ name: "Email", value: payload.email, inline: true }] : []),
|
||||
...(payload.gameUrl ? [{ name: "Game URL", value: payload.gameUrl, inline: false }] : []),
|
||||
{ name: "IP Hash", value: `\`${(request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ?? "unknown").slice(0, 8)}...\``, inline: true },
|
||||
],
|
||||
timestamp: new Date().toISOString(),
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(webhookUrl, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ embeds: [embed] }),
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
console.error("[Contact] Discord webhook failed:", res.status, await res.text())
|
||||
set.status = 500
|
||||
return { error: "Failed to submit. Please try again later." }
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("[Contact] Discord webhook error:", err)
|
||||
set.status = 500
|
||||
return { error: "Failed to submit. Please try again later." }
|
||||
}
|
||||
|
||||
return { success: true }
|
||||
},
|
||||
{
|
||||
body: t.Object({
|
||||
category: t.String(),
|
||||
name: t.Optional(t.String()),
|
||||
email: t.Optional(t.String()),
|
||||
subject: t.String(),
|
||||
message: t.String(),
|
||||
gameUrl: t.Optional(t.String()),
|
||||
honeypot: t.Optional(t.String()),
|
||||
_timestamp: t.Optional(t.String()),
|
||||
}),
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,162 @@
|
||||
import { Elysia, t } from "elysia"
|
||||
import { db } from "@/lib/db/index"
|
||||
import { storageObjects } from "@/lib/db/schema"
|
||||
import { eq, sql } from "drizzle-orm"
|
||||
import { deleteObject, isR2Configured } from "@/lib/storage"
|
||||
|
||||
// ── Task Result Type ────────────────────────────────────────────────
|
||||
interface CronTaskResult {
|
||||
name: string
|
||||
status: "success" | "skipped" | "error"
|
||||
durationMs: number
|
||||
details: Record<string, unknown>
|
||||
}
|
||||
|
||||
// ── Task Registry ───────────────────────────────────────────────────
|
||||
type CronTask = () => Promise<CronTaskResult>
|
||||
|
||||
export const taskRegistry = new Map<string, CronTask>()
|
||||
|
||||
export function registerCronTask(name: string, task: CronTask): void {
|
||||
taskRegistry.set(name, task)
|
||||
}
|
||||
|
||||
// ── Storage Cleanup Task ────────────────────────────────────────────
|
||||
registerCronTask("storage_cleanup", async () => {
|
||||
const start = Date.now()
|
||||
const details: Record<string, unknown> = {}
|
||||
|
||||
if (!isR2Configured()) {
|
||||
return { name: "storage_cleanup", status: "skipped" as const, durationMs: Date.now() - start, details: { reason: "R2 not configured" } }
|
||||
}
|
||||
|
||||
try {
|
||||
// Find orphaned storage objects
|
||||
const orphaned = await db
|
||||
.select()
|
||||
.from(storageObjects)
|
||||
.where(eq(storageObjects.isOrphaned, true))
|
||||
|
||||
let deletedCount = 0
|
||||
let errorCount = 0
|
||||
|
||||
// Process in batches of 100
|
||||
for (let i = 0; i < orphaned.length; i += 100) {
|
||||
const batch = orphaned.slice(i, i + 100)
|
||||
await Promise.allSettled(
|
||||
batch.map(async (obj) => {
|
||||
try {
|
||||
await deleteObject(obj.key)
|
||||
deletedCount++
|
||||
} catch {
|
||||
errorCount++
|
||||
}
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
// Remove deleted objects from tracking table
|
||||
if (orphaned.length > 0) {
|
||||
await db
|
||||
.delete(storageObjects)
|
||||
.where(eq(storageObjects.isOrphaned, true))
|
||||
}
|
||||
|
||||
details.deletedCount = deletedCount
|
||||
details.errorCount = errorCount
|
||||
details.totalOrphans = orphaned.length
|
||||
|
||||
return { name: "storage_cleanup", status: "success" as const, durationMs: Date.now() - start, details }
|
||||
} catch (err) {
|
||||
details.error = err instanceof Error ? err.message : String(err)
|
||||
return { name: "storage_cleanup", status: "error" as const, durationMs: Date.now() - start, details }
|
||||
}
|
||||
})
|
||||
|
||||
// ── Orphan Detection (separate task for future extensibility) ──────
|
||||
// This marks objects as orphaned based on their entityType/entityId references
|
||||
registerCronTask("orphan_detection", async () => {
|
||||
const start = Date.now()
|
||||
const details: Record<string, unknown> = {}
|
||||
|
||||
try {
|
||||
// Avatar orphans: storage_objects with entityType='avatar' where user doesn't exist
|
||||
// or user.image doesn't contain this object's key
|
||||
const avatarOrphans = await db.execute(sql`
|
||||
UPDATE storage_objects
|
||||
SET is_orphaned = true
|
||||
WHERE entity_type = 'avatar'
|
||||
AND is_orphaned = false
|
||||
AND entity_id NOT IN (SELECT id FROM "user")
|
||||
`)
|
||||
|
||||
details.avatarOrphansMarked = avatarOrphans.rowCount ?? 0
|
||||
|
||||
// Game cover orphans: storage_objects with entityType='game_cover' where game doesn't exist
|
||||
const gameOrphans = await db.execute(sql`
|
||||
UPDATE storage_objects
|
||||
SET is_orphaned = true
|
||||
WHERE entity_type = 'game_cover'
|
||||
AND is_orphaned = false
|
||||
AND entity_id NOT IN (SELECT id FROM games)
|
||||
`)
|
||||
|
||||
details.gameCoverOrphansMarked = gameOrphans.rowCount ?? 0
|
||||
|
||||
return { name: "orphan_detection", status: "success" as const, durationMs: Date.now() - start, details }
|
||||
} catch (err) {
|
||||
details.error = err instanceof Error ? err.message : String(err)
|
||||
return { name: "orphan_detection", status: "error" as const, durationMs: Date.now() - start, details }
|
||||
}
|
||||
})
|
||||
|
||||
// ── Cron Route ──────────────────────────────────────────────────────
|
||||
export const cronRoutes = new Elysia({ prefix: "/cron", detail: { tags: ["Cron"] } }).post(
|
||||
"/daily",
|
||||
async ({ query, set }) => {
|
||||
const cronSecret = process.env.CRON_SECRET
|
||||
|
||||
// If CRON_SECRET is not configured, disable the endpoint
|
||||
if (!cronSecret) {
|
||||
set.status = 404
|
||||
return { error: "Cron endpoint not configured" }
|
||||
}
|
||||
|
||||
// Validate cron secret from query parameter
|
||||
if (query.secret !== cronSecret) {
|
||||
set.status = 401
|
||||
return { error: "Unauthorized" }
|
||||
}
|
||||
|
||||
const overallStart = Date.now()
|
||||
|
||||
// Determine which tasks to run
|
||||
const taskNames = query.tasks
|
||||
? query.tasks.split(",").filter((t) => taskRegistry.has(t))
|
||||
: Array.from(taskRegistry.keys())
|
||||
|
||||
const results: CronTaskResult[] = []
|
||||
|
||||
for (const taskName of taskNames) {
|
||||
const task = taskRegistry.get(taskName)
|
||||
if (task) {
|
||||
const result = await task()
|
||||
results.push(result)
|
||||
}
|
||||
}
|
||||
|
||||
const overallDuration = Date.now() - overallStart
|
||||
|
||||
return {
|
||||
success: true,
|
||||
duration: overallDuration,
|
||||
tasks: results,
|
||||
}
|
||||
},
|
||||
{
|
||||
query: t.Object({
|
||||
secret: t.String(),
|
||||
tasks: t.Optional(t.String()),
|
||||
}),
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,335 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import { Elysia, t } from "elysia"
|
||||
import { db } from "@/lib/db/index"
|
||||
import { getTableColumns } from "drizzle-orm"
|
||||
import {
|
||||
type AnyPgTable,
|
||||
type PgColumn,
|
||||
} from "drizzle-orm/pg-core"
|
||||
import {
|
||||
eq,
|
||||
desc,
|
||||
asc,
|
||||
ilike,
|
||||
sql,
|
||||
type SQL,
|
||||
and,
|
||||
or,
|
||||
} from "drizzle-orm"
|
||||
import { requireRole } from "@/lib/auth/guard"
|
||||
import { fuzzySearchTerm } from "@/lib/db/search"
|
||||
|
||||
/** Which columns are text-searchable via ilike */
|
||||
export type CrudSearchConfig = {
|
||||
fields: string[]
|
||||
}
|
||||
|
||||
/** Which columns support exact-match filtering via ?filter[col]=val */
|
||||
export type CrudFilterConfig = {
|
||||
fields: string[]
|
||||
}
|
||||
|
||||
/** Auth requirements per operation */
|
||||
export type CrudAuthConfig = {
|
||||
read: "public" | "auth"
|
||||
write: "user" | "contributor" | "admin"
|
||||
delete: "admin" | "contributor"
|
||||
}
|
||||
|
||||
/**
|
||||
* Create typed CRUD routes for a Drizzle table.
|
||||
*
|
||||
* @param table - Drizzle pgTable definition
|
||||
* @param config.prefix - URL prefix (e.g., "/games")
|
||||
* @param config.auth - Auth requirements per operation
|
||||
* @param config.search - Text search configuration
|
||||
* @param config.filter - Exact-match filter configuration
|
||||
* @param config.name - Human-readable name for error messages
|
||||
* @param config.primaryKey - Column name used as primary key (default: "id")
|
||||
* @param config.paramName - URL parameter name (defaults to primaryKey)
|
||||
* @param config.softDelete - If true, DELETE sets isRemoved=true instead of deleting
|
||||
*/
|
||||
export function createCrudRoutes<T extends AnyPgTable>(
|
||||
table: T,
|
||||
config: {
|
||||
prefix: string
|
||||
auth: CrudAuthConfig
|
||||
search?: CrudSearchConfig
|
||||
filter?: CrudFilterConfig
|
||||
name?: string
|
||||
primaryKey?: string
|
||||
paramName?: string
|
||||
softDelete?: boolean
|
||||
tags?: string[]
|
||||
},
|
||||
) {
|
||||
const {
|
||||
prefix,
|
||||
auth: authConfig,
|
||||
search,
|
||||
filter,
|
||||
name = "resource",
|
||||
primaryKey = "id",
|
||||
paramName = primaryKey,
|
||||
softDelete = false,
|
||||
tags,
|
||||
} = config
|
||||
|
||||
const columns = getTableColumns(table) as Record<string, PgColumn>
|
||||
const pkColumn = columns[primaryKey]
|
||||
|
||||
if (!pkColumn) {
|
||||
throw new Error(`Primary key column "${primaryKey}" not found on table`)
|
||||
}
|
||||
|
||||
const routes = new Elysia({
|
||||
prefix,
|
||||
...(tags ? { detail: { tags } } : {}),
|
||||
})
|
||||
|
||||
// ── LIST ──────────────────────────────────────────────────────────
|
||||
routes.get(
|
||||
"/",
|
||||
async ({ query }) => {
|
||||
const limit = Math.min(Number(query.limit) || 20, 100)
|
||||
const offset = Number(query.offset) || 0
|
||||
const sortCol = columns[query.sort as string] || pkColumn
|
||||
const order = query.order === "asc" ? asc : desc
|
||||
|
||||
const conditions: SQL[] = []
|
||||
|
||||
// Search
|
||||
if (query.search && search) {
|
||||
const searchStr = query.search
|
||||
const searchConditions = search.fields
|
||||
.map((field) => {
|
||||
const col = columns[field]
|
||||
if (!col) return null
|
||||
const pattern = field === "title"
|
||||
? fuzzySearchTerm(searchStr)
|
||||
: `%${searchStr}%`
|
||||
return ilike(col, pattern)
|
||||
})
|
||||
.filter(Boolean) as SQL[]
|
||||
if (searchConditions.length > 0) {
|
||||
conditions.push(or(...searchConditions)!)
|
||||
}
|
||||
}
|
||||
|
||||
// Filters
|
||||
if (filter) {
|
||||
for (const field of filter.fields) {
|
||||
const val = (query as any)[`filter_${field}`]
|
||||
if (val !== undefined) {
|
||||
const col = columns[field]
|
||||
if (col) {
|
||||
conditions.push(eq(col, val))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const where = conditions.length > 0 ? and(...conditions) : undefined
|
||||
|
||||
const [data, countResult] = await Promise.all([
|
||||
db
|
||||
.select()
|
||||
.from(table as any)
|
||||
.where(where)
|
||||
.orderBy(order(sortCol))
|
||||
.limit(limit)
|
||||
.offset(offset),
|
||||
db
|
||||
.select({ count: sql<number>`count(*)::int` })
|
||||
.from(table as any)
|
||||
.where(where),
|
||||
])
|
||||
|
||||
return {
|
||||
data,
|
||||
total: countResult[0]?.count ?? 0,
|
||||
limit,
|
||||
offset,
|
||||
}
|
||||
},
|
||||
{
|
||||
query: t.Object({
|
||||
limit: t.Optional(t.String()),
|
||||
offset: t.Optional(t.String()),
|
||||
sort: t.Optional(t.String()),
|
||||
order: t.Optional(t.String()),
|
||||
search: t.Optional(t.String()),
|
||||
// Dynamic filter fields are too varied for static TypeBox,
|
||||
// so we allow any string keys with filter_ prefix
|
||||
}),
|
||||
detail: {
|
||||
summary: `List ${name}s`,
|
||||
description: `Returns a paginated list of ${name}s with optional search and filtering.`,
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
// ── GET BY ID ─────────────────────────────────────────────────────
|
||||
routes.get(
|
||||
`/:${paramName}`,
|
||||
async ({ params, set }) => {
|
||||
const id = (params as any)[paramName]
|
||||
|
||||
const [record] = await db
|
||||
.select()
|
||||
.from(table as any)
|
||||
.where(eq(pkColumn, id))
|
||||
.limit(1)
|
||||
|
||||
if (!record) {
|
||||
set.status = 404
|
||||
return { error: `${name} not found` }
|
||||
}
|
||||
|
||||
return record
|
||||
},
|
||||
{
|
||||
params: t.Object({
|
||||
[paramName]: t.String(),
|
||||
}),
|
||||
detail: {
|
||||
summary: `Get ${name} by ID`,
|
||||
description: `Returns a single ${name} by its unique identifier.`,
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
// ── CREATE ────────────────────────────────────────────────────────
|
||||
routes.post(
|
||||
"/",
|
||||
async ({ body, request, set }) => {
|
||||
// Auth check
|
||||
const roleMap: Record<string, string[]> = {
|
||||
user: ["user", "contributor", "admin"],
|
||||
contributor: ["contributor", "admin"],
|
||||
admin: ["admin"],
|
||||
}
|
||||
const allowedRoles = roleMap[authConfig.write]
|
||||
const guard = await requireRole(request.headers, allowedRoles)
|
||||
|
||||
if (!guard.ok) {
|
||||
set.status = guard.status
|
||||
return { error: guard.error }
|
||||
}
|
||||
|
||||
const [created] = (await db.insert(table as any).values(body as any).returning()) as any[]
|
||||
|
||||
set.status = 201
|
||||
return created
|
||||
},
|
||||
{
|
||||
body: t.Record(t.String(), t.Any()),
|
||||
detail: {
|
||||
summary: `Create ${name}`,
|
||||
description: `Creates a new ${name}. Requires authentication.`,
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
// ── UPDATE ────────────────────────────────────────────────────────
|
||||
routes.patch(
|
||||
`/:${paramName}`,
|
||||
async ({ params, body, request, set }) => {
|
||||
const roleMap: Record<string, string[]> = {
|
||||
user: ["user", "contributor", "admin"],
|
||||
contributor: ["contributor", "admin"],
|
||||
admin: ["admin"],
|
||||
}
|
||||
const allowedRoles = roleMap[authConfig.write]
|
||||
const guard = await requireRole(request.headers, allowedRoles)
|
||||
|
||||
if (!guard.ok) {
|
||||
set.status = guard.status
|
||||
return { error: guard.error }
|
||||
}
|
||||
|
||||
const id = (params as any)[paramName]
|
||||
|
||||
// Add updatedAt if column exists
|
||||
const updateData = columns["updatedAt"]
|
||||
? { ...body, updatedAt: new Date() }
|
||||
: body
|
||||
|
||||
const [updated] = (await db
|
||||
.update(table as any)
|
||||
.set(updateData as any)
|
||||
.where(eq(pkColumn, id))
|
||||
.returning()) as any[]
|
||||
|
||||
if (!updated) {
|
||||
set.status = 404
|
||||
return { error: `${name} not found` }
|
||||
}
|
||||
|
||||
return updated
|
||||
},
|
||||
{
|
||||
params: t.Object({
|
||||
[paramName]: t.String(),
|
||||
}),
|
||||
body: t.Record(t.String(), t.Any()),
|
||||
detail: {
|
||||
summary: `Update ${name}`,
|
||||
description: `Updates an existing ${name} by ID. Requires authentication.`,
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
// ── DELETE ────────────────────────────────────────────────────────
|
||||
routes.delete(
|
||||
`/:${paramName}`,
|
||||
async ({ params, request, set }) => {
|
||||
const guard = await requireRole(request.headers, [authConfig.delete])
|
||||
|
||||
if (!guard.ok) {
|
||||
set.status = guard.status
|
||||
return { error: guard.error }
|
||||
}
|
||||
|
||||
const id = (params as any)[paramName]
|
||||
|
||||
if (softDelete && columns["isRemoved"]) {
|
||||
const [updated] = (await db
|
||||
.update(table as any)
|
||||
.set({ isRemoved: true, updatedAt: new Date() } as any)
|
||||
.where(eq(pkColumn, id))
|
||||
.returning()) as any[]
|
||||
|
||||
if (!updated) {
|
||||
set.status = 404
|
||||
return { error: `${name} not found` }
|
||||
}
|
||||
|
||||
return { success: true }
|
||||
}
|
||||
|
||||
const [deleted] = (await db
|
||||
.delete(table as any)
|
||||
.where(eq(pkColumn, id))
|
||||
.returning()) as any[]
|
||||
|
||||
if (!deleted) {
|
||||
set.status = 404
|
||||
return { error: `${name} not found` }
|
||||
}
|
||||
|
||||
return { success: true }
|
||||
},
|
||||
{
|
||||
params: t.Object({
|
||||
[paramName]: t.String(),
|
||||
}),
|
||||
detail: {
|
||||
summary: `Delete ${name}`,
|
||||
description: `Deletes a ${name} by ID. Requires admin or contributor role.`,
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
return routes
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
import { Elysia } from "elysia"
|
||||
import { db } from "@/lib/db/index"
|
||||
import { sql } from "drizzle-orm"
|
||||
|
||||
const SEVEN_DAYS_AGO = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000)
|
||||
|
||||
export const dashboardPublicRoutes = new Elysia({ prefix: "/dashboard", detail: { tags: ["Dashboard"] } })
|
||||
|
||||
// ── Trending Games (7-day activity) ────────────────────────────────
|
||||
.get(
|
||||
"/trending",
|
||||
async () => {
|
||||
const trending = await db.execute(sql`
|
||||
WITH recent_benchmarks AS (
|
||||
SELECT gv.game_id, COUNT(*) AS cnt
|
||||
FROM performance_entries pe
|
||||
JOIN game_versions gv ON pe.version_id = gv.id
|
||||
WHERE pe.is_removed = false
|
||||
AND pe.created_at >= ${SEVEN_DAYS_AGO}
|
||||
GROUP BY gv.game_id
|
||||
),
|
||||
recent_comments AS (
|
||||
SELECT gc.game_id, COUNT(*) AS cnt
|
||||
FROM game_comments gc
|
||||
WHERE gc.is_removed = false
|
||||
AND gc.created_at >= ${SEVEN_DAYS_AGO}
|
||||
GROUP BY gc.game_id
|
||||
),
|
||||
recent_upvotes AS (
|
||||
SELECT gv.game_id, SUM(pe.upvotes) AS total_upvotes
|
||||
FROM performance_entries pe
|
||||
JOIN game_versions gv ON pe.version_id = gv.id
|
||||
WHERE pe.is_removed = false
|
||||
AND pe.updated_at >= ${SEVEN_DAYS_AGO}
|
||||
GROUP BY gv.game_id
|
||||
)
|
||||
SELECT
|
||||
g.id,
|
||||
g.title,
|
||||
g.capsule_image,
|
||||
g.header_image,
|
||||
g.playability_status,
|
||||
COALESCE(rb.cnt, 0) AS benchmark_count,
|
||||
COALESCE(rc.cnt, 0) AS comment_count,
|
||||
COALESCE(ru.total_upvotes, 0) AS upvote_count,
|
||||
(COALESCE(rb.cnt, 0) * 3 + COALESCE(rc.cnt, 0) * 2 + COALESCE(ru.total_upvotes, 0) * 1) AS activity_score
|
||||
FROM games g
|
||||
LEFT JOIN recent_benchmarks rb ON rb.game_id = g.id
|
||||
LEFT JOIN recent_comments rc ON rc.game_id = g.id
|
||||
LEFT JOIN recent_upvotes ru ON ru.game_id = g.id
|
||||
WHERE (rb.cnt IS NOT NULL OR rc.cnt IS NOT NULL OR ru.total_upvotes IS NOT NULL)
|
||||
ORDER BY activity_score DESC
|
||||
LIMIT 10
|
||||
`)
|
||||
|
||||
return trending.rows
|
||||
},
|
||||
{ detail: { description: "Trending games based on 7-day benchmark and comment activity." } },
|
||||
)
|
||||
|
||||
// ── Best Performing New Releases ─────────────────────────────────
|
||||
.get(
|
||||
"/best-new-releases",
|
||||
async () => {
|
||||
const results = await db.execute(sql`
|
||||
SELECT
|
||||
g.id,
|
||||
g.title,
|
||||
g.capsule_image,
|
||||
g.header_image,
|
||||
g.release_date,
|
||||
g.created_at,
|
||||
g.playability_status,
|
||||
AVG(pe.fps_avg) AS avg_fps,
|
||||
COUNT(pe.id) AS benchmark_count
|
||||
FROM games g
|
||||
JOIN game_versions gv ON gv.game_id = g.id
|
||||
JOIN performance_entries pe ON pe.version_id = gv.id
|
||||
WHERE pe.is_removed = false
|
||||
AND (g.created_at >= ${new Date(Date.now() - 30 * 24 * 60 * 60 * 1000)}
|
||||
OR g.release_date IS NOT NULL)
|
||||
GROUP BY g.id, g.title, g.capsule_image, g.header_image, g.release_date, g.created_at, g.playability_status
|
||||
HAVING COUNT(pe.id) >= 3
|
||||
ORDER BY avg_fps DESC
|
||||
LIMIT 10
|
||||
`)
|
||||
|
||||
return results.rows
|
||||
},
|
||||
{ detail: { description: "Best performing new releases from the last 30 days with at least 3 benchmarks." } },
|
||||
)
|
||||
|
||||
// ── Most Tested Games ─────────────────────────────────────────────
|
||||
.get(
|
||||
"/most-tested",
|
||||
async () => {
|
||||
const results = await db.execute(sql`
|
||||
SELECT
|
||||
g.id,
|
||||
g.title,
|
||||
g.capsule_image,
|
||||
g.header_image,
|
||||
g.playability_status,
|
||||
COUNT(pe.id) AS benchmark_count,
|
||||
AVG(pe.fps_avg) AS avg_fps
|
||||
FROM games g
|
||||
JOIN game_versions gv ON gv.game_id = g.id
|
||||
JOIN performance_entries pe ON pe.version_id = gv.id
|
||||
WHERE pe.is_removed = false
|
||||
GROUP BY g.id, g.title, g.capsule_image, g.header_image, g.playability_status
|
||||
ORDER BY benchmark_count DESC
|
||||
LIMIT 10
|
||||
`)
|
||||
|
||||
return results.rows
|
||||
},
|
||||
{ detail: { description: "Games with the most benchmark entries, ordered by count descending." } },
|
||||
)
|
||||
|
||||
// ── Recently Added Benchmarks ──────────────────────────────────────
|
||||
.get(
|
||||
"/recent-benchmarks",
|
||||
async () => {
|
||||
const results = await db.execute(sql`
|
||||
SELECT
|
||||
g.id,
|
||||
g.title,
|
||||
g.capsule_image,
|
||||
g.header_image,
|
||||
g.playability_status,
|
||||
COUNT(pe.id) AS benchmark_count,
|
||||
AVG(pe.fps_avg) AS avg_fps,
|
||||
MAX(pe.created_at) AS latest_benchmark_at
|
||||
FROM games g
|
||||
JOIN game_versions gv ON gv.game_id = g.id
|
||||
JOIN performance_entries pe ON pe.version_id = gv.id
|
||||
WHERE pe.is_removed = false
|
||||
GROUP BY g.id, g.title, g.capsule_image, g.header_image, g.playability_status
|
||||
ORDER BY MAX(pe.created_at) DESC
|
||||
LIMIT 10
|
||||
`)
|
||||
|
||||
return results.rows
|
||||
},
|
||||
{ detail: { description: "Games with the most recently added benchmarks, ordered by latest entry date." } },
|
||||
)
|
||||
|
||||
// ── Most Reported Games ────────────────────────────────────────────
|
||||
.get(
|
||||
"/most-reported",
|
||||
async () => {
|
||||
const results = await db.execute(sql`
|
||||
SELECT
|
||||
g.id,
|
||||
g.title,
|
||||
g.capsule_image,
|
||||
g.header_image,
|
||||
COUNT(DISTINCT r.id) AS report_count
|
||||
FROM games g
|
||||
JOIN game_versions gv ON gv.game_id = g.id
|
||||
JOIN performance_entries pe ON pe.version_id = gv.id
|
||||
JOIN reports r ON r.entry_id = pe.id
|
||||
WHERE r.status = 'open'
|
||||
GROUP BY g.id, g.title, g.capsule_image, g.header_image
|
||||
ORDER BY report_count DESC
|
||||
LIMIT 10
|
||||
`)
|
||||
|
||||
return results.rows
|
||||
},
|
||||
{ detail: { description: "Games with the most open reports against their performance entries." } },
|
||||
)
|
||||
|
||||
// ── On Sale & Performing Well ────────────────────────────────────
|
||||
.get(
|
||||
"/on-sale",
|
||||
async () => {
|
||||
const results = await db.execute(sql`
|
||||
SELECT
|
||||
g.id,
|
||||
g.title,
|
||||
g.capsule_image,
|
||||
g.header_image,
|
||||
g.price_current,
|
||||
g.price_initial,
|
||||
g.price_currency,
|
||||
g.playability_status,
|
||||
g.steam_review_score,
|
||||
AVG(pe.fps_avg) AS avg_fps,
|
||||
MAX(pe.fps_avg) AS best_fps,
|
||||
COUNT(pe.id) AS benchmark_count
|
||||
FROM games g
|
||||
JOIN game_versions gv ON gv.game_id = g.id
|
||||
JOIN performance_entries pe ON pe.version_id = gv.id
|
||||
WHERE g.source = 'steam'
|
||||
AND g.price_current > 0
|
||||
AND g.price_initial > 0
|
||||
AND g.price_current < g.price_initial
|
||||
AND pe.is_removed = false
|
||||
AND g.last_sync >= NOW() - INTERVAL '7 days'
|
||||
GROUP BY g.id
|
||||
HAVING COUNT(pe.id) >= 3
|
||||
ORDER BY avg_fps DESC
|
||||
LIMIT 10
|
||||
`)
|
||||
|
||||
return results.rows
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,215 @@
|
||||
import { Elysia } from "elysia";
|
||||
import { db } from "@/lib/db/index";
|
||||
import {
|
||||
games,
|
||||
performanceEntries,
|
||||
reports,
|
||||
communitySuggestions,
|
||||
user,
|
||||
} from "@/lib/db/schema";
|
||||
import { eq, count, sql, gte, and, desc } from "drizzle-orm";
|
||||
import { requireContributorOrAdmin } from "@/lib/auth/guard";
|
||||
|
||||
// In-memory cache for dashboard stats (TTL 300s)
|
||||
interface DashboardStatsResult {
|
||||
overview: {
|
||||
totalGames: number;
|
||||
totalBenchmarks: number;
|
||||
totalUsers: number;
|
||||
pendingReports: number;
|
||||
pendingSuggestions: number;
|
||||
};
|
||||
recent: {
|
||||
benchmarksLast30Days: number;
|
||||
gamesLast30Days: number;
|
||||
};
|
||||
topContributors: Array<{
|
||||
userId: string | null;
|
||||
name: string;
|
||||
count: number;
|
||||
}>;
|
||||
syncHealth: Record<string, number>;
|
||||
gamesBySource: Record<string, number>;
|
||||
playabilityDistribution: Record<string, number>;
|
||||
}
|
||||
|
||||
interface CacheEntry<T> {
|
||||
data: T;
|
||||
expiresAt: number;
|
||||
}
|
||||
const statsCache = new Map<string, CacheEntry<unknown>>();
|
||||
const STATS_CACHE_TTL = 300_000; // 5 minutes
|
||||
|
||||
function getCached<T>(key: string): T | null {
|
||||
const entry = statsCache.get(key) as CacheEntry<T> | undefined;
|
||||
if (!entry) return null;
|
||||
if (Date.now() > entry.expiresAt) {
|
||||
statsCache.delete(key);
|
||||
return null;
|
||||
}
|
||||
return entry.data;
|
||||
}
|
||||
|
||||
function setCached<T>(key: string, data: T): void {
|
||||
statsCache.set(key, { data, expiresAt: Date.now() + STATS_CACHE_TTL });
|
||||
}
|
||||
|
||||
|
||||
export const dashboardRoutes = new Elysia({ prefix: "/dashboard", detail: { tags: ["Dashboard"] } }).get(
|
||||
"/stats",
|
||||
async ({ request, set }) => {
|
||||
const guard = await requireContributorOrAdmin(request.headers);
|
||||
if (!guard.ok) {
|
||||
set.status = guard.status;
|
||||
return { error: guard.error };
|
||||
}
|
||||
|
||||
// Check cache
|
||||
const cached = getCached<DashboardStatsResult>("dashboard_stats");
|
||||
if (cached) return cached;
|
||||
|
||||
const thirtyDaysAgo = new Date();
|
||||
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
|
||||
|
||||
const [
|
||||
totalGames,
|
||||
totalBenchmarks,
|
||||
totalUsers,
|
||||
pendingReports,
|
||||
pendingSuggestions,
|
||||
recentBenchmarks,
|
||||
recentGames,
|
||||
topContributors,
|
||||
syncHealth,
|
||||
gamesBySource,
|
||||
playabilityDistribution,
|
||||
] = await Promise.all([
|
||||
// Total games
|
||||
db.select({ count: count() }).from(games),
|
||||
|
||||
// Total benchmarks
|
||||
db
|
||||
.select({ count: count() })
|
||||
.from(performanceEntries)
|
||||
.where(eq(performanceEntries.isRemoved, false)),
|
||||
|
||||
// Total users
|
||||
db.select({ count: count() }).from(user),
|
||||
|
||||
// Pending reports
|
||||
db
|
||||
.select({ count: count() })
|
||||
.from(reports)
|
||||
.where(eq(reports.status, "open")),
|
||||
|
||||
// Pending suggestions
|
||||
db
|
||||
.select({ count: count() })
|
||||
.from(communitySuggestions)
|
||||
.where(eq(communitySuggestions.status, "pending")),
|
||||
|
||||
// Benchmarks in last 30 days
|
||||
db
|
||||
.select({ count: count() })
|
||||
.from(performanceEntries)
|
||||
.where(
|
||||
and(
|
||||
eq(performanceEntries.isRemoved, false),
|
||||
gte(performanceEntries.createdAt, thirtyDaysAgo),
|
||||
),
|
||||
),
|
||||
|
||||
// Games added in last 30 days
|
||||
db
|
||||
.select({ count: count() })
|
||||
.from(games)
|
||||
.where(gte(games.createdAt, thirtyDaysAgo)),
|
||||
|
||||
// Top contributors
|
||||
db
|
||||
.select({
|
||||
userId: performanceEntries.userId,
|
||||
userName: user.name,
|
||||
entryCount: count(),
|
||||
})
|
||||
.from(performanceEntries)
|
||||
.leftJoin(user, eq(performanceEntries.userId, user.id))
|
||||
.where(eq(performanceEntries.isRemoved, false))
|
||||
.groupBy(performanceEntries.userId, user.name)
|
||||
.orderBy(desc(count()))
|
||||
.limit(10),
|
||||
|
||||
// Sync health
|
||||
db
|
||||
.select({
|
||||
status: games.syncStatus,
|
||||
count: count(),
|
||||
})
|
||||
.from(games)
|
||||
.where(sql`${games.steamAppId} IS NOT NULL`)
|
||||
.groupBy(games.syncStatus),
|
||||
|
||||
// Games by source
|
||||
db
|
||||
.select({
|
||||
source: games.source,
|
||||
count: count(),
|
||||
})
|
||||
.from(games)
|
||||
.groupBy(games.source),
|
||||
|
||||
// Playability distribution
|
||||
db
|
||||
.select({
|
||||
status: games.playabilityStatus,
|
||||
count: count(),
|
||||
})
|
||||
.from(games)
|
||||
.where(sql`${games.playabilityStatus} IS NOT NULL`)
|
||||
.groupBy(games.playabilityStatus),
|
||||
]);
|
||||
|
||||
const result = {
|
||||
overview: {
|
||||
totalGames: totalGames[0]?.count ?? 0,
|
||||
totalBenchmarks: totalBenchmarks[0]?.count ?? 0,
|
||||
totalUsers: totalUsers[0]?.count ?? 0,
|
||||
pendingReports: pendingReports[0]?.count ?? 0,
|
||||
pendingSuggestions: pendingSuggestions[0]?.count ?? 0,
|
||||
},
|
||||
recent: {
|
||||
benchmarksLast30Days: recentBenchmarks[0]?.count ?? 0,
|
||||
gamesLast30Days: recentGames[0]?.count ?? 0,
|
||||
},
|
||||
topContributors: topContributors.map((c) => ({
|
||||
userId: c.userId,
|
||||
name: c.userName ?? "Anonymous",
|
||||
count: c.entryCount,
|
||||
})),
|
||||
syncHealth: syncHealth.reduce(
|
||||
(acc, s) => {
|
||||
acc[s.status ?? "unknown"] = s.count;
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, number>,
|
||||
),
|
||||
gamesBySource: gamesBySource.reduce(
|
||||
(acc, s) => {
|
||||
acc[s.source ?? "unknown"] = s.count;
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, number>,
|
||||
),
|
||||
playabilityDistribution: playabilityDistribution.reduce(
|
||||
(acc, p) => {
|
||||
acc[p.status ?? "unknown"] = p.count;
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, number>,
|
||||
),
|
||||
};
|
||||
|
||||
setCached("dashboard_stats", result);
|
||||
return result;
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,376 @@
|
||||
import { Elysia, t } from "elysia"
|
||||
import { db } from "@/lib/db/index"
|
||||
import {
|
||||
games,
|
||||
gameVersions,
|
||||
performanceEntries,
|
||||
hardware,
|
||||
} from "@/lib/db/schema"
|
||||
import { eq, and, inArray, sql } from "drizzle-orm"
|
||||
|
||||
export const gameStatsRoutes = new Elysia({ prefix: "/games", detail: { tags: ["Games"] } }).get(
|
||||
"/:gameId/stats",
|
||||
async ({ params, set }) => {
|
||||
const { gameId } = params
|
||||
|
||||
try {
|
||||
// Verify game exists
|
||||
const [game] = await db
|
||||
.select({ id: games.id, steamAppId: games.steamAppId })
|
||||
.from(games)
|
||||
.where(eq(games.id, gameId))
|
||||
.limit(1)
|
||||
|
||||
if (!game) {
|
||||
set.status = 404
|
||||
return { error: "Game not found" }
|
||||
}
|
||||
|
||||
// ── 1. All non-removed entries with joins ──────────────────────
|
||||
const entries = await db
|
||||
.select({
|
||||
id: performanceEntries.id,
|
||||
hardwareSlug: performanceEntries.hardwareSlug,
|
||||
hardwareName: hardware.name,
|
||||
fpsAvg: performanceEntries.fpsAvg,
|
||||
fpsLow: performanceEntries.fpsLow,
|
||||
fpsHigh: performanceEntries.fpsHigh,
|
||||
fpsOnePercentLow: performanceEntries.fpsOnePercentLow,
|
||||
upscalerType: performanceEntries.upscalerType,
|
||||
upscalerVersion: performanceEntries.upscalerVersion,
|
||||
frameGenMethod: performanceEntries.frameGenMethod,
|
||||
protonVersion: performanceEntries.protonVersion,
|
||||
osVersion: performanceEntries.osVersion,
|
||||
upvotes: performanceEntries.upvotes,
|
||||
downvotes: performanceEntries.downvotes,
|
||||
verifiedAt: performanceEntries.verifiedAt,
|
||||
userNotes: performanceEntries.userNotes,
|
||||
createdAt: performanceEntries.createdAt,
|
||||
versionId: performanceEntries.versionId,
|
||||
tdpWatts: performanceEntries.tdpWatts,
|
||||
})
|
||||
.from(performanceEntries)
|
||||
.innerJoin(
|
||||
gameVersions,
|
||||
eq(performanceEntries.versionId, gameVersions.id),
|
||||
)
|
||||
.innerJoin(hardware, eq(performanceEntries.hardwareSlug, hardware.slug))
|
||||
.where(
|
||||
and(
|
||||
eq(gameVersions.gameId, gameId),
|
||||
eq(performanceEntries.isRemoved, false),
|
||||
),
|
||||
)
|
||||
|
||||
if (entries.length === 0) {
|
||||
return {
|
||||
summary: {
|
||||
totalEntries: 0,
|
||||
avgFps: null,
|
||||
bestDevice: null,
|
||||
verifiedCount: 0,
|
||||
versionCount: 0,
|
||||
},
|
||||
isRawPerformer: false,
|
||||
boxplot: [],
|
||||
historical: [],
|
||||
upscalerStats: [],
|
||||
fpsRange: [],
|
||||
deviceBreakdown: [],
|
||||
filterOptions: { protonVersions: [], osVersions: [] },
|
||||
batteryLife: [],
|
||||
}
|
||||
}
|
||||
|
||||
// ── 2. Summary stats ──────────────────────────────────────────
|
||||
const totalEntries = entries.length
|
||||
const avgFps =
|
||||
entries.reduce((sum, e) => sum + (e.fpsAvg ?? 0), 0) / totalEntries
|
||||
const verifiedCount = entries.filter((e) => e.verifiedAt !== null).length
|
||||
|
||||
// Best device by mean fpsAvg
|
||||
const deviceFpsMap = new Map<string, number[]>()
|
||||
for (const e of entries) {
|
||||
const arr = deviceFpsMap.get(e.hardwareSlug) || []
|
||||
arr.push(e.fpsAvg ?? 0)
|
||||
deviceFpsMap.set(e.hardwareSlug, arr)
|
||||
}
|
||||
let bestDevice: string | null = null
|
||||
let bestDeviceAvg = 0
|
||||
for (const [slug, fpsArr] of deviceFpsMap) {
|
||||
const mean = fpsArr.reduce((a, b) => a + b, 0) / fpsArr.length
|
||||
if (mean > bestDeviceAvg) {
|
||||
bestDeviceAvg = mean
|
||||
bestDevice = slug
|
||||
}
|
||||
}
|
||||
|
||||
// Version count
|
||||
const [versionRow] = await db
|
||||
.select({ count: sql<number>`count(*)::int` })
|
||||
.from(gameVersions)
|
||||
.where(eq(gameVersions.gameId, gameId))
|
||||
const versionCount = versionRow?.count ?? 0
|
||||
|
||||
// ── 3. Raw Performer check (handheld devices only) ─────────────
|
||||
const handheldEntries = entries.filter((e) =>
|
||||
e.hardwareSlug.startsWith("steamdeck"),
|
||||
)
|
||||
const isRawPerformer = handheldEntries.some(
|
||||
(e) =>
|
||||
(e.fpsAvg ?? 0) >= 60 &&
|
||||
e.upscalerType === "none" &&
|
||||
e.frameGenMethod === "none",
|
||||
)
|
||||
|
||||
// ── 3b. Poor Performance check (handheld devices only) ─────────
|
||||
const isPoorPerformance = handheldEntries.some((e) => (e.fpsAvg ?? 0) < 30)
|
||||
|
||||
// ── 4. Boxplot per device ─────────────────────────────────────
|
||||
const boxplotMap = new Map<
|
||||
string,
|
||||
{ hardwareName: string; values: number[] }
|
||||
>()
|
||||
for (const e of entries) {
|
||||
const existing = boxplotMap.get(e.hardwareSlug) || {
|
||||
hardwareName: e.hardwareName,
|
||||
values: [],
|
||||
}
|
||||
existing.values.push(e.fpsAvg ?? 0)
|
||||
boxplotMap.set(e.hardwareSlug, existing)
|
||||
}
|
||||
|
||||
const boxplot = Array.from(boxplotMap.entries()).map(
|
||||
([slug, { hardwareName, values }]) => {
|
||||
const sorted = [...values].sort((a, b) => a - b)
|
||||
const n = sorted.length
|
||||
const q1Idx = Math.floor(n * 0.25)
|
||||
const medIdx = Math.floor(n * 0.5)
|
||||
const q3Idx = Math.floor(n * 0.75)
|
||||
const onePercentLow = sorted.length > 0 ? sorted[Math.max(0, Math.floor(sorted.length * 0.01))] : sorted[0]
|
||||
return {
|
||||
hardwareSlug: slug,
|
||||
hardwareName,
|
||||
min: sorted[0],
|
||||
q1: sorted[q1Idx],
|
||||
median: sorted[medIdx],
|
||||
onePercentLow,
|
||||
q3: sorted[q3Idx],
|
||||
max: sorted[n - 1],
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
// ── 5. Historical (group by month + device) ───────────────────
|
||||
const histMap = new Map<
|
||||
string,
|
||||
Map<string, { sum: number; count: number }>
|
||||
>()
|
||||
for (const e of entries) {
|
||||
const date = new Date(e.createdAt)
|
||||
const month = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}`
|
||||
if (!histMap.has(month)) histMap.set(month, new Map())
|
||||
const deviceMap = histMap.get(month)!
|
||||
const existing = deviceMap.get(e.hardwareSlug) || {
|
||||
sum: 0,
|
||||
count: 0,
|
||||
}
|
||||
existing.sum += e.fpsAvg ?? 0
|
||||
existing.count++
|
||||
deviceMap.set(e.hardwareSlug, existing)
|
||||
}
|
||||
|
||||
const historical = Array.from(histMap.entries())
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(([month, deviceMap]) => ({
|
||||
period: month,
|
||||
entries: Array.from(deviceMap.entries()).map(([slug, data]) => ({
|
||||
hardwareSlug: slug,
|
||||
avgFps: Math.round((data.sum / data.count) * 10) / 10,
|
||||
count: data.count,
|
||||
})),
|
||||
}))
|
||||
|
||||
// ── 6. Upscaler/framegen stats ────────────────────────────────
|
||||
const upscalerMap = new Map<
|
||||
string,
|
||||
{ hardwareSlug: string; sum: number; count: number }
|
||||
>()
|
||||
for (const e of entries) {
|
||||
const key = `${e.upscalerType}|${e.upscalerVersion ?? ''}|${e.frameGenMethod}|${e.hardwareSlug}`
|
||||
const existing = upscalerMap.get(key) || {
|
||||
hardwareSlug: e.hardwareSlug,
|
||||
sum: 0,
|
||||
count: 0,
|
||||
}
|
||||
existing.sum += e.fpsAvg ?? 0
|
||||
existing.count++
|
||||
upscalerMap.set(key, existing)
|
||||
}
|
||||
|
||||
const upscalerStats = Array.from(upscalerMap.entries()).map(
|
||||
([key, data]) => {
|
||||
const [upscalerType, upscalerVersion, frameGenMethod] = key.split("|")
|
||||
return {
|
||||
upscalerType,
|
||||
upscalerVersion: upscalerVersion || null,
|
||||
frameGenMethod,
|
||||
hardwareSlug: data.hardwareSlug,
|
||||
avgFps: Math.round((data.sum / data.count) * 10) / 10,
|
||||
count: data.count,
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
// ── 7. FPS range per entry ────────────────────────────────────
|
||||
const fpsRange = entries
|
||||
.filter((e) => e.fpsLow !== null && e.fpsHigh !== null)
|
||||
.map((e) => ({
|
||||
id: e.id,
|
||||
hardwareSlug: e.hardwareSlug,
|
||||
fpsLow: e.fpsLow!,
|
||||
fpsAvg: e.fpsAvg ?? 0,
|
||||
fpsHigh: e.fpsHigh!,
|
||||
fpsOnePercentLow: e.fpsOnePercentLow ?? null,
|
||||
isRawPerformer:
|
||||
(e.fpsAvg ?? 0) >= 60 &&
|
||||
e.upscalerType === "none" &&
|
||||
e.frameGenMethod === "none",
|
||||
isPoorPerformer: (e.fpsAvg ?? 0) < 30,
|
||||
}))
|
||||
|
||||
// Stability score computation
|
||||
const stabilityScores = entries
|
||||
.filter(e => e.fpsOnePercentLow != null && (e.fpsAvg ?? 0) > 0)
|
||||
.map(e => Math.min(1, e.fpsOnePercentLow! / (e.fpsAvg ?? 1)))
|
||||
const avgStability = stabilityScores.length > 0
|
||||
? Math.round((stabilityScores.reduce((a, b) => a + b, 0) / stabilityScores.length) * 100) / 100
|
||||
: null
|
||||
const bestOnePercentLow = entries.reduce((best, e) =>
|
||||
e.fpsOnePercentLow != null && e.fpsOnePercentLow > (best ?? 0) ? e.fpsOnePercentLow : best, null as number | null)
|
||||
|
||||
// Fetch hardware wattHours for the relevant devices (used by deviceBreakdown + batteryLife)
|
||||
const deviceSlugs = [...new Set(entries.map((e) => e.hardwareSlug))]
|
||||
const deviceData = await db
|
||||
.select({
|
||||
slug: hardware.slug,
|
||||
wattHours: hardware.wattHours,
|
||||
tdpMax: hardware.tdpMax,
|
||||
deviceType: hardware.deviceType,
|
||||
})
|
||||
.from(hardware)
|
||||
.where(inArray(hardware.slug, deviceSlugs))
|
||||
|
||||
const deviceWattHoursMap = new Map(
|
||||
deviceData.map((d) => [d.slug, d]),
|
||||
)
|
||||
|
||||
// ── 8. Device breakdown ───────────────────────────────────────
|
||||
const deviceBreakdown = Array.from(boxplotMap.entries()).map(
|
||||
([slug, { hardwareName, values }]) => {
|
||||
const dev = deviceWattHoursMap.get(slug)
|
||||
return {
|
||||
hardwareSlug: slug,
|
||||
hardwareName,
|
||||
count: values.length,
|
||||
wattHours: dev?.wattHours ? Number(dev.wattHours) : null,
|
||||
tdpMax: dev?.tdpMax ? Number(dev.tdpMax) : null,
|
||||
deviceType: dev?.deviceType ?? null,
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
// ── 9. Performance tier breakdown per device ────────────────
|
||||
const tierMap = new Map<string, { unplayable: number; playable: number; smooth: number; excellent: number }>()
|
||||
for (const e of entries) {
|
||||
const existing = tierMap.get(e.hardwareSlug) || { unplayable: 0, playable: 0, smooth: 0, excellent: 0 }
|
||||
if (e.fpsAvg < 30) existing.unplayable++
|
||||
else if (e.fpsAvg < 60) existing.playable++
|
||||
else if (e.fpsAvg < 120) existing.smooth++
|
||||
else existing.excellent++
|
||||
tierMap.set(e.hardwareSlug, existing)
|
||||
}
|
||||
|
||||
const performanceTiers = Array.from(tierMap.entries()).map(([slug, tiers]) => ({
|
||||
hardwareSlug: slug,
|
||||
...tiers,
|
||||
}))
|
||||
|
||||
// ── 10. Stability scatter data ──────────────────────────────
|
||||
const stabilityScatter = entries
|
||||
.filter((e) => e.fpsOnePercentLow != null)
|
||||
.map((e) => ({
|
||||
id: e.id,
|
||||
hardwareSlug: e.hardwareSlug,
|
||||
fpsAvg: e.fpsAvg ?? 0,
|
||||
fpsOnePercentLow: e.fpsOnePercentLow!,
|
||||
stabilityRatio: e.fpsAvg > 0 ? Math.min(1, e.fpsOnePercentLow! / e.fpsAvg) : 0,
|
||||
}))
|
||||
|
||||
// ── 12. Battery life estimates ─────────────────────────────
|
||||
const batteryLife = entries
|
||||
.filter((e) => e.tdpWatts != null && e.tdpWatts > 0)
|
||||
.map((e) => {
|
||||
const device = deviceWattHoursMap.get(e.hardwareSlug)
|
||||
const wh = device?.wattHours ? Number(device.wattHours) : null
|
||||
const tdpMax = device?.tdpMax ? Number(device.tdpMax) : null
|
||||
if (!wh) return null
|
||||
const estimatedBatteryHours = wh / e.tdpWatts!
|
||||
const estimatedBatteryMin = estimatedBatteryHours * 60
|
||||
const estimatedAtMaxTdpMin = tdpMax ? (wh / tdpMax) * 60 : null
|
||||
return {
|
||||
id: e.id,
|
||||
hardwareSlug: e.hardwareSlug,
|
||||
tdpWatts: Number(e.tdpWatts),
|
||||
estimatedBatteryMin: Math.round(estimatedBatteryMin),
|
||||
estimatedBatteryHours: Math.round(estimatedBatteryHours * 10) / 10,
|
||||
wattHours: wh,
|
||||
tdpMax,
|
||||
estimatedAtMaxTdpMin: estimatedAtMaxTdpMin
|
||||
? Math.round(estimatedAtMaxTdpMin)
|
||||
: null,
|
||||
}
|
||||
})
|
||||
.filter(Boolean)
|
||||
|
||||
// ── 11. Filter options ────────────────────────────────────────
|
||||
const protonVersions = [
|
||||
...new Set(entries.map((e) => e.protonVersion).filter(Boolean)),
|
||||
] as string[]
|
||||
const osVersions = [
|
||||
...new Set(entries.map((e) => e.osVersion).filter(Boolean)),
|
||||
] as string[]
|
||||
|
||||
return {
|
||||
summary: {
|
||||
totalEntries,
|
||||
avgFps: Math.round(avgFps * 10) / 10,
|
||||
bestDevice,
|
||||
verifiedCount,
|
||||
versionCount,
|
||||
avgStability,
|
||||
bestOnePercentLow,
|
||||
},
|
||||
isRawPerformer,
|
||||
isPoorPerformance,
|
||||
boxplot,
|
||||
historical,
|
||||
upscalerStats,
|
||||
fpsRange,
|
||||
deviceBreakdown,
|
||||
performanceTiers,
|
||||
stabilityScatter,
|
||||
batteryLife,
|
||||
filterOptions: { protonVersions, osVersions },
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Error computing game stats:", err)
|
||||
set.status = 500
|
||||
return { error: "Failed to compute game stats" }
|
||||
}
|
||||
},
|
||||
{
|
||||
params: t.Object({ gameId: t.String() }),
|
||||
detail: { description: "Aggregated performance statistics for a game — boxplot, historical, upscaler stats, FPS ranges, device breakdown, and battery life estimates." },
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,33 @@
|
||||
import { Elysia, t } from "elysia"
|
||||
import { ensureSteamGame } from "@/lib/steam/sync"
|
||||
|
||||
export const gameStubRoutes = new Elysia({ prefix: "/games", detail: { tags: ["Games"] } }).post(
|
||||
"/stub",
|
||||
async ({ body, set }) => {
|
||||
const result = await ensureSteamGame(body.steamAppId)
|
||||
|
||||
if (!result.game) {
|
||||
set.status = 500
|
||||
return { error: "Failed to create or retrieve game" }
|
||||
}
|
||||
|
||||
// If game already existed, return 200 with created:false
|
||||
if (!result.created) {
|
||||
return { game: result.game, created: false }
|
||||
}
|
||||
|
||||
// If sync failed but stub exists, still return 201 with error info
|
||||
if (result.error) {
|
||||
set.status = 201
|
||||
return { game: result.game, created: true, syncError: result.error }
|
||||
}
|
||||
|
||||
set.status = 201
|
||||
return { game: result.game, created: true }
|
||||
},
|
||||
{
|
||||
body: t.Object({
|
||||
steamAppId: t.Number(),
|
||||
}),
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,495 @@
|
||||
import { Elysia, t } from "elysia"
|
||||
import { db } from "@/lib/db/index"
|
||||
import {
|
||||
games,
|
||||
gameVersions,
|
||||
performanceEntries,
|
||||
gamePlatformSupport,
|
||||
hardware,
|
||||
} from "@/lib/db/schema"
|
||||
import { ilike, or, sql, eq, and, desc, asc, inArray, gte, lte, type SQL } from "drizzle-orm"
|
||||
import { fuzzySearchTerm } from "@/lib/db/search"
|
||||
|
||||
const MAX_OFFSET = 10000
|
||||
const PAGE_SIZE = 24
|
||||
|
||||
export const gamesListingRoutes = new Elysia({ prefix: "/games/listing", detail: { tags: ["Games"] } }).get(
|
||||
"/",
|
||||
async ({ query }) => {
|
||||
const offset = Math.min(Number(query.offset) || 0, MAX_OFFSET)
|
||||
const limit = Math.min(Number(query.limit) || PAGE_SIZE, 100)
|
||||
const search = query.search || ""
|
||||
const genre = query.genre || ""
|
||||
const device = query.device || ""
|
||||
const sort = query.sort || "recent"
|
||||
const order = query.order === "asc" ? asc : desc
|
||||
|
||||
// ── New filter parameters ─────────────────────────────────────
|
||||
const minFps = query.minFps
|
||||
const maxFps = query.maxFps
|
||||
const fsrSupport = query.fsrSupport
|
||||
const protonNative = query.protonNative
|
||||
const antiCheatStatus = query.antiCheatStatus
|
||||
const playabilityStatus = query.playabilityStatus
|
||||
const steamReviewScore = query.steamReviewScore
|
||||
const isFree = query.isFree
|
||||
const hasMultiplayer = query.hasMultiplayer
|
||||
|
||||
// Build where conditions
|
||||
const conditions = []
|
||||
|
||||
// Search filter (title, developer, publisher)
|
||||
if (search) {
|
||||
const titleTerm = fuzzySearchTerm(search)
|
||||
const term = `%${search}%`
|
||||
conditions.push(
|
||||
or(
|
||||
ilike(games.title, titleTerm),
|
||||
ilike(games.developer, term),
|
||||
ilike(games.publisher, term),
|
||||
)!,
|
||||
)
|
||||
}
|
||||
|
||||
// Genre filter (supports comma-separated list with OR logic)
|
||||
if (genre) {
|
||||
const genres = genre.split(",").filter(Boolean)
|
||||
if (genres.length === 1) {
|
||||
conditions.push(sql`${games.genres} @> ${JSON.stringify([genres[0]])}::jsonb`)
|
||||
} else if (genres.length > 1) {
|
||||
// OR logic: game matches ANY of the selected genres
|
||||
const genreConditions = genres.map(
|
||||
(g) => sql`${games.genres} @> ${JSON.stringify([g])}::jsonb`,
|
||||
)
|
||||
conditions.push(or(...genreConditions)!)
|
||||
}
|
||||
}
|
||||
|
||||
// Device filter
|
||||
if (device) {
|
||||
// Get games with platform support
|
||||
const supportedIds = await db
|
||||
.select({ gameId: gamePlatformSupport.gameId })
|
||||
.from(gamePlatformSupport)
|
||||
.where(
|
||||
and(
|
||||
eq(gamePlatformSupport.hardwareSlug, device),
|
||||
eq(gamePlatformSupport.isSupported, true),
|
||||
),
|
||||
)
|
||||
|
||||
// Also get games with performance entries for this hardware
|
||||
const gamesWithBenchmarks = await db
|
||||
.select({ gameId: gameVersions.gameId })
|
||||
.from(performanceEntries)
|
||||
.innerJoin(gameVersions, eq(performanceEntries.versionId, gameVersions.id))
|
||||
.where(eq(performanceEntries.hardwareSlug, device))
|
||||
.groupBy(gameVersions.gameId)
|
||||
|
||||
// Combine both sets
|
||||
const deviceGameIds = new Set([
|
||||
...supportedIds.map((s) => s.gameId),
|
||||
...gamesWithBenchmarks.map((b) => b.gameId),
|
||||
])
|
||||
|
||||
if (deviceGameIds.size > 0) {
|
||||
conditions.push(inArray(games.id, Array.from(deviceGameIds)))
|
||||
} else {
|
||||
return { data: [], total: 0, limit, offset, genres: [], devices: [] }
|
||||
}
|
||||
}
|
||||
|
||||
// ── FPS range filter (games with benchmarks in this range) ────
|
||||
if (minFps || maxFps) {
|
||||
const fpsConditions: (SQL | undefined)[] = [
|
||||
eq(performanceEntries.isRemoved, false),
|
||||
minFps ? gte(performanceEntries.fpsAvg, Number(minFps)) : undefined,
|
||||
maxFps ? lte(performanceEntries.fpsAvg, Number(maxFps)) : undefined,
|
||||
// Scope to active device when device filter is set
|
||||
device ? eq(performanceEntries.hardwareSlug, device) : undefined,
|
||||
].filter((c): c is SQL => c !== undefined)
|
||||
|
||||
const fpsSubquery = db
|
||||
.select({ gameId: gameVersions.gameId })
|
||||
.from(performanceEntries)
|
||||
.innerJoin(gameVersions, eq(performanceEntries.versionId, gameVersions.id))
|
||||
.where(and(...fpsConditions))
|
||||
.groupBy(gameVersions.gameId)
|
||||
|
||||
conditions.push(sql`${games.id} IN (SELECT "gameId" FROM (${fpsSubquery}) AS fps_sub)`)
|
||||
}
|
||||
|
||||
// ── FSR support filter ────────────────────────────────────────
|
||||
if (fsrSupport === "true") {
|
||||
const fsrSubquery = db
|
||||
.select({ gameId: gameVersions.gameId })
|
||||
.from(performanceEntries)
|
||||
.innerJoin(gameVersions, eq(performanceEntries.versionId, gameVersions.id))
|
||||
.where(
|
||||
and(
|
||||
eq(performanceEntries.isRemoved, false),
|
||||
eq(performanceEntries.upscalerType, "fsr"),
|
||||
),
|
||||
)
|
||||
.groupBy(gameVersions.gameId)
|
||||
|
||||
conditions.push(sql`${games.id} IN (SELECT "gameId" FROM (${fsrSubquery}) AS fsr_sub)`)
|
||||
}
|
||||
|
||||
// ── Proton / Native filter ────────────────────────────────────
|
||||
if (protonNative && ["proton", "native", "both"].includes(protonNative)) {
|
||||
const protonConditions: SQL[] = []
|
||||
|
||||
if (protonNative === "proton" || protonNative === "both") {
|
||||
protonConditions.push(eq(gamePlatformSupport.protonStatus, "proton"))
|
||||
}
|
||||
if (protonNative === "native" || protonNative === "both") {
|
||||
protonConditions.push(eq(gamePlatformSupport.protonStatus, "native"))
|
||||
}
|
||||
|
||||
const protonSubquery = db
|
||||
.select({ gameId: gamePlatformSupport.gameId })
|
||||
.from(gamePlatformSupport)
|
||||
.where(or(...protonConditions))
|
||||
.groupBy(gamePlatformSupport.gameId)
|
||||
|
||||
conditions.push(sql`${games.id} IN (SELECT "gameId" FROM (${protonSubquery}) AS proton_sub)`)
|
||||
}
|
||||
|
||||
// ── Anti-cheat status filter ──────────────────────────────────
|
||||
const validAcStatuses = ["supported", "unsupported", "unknown", "none"]
|
||||
if (antiCheatStatus && antiCheatStatus !== "any" && validAcStatuses.includes(antiCheatStatus)) {
|
||||
const acConditions: SQL[] = [
|
||||
eq(gamePlatformSupport.antiCheatRelevant, true),
|
||||
eq(gamePlatformSupport.antiCheatStatus, antiCheatStatus as "none" | "supported" | "unsupported" | "unknown"),
|
||||
]
|
||||
|
||||
const acSubquery = db
|
||||
.select({ gameId: gamePlatformSupport.gameId })
|
||||
.from(gamePlatformSupport)
|
||||
.where(and(...acConditions))
|
||||
.groupBy(gamePlatformSupport.gameId)
|
||||
|
||||
conditions.push(sql`${games.id} IN (SELECT "gameId" FROM (${acSubquery}) AS ac_sub)`)
|
||||
}
|
||||
|
||||
// ── Playability status filter ─────────────────────────────────
|
||||
const validPlayStatuses = ["great", "playable", "needs_tweaks", "unplayable", "unknown"]
|
||||
if (playabilityStatus && validPlayStatuses.includes(playabilityStatus)) {
|
||||
conditions.push(eq(games.playabilityStatus, playabilityStatus as "great" | "playable" | "needs_tweaks" | "unplayable" | "unknown"))
|
||||
}
|
||||
|
||||
// ── Steam review score filter (minimum score) ─────────────────
|
||||
if (steamReviewScore) {
|
||||
conditions.push(gte(games.steamReviewScore, Number(steamReviewScore)))
|
||||
}
|
||||
|
||||
// ── Free-to-play filter ───────────────────────────────────────
|
||||
if (isFree === "true") {
|
||||
conditions.push(eq(games.isFree, true))
|
||||
}
|
||||
|
||||
// ── Has multiplayer filter ────────────────────────────────────
|
||||
if (hasMultiplayer === "true") {
|
||||
conditions.push(
|
||||
or(
|
||||
eq(games.onlineMultiplayerStatus, "supported"),
|
||||
eq(games.onlineMultiplayerStatus, "unknown"),
|
||||
)!,
|
||||
)
|
||||
}
|
||||
|
||||
const where = conditions.length > 0 ? and(...conditions) : undefined
|
||||
|
||||
// Fetch all genres (for filter options)
|
||||
const genreRows = await db
|
||||
.select({ genres: games.genres })
|
||||
.from(games)
|
||||
.where(sql`${games.genres} IS NOT NULL`)
|
||||
|
||||
const genreSet = new Set<string>()
|
||||
for (const row of genreRows) {
|
||||
if (Array.isArray(row.genres)) {
|
||||
for (const g of row.genres) {
|
||||
if (typeof g === "string") genreSet.add(g)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch all hardware devices (for filter options)
|
||||
const deviceRows = await db
|
||||
.select({ slug: hardware.slug, name: hardware.name })
|
||||
.from(hardware)
|
||||
.orderBy(hardware.sortOrder)
|
||||
|
||||
// Count total games matching filters
|
||||
const countQuery = db
|
||||
.select({ count: sql<number>`count(*)::int` })
|
||||
.from(games)
|
||||
.where(where)
|
||||
|
||||
// Determine sort order
|
||||
let orderBy
|
||||
let needsPostSort = false
|
||||
let postSortField: string | null = null
|
||||
|
||||
switch (sort) {
|
||||
case "name":
|
||||
orderBy = order(games.title)
|
||||
break
|
||||
case "popularity":
|
||||
orderBy = order(games.recommendationsTotal)
|
||||
break
|
||||
case "release_date":
|
||||
orderBy = order(games.releaseDate)
|
||||
break
|
||||
case "steam_reviews":
|
||||
orderBy = order(games.steamReviewScore)
|
||||
break
|
||||
case "performance":
|
||||
// Performance sort requires a subquery — use SQL ORDER BY directly
|
||||
orderBy = sql`(
|
||||
SELECT AVG(pe."fps_avg")
|
||||
FROM ${performanceEntries} pe
|
||||
JOIN ${gameVersions} gv ON pe."version_id" = gv.id
|
||||
WHERE gv."game_id" = ${games.id} AND pe."is_removed" = false
|
||||
) DESC NULLS LAST`
|
||||
if (query.order === "asc") {
|
||||
orderBy = sql`(
|
||||
SELECT AVG(pe."fps_avg")
|
||||
FROM ${performanceEntries} pe
|
||||
JOIN ${gameVersions} gv ON pe."version_id" = gv.id
|
||||
WHERE gv."game_id" = ${games.id} AND pe."is_removed" = false
|
||||
) ASC NULLS LAST`
|
||||
}
|
||||
break
|
||||
case "benchmarks":
|
||||
needsPostSort = true
|
||||
postSortField = "benchmarkCount"
|
||||
orderBy = order(games.createdAt)
|
||||
break
|
||||
case "recent":
|
||||
default:
|
||||
orderBy = order(games.createdAt)
|
||||
break
|
||||
}
|
||||
|
||||
// Fetch games page
|
||||
const data = await db
|
||||
.select({
|
||||
id: games.id,
|
||||
steamAppId: games.steamAppId,
|
||||
title: games.title,
|
||||
developer: games.developer,
|
||||
capsuleImage: games.capsuleImage,
|
||||
headerImage: games.headerImage,
|
||||
genres: games.genres,
|
||||
source: games.source,
|
||||
createdAt: games.createdAt,
|
||||
isFree: games.isFree,
|
||||
releaseDate: games.releaseDate,
|
||||
steamReviewScore: games.steamReviewScore,
|
||||
recommendationsTotal: games.recommendationsTotal,
|
||||
playabilityStatus: games.playabilityStatus,
|
||||
onlineMultiplayerStatus: games.onlineMultiplayerStatus,
|
||||
})
|
||||
.from(games)
|
||||
.where(where)
|
||||
.orderBy(orderBy)
|
||||
.limit(limit)
|
||||
.offset(offset)
|
||||
|
||||
const gameIds = data.map((g) => g.id)
|
||||
|
||||
// Fetch benchmark counts for the returned games (separate query to avoid subquery ambiguity)
|
||||
const benchmarkCounts = gameIds.length > 0
|
||||
? await db
|
||||
.select({
|
||||
gameId: gameVersions.gameId,
|
||||
count: sql<number>`count(*)::int`,
|
||||
})
|
||||
.from(performanceEntries)
|
||||
.innerJoin(gameVersions, eq(performanceEntries.versionId, gameVersions.id))
|
||||
.where(
|
||||
and(
|
||||
inArray(gameVersions.gameId, gameIds),
|
||||
eq(performanceEntries.isRemoved, false),
|
||||
),
|
||||
)
|
||||
.groupBy(gameVersions.gameId)
|
||||
: []
|
||||
|
||||
const benchmarkMap = new Map<string, number>()
|
||||
for (const row of benchmarkCounts) {
|
||||
benchmarkMap.set(row.gameId, row.count)
|
||||
}
|
||||
|
||||
// Fetch platform support for the returned games (prioritise Steam Deck)
|
||||
const platformMap = new Map<string, string>()
|
||||
const antiCheatMap = new Map<string, { antiCheatRelevant: boolean; antiCheatStatus: string | null }>()
|
||||
if (gameIds.length > 0) {
|
||||
const platformRows = await db
|
||||
.select({
|
||||
gameId: gamePlatformSupport.gameId,
|
||||
hardwareSlug: gamePlatformSupport.hardwareSlug,
|
||||
protonStatus: gamePlatformSupport.protonStatus,
|
||||
antiCheatRelevant: gamePlatformSupport.antiCheatRelevant,
|
||||
antiCheatStatus: gamePlatformSupport.antiCheatStatus,
|
||||
})
|
||||
.from(gamePlatformSupport)
|
||||
.where(inArray(gamePlatformSupport.gameId, gameIds))
|
||||
|
||||
for (const row of platformRows) {
|
||||
const isSteamDeck = row.hardwareSlug.startsWith("steamdeck")
|
||||
const existing = platformMap.get(row.gameId)
|
||||
if (!existing || (!existing.startsWith("steamdeck") && isSteamDeck)) {
|
||||
platformMap.set(row.gameId, row.protonStatus)
|
||||
}
|
||||
|
||||
const existingAc = antiCheatMap.get(row.gameId)
|
||||
if (row.antiCheatRelevant) {
|
||||
if (!existingAc || (!existingAc.antiCheatRelevant && isSteamDeck) || (!existingAc.antiCheatRelevant)) {
|
||||
antiCheatMap.set(row.gameId, {
|
||||
antiCheatRelevant: row.antiCheatRelevant,
|
||||
antiCheatStatus: row.antiCheatStatus,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Performance stats: best FPS, raw performer, poor performance ──
|
||||
const rawPerformerMap = new Map<string, boolean>()
|
||||
const poorPerformerMap = new Map<string, boolean>()
|
||||
const bestFpsMap = new Map<string, number>()
|
||||
const batteryMinMap = new Map<string, number>()
|
||||
|
||||
if (gameIds.length > 0) {
|
||||
const perfStats = await db
|
||||
.select({
|
||||
gameId: gameVersions.gameId,
|
||||
bestFps: sql<number>`MAX(${performanceEntries.fpsAvg})::real`,
|
||||
isRawPerformer: sql<boolean>`BOOL_OR(
|
||||
${performanceEntries.fpsAvg} >= 60
|
||||
AND ${performanceEntries.upscalerType} = 'none'
|
||||
AND ${performanceEntries.frameGenMethod} = 'none'
|
||||
)`,
|
||||
isPoorPerformance: sql<boolean>`BOOL_OR(${performanceEntries.fpsAvg} < 30)`,
|
||||
})
|
||||
.from(performanceEntries)
|
||||
.innerJoin(gameVersions, eq(performanceEntries.versionId, gameVersions.id))
|
||||
.innerJoin(hardware, and(
|
||||
eq(performanceEntries.hardwareSlug, hardware.slug),
|
||||
eq(hardware.deviceType, "handheld"),
|
||||
))
|
||||
.where(
|
||||
and(
|
||||
inArray(gameVersions.gameId, gameIds),
|
||||
eq(performanceEntries.isRemoved, false),
|
||||
),
|
||||
)
|
||||
.groupBy(gameVersions.gameId)
|
||||
|
||||
for (const row of perfStats) {
|
||||
bestFpsMap.set(row.gameId, row.bestFps)
|
||||
rawPerformerMap.set(row.gameId, row.isRawPerformer)
|
||||
poorPerformerMap.set(row.gameId, row.isPoorPerformance)
|
||||
}
|
||||
|
||||
// Battery estimate for handheld devices
|
||||
const batteryStats = await db
|
||||
.select({
|
||||
gameId: gameVersions.gameId,
|
||||
estimatedBatteryMin: sql<number>`ROUND(
|
||||
(${hardware.wattHours}::real / ${performanceEntries.tdpWatts}) * 60
|
||||
)::int`,
|
||||
})
|
||||
.from(performanceEntries)
|
||||
.innerJoin(gameVersions, eq(performanceEntries.versionId, gameVersions.id))
|
||||
.innerJoin(hardware, eq(performanceEntries.hardwareSlug, hardware.slug))
|
||||
.where(
|
||||
and(
|
||||
inArray(gameVersions.gameId, gameIds),
|
||||
eq(performanceEntries.isRemoved, false),
|
||||
eq(hardware.deviceType, "handheld"),
|
||||
sql`${performanceEntries.tdpWatts} IS NOT NULL AND ${performanceEntries.tdpWatts} > 0`,
|
||||
sql`${hardware.wattHours} IS NOT NULL`,
|
||||
),
|
||||
)
|
||||
.orderBy(desc(performanceEntries.fpsAvg))
|
||||
|
||||
const seenGames = new Set<string>()
|
||||
for (const row of batteryStats) {
|
||||
if (!seenGames.has(row.gameId)) {
|
||||
seenGames.add(row.gameId)
|
||||
batteryMinMap.set(row.gameId, row.estimatedBatteryMin)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const enrichedData = data.map((g) => ({
|
||||
id: g.id,
|
||||
steamAppId: g.steamAppId,
|
||||
title: g.title,
|
||||
developer: g.developer,
|
||||
capsuleImage: g.capsuleImage,
|
||||
headerImage: g.headerImage,
|
||||
genres: g.genres,
|
||||
source: g.source,
|
||||
isFree: g.isFree,
|
||||
releaseDate: g.releaseDate,
|
||||
steamReviewScore: g.steamReviewScore,
|
||||
recommendationsTotal: g.recommendationsTotal,
|
||||
playabilityStatus: g.playabilityStatus,
|
||||
onlineMultiplayerStatus: g.onlineMultiplayerStatus,
|
||||
benchmarkCount: benchmarkMap.get(g.id) ?? 0,
|
||||
deckStatus: platformMap.get(g.id) ?? null,
|
||||
antiCheatRelevant: antiCheatMap.get(g.id)?.antiCheatRelevant ?? false,
|
||||
antiCheatStatus: antiCheatMap.get(g.id)?.antiCheatStatus ?? null,
|
||||
bestFps: bestFpsMap.get(g.id) ?? null,
|
||||
isRawPerformer: rawPerformerMap.get(g.id) ?? false,
|
||||
isPoorPerformance: poorPerformerMap.get(g.id) ?? false,
|
||||
estimatedBatteryMin: batteryMinMap.get(g.id) ?? null,
|
||||
}))
|
||||
|
||||
// If sorting by benchmarks, re-sort the enriched data
|
||||
if (needsPostSort && postSortField === "benchmarkCount") {
|
||||
const sortFn = query.order === "asc"
|
||||
? (a: { benchmarkCount: number }, b: { benchmarkCount: number }) => a.benchmarkCount - b.benchmarkCount
|
||||
: (a: { benchmarkCount: number }, b: { benchmarkCount: number }) => b.benchmarkCount - a.benchmarkCount
|
||||
enrichedData.sort(sortFn)
|
||||
}
|
||||
|
||||
const [{ count: total }] = await countQuery
|
||||
|
||||
return {
|
||||
data: enrichedData,
|
||||
total,
|
||||
limit,
|
||||
offset,
|
||||
genres: Array.from(genreSet).sort(),
|
||||
devices: deviceRows,
|
||||
}
|
||||
},
|
||||
{
|
||||
query: t.Object({
|
||||
offset: t.Optional(t.String()),
|
||||
limit: t.Optional(t.String()),
|
||||
search: t.Optional(t.String()),
|
||||
genre: t.Optional(t.String()),
|
||||
device: t.Optional(t.String()),
|
||||
sort: t.Optional(t.String()),
|
||||
order: t.Optional(t.String()),
|
||||
// New filter parameters
|
||||
minFps: t.Optional(t.String()),
|
||||
maxFps: t.Optional(t.String()),
|
||||
fsrSupport: t.Optional(t.String()),
|
||||
protonNative: t.Optional(t.String()),
|
||||
antiCheatStatus: t.Optional(t.String()),
|
||||
playabilityStatus: t.Optional(t.String()),
|
||||
steamReviewScore: t.Optional(t.String()),
|
||||
isFree: t.Optional(t.String()),
|
||||
hasMultiplayer: t.Optional(t.String()),
|
||||
}),
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,182 @@
|
||||
import { Elysia, t } from "elysia"
|
||||
import { db } from "@/lib/db/index"
|
||||
import { games, gameVersions, gamePlatformSupport } from "@/lib/db/schema"
|
||||
import { ilike, eq } from "drizzle-orm"
|
||||
import { requireRole } from "@/lib/auth/guard"
|
||||
import { generateSlug } from "@/lib/utils/slug"
|
||||
|
||||
export const gamesManualRoutes = new Elysia({ prefix: "/games", detail: { tags: ["Games"] } })
|
||||
.post(
|
||||
"/manual",
|
||||
async ({ request, body, set }) => {
|
||||
const guard = await requireRole(request.headers, ["user", "contributor", "admin"])
|
||||
if (!guard.ok) {
|
||||
set.status = guard.status
|
||||
return { error: guard.error }
|
||||
}
|
||||
|
||||
// Duplicate check: exact title match (case-insensitive)
|
||||
const existing = await db
|
||||
.select({ id: games.id, title: games.title, source: games.source })
|
||||
.from(games)
|
||||
.where(ilike(games.title, body.title))
|
||||
.limit(5)
|
||||
|
||||
if (existing.length > 0) {
|
||||
const exactMatch = existing.find(
|
||||
(g) => g.title.toLowerCase() === body.title.toLowerCase()
|
||||
)
|
||||
if (exactMatch) {
|
||||
set.status = 409
|
||||
return { error: "Game already exists", existingGame: exactMatch }
|
||||
}
|
||||
}
|
||||
|
||||
// Generate slug for non-Steam games
|
||||
const slug = generateSlug(body.title)
|
||||
let finalSlug: string | null = slug
|
||||
if (slug) {
|
||||
let suffix = 2
|
||||
while (true) {
|
||||
const existingSlug = await db
|
||||
.select({ id: games.id })
|
||||
.from(games)
|
||||
.where(eq(games.slug, finalSlug))
|
||||
.limit(1)
|
||||
if (existingSlug.length === 0) break
|
||||
finalSlug = `${slug}-${suffix}`
|
||||
suffix++
|
||||
}
|
||||
} else {
|
||||
finalSlug = null
|
||||
}
|
||||
|
||||
// Create the game
|
||||
const [game] = await db
|
||||
.insert(games)
|
||||
.values({
|
||||
title: body.title,
|
||||
developer: body.developer || null,
|
||||
publisher: body.publisher || null,
|
||||
description: body.description || null,
|
||||
source: body.source || "manual",
|
||||
slug: finalSlug,
|
||||
headerImage: body.headerImage || null,
|
||||
capsuleImage: body.capsuleImage || null,
|
||||
storeUrl: body.storeUrl || null,
|
||||
genres: body.genres || null,
|
||||
releaseDate: body.releaseDate || null,
|
||||
createdBy: guard.user.id,
|
||||
})
|
||||
.returning()
|
||||
|
||||
// Create platform support entries
|
||||
if (body.platformSupport && body.platformSupport.length > 0) {
|
||||
await db.insert(gamePlatformSupport).values(
|
||||
body.platformSupport.map((ps) => ({
|
||||
gameId: game.id,
|
||||
hardwareSlug: ps.hardwareSlug,
|
||||
isSupported: ps.isSupported,
|
||||
protonStatus: ps.protonStatus as "native" | "proton" | "unsupported" | "unknown",
|
||||
}))
|
||||
)
|
||||
}
|
||||
|
||||
// Create initial game version
|
||||
await db.insert(gameVersions).values({
|
||||
gameId: game.id,
|
||||
isLatest: true,
|
||||
})
|
||||
|
||||
set.status = 201
|
||||
return { game }
|
||||
},
|
||||
{
|
||||
body: t.Object({
|
||||
title: t.String({ minLength: 1, maxLength: 200 }),
|
||||
developer: t.Optional(t.String()),
|
||||
publisher: t.Optional(t.String()),
|
||||
description: t.Optional(t.String()),
|
||||
source: t.Optional(t.Union([t.Literal("manual"), t.Literal("gog"), t.Literal("epic")])),
|
||||
headerImage: t.Optional(t.String()),
|
||||
capsuleImage: t.Optional(t.String()),
|
||||
storeUrl: t.Optional(t.String()),
|
||||
genres: t.Optional(t.Array(t.String())),
|
||||
releaseDate: t.Optional(t.String()),
|
||||
platformSupport: t.Optional(
|
||||
t.Array(
|
||||
t.Object({
|
||||
hardwareSlug: t.String(),
|
||||
isSupported: t.Boolean(),
|
||||
protonStatus: t.String(),
|
||||
})
|
||||
)
|
||||
),
|
||||
}),
|
||||
}
|
||||
)
|
||||
.put(
|
||||
"/:gameId/manual",
|
||||
async ({ request, params, body, set }) => {
|
||||
const guard = await requireRole(request.headers, ["user", "contributor", "admin"])
|
||||
if (!guard.ok) {
|
||||
set.status = guard.status
|
||||
return { error: guard.error }
|
||||
}
|
||||
|
||||
const [game] = await db
|
||||
.select()
|
||||
.from(games)
|
||||
.where(eq(games.id, params.gameId))
|
||||
.limit(1)
|
||||
|
||||
if (!game) {
|
||||
set.status = 404
|
||||
return { error: "Game not found" }
|
||||
}
|
||||
|
||||
if (game.source === "steam") {
|
||||
set.status = 403
|
||||
return { error: "Steam games are synced automatically and cannot be manually edited" }
|
||||
}
|
||||
|
||||
// Only creator or admin can edit
|
||||
if (game.createdBy && game.createdBy !== guard.user.id && guard.user.role !== "admin") {
|
||||
set.status = 403
|
||||
return { error: "Only the creator or an admin can edit this game" }
|
||||
}
|
||||
|
||||
const [updated] = await db
|
||||
.update(games)
|
||||
.set({
|
||||
title: body.title ?? game.title,
|
||||
developer: body.developer ?? game.developer,
|
||||
publisher: body.publisher ?? game.publisher,
|
||||
description: body.description ?? game.description,
|
||||
headerImage: body.headerImage ?? game.headerImage,
|
||||
capsuleImage: body.capsuleImage ?? game.capsuleImage,
|
||||
storeUrl: body.storeUrl ?? game.storeUrl,
|
||||
genres: body.genres ?? game.genres,
|
||||
releaseDate: body.releaseDate ?? game.releaseDate,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(games.id, params.gameId))
|
||||
.returning()
|
||||
|
||||
return { game: updated }
|
||||
},
|
||||
{
|
||||
params: t.Object({ gameId: t.String() }),
|
||||
body: t.Object({
|
||||
title: t.Optional(t.String()),
|
||||
developer: t.Optional(t.String()),
|
||||
publisher: t.Optional(t.String()),
|
||||
description: t.Optional(t.String()),
|
||||
headerImage: t.Optional(t.String()),
|
||||
capsuleImage: t.Optional(t.String()),
|
||||
storeUrl: t.Optional(t.String()),
|
||||
genres: t.Optional(t.Array(t.String())),
|
||||
releaseDate: t.Optional(t.String()),
|
||||
}),
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,189 @@
|
||||
import { Elysia, t } from "elysia"
|
||||
import { db } from "@/lib/db/index"
|
||||
import {
|
||||
gameVersions,
|
||||
performanceEntries,
|
||||
hardware,
|
||||
user,
|
||||
gamePlatformSupport,
|
||||
entryScreenshots,
|
||||
} from "@/lib/db/schema"
|
||||
import { and, desc, eq, sql } from "drizzle-orm"
|
||||
import { getR2PublicUrl } from "@/lib/storage"
|
||||
|
||||
/**
|
||||
* GET /api/games/:gameId/performance
|
||||
*
|
||||
* Returns all non-removed performance entries (presets) for a game,
|
||||
* with joined user, hardware, platform support, and screenshot data.
|
||||
* Ordered by isPinned desc, then upvotes desc.
|
||||
*/
|
||||
export const gamesPerformanceRoutes = new Elysia({
|
||||
prefix: "/games",
|
||||
detail: { tags: ["Games"] },
|
||||
}).get(
|
||||
"/:gameId/performance",
|
||||
async ({ params, set }) => {
|
||||
const { gameId } = params
|
||||
|
||||
const presetRows = await db
|
||||
.select({
|
||||
id: performanceEntries.id,
|
||||
hardwareSlug: performanceEntries.hardwareSlug,
|
||||
hardwareName: hardware.name,
|
||||
upvotes: performanceEntries.upvotes,
|
||||
settingsJson: performanceEntries.settingsJson,
|
||||
fpsAvg: performanceEntries.fpsAvg,
|
||||
fpsLow: performanceEntries.fpsLow,
|
||||
fpsHigh: performanceEntries.fpsHigh,
|
||||
fpsOnePercentLow: performanceEntries.fpsOnePercentLow,
|
||||
upscalerType: performanceEntries.upscalerType,
|
||||
upscalerVersion: performanceEntries.upscalerVersion,
|
||||
frameGenMethod: performanceEntries.frameGenMethod,
|
||||
protonVersion: performanceEntries.protonVersion,
|
||||
osVersion: performanceEntries.osVersion,
|
||||
createdAt: performanceEntries.createdAt,
|
||||
userId: performanceEntries.userId,
|
||||
userName: user.name,
|
||||
userImage: user.image,
|
||||
downvotes: performanceEntries.downvotes,
|
||||
launchOptions: performanceEntries.launchOptions,
|
||||
loadTimeSsd: performanceEntries.loadTimeSsd,
|
||||
loadTimeSd: performanceEntries.loadTimeSd,
|
||||
tdpWatts: performanceEntries.tdpWatts,
|
||||
youtubeVideoId: performanceEntries.youtubeVideoId,
|
||||
customSystem: performanceEntries.customSystem,
|
||||
userNotes: performanceEntries.userNotes,
|
||||
versionString: gameVersions.versionString,
|
||||
buildId: gameVersions.buildId,
|
||||
gameAntiCheatName: gamePlatformSupport.antiCheatName,
|
||||
gameAntiCheatStatus: gamePlatformSupport.antiCheatStatus,
|
||||
verifiedAt: performanceEntries.verifiedAt,
|
||||
isPinned: performanceEntries.isPinned,
|
||||
pinnedAt: performanceEntries.pinnedAt,
|
||||
})
|
||||
.from(performanceEntries)
|
||||
.innerJoin(
|
||||
gameVersions,
|
||||
eq(performanceEntries.versionId, gameVersions.id),
|
||||
)
|
||||
.innerJoin(
|
||||
hardware,
|
||||
eq(performanceEntries.hardwareSlug, hardware.slug),
|
||||
)
|
||||
.innerJoin(user, eq(performanceEntries.userId, user.id))
|
||||
.innerJoin(
|
||||
gamePlatformSupport,
|
||||
and(
|
||||
eq(gamePlatformSupport.gameId, gameVersions.gameId),
|
||||
eq(
|
||||
gamePlatformSupport.hardwareSlug,
|
||||
performanceEntries.hardwareSlug,
|
||||
),
|
||||
),
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
eq(gameVersions.gameId, gameId),
|
||||
eq(performanceEntries.isRemoved, false),
|
||||
sql`${performanceEntries.settingsJson} IS NOT NULL`,
|
||||
),
|
||||
)
|
||||
.orderBy(
|
||||
desc(performanceEntries.isPinned),
|
||||
desc(performanceEntries.upvotes),
|
||||
)
|
||||
|
||||
if (presetRows.length === 0) {
|
||||
return []
|
||||
}
|
||||
|
||||
const publicUrl = getR2PublicUrl()
|
||||
|
||||
// Fetch screenshots and hardware details per preset
|
||||
const presets = await Promise.all(
|
||||
presetRows.map(async (p) => {
|
||||
const screenshots = await db
|
||||
.select({
|
||||
id: entryScreenshots.id,
|
||||
storageKey: entryScreenshots.storageKey,
|
||||
orderIndex: entryScreenshots.orderIndex,
|
||||
width: entryScreenshots.width,
|
||||
height: entryScreenshots.height,
|
||||
})
|
||||
.from(entryScreenshots)
|
||||
.where(eq(entryScreenshots.entryId, p.id))
|
||||
.orderBy(entryScreenshots.orderIndex)
|
||||
|
||||
const [hw] = await db
|
||||
.select({
|
||||
wattHours: hardware.wattHours,
|
||||
deviceType: hardware.deviceType,
|
||||
})
|
||||
.from(hardware)
|
||||
.where(eq(hardware.slug, p.hardwareSlug))
|
||||
.limit(1)
|
||||
|
||||
const settingsCount = Array.isArray(p.settingsJson)
|
||||
? (p.settingsJson as Array<{ settings: unknown[] }>).reduce(
|
||||
(sum, cat) => sum + cat.settings.length,
|
||||
0,
|
||||
)
|
||||
: 0
|
||||
|
||||
return {
|
||||
id: p.id,
|
||||
gameId,
|
||||
hardwareSlug: p.hardwareSlug,
|
||||
hardwareName: p.hardwareName,
|
||||
upvotes: p.upvotes,
|
||||
downvotes: p.downvotes,
|
||||
settingsCount,
|
||||
fpsAvg: p.fpsAvg,
|
||||
fpsLow: p.fpsLow,
|
||||
fpsHigh: p.fpsHigh,
|
||||
fpsOnePercentLow: p.fpsOnePercentLow ?? null,
|
||||
upscalerType: p.upscalerType,
|
||||
upscalerVersion: p.upscalerVersion,
|
||||
frameGenMethod: p.frameGenMethod,
|
||||
protonVersion: p.protonVersion,
|
||||
osVersion: p.osVersion,
|
||||
createdAt: p.createdAt.toISOString(),
|
||||
settingsJson: p.settingsJson,
|
||||
launchOptions: p.launchOptions,
|
||||
loadTimeSsd: p.loadTimeSsd ?? null,
|
||||
loadTimeSd: p.loadTimeSd ?? null,
|
||||
tdpWatts: p.tdpWatts ?? null,
|
||||
youtubeVideoId: p.youtubeVideoId ?? null,
|
||||
screenshots: screenshots.map((ss) => ({
|
||||
id: ss.id,
|
||||
url: `${publicUrl}/${ss.storageKey}`,
|
||||
width: ss.width,
|
||||
height: ss.height,
|
||||
orderIndex: ss.orderIndex,
|
||||
})),
|
||||
hardwareWattHours: hw?.wattHours ? Number(hw.wattHours) : null,
|
||||
hardwareDeviceType: hw?.deviceType ?? null,
|
||||
customSystem: p.customSystem ?? false,
|
||||
userNotes: p.userNotes,
|
||||
versionString: p.versionString ?? null,
|
||||
buildId: p.buildId ?? null,
|
||||
gameAntiCheatName: p.gameAntiCheatName ?? null,
|
||||
gameAntiCheatStatus: p.gameAntiCheatStatus ?? null,
|
||||
userId: p.userId,
|
||||
userName: p.userName,
|
||||
userImage: p.userImage,
|
||||
verifiedAt: p.verifiedAt ? p.verifiedAt.toISOString() : null,
|
||||
isPinned: p.isPinned,
|
||||
pinnedAt: p.pinnedAt ? p.pinnedAt.toISOString() : null,
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
return presets
|
||||
},
|
||||
{
|
||||
params: t.Object({ gameId: t.String() }),
|
||||
detail: { description: "Returns all non-removed performance presets for a game with user, hardware, platform support, and screenshot data. Ordered by pinned then upvotes." },
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,359 @@
|
||||
import { Elysia, t } from "elysia"
|
||||
import { createCrudRoutes } from "./crud-builder"
|
||||
import { games, gameVersions } from "@/lib/db/schema"
|
||||
import { db } from "@/lib/db/index"
|
||||
import { eq, and, or, desc, sql } from "drizzle-orm"
|
||||
import { syncSteamGame } from "@/lib/steam/sync"
|
||||
import { requireRole } from "@/lib/auth/guard"
|
||||
|
||||
// ── Games CRUD (uses builder) ─────────────────────────────────────
|
||||
export const gamesRoutes = createCrudRoutes(games, {
|
||||
prefix: "/games",
|
||||
name: "Game",
|
||||
tags: ["Games"],
|
||||
auth: { read: "public", write: "contributor", delete: "admin" },
|
||||
search: { fields: ["title", "developer", "publisher"] },
|
||||
filter: { fields: ["source", "onlineMultiplayerStatus", "syncStatus"] },
|
||||
paramName: "gameId",
|
||||
})
|
||||
|
||||
// ── Game Versions (nested under /games/:gameId/versions) ──────────
|
||||
export const gameVersionsRoutes = new Elysia({
|
||||
prefix: "/games/:gameId/versions",
|
||||
detail: { tags: ["Games"] },
|
||||
})
|
||||
// LIST versions for a game
|
||||
.get(
|
||||
"/",
|
||||
async ({ params, query }) => {
|
||||
const limit = Math.min(Number(query.limit) || 20, 100)
|
||||
const offset = Number(query.offset) || 0
|
||||
|
||||
const data = await db
|
||||
.select()
|
||||
.from(gameVersions)
|
||||
.where(eq(gameVersions.gameId, params.gameId))
|
||||
.orderBy(desc(gameVersions.createdAt))
|
||||
.limit(limit)
|
||||
.offset(offset)
|
||||
|
||||
const [{ count }] = await db
|
||||
.select({ count: sql<number>`count(*)::int` })
|
||||
.from(gameVersions)
|
||||
.where(eq(gameVersions.gameId, params.gameId))
|
||||
|
||||
return { data, total: count, limit, offset }
|
||||
},
|
||||
{
|
||||
params: t.Object({ gameId: t.String() }),
|
||||
query: t.Object({
|
||||
limit: t.Optional(t.String()),
|
||||
offset: t.Optional(t.String()),
|
||||
}),
|
||||
},
|
||||
)
|
||||
// GET single version
|
||||
.get(
|
||||
"/:id",
|
||||
async ({ params, set }) => {
|
||||
const [version] = await db
|
||||
.select()
|
||||
.from(gameVersions)
|
||||
.where(
|
||||
and(
|
||||
eq(gameVersions.id, params.id),
|
||||
eq(gameVersions.gameId, params.gameId),
|
||||
),
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
if (!version) {
|
||||
set.status = 404
|
||||
return { error: "Version not found" }
|
||||
}
|
||||
|
||||
return version
|
||||
},
|
||||
{
|
||||
params: t.Object({ gameId: t.String(), id: t.String() }),
|
||||
},
|
||||
)
|
||||
// CREATE version (contributor+ only)
|
||||
.post(
|
||||
"/",
|
||||
async ({ params, body, request, set }) => {
|
||||
const guard = await requireRole(request.headers, [
|
||||
"contributor",
|
||||
"admin",
|
||||
])
|
||||
if (!guard.ok) {
|
||||
set.status = guard.status
|
||||
return { error: guard.error }
|
||||
}
|
||||
|
||||
// Verify game exists
|
||||
const [game] = await db
|
||||
.select({ id: games.id })
|
||||
.from(games)
|
||||
.where(eq(games.id, params.gameId))
|
||||
.limit(1)
|
||||
|
||||
if (!game) {
|
||||
set.status = 404
|
||||
return { error: "Game not found" }
|
||||
}
|
||||
|
||||
const [created] = await db
|
||||
.insert(gameVersions)
|
||||
.values({ ...body, gameId: params.gameId })
|
||||
.returning()
|
||||
|
||||
set.status = 201
|
||||
return created
|
||||
},
|
||||
{
|
||||
params: t.Object({ gameId: t.String() }),
|
||||
body: t.Object({
|
||||
buildId: t.Optional(t.String()),
|
||||
versionString: t.Optional(t.String()),
|
||||
isLatest: t.Optional(t.Boolean()),
|
||||
}),
|
||||
},
|
||||
)
|
||||
// UPDATE version
|
||||
.patch(
|
||||
"/:id",
|
||||
async ({ params, body, request, set }) => {
|
||||
const guard = await requireRole(request.headers, [
|
||||
"contributor",
|
||||
"admin",
|
||||
])
|
||||
if (!guard.ok) {
|
||||
set.status = guard.status
|
||||
return { error: guard.error }
|
||||
}
|
||||
|
||||
const [updated] = await db
|
||||
.update(gameVersions)
|
||||
.set(body)
|
||||
.where(
|
||||
and(
|
||||
eq(gameVersions.id, params.id),
|
||||
eq(gameVersions.gameId, params.gameId),
|
||||
),
|
||||
)
|
||||
.returning()
|
||||
|
||||
if (!updated) {
|
||||
set.status = 404
|
||||
return { error: "Version not found" }
|
||||
}
|
||||
|
||||
return updated
|
||||
},
|
||||
{
|
||||
params: t.Object({ gameId: t.String(), id: t.String() }),
|
||||
body: t.Object({
|
||||
buildId: t.Optional(t.String()),
|
||||
versionString: t.Optional(t.String()),
|
||||
isLatest: t.Optional(t.Boolean()),
|
||||
}),
|
||||
},
|
||||
)
|
||||
// DELETE version (admin only)
|
||||
.delete(
|
||||
"/:id",
|
||||
async ({ params, request, set }) => {
|
||||
const guard = await requireRole(request.headers, ["admin"])
|
||||
if (!guard.ok) {
|
||||
set.status = guard.status
|
||||
return { error: guard.error }
|
||||
}
|
||||
|
||||
const [deleted] = await db
|
||||
.delete(gameVersions)
|
||||
.where(
|
||||
and(
|
||||
eq(gameVersions.id, params.id),
|
||||
eq(gameVersions.gameId, params.gameId),
|
||||
),
|
||||
)
|
||||
.returning()
|
||||
|
||||
if (!deleted) {
|
||||
set.status = 404
|
||||
return { error: "Version not found" }
|
||||
}
|
||||
|
||||
return { success: true }
|
||||
},
|
||||
{
|
||||
params: t.Object({ gameId: t.String(), id: t.String() }),
|
||||
},
|
||||
)
|
||||
|
||||
// ── Game Sync Routes ────────────────────────────────────────────────
|
||||
const MAX_BULK_SYNC = 1000
|
||||
|
||||
|
||||
export const gameSyncRoutes = new Elysia({
|
||||
prefix: "/games",
|
||||
detail: { tags: ["Admin"] },
|
||||
})
|
||||
// Bulk sync with streaming progress (defined before /:gameId/sync to avoid route conflict)
|
||||
.post(
|
||||
"/sync/bulk",
|
||||
async ({ body, request, set }) => {
|
||||
const guard = await requireRole(request.headers, ["admin"]);
|
||||
if (!guard.ok) {
|
||||
set.status = guard.status;
|
||||
return { error: guard.error };
|
||||
}
|
||||
|
||||
let gamesToSync: { id: string; steamAppId: number | null }[] = [];
|
||||
|
||||
if (body.mode === "all") {
|
||||
gamesToSync = await db
|
||||
.select({ id: games.id, steamAppId: games.steamAppId })
|
||||
.from(games)
|
||||
.where(sql`${games.steamAppId} IS NOT NULL`);
|
||||
} else if (body.mode === "stale") {
|
||||
gamesToSync = await db
|
||||
.select({ id: games.id, steamAppId: games.steamAppId })
|
||||
.from(games)
|
||||
.where(
|
||||
and(
|
||||
sql`${games.steamAppId} IS NOT NULL`,
|
||||
or(
|
||||
sql`${games.lastSync} IS NULL`,
|
||||
sql`${games.lastSync} < NOW() - INTERVAL '7 days'`
|
||||
)
|
||||
)
|
||||
);
|
||||
} else {
|
||||
const gameIds = (body.gameIds || []).slice(0, MAX_BULK_SYNC);
|
||||
if (gameIds.length > 0) {
|
||||
gamesToSync = await db
|
||||
.select({ id: games.id, steamAppId: games.steamAppId })
|
||||
.from(games)
|
||||
.where(
|
||||
and(
|
||||
sql`${games.steamAppId} IS NOT NULL`,
|
||||
sql`${games.id} IN (${sql.join(gameIds.map(id => sql`${id}`), sql`, `)})`
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (gamesToSync.length === 0) {
|
||||
return { total: 0, synced: 0, failed: 0, message: "No games to sync" };
|
||||
}
|
||||
|
||||
// Return streaming response for real-time progress
|
||||
const encoder = new TextEncoder()
|
||||
const stream = new ReadableStream({
|
||||
async start(controller) {
|
||||
type ProgressEvent = { type: string; current?: number; total: number; synced: number; failed: number; currentGame?: string | null }
|
||||
const send = (data: ProgressEvent) => {
|
||||
controller.enqueue(encoder.encode(JSON.stringify(data) + "\n"))
|
||||
}
|
||||
|
||||
// Send initial progress
|
||||
send({ type: "progress", current: 0, total: gamesToSync.length, synced: 0, failed: 0, currentGame: null })
|
||||
|
||||
let synced = 0
|
||||
let failed = 0
|
||||
|
||||
// Process syncs sequentially with delay to avoid rate limiting
|
||||
for (let i = 0; i < gamesToSync.length; i++) {
|
||||
const game = gamesToSync[i]
|
||||
if (!game.steamAppId) continue
|
||||
|
||||
try {
|
||||
const result = await syncSteamGame(game.steamAppId, { forceRetry: true })
|
||||
if (result.success) synced++
|
||||
else failed++
|
||||
} catch {
|
||||
failed++
|
||||
}
|
||||
|
||||
// Send progress update
|
||||
send({
|
||||
type: "progress",
|
||||
current: i + 1,
|
||||
total: gamesToSync.length,
|
||||
synced,
|
||||
failed,
|
||||
currentGame: null,
|
||||
})
|
||||
|
||||
// Delay between syncs to avoid rate limiting (1.5 seconds)
|
||||
if (i < gamesToSync.length - 1) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1500))
|
||||
}
|
||||
}
|
||||
|
||||
// Send completion
|
||||
send({ type: "complete", total: gamesToSync.length, synced, failed })
|
||||
controller.close()
|
||||
}
|
||||
})
|
||||
|
||||
return new Response(stream, {
|
||||
headers: {
|
||||
"Content-Type": "text/event-stream",
|
||||
"Cache-Control": "no-cache",
|
||||
"Connection": "keep-alive",
|
||||
},
|
||||
})
|
||||
},
|
||||
{
|
||||
body: t.Object({
|
||||
gameIds: t.Optional(t.Array(t.String())),
|
||||
mode: t.Union([
|
||||
t.Literal("selected"),
|
||||
t.Literal("all"),
|
||||
t.Literal("stale"),
|
||||
]),
|
||||
}),
|
||||
}
|
||||
)
|
||||
// Single game sync
|
||||
.post(
|
||||
"/:gameId/sync",
|
||||
async ({ params, request, set }) => {
|
||||
const guard = await requireRole(request.headers, ["admin"]);
|
||||
if (!guard.ok) {
|
||||
set.status = guard.status;
|
||||
return { error: guard.error };
|
||||
}
|
||||
|
||||
const [game] = await db
|
||||
.select({ id: games.id, steamAppId: games.steamAppId })
|
||||
.from(games)
|
||||
.where(eq(games.id, params.gameId))
|
||||
.limit(1);
|
||||
|
||||
if (!game) {
|
||||
set.status = 404;
|
||||
return { error: "Game not found" };
|
||||
}
|
||||
|
||||
if (!game.steamAppId) {
|
||||
set.status = 400;
|
||||
return { error: "Game has no Steam App ID" };
|
||||
}
|
||||
|
||||
const result = await syncSteamGame(game.steamAppId, { forceRetry: true });
|
||||
|
||||
if (!result.success) {
|
||||
set.status = 502;
|
||||
return { status: "failed", error: result.error };
|
||||
}
|
||||
|
||||
return { status: "synced", gameId: params.gameId };
|
||||
},
|
||||
{
|
||||
params: t.Object({ gameId: t.String() }),
|
||||
}
|
||||
);
|
||||
@@ -0,0 +1,299 @@
|
||||
import { Elysia, t } from "elysia"
|
||||
import { db } from "@/lib/db/index"
|
||||
import {
|
||||
hardware,
|
||||
performanceEntries,
|
||||
gameVersions,
|
||||
games,
|
||||
} from "@/lib/db/schema"
|
||||
import { eq, and, sql, desc } from "drizzle-orm"
|
||||
|
||||
export const hardwareStatsRoutes = new Elysia({ prefix: "/hardware", detail: { tags: ["Hardware"] } })
|
||||
// ── All devices with aggregated stats ──────────────────────
|
||||
.get(
|
||||
"/stats",
|
||||
async () => {
|
||||
// Get all hardware devices ordered by sortOrder
|
||||
const devices = await db
|
||||
.select({
|
||||
slug: hardware.slug,
|
||||
name: hardware.name,
|
||||
deviceType: hardware.deviceType,
|
||||
sortOrder: hardware.sortOrder,
|
||||
wattHours: hardware.wattHours,
|
||||
tdpMax: hardware.tdpMax,
|
||||
})
|
||||
.from(hardware)
|
||||
.orderBy(hardware.sortOrder)
|
||||
|
||||
// Get aggregated stats per device
|
||||
const statsPerDevice = await db
|
||||
.select({
|
||||
hardwareSlug: performanceEntries.hardwareSlug,
|
||||
totalBenchmarks: sql<number>`count(*)::int`,
|
||||
avgFps: sql<number>`round(avg(${performanceEntries.fpsAvg})::numeric, 1)`,
|
||||
verifiedCount: sql<number>`count(*) filter (where ${performanceEntries.verifiedAt} is not null)::int`,
|
||||
gameCount: sql<number>`count(distinct ${gameVersions.gameId})::int`,
|
||||
})
|
||||
.from(performanceEntries)
|
||||
.innerJoin(gameVersions, eq(performanceEntries.versionId, gameVersions.id))
|
||||
.innerJoin(games, eq(gameVersions.gameId, games.id))
|
||||
.innerJoin(hardware, eq(performanceEntries.hardwareSlug, hardware.slug))
|
||||
.where(eq(performanceEntries.isRemoved, false))
|
||||
.groupBy(performanceEntries.hardwareSlug)
|
||||
|
||||
const statsMap = new Map(statsPerDevice.map((s) => [s.hardwareSlug, s]))
|
||||
|
||||
// Best game per device (highest avg FPS)
|
||||
const bestGames = await db
|
||||
.select({
|
||||
hardwareSlug: performanceEntries.hardwareSlug,
|
||||
gameId: games.id,
|
||||
gameTitle: games.title,
|
||||
gameHeaderImage: games.headerImage,
|
||||
fpsAvg: sql<number>`round(avg(${performanceEntries.fpsAvg})::numeric, 1)`,
|
||||
})
|
||||
.from(performanceEntries)
|
||||
.innerJoin(gameVersions, eq(performanceEntries.versionId, gameVersions.id))
|
||||
.innerJoin(games, eq(gameVersions.gameId, games.id))
|
||||
.where(eq(performanceEntries.isRemoved, false))
|
||||
.groupBy(performanceEntries.hardwareSlug, games.id, games.title, games.headerImage)
|
||||
.orderBy(desc(sql`avg(${performanceEntries.fpsAvg})`))
|
||||
|
||||
// For each device, pick the best game (first result per device slug)
|
||||
const bestGameMap = new Map<string, { id: string; title: string; headerImage: string | null; fpsAvg: number }>()
|
||||
for (const bg of bestGames) {
|
||||
if (!bestGameMap.has(bg.hardwareSlug)) {
|
||||
bestGameMap.set(bg.hardwareSlug, {
|
||||
id: bg.gameId,
|
||||
title: bg.gameTitle,
|
||||
headerImage: bg.gameHeaderImage,
|
||||
fpsAvg: Number(bg.fpsAvg),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return devices.map((device) => {
|
||||
const stats = statsMap.get(device.slug)
|
||||
const bestGame = bestGameMap.get(device.slug)
|
||||
return {
|
||||
slug: device.slug,
|
||||
name: device.name,
|
||||
deviceType: device.deviceType,
|
||||
sortOrder: device.sortOrder,
|
||||
wattHours: device.wattHours ? Number(device.wattHours) : null,
|
||||
tdpMax: device.tdpMax ? Number(device.tdpMax) : null,
|
||||
totalBenchmarks: stats?.totalBenchmarks ?? 0,
|
||||
avgFps: stats?.avgFps ? Number(stats.avgFps) : null,
|
||||
gameCount: stats?.gameCount ?? 0,
|
||||
verifiedCount: stats?.verifiedCount ?? 0,
|
||||
bestGame: bestGame ?? null,
|
||||
}
|
||||
})
|
||||
},
|
||||
)
|
||||
// ── Single device detailed stats ───────────────────────────
|
||||
.get(
|
||||
"/:slug/stats",
|
||||
async ({ params, set }) => {
|
||||
const { slug } = params
|
||||
|
||||
// Verify hardware exists
|
||||
const [device] = await db
|
||||
.select({
|
||||
slug: hardware.slug,
|
||||
name: hardware.name,
|
||||
deviceType: hardware.deviceType,
|
||||
wattHours: hardware.wattHours,
|
||||
tdpMax: hardware.tdpMax,
|
||||
})
|
||||
.from(hardware)
|
||||
.where(eq(hardware.slug, slug))
|
||||
.limit(1)
|
||||
|
||||
if (!device) {
|
||||
set.status = 404
|
||||
return { error: "Device not found" }
|
||||
}
|
||||
|
||||
// All entries for this device
|
||||
const entries = await db
|
||||
.select({
|
||||
id: performanceEntries.id,
|
||||
gameId: games.id,
|
||||
gameTitle: games.title,
|
||||
gameHeaderImage: games.headerImage,
|
||||
fpsAvg: performanceEntries.fpsAvg,
|
||||
fpsLow: performanceEntries.fpsLow,
|
||||
fpsHigh: performanceEntries.fpsHigh,
|
||||
upscalerType: performanceEntries.upscalerType,
|
||||
upscalerVersion: performanceEntries.upscalerVersion,
|
||||
frameGenMethod: performanceEntries.frameGenMethod,
|
||||
protonVersion: performanceEntries.protonVersion,
|
||||
osVersion: performanceEntries.osVersion,
|
||||
upvotes: performanceEntries.upvotes,
|
||||
downvotes: performanceEntries.downvotes,
|
||||
verifiedAt: performanceEntries.verifiedAt,
|
||||
createdAt: performanceEntries.createdAt,
|
||||
genres: games.genres,
|
||||
})
|
||||
.from(performanceEntries)
|
||||
.innerJoin(gameVersions, eq(performanceEntries.versionId, gameVersions.id))
|
||||
.innerJoin(games, eq(gameVersions.gameId, games.id))
|
||||
.where(
|
||||
and(
|
||||
eq(performanceEntries.hardwareSlug, slug),
|
||||
eq(performanceEntries.isRemoved, false),
|
||||
)
|
||||
)
|
||||
|
||||
if (entries.length === 0) {
|
||||
return {
|
||||
...device,
|
||||
wattHours: null,
|
||||
tdpMax: null,
|
||||
totalBenchmarks: 0,
|
||||
avgFps: null,
|
||||
verifiedCount: 0,
|
||||
gameCount: 0,
|
||||
boxplot: [],
|
||||
historical: [],
|
||||
topGames: [],
|
||||
genreBreakdown: [],
|
||||
protonBreakdown: [],
|
||||
upscalerBreakdown: [],
|
||||
}
|
||||
}
|
||||
|
||||
const totalBenchmarks = entries.length
|
||||
// Filter out null FPS entries before computing average (SQL avg() ignores nulls)
|
||||
const fpsEntries = entries.filter((e) => e.fpsAvg !== null)
|
||||
const avgFps =
|
||||
fpsEntries.length > 0
|
||||
? Math.round(
|
||||
(fpsEntries.reduce((s, e) => s + e.fpsAvg!, 0) / fpsEntries.length) * 10
|
||||
) / 10
|
||||
: null
|
||||
const verifiedCount = entries.filter((e) => e.verifiedAt !== null).length
|
||||
|
||||
// Unique game count
|
||||
const gameIds = new Set(entries.map((e) => e.gameId))
|
||||
const gameCount = gameIds.size
|
||||
|
||||
// ── Boxplot: FPS distribution per game ───────────────
|
||||
const gameFpsMap = new Map<string, { title: string; values: number[] }>()
|
||||
for (const e of entries) {
|
||||
if (!gameFpsMap.has(e.gameId)) {
|
||||
gameFpsMap.set(e.gameId, { title: e.gameTitle, values: [] })
|
||||
}
|
||||
gameFpsMap.get(e.gameId)!.values.push(e.fpsAvg ?? 0)
|
||||
}
|
||||
|
||||
// Top 10 games by benchmark count for boxplot
|
||||
const topGameEntries = [...gameFpsMap.entries()]
|
||||
.sort((a, b) => b[1].values.length - a[1].values.length)
|
||||
.slice(0, 10)
|
||||
|
||||
const boxplot = topGameEntries.map(([gameId, { title, values }]) => {
|
||||
const sorted = [...values].sort((a, b) => a - b)
|
||||
const n = sorted.length
|
||||
return {
|
||||
gameId,
|
||||
gameTitle: title,
|
||||
min: sorted[0],
|
||||
q1: sorted[Math.floor(n * 0.25)] ?? sorted[0],
|
||||
median: sorted[Math.floor(n * 0.5)] ?? sorted[0],
|
||||
q3: sorted[Math.floor(n * 0.75)] ?? sorted[n - 1],
|
||||
max: sorted[n - 1],
|
||||
count: n,
|
||||
}
|
||||
})
|
||||
|
||||
// ── Historical: avg FPS per month ───────────────────
|
||||
const monthMap = new Map<string, { sum: number; count: number }>()
|
||||
for (const e of entries) {
|
||||
const month = `${e.createdAt.getFullYear()}-${String(e.createdAt.getMonth() + 1).padStart(2, "0")}`
|
||||
if (!monthMap.has(month)) monthMap.set(month, { sum: 0, count: 0 })
|
||||
const m = monthMap.get(month)!
|
||||
m.sum += e.fpsAvg ?? 0
|
||||
m.count++
|
||||
}
|
||||
|
||||
const historical = [...monthMap.entries()]
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(([period, { sum, count }]) => ({
|
||||
period,
|
||||
avgFps: Math.round((sum / count) * 10) / 10,
|
||||
count,
|
||||
}))
|
||||
|
||||
// ── Top Games by avg FPS ────────────────────────────
|
||||
const topGames = [...gameFpsMap.entries()]
|
||||
.map(([gameId, { title, values }]) => ({
|
||||
gameId,
|
||||
gameTitle: title,
|
||||
headerImage: entries.find((e) => e.gameId === gameId)?.gameHeaderImage ?? null,
|
||||
avgFps: Math.round((values.reduce((a, b) => a + b, 0) / values.length) * 10) / 10,
|
||||
benchmarkCount: values.length,
|
||||
}))
|
||||
.sort((a, b) => b.avgFps - a.avgFps)
|
||||
.slice(0, 20)
|
||||
|
||||
// ── Genre breakdown ─────────────────────────────────
|
||||
const genreMap = new Map<string, number>()
|
||||
for (const e of entries) {
|
||||
if (e.genres && Array.isArray(e.genres)) {
|
||||
for (const g of e.genres) {
|
||||
genreMap.set(g, (genreMap.get(g) || 0) + 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
const genreBreakdown = [...genreMap.entries()]
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.slice(0, 10)
|
||||
.map(([genre, count]) => ({ genre, count }))
|
||||
|
||||
// ── Proton breakdown ────────────────────────────────
|
||||
const protonMap = new Map<string, number>()
|
||||
for (const e of entries) {
|
||||
if (e.protonVersion) {
|
||||
protonMap.set(e.protonVersion, (protonMap.get(e.protonVersion) || 0) + 1)
|
||||
}
|
||||
}
|
||||
const protonBreakdown = [...protonMap.entries()]
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.map(([version, count]) => ({ version, count }))
|
||||
|
||||
// ── Upscaler breakdown ───────────────────────────────────
|
||||
const upscalerMap = new Map<string, { count: number; avgFps: number }>()
|
||||
for (const e of entries) {
|
||||
const key = e.upscalerType ?? "none"
|
||||
if (!upscalerMap.has(key)) upscalerMap.set(key, { count: 0, avgFps: 0 })
|
||||
const f = upscalerMap.get(key)!
|
||||
f.count++
|
||||
f.avgFps += e.fpsAvg ?? 0
|
||||
}
|
||||
const upscalerBreakdown = [...upscalerMap.entries()].map(([type, data]) => ({
|
||||
upscalerType: type,
|
||||
count: data.count,
|
||||
avgFps: Math.round((data.avgFps / data.count) * 10) / 10,
|
||||
}))
|
||||
|
||||
return {
|
||||
...device,
|
||||
totalBenchmarks,
|
||||
avgFps,
|
||||
verifiedCount,
|
||||
gameCount,
|
||||
boxplot,
|
||||
historical,
|
||||
topGames,
|
||||
genreBreakdown,
|
||||
protonBreakdown,
|
||||
upscalerBreakdown,
|
||||
}
|
||||
},
|
||||
{
|
||||
params: t.Object({ slug: t.String() }),
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,11 @@
|
||||
import { createCrudRoutes } from "./crud-builder"
|
||||
import { hardware } from "@/lib/db/schema"
|
||||
|
||||
export const hardwareRoutes = createCrudRoutes(hardware, {
|
||||
prefix: "/hardware",
|
||||
name: "Hardware",
|
||||
tags: ["Hardware"],
|
||||
primaryKey: "slug",
|
||||
auth: { read: "public", write: "admin", delete: "admin" },
|
||||
filter: { fields: ["deviceType"] },
|
||||
})
|
||||
@@ -0,0 +1,19 @@
|
||||
import { Elysia } from "elysia"
|
||||
|
||||
export const healthRoutes = new Elysia({
|
||||
prefix: "/health",
|
||||
detail: { tags: ["Health"] },
|
||||
}).get(
|
||||
"/",
|
||||
() => ({
|
||||
status: "ok",
|
||||
timestamp: new Date().toISOString(),
|
||||
service: "deckyvault-api",
|
||||
}),
|
||||
{
|
||||
detail: {
|
||||
summary: "Health check",
|
||||
description: "Returns the current health status of the API.",
|
||||
},
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,32 @@
|
||||
export { healthRoutes } from "./health"
|
||||
export { userRoutes } from "./user"
|
||||
export { gamesRoutes, gameVersionsRoutes, gameSyncRoutes } from "./games"
|
||||
export { hardwareRoutes } from "./hardware"
|
||||
export { performanceRoutes, performanceVerifyRoutes } from "./performance"
|
||||
export { performanceSubmitRoutes } from "./performance-submit"
|
||||
export { commentsRoutes } from "./comments"
|
||||
export { gamesListingRoutes } from "./games-listing"
|
||||
export { gameStatsRoutes } from "./game-stats"
|
||||
export { gamesPerformanceRoutes } from "./games-performance"
|
||||
export { hardwareStatsRoutes } from "./hardware-stats"
|
||||
export { savedGamesRoutes } from "./saved-games"
|
||||
export { reportRoutes } from "./reports"
|
||||
export { contactRoutes } from "./contact"
|
||||
export { adminReportRoutes } from "./admin-reports"
|
||||
export { adminPerformanceRoutes } from "./admin-performance"
|
||||
export { adminCommentRoutes } from "./admin-comments"
|
||||
export { adminStorageRoutes } from "./admin-storage"
|
||||
export { adminAnalyticsRoutes } from "./admin-analytics"
|
||||
export { steamdbVersionRoutes } from "./steamdb-version"
|
||||
export { steamgridProxyRoutes } from "./steamgrid-proxy"
|
||||
export { gamesManualRoutes } from "./games-manual"
|
||||
export { compareRoutes } from "./compare"
|
||||
export { playabilityRoutes } from "./playability"
|
||||
export { steamReviewRoutes } from "./steam-reviews"
|
||||
export { communitySuggestionRoutes } from "./community-suggestions"
|
||||
export { savedFilterRoutes } from "./saved-filters"
|
||||
export { dashboardRoutes } from "./dashboard"
|
||||
export { dashboardPublicRoutes } from "./dashboard-public"
|
||||
export { cronRoutes } from "./cron"
|
||||
export { profilePhotoRoutes } from "./profile-photo"
|
||||
export { mobileRoutes } from "./mobile"
|
||||
@@ -0,0 +1,966 @@
|
||||
import { Elysia, t } from "elysia"
|
||||
import { db } from "@/lib/db/index"
|
||||
import {
|
||||
games,
|
||||
gameVersions,
|
||||
performanceEntries,
|
||||
hardware,
|
||||
user,
|
||||
gamePlatformSupport,
|
||||
entryScreenshots,
|
||||
gameComments,
|
||||
} from "@/lib/db/schema"
|
||||
import { eq, and, desc, sql, isNull, inArray } from "drizzle-orm"
|
||||
import { getR2PublicUrl } from "@/lib/storage"
|
||||
|
||||
/**
|
||||
* GET /api/mobile/game/:gameId
|
||||
*
|
||||
* Consolidated mobile-optimized endpoint that bundles game details,
|
||||
* stats, presets, platform support, and first page of comments
|
||||
* into a single response — replacing 4+ separate API calls.
|
||||
*/
|
||||
export const mobileRoutes = new Elysia({
|
||||
prefix: "/mobile",
|
||||
detail: { tags: ["Mobile"] },
|
||||
}).get(
|
||||
"/game/:gameId",
|
||||
async ({ params, set }) => {
|
||||
const { gameId } = params
|
||||
|
||||
// ── 1. Fetch game ───────────────────────────────────────────
|
||||
const [game] = await db
|
||||
.select()
|
||||
.from(games)
|
||||
.where(eq(games.id, gameId))
|
||||
.limit(1)
|
||||
|
||||
if (!game) {
|
||||
set.status = 404
|
||||
return { error: "Game not found" }
|
||||
}
|
||||
|
||||
// ── 2. Fetch all non-removed performance entries with joins ──
|
||||
// Used for both stats and presets
|
||||
const entries = await db
|
||||
.select({
|
||||
id: performanceEntries.id,
|
||||
hardwareSlug: performanceEntries.hardwareSlug,
|
||||
hardwareName: hardware.name,
|
||||
fpsAvg: performanceEntries.fpsAvg,
|
||||
fpsLow: performanceEntries.fpsLow,
|
||||
fpsHigh: performanceEntries.fpsHigh,
|
||||
fpsOnePercentLow: performanceEntries.fpsOnePercentLow,
|
||||
upscalerType: performanceEntries.upscalerType,
|
||||
upscalerVersion: performanceEntries.upscalerVersion,
|
||||
frameGenMethod: performanceEntries.frameGenMethod,
|
||||
protonVersion: performanceEntries.protonVersion,
|
||||
osVersion: performanceEntries.osVersion,
|
||||
upvotes: performanceEntries.upvotes,
|
||||
downvotes: performanceEntries.downvotes,
|
||||
verifiedAt: performanceEntries.verifiedAt,
|
||||
userNotes: performanceEntries.userNotes,
|
||||
createdAt: performanceEntries.createdAt,
|
||||
versionId: performanceEntries.versionId,
|
||||
tdpWatts: performanceEntries.tdpWatts,
|
||||
settingsJson: performanceEntries.settingsJson,
|
||||
userId: performanceEntries.userId,
|
||||
userName: user.name,
|
||||
userImage: user.image,
|
||||
launchOptions: performanceEntries.launchOptions,
|
||||
loadTimeSsd: performanceEntries.loadTimeSsd,
|
||||
loadTimeSd: performanceEntries.loadTimeSd,
|
||||
youtubeVideoId: performanceEntries.youtubeVideoId,
|
||||
customSystem: performanceEntries.customSystem,
|
||||
isPinned: performanceEntries.isPinned,
|
||||
pinnedAt: performanceEntries.pinnedAt,
|
||||
versionString: gameVersions.versionString,
|
||||
buildId: gameVersions.buildId,
|
||||
gameAntiCheatName: gamePlatformSupport.antiCheatName,
|
||||
gameAntiCheatStatus: gamePlatformSupport.antiCheatStatus,
|
||||
})
|
||||
.from(performanceEntries)
|
||||
.innerJoin(gameVersions, eq(performanceEntries.versionId, gameVersions.id))
|
||||
.innerJoin(hardware, eq(performanceEntries.hardwareSlug, hardware.slug))
|
||||
.innerJoin(user, eq(performanceEntries.userId, user.id))
|
||||
.innerJoin(
|
||||
gamePlatformSupport,
|
||||
and(
|
||||
eq(gamePlatformSupport.gameId, gameVersions.gameId),
|
||||
eq(gamePlatformSupport.hardwareSlug, performanceEntries.hardwareSlug),
|
||||
),
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
eq(gameVersions.gameId, gameId),
|
||||
eq(performanceEntries.isRemoved, false),
|
||||
),
|
||||
)
|
||||
.orderBy(desc(performanceEntries.isPinned), desc(performanceEntries.upvotes))
|
||||
|
||||
// ── 3. Fetch hardware details for all devices involved ───────
|
||||
const deviceSlugs = [...new Set(entries.map((e) => e.hardwareSlug))]
|
||||
const deviceData = deviceSlugs.length > 0
|
||||
? await db
|
||||
.select({
|
||||
slug: hardware.slug,
|
||||
name: hardware.name,
|
||||
wattHours: hardware.wattHours,
|
||||
tdpMax: hardware.tdpMax,
|
||||
deviceType: hardware.deviceType,
|
||||
})
|
||||
.from(hardware)
|
||||
.where(inArray(hardware.slug, deviceSlugs))
|
||||
: []
|
||||
|
||||
const deviceWattHoursMap = new Map(
|
||||
deviceData.map((d) => [d.slug, d]),
|
||||
)
|
||||
|
||||
// ── 4. Compute stats ────────────────────────────────────────
|
||||
const stats = (() => {
|
||||
if (entries.length === 0) {
|
||||
return {
|
||||
summary: {
|
||||
totalEntries: 0,
|
||||
avgFps: null as number | null,
|
||||
bestDevice: null as string | null,
|
||||
verifiedCount: 0,
|
||||
versionCount: 0,
|
||||
},
|
||||
isRawPerformer: false,
|
||||
isPoorPerformance: false,
|
||||
boxplot: [] as Array<{
|
||||
hardwareSlug: string
|
||||
hardwareName: string
|
||||
min: number
|
||||
q1: number
|
||||
median: number
|
||||
onePercentLow: number
|
||||
q3: number
|
||||
max: number
|
||||
}>,
|
||||
fpsRange: [] as Array<{
|
||||
id: string
|
||||
hardwareSlug: string
|
||||
fpsLow: number
|
||||
fpsAvg: number
|
||||
fpsHigh: number
|
||||
fpsOnePercentLow: number | null
|
||||
isRawPerformer: boolean
|
||||
isPoorPerformer: boolean
|
||||
}>,
|
||||
deviceBreakdown: [] as Array<{
|
||||
hardwareSlug: string
|
||||
hardwareName: string
|
||||
count: number
|
||||
wattHours: number | null
|
||||
tdpMax: number | null
|
||||
deviceType: string | null
|
||||
}>,
|
||||
batteryLife: [] as Array<{
|
||||
id: string
|
||||
hardwareSlug: string
|
||||
tdpWatts: number
|
||||
estimatedBatteryMin: number
|
||||
estimatedBatteryHours: number
|
||||
wattHours: number | null
|
||||
tdpMax: number | null
|
||||
estimatedAtMaxTdpMin: number | null
|
||||
}>,
|
||||
filterOptions: { protonVersions: [] as string[], osVersions: [] as string[] },
|
||||
}
|
||||
}
|
||||
|
||||
const totalEntries = entries.length
|
||||
const avgFps =
|
||||
Math.round(
|
||||
(entries.reduce((sum, e) => sum + (e.fpsAvg ?? 0), 0) / totalEntries) * 10,
|
||||
) / 10
|
||||
const verifiedCount = entries.filter((e) => e.verifiedAt !== null).length
|
||||
|
||||
// Best device by mean fpsAvg
|
||||
const deviceFpsMap = new Map<string, number[]>()
|
||||
for (const e of entries) {
|
||||
const arr = deviceFpsMap.get(e.hardwareSlug) || []
|
||||
arr.push(e.fpsAvg ?? 0)
|
||||
deviceFpsMap.set(e.hardwareSlug, arr)
|
||||
}
|
||||
let bestDevice: string | null = null
|
||||
let bestDeviceAvg = 0
|
||||
for (const [slug, fpsArr] of deviceFpsMap) {
|
||||
const mean = fpsArr.reduce((a, b) => a + b, 0) / fpsArr.length
|
||||
if (mean > bestDeviceAvg) {
|
||||
bestDeviceAvg = mean
|
||||
bestDevice = slug
|
||||
}
|
||||
}
|
||||
|
||||
// Version count
|
||||
const versionCount = new Set(entries.map((e) => e.versionId)).size
|
||||
|
||||
// Flags
|
||||
const isRawPerformer = entries.some(
|
||||
(e) =>
|
||||
(e.fpsAvg ?? 0) >= 60 &&
|
||||
e.upscalerType === "none" &&
|
||||
e.frameGenMethod === "none",
|
||||
)
|
||||
const isPoorPerformance = entries.some((e) => (e.fpsAvg ?? 0) < 30)
|
||||
|
||||
// Boxplot per device
|
||||
const boxplotMap = new Map<
|
||||
string,
|
||||
{ hardwareName: string; values: number[] }
|
||||
>()
|
||||
for (const e of entries) {
|
||||
const existing = boxplotMap.get(e.hardwareSlug) || {
|
||||
hardwareName: e.hardwareName,
|
||||
values: [],
|
||||
}
|
||||
existing.values.push(e.fpsAvg ?? 0)
|
||||
boxplotMap.set(e.hardwareSlug, existing)
|
||||
}
|
||||
|
||||
const boxplot = Array.from(boxplotMap.entries()).map(
|
||||
([slug, { hardwareName, values }]) => {
|
||||
const sorted = [...values].sort((a, b) => a - b)
|
||||
const n = sorted.length
|
||||
const q1Idx = Math.floor(n * 0.25)
|
||||
const medIdx = Math.floor(n * 0.5)
|
||||
const q3Idx = Math.floor(n * 0.75)
|
||||
const onePercentLow =
|
||||
sorted.length > 0
|
||||
? sorted[Math.max(0, Math.floor(sorted.length * 0.01))]
|
||||
: sorted[0]
|
||||
return {
|
||||
hardwareSlug: slug,
|
||||
hardwareName,
|
||||
min: sorted[0],
|
||||
q1: sorted[q1Idx],
|
||||
median: sorted[medIdx],
|
||||
onePercentLow,
|
||||
q3: sorted[q3Idx],
|
||||
max: sorted[n - 1],
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
// FPS range per entry
|
||||
const fpsRange = entries
|
||||
.filter((e) => e.fpsLow !== null && e.fpsHigh !== null)
|
||||
.map((e) => ({
|
||||
id: e.id,
|
||||
hardwareSlug: e.hardwareSlug,
|
||||
fpsLow: e.fpsLow!,
|
||||
fpsAvg: e.fpsAvg ?? 0,
|
||||
fpsHigh: e.fpsHigh!,
|
||||
fpsOnePercentLow: e.fpsOnePercentLow ?? null,
|
||||
isRawPerformer:
|
||||
(e.fpsAvg ?? 0) >= 60 &&
|
||||
e.upscalerType === "none" &&
|
||||
e.frameGenMethod === "none",
|
||||
isPoorPerformer: (e.fpsAvg ?? 0) < 30,
|
||||
}))
|
||||
|
||||
// Device breakdown with hardware info
|
||||
const deviceBreakdown = Array.from(boxplotMap.entries()).map(
|
||||
([slug, { hardwareName, values }]) => {
|
||||
const dev = deviceWattHoursMap.get(slug)
|
||||
return {
|
||||
hardwareSlug: slug,
|
||||
hardwareName,
|
||||
count: values.length,
|
||||
wattHours: dev?.wattHours ? Number(dev.wattHours) : null,
|
||||
tdpMax: dev?.tdpMax ? Number(dev.tdpMax) : null,
|
||||
deviceType: dev?.deviceType ?? null,
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
// Battery life estimates
|
||||
const batteryLife = entries
|
||||
.filter((e) => e.tdpWatts != null && e.tdpWatts > 0)
|
||||
.map((e) => {
|
||||
const device = deviceWattHoursMap.get(e.hardwareSlug)
|
||||
const wh = device?.wattHours ? Number(device.wattHours) : null
|
||||
const tdpMax = device?.tdpMax ? Number(device.tdpMax) : null
|
||||
if (!wh) return null
|
||||
const estimatedBatteryHours = wh / e.tdpWatts!
|
||||
const estimatedBatteryMin = estimatedBatteryHours * 60
|
||||
const estimatedAtMaxTdpMin = tdpMax ? (wh / tdpMax) * 60 : null
|
||||
return {
|
||||
id: e.id,
|
||||
hardwareSlug: e.hardwareSlug,
|
||||
tdpWatts: Number(e.tdpWatts),
|
||||
estimatedBatteryMin: Math.round(estimatedBatteryMin),
|
||||
estimatedBatteryHours: Math.round(estimatedBatteryHours * 10) / 10,
|
||||
wattHours: wh,
|
||||
tdpMax,
|
||||
estimatedAtMaxTdpMin: estimatedAtMaxTdpMin
|
||||
? Math.round(estimatedAtMaxTdpMin)
|
||||
: null,
|
||||
}
|
||||
})
|
||||
.filter(Boolean) as Array<{
|
||||
id: string
|
||||
hardwareSlug: string
|
||||
tdpWatts: number
|
||||
estimatedBatteryMin: number
|
||||
estimatedBatteryHours: number
|
||||
wattHours: number | null
|
||||
tdpMax: number | null
|
||||
estimatedAtMaxTdpMin: number | null
|
||||
}>
|
||||
|
||||
// Filter options
|
||||
const protonVersions = [
|
||||
...new Set(entries.map((e) => e.protonVersion).filter(Boolean)),
|
||||
] as string[]
|
||||
const osVersions = [
|
||||
...new Set(entries.map((e) => e.osVersion).filter(Boolean)),
|
||||
] as string[]
|
||||
|
||||
return {
|
||||
summary: {
|
||||
totalEntries,
|
||||
avgFps,
|
||||
bestDevice,
|
||||
verifiedCount,
|
||||
versionCount,
|
||||
},
|
||||
isRawPerformer,
|
||||
isPoorPerformance,
|
||||
boxplot,
|
||||
fpsRange,
|
||||
deviceBreakdown,
|
||||
batteryLife,
|
||||
filterOptions: { protonVersions, osVersions },
|
||||
}
|
||||
})()
|
||||
|
||||
// ── 5. Build presets (entries with settingsJson) ──────────────
|
||||
const presetEntries = entries.filter((e) => e.settingsJson !== null)
|
||||
const publicUrl = getR2PublicUrl()
|
||||
|
||||
const presetEntryIds = presetEntries.map((e) => e.id)
|
||||
|
||||
// Batch-fetch screenshots for all presets
|
||||
const screenshotsMap = new Map<
|
||||
string,
|
||||
Array<{
|
||||
id: string
|
||||
url: string
|
||||
width: number
|
||||
height: number
|
||||
orderIndex: number
|
||||
}>
|
||||
>()
|
||||
|
||||
if (presetEntryIds.length > 0) {
|
||||
const allScreenshots = await db
|
||||
.select({
|
||||
id: entryScreenshots.id,
|
||||
storageKey: entryScreenshots.storageKey,
|
||||
orderIndex: entryScreenshots.orderIndex,
|
||||
width: entryScreenshots.width,
|
||||
height: entryScreenshots.height,
|
||||
entryId: entryScreenshots.entryId,
|
||||
})
|
||||
.from(entryScreenshots)
|
||||
.where(inArray(entryScreenshots.entryId, presetEntryIds))
|
||||
.orderBy(entryScreenshots.orderIndex)
|
||||
|
||||
for (const ss of allScreenshots) {
|
||||
const arr = screenshotsMap.get(ss.entryId) || []
|
||||
arr.push({
|
||||
id: ss.id,
|
||||
url: `${publicUrl}/${ss.storageKey}`,
|
||||
width: ss.width,
|
||||
height: ss.height,
|
||||
orderIndex: ss.orderIndex,
|
||||
})
|
||||
screenshotsMap.set(ss.entryId, arr)
|
||||
}
|
||||
}
|
||||
|
||||
const presets = presetEntries.map((p) => {
|
||||
const settingsCount = Array.isArray(p.settingsJson)
|
||||
? (p.settingsJson as Array<{ settings: unknown[] }>).reduce(
|
||||
(sum, cat) => sum + cat.settings.length,
|
||||
0,
|
||||
)
|
||||
: 0
|
||||
|
||||
const hw = deviceWattHoursMap.get(p.hardwareSlug)
|
||||
|
||||
return {
|
||||
id: p.id,
|
||||
gameId,
|
||||
hardwareSlug: p.hardwareSlug,
|
||||
hardwareName: p.hardwareName,
|
||||
upvotes: p.upvotes,
|
||||
downvotes: p.downvotes,
|
||||
settingsCount,
|
||||
fpsAvg: p.fpsAvg,
|
||||
fpsLow: p.fpsLow,
|
||||
fpsHigh: p.fpsHigh,
|
||||
fpsOnePercentLow: p.fpsOnePercentLow ?? null,
|
||||
upscalerType: p.upscalerType,
|
||||
upscalerVersion: p.upscalerVersion,
|
||||
frameGenMethod: p.frameGenMethod,
|
||||
protonVersion: p.protonVersion,
|
||||
osVersion: p.osVersion,
|
||||
createdAt: p.createdAt.toISOString(),
|
||||
settingsJson: p.settingsJson,
|
||||
launchOptions: p.launchOptions,
|
||||
loadTimeSsd: p.loadTimeSsd ?? null,
|
||||
loadTimeSd: p.loadTimeSd ?? null,
|
||||
tdpWatts: p.tdpWatts ?? null,
|
||||
youtubeVideoId: p.youtubeVideoId ?? null,
|
||||
screenshots: screenshotsMap.get(p.id) || [],
|
||||
hardwareWattHours: hw?.wattHours ? Number(hw.wattHours) : null,
|
||||
hardwareDeviceType: hw?.deviceType ?? null,
|
||||
customSystem: p.customSystem ?? false,
|
||||
userNotes: p.userNotes,
|
||||
versionString: p.versionString ?? null,
|
||||
buildId: p.buildId ?? null,
|
||||
gameAntiCheatName: p.gameAntiCheatName ?? null,
|
||||
gameAntiCheatStatus: p.gameAntiCheatStatus ?? null,
|
||||
userId: p.userId,
|
||||
userName: p.userName,
|
||||
userImage: p.userImage,
|
||||
verifiedAt: p.verifiedAt ? p.verifiedAt.toISOString() : null,
|
||||
isPinned: p.isPinned,
|
||||
pinnedAt: p.pinnedAt ? p.pinnedAt.toISOString() : null,
|
||||
}
|
||||
})
|
||||
|
||||
// ── 6. Fetch platform support ────────────────────────────────
|
||||
const platformSupport = await db
|
||||
.select({
|
||||
id: gamePlatformSupport.id,
|
||||
hardwareSlug: gamePlatformSupport.hardwareSlug,
|
||||
isSupported: gamePlatformSupport.isSupported,
|
||||
protonStatus: gamePlatformSupport.protonStatus,
|
||||
antiCheatRelevant: gamePlatformSupport.antiCheatRelevant,
|
||||
antiCheatName: gamePlatformSupport.antiCheatName,
|
||||
antiCheatVersion: gamePlatformSupport.antiCheatVersion,
|
||||
antiCheatStatus: gamePlatformSupport.antiCheatStatus,
|
||||
playabilityStatus: gamePlatformSupport.playabilityStatus,
|
||||
playabilityOverride: gamePlatformSupport.playabilityOverride,
|
||||
playabilityCalculatedAt: gamePlatformSupport.playabilityCalculatedAt,
|
||||
createdAt: gamePlatformSupport.createdAt,
|
||||
updatedAt: gamePlatformSupport.updatedAt,
|
||||
})
|
||||
.from(gamePlatformSupport)
|
||||
.where(eq(gamePlatformSupport.gameId, gameId))
|
||||
|
||||
// ── 7. Fetch first page of comments ──────────────────────────
|
||||
const commentLimit = 20
|
||||
const commentConditions = [
|
||||
eq(gameComments.gameId, gameId),
|
||||
eq(gameComments.isRemoved, false),
|
||||
isNull(gameComments.parentId),
|
||||
]
|
||||
|
||||
const [commentData, [{ count: commentTotal }]] = await Promise.all([
|
||||
db
|
||||
.select({
|
||||
id: gameComments.id,
|
||||
gameId: gameComments.gameId,
|
||||
userId: gameComments.userId,
|
||||
parentId: gameComments.parentId,
|
||||
content: gameComments.content,
|
||||
upvotes: gameComments.upvotes,
|
||||
createdAt: gameComments.createdAt,
|
||||
updatedAt: gameComments.updatedAt,
|
||||
userName: user.name,
|
||||
userImage: user.image,
|
||||
})
|
||||
.from(gameComments)
|
||||
.innerJoin(user, eq(gameComments.userId, user.id))
|
||||
.where(and(...commentConditions))
|
||||
.orderBy(desc(gameComments.createdAt))
|
||||
.limit(commentLimit),
|
||||
db
|
||||
.select({ count: sql<number>`count(*)::int` })
|
||||
.from(gameComments)
|
||||
.where(and(...commentConditions)),
|
||||
])
|
||||
|
||||
// ── 8. Assemble response ─────────────────────────────────────
|
||||
return {
|
||||
game: {
|
||||
id: game.id,
|
||||
steamAppId: game.steamAppId,
|
||||
title: game.title,
|
||||
description: game.description,
|
||||
developer: game.developer,
|
||||
publisher: game.publisher,
|
||||
genres: game.genres,
|
||||
headerImage: game.headerImage,
|
||||
capsuleImage: game.capsuleImage,
|
||||
storeUrl: game.storeUrl,
|
||||
source: game.source,
|
||||
lastSync: game.lastSync?.toISOString() ?? null,
|
||||
syncStatus: game.syncStatus,
|
||||
createdAt: game.createdAt.toISOString(),
|
||||
systemRequirements: game.systemRequirements,
|
||||
metacriticScore: game.metacriticScore,
|
||||
metacriticUrl: game.metacriticUrl,
|
||||
recommendationsTotal: game.recommendationsTotal,
|
||||
priceCurrent: game.priceCurrent,
|
||||
priceInitial: game.priceInitial,
|
||||
priceCurrency: game.priceCurrency,
|
||||
isFree: game.isFree,
|
||||
releaseDate: game.releaseDate,
|
||||
categories: game.categories,
|
||||
platforms: game.platforms,
|
||||
steamReviewScore: game.steamReviewScore,
|
||||
steamReviewSentiment: game.steamReviewSentiment,
|
||||
steamReviewCount: game.steamReviewCount,
|
||||
},
|
||||
stats,
|
||||
presets,
|
||||
platformSupport,
|
||||
comments: {
|
||||
data: commentData,
|
||||
total: commentTotal,
|
||||
},
|
||||
}
|
||||
},
|
||||
{
|
||||
params: t.Object({ gameId: t.String() }),
|
||||
response: t.Union([
|
||||
t.Object({
|
||||
game: t.Any(),
|
||||
stats: t.Any(),
|
||||
presets: t.Array(t.Any()),
|
||||
platformSupport: t.Array(t.Any()),
|
||||
comments: t.Object({
|
||||
data: t.Array(t.Any()),
|
||||
total: t.Number(),
|
||||
}),
|
||||
}),
|
||||
t.Object({ error: t.String() }),
|
||||
]),
|
||||
},
|
||||
)
|
||||
// ── Mobile Search (DB-synced games only, no Steam results) ──────
|
||||
.get(
|
||||
"/search",
|
||||
async ({ query, set }) => {
|
||||
if (!query.q || query.q.length < 2) {
|
||||
set.status = 400
|
||||
return { error: "Query must be at least 2 characters" }
|
||||
}
|
||||
|
||||
const { ilike, or, sql: dsql, eq: deq, and: dand, desc: ddesc, inArray, gte } = await import("drizzle-orm")
|
||||
const { fuzzySearchTerm } = await import("@/lib/db/search")
|
||||
|
||||
const titleTerm = fuzzySearchTerm(query.q)
|
||||
const term = `%${query.q}%`
|
||||
|
||||
// Build filter conditions
|
||||
const baseFilterConditions = [
|
||||
or(
|
||||
ilike(games.title, titleTerm),
|
||||
ilike(games.developer, term),
|
||||
ilike(games.publisher, term),
|
||||
),
|
||||
]
|
||||
|
||||
if (query.playabilityStatus) {
|
||||
baseFilterConditions.push(dsql`${games.playabilityStatus} = ${query.playabilityStatus}`)
|
||||
}
|
||||
|
||||
// Search local database only
|
||||
const localGames = await db
|
||||
.select({
|
||||
id: games.id,
|
||||
steamAppId: games.steamAppId,
|
||||
title: games.title,
|
||||
capsuleImage: games.capsuleImage,
|
||||
headerImage: games.headerImage,
|
||||
playabilityStatus: games.playabilityStatus,
|
||||
steamReviewScore: games.steamReviewScore,
|
||||
})
|
||||
.from(games)
|
||||
.where(dand(...baseFilterConditions))
|
||||
.limit(40)
|
||||
|
||||
if (localGames.length === 0) {
|
||||
return { results: [], total: 0 }
|
||||
}
|
||||
|
||||
const gameIds = localGames.map((g) => g.id)
|
||||
|
||||
// Fetch benchmark counts
|
||||
const benchmarkCounts = await db
|
||||
.select({ gameId: gameVersions.gameId, count: sql<number>`count(*)::int` })
|
||||
.from(performanceEntries)
|
||||
.innerJoin(gameVersions, deq(performanceEntries.versionId, gameVersions.id))
|
||||
.where(dand(inArray(gameVersions.gameId, gameIds), deq(performanceEntries.isRemoved, false)))
|
||||
.groupBy(gameVersions.gameId)
|
||||
|
||||
const bmMap = new Map<string, number>()
|
||||
for (const r of benchmarkCounts) bmMap.set(r.gameId, r.count)
|
||||
|
||||
// Fetch comment counts
|
||||
const commentCounts = await db
|
||||
.select({ gameId: gameComments.gameId, count: sql<number>`count(*)::int` })
|
||||
.from(gameComments)
|
||||
.where(inArray(gameComments.gameId, gameIds))
|
||||
.groupBy(gameComments.gameId)
|
||||
|
||||
const cmMap = new Map<string, number>()
|
||||
for (const r of commentCounts) cmMap.set(r.gameId, r.count)
|
||||
|
||||
// Platform support (for playability status per game)
|
||||
const platformRows = await db
|
||||
.select({ gameId: gamePlatformSupport.gameId, hardwareSlug: gamePlatformSupport.hardwareSlug, protonStatus: gamePlatformSupport.protonStatus })
|
||||
.from(gamePlatformSupport)
|
||||
.where(inArray(gamePlatformSupport.gameId, gameIds))
|
||||
|
||||
const platformMap = new Map<string, string>()
|
||||
for (const r of platformRows) {
|
||||
const existing = platformMap.get(r.gameId)
|
||||
if (!existing || (!existing.startsWith("steamdeck") && r.hardwareSlug.startsWith("steamdeck"))) {
|
||||
platformMap.set(r.gameId, r.protonStatus)
|
||||
}
|
||||
}
|
||||
|
||||
// Performance stats (raw performer, poor performance, best FPS)
|
||||
const perfStats = await db
|
||||
.select({
|
||||
gameId: gameVersions.gameId,
|
||||
bestFps: sql<number>`MAX(${performanceEntries.fpsAvg})::real`,
|
||||
isRawPerformer: sql<boolean>`BOOL_OR(${performanceEntries.fpsAvg} >= 60 AND ${performanceEntries.upscalerType} = 'none' AND ${performanceEntries.frameGenMethod} = 'none')`,
|
||||
isPoorPerformance: sql<boolean>`BOOL_OR(${performanceEntries.fpsAvg} < 30)`,
|
||||
})
|
||||
.from(performanceEntries)
|
||||
.innerJoin(gameVersions, deq(performanceEntries.versionId, gameVersions.id))
|
||||
.where(dand(inArray(gameVersions.gameId, gameIds), deq(performanceEntries.isRemoved, false)))
|
||||
.groupBy(gameVersions.gameId)
|
||||
|
||||
const perfMap = new Map<string, { bestFps: number | null; isRawPerformer: boolean; isPoorPerformance: boolean }>()
|
||||
for (const r of perfStats) {
|
||||
perfMap.set(r.gameId, { bestFps: r.bestFps, isRawPerformer: r.isRawPerformer, isPoorPerformance: r.isPoorPerformance })
|
||||
}
|
||||
|
||||
// Battery estimates (handheld only)
|
||||
const batteryStats = await db
|
||||
.select({
|
||||
gameId: gameVersions.gameId,
|
||||
estimatedBatteryMin: sql<number>`ROUND((${hardware.wattHours}::real / ${performanceEntries.tdpWatts}) * 60)::int`,
|
||||
})
|
||||
.from(performanceEntries)
|
||||
.innerJoin(gameVersions, deq(performanceEntries.versionId, gameVersions.id))
|
||||
.innerJoin(hardware, deq(performanceEntries.hardwareSlug, hardware.slug))
|
||||
.where(
|
||||
dand(
|
||||
inArray(gameVersions.gameId, gameIds),
|
||||
deq(performanceEntries.isRemoved, false),
|
||||
deq(hardware.deviceType, "handheld"),
|
||||
dsql`${performanceEntries.tdpWatts} IS NOT NULL AND ${performanceEntries.tdpWatts} > 0`,
|
||||
dsql`${hardware.wattHours} IS NOT NULL`,
|
||||
),
|
||||
)
|
||||
.orderBy(ddesc(performanceEntries.fpsAvg))
|
||||
|
||||
const batteryMap = new Map<string, number>()
|
||||
const seen = new Set<string>()
|
||||
for (const r of batteryStats) {
|
||||
if (!seen.has(r.gameId)) { seen.add(r.gameId); batteryMap.set(r.gameId, r.estimatedBatteryMin) }
|
||||
}
|
||||
|
||||
// Build results
|
||||
const results = localGames.map((g) => {
|
||||
const p = perfMap.get(g.id)
|
||||
return {
|
||||
id: g.id,
|
||||
title: g.title,
|
||||
capsuleImage: g.capsuleImage,
|
||||
headerImage: g.headerImage,
|
||||
playabilityStatus: g.playabilityStatus ?? null,
|
||||
platformStatus: platformMap.get(g.id) ?? null,
|
||||
isRawPerformer: p?.isRawPerformer ?? false,
|
||||
isPoorPerformance: p?.isPoorPerformance ?? false,
|
||||
bestFps: p?.bestFps ?? null,
|
||||
estimatedBatteryMin: batteryMap.get(g.id) ?? null,
|
||||
benchmarkCount: bmMap.get(g.id) ?? 0,
|
||||
commentCount: cmMap.get(g.id) ?? 0,
|
||||
steamReviewScore: g.steamReviewScore ?? null,
|
||||
}
|
||||
})
|
||||
|
||||
return { results, total: results.length }
|
||||
},
|
||||
{
|
||||
query: t.Object({
|
||||
q: t.String(),
|
||||
playabilityStatus: t.Optional(t.String()),
|
||||
}),
|
||||
detail: {
|
||||
description: "Search synced games only — returns mobile-optimized results with performance tags. No Steam-only entries.",
|
||||
},
|
||||
response: t.Union([
|
||||
t.Object({
|
||||
results: t.Array(t.Object({
|
||||
id: t.String(),
|
||||
title: t.String(),
|
||||
capsuleImage: t.Union([t.String(), t.Null()]),
|
||||
headerImage: t.Union([t.String(), t.Null()]),
|
||||
playabilityStatus: t.Union([t.String(), t.Null()]),
|
||||
platformStatus: t.Union([t.String(), t.Null()]),
|
||||
isRawPerformer: t.Boolean(),
|
||||
isPoorPerformance: t.Boolean(),
|
||||
bestFps: t.Union([t.Number(), t.Null()]),
|
||||
estimatedBatteryMin: t.Union([t.Number(), t.Null()]),
|
||||
benchmarkCount: t.Number(),
|
||||
commentCount: t.Number(),
|
||||
steamReviewScore: t.Union([t.Number(), t.Null()]),
|
||||
})),
|
||||
total: t.Number(),
|
||||
}),
|
||||
t.Object({ error: t.String() }),
|
||||
]),
|
||||
},
|
||||
)
|
||||
// ── Benchmark Detail (structured sections) ────────────────────
|
||||
.get(
|
||||
"/benchmark/:entryId",
|
||||
async ({ params, set }) => {
|
||||
const { entryId } = params
|
||||
|
||||
const [entry] = await db
|
||||
.select({
|
||||
id: performanceEntries.id,
|
||||
fpsAvg: performanceEntries.fpsAvg,
|
||||
fpsLow: performanceEntries.fpsLow,
|
||||
fpsHigh: performanceEntries.fpsHigh,
|
||||
fpsOnePercentLow: performanceEntries.fpsOnePercentLow,
|
||||
hardwareSlug: performanceEntries.hardwareSlug,
|
||||
tdpWatts: performanceEntries.tdpWatts,
|
||||
upscalerType: performanceEntries.upscalerType,
|
||||
upscalerVersion: performanceEntries.upscalerVersion,
|
||||
frameGenMethod: performanceEntries.frameGenMethod,
|
||||
protonVersion: performanceEntries.protonVersion,
|
||||
osVersion: performanceEntries.osVersion,
|
||||
launchOptions: performanceEntries.launchOptions,
|
||||
loadTimeSsd: performanceEntries.loadTimeSsd,
|
||||
loadTimeSd: performanceEntries.loadTimeSd,
|
||||
youtubeVideoId: performanceEntries.youtubeVideoId,
|
||||
userNotes: performanceEntries.userNotes,
|
||||
customSystem: performanceEntries.customSystem,
|
||||
userId: performanceEntries.userId,
|
||||
verifiedAt: performanceEntries.verifiedAt,
|
||||
isPinned: performanceEntries.isPinned,
|
||||
createdAt: performanceEntries.createdAt,
|
||||
upvotes: performanceEntries.upvotes,
|
||||
downvotes: performanceEntries.downvotes,
|
||||
settingsJson: performanceEntries.settingsJson,
|
||||
hardwareName: hardware.name,
|
||||
hardwareDeviceType: hardware.deviceType,
|
||||
hardwareWattHours: hardware.wattHours,
|
||||
hardwareTdpMax: hardware.tdpMax,
|
||||
gameId: gameVersions.gameId,
|
||||
gameTitle: games.title,
|
||||
gameCapsuleImage: games.capsuleImage,
|
||||
versionString: gameVersions.versionString,
|
||||
buildId: gameVersions.buildId,
|
||||
userName: user.name,
|
||||
userImage: user.image,
|
||||
gameAntiCheatName: gamePlatformSupport.antiCheatName,
|
||||
gameAntiCheatStatus: gamePlatformSupport.antiCheatStatus,
|
||||
})
|
||||
.from(performanceEntries)
|
||||
.innerJoin(hardware, eq(performanceEntries.hardwareSlug, hardware.slug))
|
||||
.innerJoin(gameVersions, eq(performanceEntries.versionId, gameVersions.id))
|
||||
.innerJoin(games, eq(gameVersions.gameId, games.id))
|
||||
.innerJoin(user, eq(performanceEntries.userId, user.id))
|
||||
.innerJoin(
|
||||
gamePlatformSupport,
|
||||
and(
|
||||
eq(gamePlatformSupport.gameId, gameVersions.gameId),
|
||||
eq(gamePlatformSupport.hardwareSlug, performanceEntries.hardwareSlug),
|
||||
),
|
||||
)
|
||||
.where(and(eq(performanceEntries.id, entryId), eq(performanceEntries.isRemoved, false)))
|
||||
.limit(1)
|
||||
|
||||
if (!entry) {
|
||||
set.status = 404
|
||||
return { error: "Benchmark entry not found" }
|
||||
}
|
||||
|
||||
const publicUrl = getR2PublicUrl()
|
||||
const screenshotRows = await db
|
||||
.select({ id: entryScreenshots.id, storageKey: entryScreenshots.storageKey, orderIndex: entryScreenshots.orderIndex, width: entryScreenshots.width, height: entryScreenshots.height })
|
||||
.from(entryScreenshots)
|
||||
.where(eq(entryScreenshots.entryId, entryId))
|
||||
.orderBy(entryScreenshots.orderIndex)
|
||||
|
||||
const screenshots = screenshotRows.map((ss) => ({
|
||||
id: ss.id,
|
||||
url: `${publicUrl}/${ss.storageKey}`,
|
||||
width: ss.width,
|
||||
height: ss.height,
|
||||
orderIndex: ss.orderIndex,
|
||||
}))
|
||||
|
||||
// Compute battery estimates
|
||||
const wh = entry.hardwareWattHours ? Number(entry.hardwareWattHours) : null
|
||||
const tdpMax = entry.hardwareTdpMax ? Number(entry.hardwareTdpMax) : null
|
||||
const isHandheld = entry.hardwareDeviceType === "handheld"
|
||||
const estimatedBatteryHours = (isHandheld && wh && entry.tdpWatts) ? wh / Number(entry.tdpWatts) : null
|
||||
const estimatedBatteryMin = estimatedBatteryHours ? Math.round(estimatedBatteryHours * 60) : null
|
||||
const estimatedAtMaxTdpMin = (isHandheld && wh && tdpMax) ? Math.round((wh / tdpMax) * 60) : null
|
||||
|
||||
return {
|
||||
benchmark: {
|
||||
id: entry.id,
|
||||
gameId: entry.gameId,
|
||||
gameTitle: entry.gameTitle,
|
||||
gameCapsuleImage: entry.gameCapsuleImage,
|
||||
hardwareSlug: entry.hardwareSlug,
|
||||
hardwareName: entry.hardwareName,
|
||||
hardwareDeviceType: entry.hardwareDeviceType,
|
||||
createdAt: entry.createdAt.toISOString(),
|
||||
userName: entry.userName,
|
||||
userImage: entry.userImage,
|
||||
verifiedAt: entry.verifiedAt ? entry.verifiedAt.toISOString() : null,
|
||||
isPinned: entry.isPinned,
|
||||
upvotes: entry.upvotes,
|
||||
downvotes: entry.downvotes,
|
||||
},
|
||||
performance: {
|
||||
fpsAvg: entry.fpsAvg,
|
||||
fpsLow: entry.fpsLow,
|
||||
fpsHigh: entry.fpsHigh,
|
||||
fpsOnePercentLow: entry.fpsOnePercentLow ?? null,
|
||||
loadTimeSsd: entry.loadTimeSsd ?? null,
|
||||
loadTimeSd: entry.loadTimeSd ?? null,
|
||||
},
|
||||
hardwarePower: {
|
||||
tdpWatts: entry.tdpWatts ? Number(entry.tdpWatts) : null,
|
||||
hardwareWattHours: wh,
|
||||
estimatedBatteryHours,
|
||||
estimatedBatteryMin,
|
||||
estimatedAtMaxTdpMin,
|
||||
},
|
||||
software: {
|
||||
protonVersion: entry.protonVersion ?? null,
|
||||
osVersion: entry.osVersion ?? null,
|
||||
upscalerType: entry.upscalerType ?? null,
|
||||
upscalerVersion: entry.upscalerVersion ?? null,
|
||||
frameGenMethod: entry.frameGenMethod ?? null,
|
||||
launchOptions: entry.launchOptions ?? null,
|
||||
customSystem: entry.customSystem ?? false,
|
||||
},
|
||||
gameInfo: {
|
||||
versionString: entry.versionString ?? null,
|
||||
buildId: entry.buildId ?? null,
|
||||
gameAntiCheatName: entry.gameAntiCheatName ?? null,
|
||||
gameAntiCheatStatus: entry.gameAntiCheatStatus ?? null,
|
||||
},
|
||||
settingsJson: entry.settingsJson as any,
|
||||
screenshots,
|
||||
youtubeVideoId: entry.youtubeVideoId ?? null,
|
||||
userNotes: entry.userNotes ?? null,
|
||||
}
|
||||
},
|
||||
{
|
||||
params: t.Object({ entryId: t.String() }),
|
||||
detail: {
|
||||
description: "Full benchmark entry detail with structured sections for mobile display — Performance, Hardware & Power, Software, and Game Info.",
|
||||
},
|
||||
response: t.Union([
|
||||
t.Object({
|
||||
benchmark: t.Any(),
|
||||
performance: t.Any(),
|
||||
hardwarePower: t.Any(),
|
||||
software: t.Any(),
|
||||
gameInfo: t.Any(),
|
||||
settingsJson: t.Union([t.Array(t.Any()), t.Null()]),
|
||||
screenshots: t.Array(t.Any()),
|
||||
youtubeVideoId: t.Union([t.String(), t.Null()]),
|
||||
userNotes: t.Union([t.String(), t.Null()]),
|
||||
}),
|
||||
t.Object({ error: t.String() }),
|
||||
]),
|
||||
},
|
||||
)
|
||||
// ── Dashboard (consolidated home screen) ───────────────────────
|
||||
.get(
|
||||
"/dashboard",
|
||||
async () => {
|
||||
const { sql: dsql } = await import("drizzle-orm")
|
||||
|
||||
const SEVEN_DAYS_AGO = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000)
|
||||
|
||||
const recentBenchmarks = await db.execute(dsql`
|
||||
SELECT g.id, g.title, g.capsule_image, g.header_image, g.playability_status,
|
||||
COUNT(pe.id) AS benchmark_count, AVG(pe.fps_avg) AS avg_fps, MAX(pe.created_at) AS latest_benchmark_at
|
||||
FROM games g
|
||||
JOIN game_versions gv ON gv.game_id = g.id
|
||||
JOIN performance_entries pe ON pe.version_id = gv.id
|
||||
WHERE pe.is_removed = false
|
||||
GROUP BY g.id, g.title, g.capsule_image, g.header_image, g.playability_status
|
||||
ORDER BY MAX(pe.created_at) DESC
|
||||
LIMIT 10
|
||||
`)
|
||||
|
||||
const trending = await db.execute(dsql`
|
||||
WITH recent_benchmarks AS (
|
||||
SELECT gv.game_id, COUNT(*) AS cnt FROM performance_entries pe
|
||||
JOIN game_versions gv ON pe.version_id = gv.id
|
||||
WHERE pe.is_removed = false AND pe.created_at >= ${SEVEN_DAYS_AGO}
|
||||
GROUP BY gv.game_id
|
||||
),
|
||||
recent_comments AS (
|
||||
SELECT gc.game_id, COUNT(*) AS cnt FROM game_comments gc
|
||||
WHERE gc.is_removed = false AND gc.created_at >= ${SEVEN_DAYS_AGO}
|
||||
GROUP BY gc.game_id
|
||||
)
|
||||
SELECT g.id, g.title, g.capsule_image, g.header_image, g.playability_status,
|
||||
COALESCE(rb.cnt, 0) AS benchmark_count, COALESCE(rc.cnt, 0) AS comment_count,
|
||||
(COALESCE(rb.cnt, 0) * 3 + COALESCE(rc.cnt, 0) * 2) AS activity_score
|
||||
FROM games g
|
||||
LEFT JOIN recent_benchmarks rb ON rb.game_id = g.id
|
||||
LEFT JOIN recent_comments rc ON rc.game_id = g.id
|
||||
WHERE (rb.cnt IS NOT NULL OR rc.cnt IS NOT NULL)
|
||||
ORDER BY activity_score DESC
|
||||
LIMIT 10
|
||||
`)
|
||||
|
||||
const mostTested = await db.execute(dsql`
|
||||
SELECT g.id, g.title, g.capsule_image, g.header_image, g.playability_status,
|
||||
COUNT(pe.id) AS benchmark_count, AVG(pe.fps_avg) AS avg_fps
|
||||
FROM games g
|
||||
JOIN game_versions gv ON gv.game_id = g.id
|
||||
JOIN performance_entries pe ON pe.version_id = gv.id
|
||||
WHERE pe.is_removed = false
|
||||
GROUP BY g.id, g.title, g.capsule_image, g.header_image, g.playability_status
|
||||
ORDER BY benchmark_count DESC
|
||||
LIMIT 10
|
||||
`)
|
||||
|
||||
return {
|
||||
recentBenchmarks: recentBenchmarks.rows,
|
||||
trending: trending.rows,
|
||||
mostTested: mostTested.rows,
|
||||
}
|
||||
},
|
||||
{
|
||||
detail: {
|
||||
description: "Consolidated home screen data — recent benchmarks, trending, and most tested in one call.",
|
||||
},
|
||||
response: t.Object({
|
||||
recentBenchmarks: t.Array(t.Any()),
|
||||
trending: t.Array(t.Any()),
|
||||
mostTested: t.Array(t.Any()),
|
||||
}),
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,473 @@
|
||||
import { Elysia, t } from "elysia"
|
||||
import { db } from "@/lib/db/index"
|
||||
import {
|
||||
performanceEntries,
|
||||
gameVersions,
|
||||
hardware,
|
||||
gamePlatformSupport,
|
||||
entryScreenshots,
|
||||
storageObjects,
|
||||
} from "@/lib/db/schema"
|
||||
import type { GameSettingCategory } from "@/lib/db/schema"
|
||||
import { eq, and, sql } from "drizzle-orm"
|
||||
import { requireRole } from "@/lib/auth/guard"
|
||||
import { recalculatePlayability } from "./playability"
|
||||
import { uploadObject, deleteObject, isR2Configured } from "@/lib/storage/r2-client"
|
||||
import { processScreenshot, isAllowedMimeType, validateMagicBytes } from "@/lib/image-processing"
|
||||
|
||||
const MAX_SCREENSHOTS_PER_ENTRY = 2
|
||||
const MAX_UPLOAD_SIZE = 10 * 1024 * 1024 // 10 MB
|
||||
|
||||
export const performanceSubmitRoutes = new Elysia({ prefix: "/performance", detail: { tags: ["Performance"] } })
|
||||
.get(
|
||||
"/autocomplete",
|
||||
async ({ query }) => {
|
||||
const field = query.field
|
||||
|
||||
if (field !== "protonVersion" && field !== "osVersion") {
|
||||
return { data: [] }
|
||||
}
|
||||
|
||||
const results = await db
|
||||
.select({ value: performanceEntries[field] })
|
||||
.from(performanceEntries)
|
||||
.where(sql`${performanceEntries[field]} IS NOT NULL`)
|
||||
.groupBy(performanceEntries[field])
|
||||
.orderBy(sql`count(*) DESC`)
|
||||
.limit(20)
|
||||
|
||||
return {
|
||||
data: results
|
||||
.map((r) => r.value)
|
||||
.filter((v): v is string => v !== null),
|
||||
}
|
||||
},
|
||||
{
|
||||
query: t.Object({
|
||||
field: t.Union([
|
||||
t.Literal("protonVersion"),
|
||||
t.Literal("osVersion"),
|
||||
]),
|
||||
}),
|
||||
},
|
||||
)
|
||||
.get(
|
||||
"/hardware",
|
||||
async () => {
|
||||
const devices = await db
|
||||
.select({
|
||||
slug: hardware.slug,
|
||||
name: hardware.name,
|
||||
deviceType: hardware.deviceType,
|
||||
wattHours: hardware.wattHours,
|
||||
tdpMax: hardware.tdpMax,
|
||||
})
|
||||
.from(hardware)
|
||||
.orderBy(hardware.sortOrder)
|
||||
|
||||
return { data: devices }
|
||||
},
|
||||
)
|
||||
.post(
|
||||
"/submit",
|
||||
async ({ request, set }) => {
|
||||
const guard = await requireRole(request.headers, [
|
||||
"user",
|
||||
"contributor",
|
||||
"admin",
|
||||
])
|
||||
if (!guard.ok) {
|
||||
set.status = guard.status
|
||||
return { error: guard.error }
|
||||
}
|
||||
|
||||
// Parse multipart form data
|
||||
let formData: FormData
|
||||
try {
|
||||
formData = await request.formData()
|
||||
} catch {
|
||||
set.status = 400
|
||||
return { error: "Invalid multipart/form-data" }
|
||||
}
|
||||
|
||||
// Extract and parse the JSON payload field
|
||||
const payloadField = formData.get("payload")
|
||||
if (!payloadField || typeof payloadField !== "string") {
|
||||
set.status = 400
|
||||
return { error: "Missing or invalid payload field" }
|
||||
}
|
||||
|
||||
let payload: Record<string, unknown>
|
||||
try {
|
||||
payload = JSON.parse(payloadField)
|
||||
} catch {
|
||||
set.status = 400
|
||||
return { error: "Invalid JSON in payload field" }
|
||||
}
|
||||
|
||||
// Extract fields from payload
|
||||
const versionId = payload.versionId as string
|
||||
const hardwareSlug = payload.hardwareSlug as string
|
||||
const fpsAvg = payload.fpsAvg as number
|
||||
const fpsOnePercentLow = (payload.fpsOnePercentLow as number | null | undefined) ?? null
|
||||
const fpsLow = (payload.fpsLow as number | null | undefined) ?? null
|
||||
const fpsHigh = (payload.fpsHigh as number | null | undefined) ?? null
|
||||
const protonVersion = (payload.protonVersion as string | null | undefined) ?? null
|
||||
const osVersion = (payload.osVersion as string | null | undefined) ?? null
|
||||
const VALID_UPSCALER_TYPES = ["none", "fsr", "dlss", "xess", "lsfg", "other"] as const
|
||||
const VALID_FRAME_GEN_METHODS = ["none", "fsr_fg", "dlss_fg", "lsfg", "other"] as const
|
||||
const VALID_ANTICHEAT_STATUSES = ["none", "supported", "unsupported", "unknown"] as const
|
||||
type UpscalerType = (typeof VALID_UPSCALER_TYPES)[number]
|
||||
type FrameGenMethod = (typeof VALID_FRAME_GEN_METHODS)[number]
|
||||
type AntiCheatStatus = (typeof VALID_ANTICHEAT_STATUSES)[number]
|
||||
|
||||
const rawUpscalerType = payload.upscalerType as string | undefined
|
||||
const upscalerType: UpscalerType = rawUpscalerType && VALID_UPSCALER_TYPES.includes(rawUpscalerType as UpscalerType) ? (rawUpscalerType as UpscalerType) : "none"
|
||||
const upscalerVersion = (payload.upscalerVersion as string | null | undefined) ?? null
|
||||
const customSystem = (payload.customSystem as boolean | undefined) ?? false
|
||||
const rawFrameGenMethod = payload.frameGenMethod as string | undefined
|
||||
const frameGenMethod: FrameGenMethod = rawFrameGenMethod && VALID_FRAME_GEN_METHODS.includes(rawFrameGenMethod as FrameGenMethod) ? (rawFrameGenMethod as FrameGenMethod) : "none"
|
||||
const loadTimeSsd = (payload.loadTimeSsd as number | null | undefined) ?? null
|
||||
const loadTimeSd = (payload.loadTimeSd as number | null | undefined) ?? null
|
||||
const tdpWatts = (payload.tdpWatts as number | null | undefined) ?? null
|
||||
const youtubeVideoId = (payload.youtubeVideoId as string | null | undefined) ?? null
|
||||
const launchOptions = (payload.launchOptions as string | null | undefined) ?? null
|
||||
const settingsJson = (payload.settingsJson as GameSettingCategory[] | null | undefined) ?? null
|
||||
const userNotes = (payload.userNotes as string | null | undefined) ?? null
|
||||
const antiCheatRelevant = (payload.antiCheatRelevant as boolean | undefined) ?? false
|
||||
const antiCheatName = (payload.antiCheatName as string | null | undefined) ?? null
|
||||
const rawAntiCheatStatus = payload.antiCheatStatus as string | undefined
|
||||
const antiCheatStatus: AntiCheatStatus = rawAntiCheatStatus && VALID_ANTICHEAT_STATUSES.includes(rawAntiCheatStatus as AntiCheatStatus) ? (rawAntiCheatStatus as AntiCheatStatus) : "unknown"
|
||||
|
||||
// Validate required fields
|
||||
if (!versionId || !hardwareSlug || fpsAvg === undefined || fpsAvg === null) {
|
||||
set.status = 400
|
||||
return { error: "Missing required fields: versionId, hardwareSlug, fpsAvg" }
|
||||
}
|
||||
|
||||
// ── Validation: fpsAvg bounds ───────────────────────────────
|
||||
if (typeof fpsAvg !== "number" || fpsAvg < 1 || fpsAvg > 500) {
|
||||
set.status = 400
|
||||
return { error: "fpsAvg must be between 1 and 500" }
|
||||
}
|
||||
|
||||
// ── Validation: optional FPS bounds ─────────────────────────
|
||||
if (fpsLow !== null && (fpsLow < 0 || fpsLow > 500)) {
|
||||
set.status = 400
|
||||
return { error: "fpsLow must be between 0 and 500" }
|
||||
}
|
||||
if (fpsHigh !== null && (fpsHigh < 0 || fpsHigh > 500)) {
|
||||
set.status = 400
|
||||
return { error: "fpsHigh must be between 0 and 500" }
|
||||
}
|
||||
if (fpsOnePercentLow !== null && (fpsOnePercentLow < 0 || fpsOnePercentLow > 500)) {
|
||||
set.status = 400
|
||||
return { error: "fpsOnePercentLow must be between 0 and 500" }
|
||||
}
|
||||
|
||||
// ── Validation: settingsJson size limits ────────────────────
|
||||
if (settingsJson) {
|
||||
if (!Array.isArray(settingsJson)) {
|
||||
set.status = 400
|
||||
return { error: "settingsJson must be an array" }
|
||||
}
|
||||
if (settingsJson.length > 20) {
|
||||
set.status = 400
|
||||
return { error: "Maximum 20 settings categories allowed" }
|
||||
}
|
||||
for (const cat of settingsJson) {
|
||||
if (cat.settings && Array.isArray(cat.settings) && cat.settings.length > 50) {
|
||||
set.status = 400
|
||||
return { error: `Maximum 50 settings per category (exceeded in "${cat.category}")` }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Validation: userNotes length ────────────────────────────
|
||||
if (userNotes && typeof userNotes === "string" && userNotes.length > 5000) {
|
||||
set.status = 400
|
||||
return { error: "userNotes must be 5000 characters or less" }
|
||||
}
|
||||
|
||||
// Verify the game version exists
|
||||
const [version] = await db
|
||||
.select({ id: gameVersions.id, gameId: gameVersions.gameId })
|
||||
.from(gameVersions)
|
||||
.where(eq(gameVersions.id, versionId))
|
||||
.limit(1)
|
||||
|
||||
if (!version) {
|
||||
set.status = 404
|
||||
return { error: "Game version not found" }
|
||||
}
|
||||
|
||||
// Verify hardware exists
|
||||
const [device] = await db
|
||||
.select({ slug: hardware.slug })
|
||||
.from(hardware)
|
||||
.where(eq(hardware.slug, hardwareSlug))
|
||||
.limit(1)
|
||||
|
||||
if (!device) {
|
||||
set.status = 404
|
||||
return { error: "Hardware not found" }
|
||||
}
|
||||
|
||||
// Validate YouTube video ID format (11 alphanumeric + dash/underscore)
|
||||
if (youtubeVideoId) {
|
||||
const ytId = youtubeVideoId.trim()
|
||||
if (!/^[a-zA-Z0-9_-]{11}$/.test(ytId)) {
|
||||
set.status = 400
|
||||
return { error: "Invalid YouTube video ID format (must be 11 characters)" }
|
||||
}
|
||||
}
|
||||
|
||||
// Validate TDP
|
||||
if (tdpWatts !== null && tdpWatts <= 0) {
|
||||
set.status = 400
|
||||
return { error: "TDP must be greater than 0" }
|
||||
}
|
||||
|
||||
// ── Process screenshots ──────────────────────────────────────────
|
||||
const screenshotFiles = formData.getAll("screenshots").filter((f): f is File => f instanceof File)
|
||||
|
||||
if (screenshotFiles.length > MAX_SCREENSHOTS_PER_ENTRY) {
|
||||
set.status = 400
|
||||
return { error: `Maximum ${MAX_SCREENSHOTS_PER_ENTRY} screenshots allowed` }
|
||||
}
|
||||
|
||||
// Process all screenshots with sharp BEFORE any DB writes
|
||||
const processedScreenshots: Array<{
|
||||
buffer: Buffer
|
||||
width: number
|
||||
height: number
|
||||
mimeType: string
|
||||
size: number
|
||||
originalName: string | null
|
||||
}> = []
|
||||
|
||||
for (const file of screenshotFiles) {
|
||||
if (file.size > MAX_UPLOAD_SIZE) {
|
||||
set.status = 400
|
||||
return { error: `Screenshot "${file.name}" exceeds 10 MB limit` }
|
||||
}
|
||||
|
||||
if (!isAllowedMimeType(file.type)) {
|
||||
set.status = 400
|
||||
return { error: `Screenshot "${file.name}" has unsupported MIME type: ${file.type}` }
|
||||
}
|
||||
|
||||
const arrayBuffer = await file.arrayBuffer()
|
||||
const rawBuffer = Buffer.from(arrayBuffer)
|
||||
|
||||
if (!validateMagicBytes(rawBuffer, file.type)) {
|
||||
set.status = 400
|
||||
return { error: `Screenshot "${file.name}" content does not match declared type` }
|
||||
}
|
||||
|
||||
try {
|
||||
const processed = await processScreenshot(rawBuffer, file.type)
|
||||
processedScreenshots.push({
|
||||
...processed,
|
||||
originalName: file.name || null,
|
||||
})
|
||||
} catch (err) {
|
||||
set.status = 400
|
||||
return { error: `Failed to process screenshot "${file.name}": ${err instanceof Error ? err.message : "Unknown error"}` }
|
||||
}
|
||||
}
|
||||
|
||||
// ── Create performance entry ────────────────────────────────────
|
||||
const [entry] = await db
|
||||
.insert(performanceEntries)
|
||||
.values({
|
||||
versionId,
|
||||
hardwareSlug,
|
||||
userId: guard.user.id,
|
||||
fpsAvg,
|
||||
fpsOnePercentLow,
|
||||
fpsLow,
|
||||
fpsHigh,
|
||||
protonVersion,
|
||||
osVersion,
|
||||
upscalerType,
|
||||
upscalerVersion,
|
||||
customSystem,
|
||||
frameGenMethod,
|
||||
loadTimeSsd,
|
||||
loadTimeSd,
|
||||
tdpWatts,
|
||||
youtubeVideoId: youtubeVideoId ? youtubeVideoId.trim() : null,
|
||||
launchOptions,
|
||||
settingsJson,
|
||||
userNotes,
|
||||
})
|
||||
.returning()
|
||||
|
||||
// ── Upload screenshots to R2 ─────────────────────────────────────
|
||||
const uploadedKeys: string[] = []
|
||||
const uploadedStorageIds: string[] = []
|
||||
const screenshotResults: Array<{
|
||||
id: string
|
||||
storageKey: string
|
||||
url: string
|
||||
width: number
|
||||
height: number
|
||||
mimeType: string
|
||||
size: number
|
||||
originalName: string | null
|
||||
orderIndex: number
|
||||
}> = []
|
||||
|
||||
let uploadFailed = false
|
||||
let uploadError = ""
|
||||
|
||||
for (let i = 0; i < processedScreenshots.length; i++) {
|
||||
const shot = processedScreenshots[i]
|
||||
const r2Key = `screenshots/${entry.id}/${crypto.randomUUID()}.jpg`
|
||||
|
||||
try {
|
||||
if (!isR2Configured()) {
|
||||
throw new Error("R2 storage is not configured")
|
||||
}
|
||||
const url = await uploadObject(r2Key, shot.buffer, shot.mimeType, {
|
||||
entryId: entry.id,
|
||||
orderIndex: String(i),
|
||||
})
|
||||
uploadedKeys.push(r2Key)
|
||||
|
||||
// Insert into storageObjects
|
||||
const [storageObj] = await db
|
||||
.insert(storageObjects)
|
||||
.values({
|
||||
key: r2Key,
|
||||
bucket: "deckyvault",
|
||||
size: shot.size,
|
||||
mimeType: shot.mimeType,
|
||||
entityType: "entry_screenshot",
|
||||
entityId: entry.id,
|
||||
uploadedBy: guard.user.id,
|
||||
})
|
||||
.returning()
|
||||
uploadedStorageIds.push(storageObj.id)
|
||||
|
||||
// Insert into entryScreenshots
|
||||
const [screenshotRow] = await db
|
||||
.insert(entryScreenshots)
|
||||
.values({
|
||||
entryId: entry.id,
|
||||
storageKey: r2Key,
|
||||
orderIndex: i,
|
||||
mimeType: shot.mimeType,
|
||||
width: shot.width,
|
||||
height: shot.height,
|
||||
originalName: shot.originalName,
|
||||
})
|
||||
.returning()
|
||||
|
||||
screenshotResults.push({
|
||||
id: screenshotRow.id,
|
||||
storageKey: r2Key,
|
||||
url,
|
||||
width: shot.width,
|
||||
height: shot.height,
|
||||
mimeType: shot.mimeType,
|
||||
size: shot.size,
|
||||
originalName: shot.originalName,
|
||||
orderIndex: i,
|
||||
})
|
||||
} catch (err) {
|
||||
uploadFailed = true
|
||||
uploadError = err instanceof Error ? err.message : "Upload failed"
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Roll back if any upload failed
|
||||
if (uploadFailed) {
|
||||
// Delete uploaded R2 objects
|
||||
for (const key of uploadedKeys) {
|
||||
try {
|
||||
await deleteObject(key)
|
||||
} catch {
|
||||
// Best-effort cleanup
|
||||
}
|
||||
}
|
||||
|
||||
// Delete storageObjects rows
|
||||
for (const id of uploadedStorageIds) {
|
||||
try {
|
||||
await db.delete(storageObjects).where(eq(storageObjects.id, id))
|
||||
} catch {
|
||||
// Best-effort cleanup
|
||||
}
|
||||
}
|
||||
|
||||
// Delete entry screenshots (should cascade, but be explicit)
|
||||
try {
|
||||
await db.delete(entryScreenshots).where(eq(entryScreenshots.entryId, entry.id))
|
||||
} catch {
|
||||
// Best-effort cleanup
|
||||
}
|
||||
|
||||
// Delete the performance entry
|
||||
try {
|
||||
await db.delete(performanceEntries).where(eq(performanceEntries.id, entry.id))
|
||||
} catch {
|
||||
// Best-effort cleanup
|
||||
}
|
||||
|
||||
set.status = 500
|
||||
return { error: `Screenshot upload failed: ${uploadError}` }
|
||||
}
|
||||
|
||||
// ── Update / create gamePlatformSupport ──────────────────────────
|
||||
const [existingSupport] = await db
|
||||
.select()
|
||||
.from(gamePlatformSupport)
|
||||
.where(
|
||||
and(
|
||||
eq(gamePlatformSupport.gameId, version.gameId),
|
||||
eq(gamePlatformSupport.hardwareSlug, hardwareSlug),
|
||||
),
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
if (existingSupport) {
|
||||
await db
|
||||
.update(gamePlatformSupport)
|
||||
.set({
|
||||
antiCheatRelevant: antiCheatRelevant ?? existingSupport.antiCheatRelevant,
|
||||
antiCheatName: antiCheatRelevant
|
||||
? (antiCheatName ?? existingSupport.antiCheatName)
|
||||
: null,
|
||||
antiCheatStatus: antiCheatStatus ?? existingSupport.antiCheatStatus,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(gamePlatformSupport.id, existingSupport.id))
|
||||
} else {
|
||||
await db.insert(gamePlatformSupport).values({
|
||||
gameId: version.gameId,
|
||||
hardwareSlug,
|
||||
isSupported: true,
|
||||
protonStatus: "unknown",
|
||||
antiCheatRelevant,
|
||||
antiCheatName: antiCheatRelevant ? antiCheatName : null,
|
||||
antiCheatStatus,
|
||||
playabilityStatus: "unknown",
|
||||
})
|
||||
}
|
||||
|
||||
// Recalculate playability for this game (fire and forget)
|
||||
recalculatePlayability(version.gameId).catch((err) =>
|
||||
console.error("Failed to recalculate playability:", err),
|
||||
)
|
||||
|
||||
set.status = 201
|
||||
return {
|
||||
id: entry.id,
|
||||
createdAt: entry.createdAt.toISOString(),
|
||||
screenshots: screenshotResults,
|
||||
}
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,756 @@
|
||||
import { Elysia, t } from "elysia"
|
||||
import { createCrudRoutes } from "./crud-builder"
|
||||
import { performanceEntries, games, gameVersions, hardware, user, gamePlatformSupport, entryScreenshots, storageObjects } from "@/lib/db/schema"
|
||||
import { db } from "@/lib/db/index"
|
||||
import { eq, and, desc, sql } from "drizzle-orm"
|
||||
import { requireRole } from "@/lib/auth/guard"
|
||||
import { checkAndAutoPin } from "./auto-pin"
|
||||
import { uploadObject, deleteObject, isR2Configured } from "@/lib/storage/r2-client"
|
||||
import { processScreenshot, isAllowedMimeType, validateMagicBytes } from "@/lib/image-processing"
|
||||
|
||||
const MAX_SCREENSHOTS_PER_ENTRY = 2
|
||||
const MAX_UPLOAD_SIZE = 10 * 1024 * 1024 // 10 MB
|
||||
|
||||
// ── Performance Entries CRUD ──────────────────────────────────────
|
||||
export const performanceRoutes = createCrudRoutes(performanceEntries, {
|
||||
prefix: "/performance",
|
||||
name: "Performance Entry",
|
||||
tags: ["Performance"],
|
||||
auth: { read: "public", write: "user", delete: "admin" },
|
||||
softDelete: true,
|
||||
search: { fields: ["userNotes"] },
|
||||
filter: { fields: ["hardwareSlug", "upscalerType", "upscalerVersion", "frameGenMethod"] },
|
||||
})
|
||||
|
||||
// ── Verify endpoint (admin/mod) ───────────────────────────────────
|
||||
export const performanceVerifyRoutes = new Elysia({
|
||||
prefix: "/performance",
|
||||
detail: { tags: ["Performance"] },
|
||||
})
|
||||
.post(
|
||||
"/:id/verify",
|
||||
async ({ params, request, set }) => {
|
||||
const guard = await requireRole(request.headers, ["contributor", "admin"])
|
||||
if (!guard.ok) {
|
||||
set.status = guard.status
|
||||
return { error: guard.error }
|
||||
}
|
||||
|
||||
const [entry] = await db
|
||||
.select()
|
||||
.from(performanceEntries)
|
||||
.where(eq(performanceEntries.id, params.id))
|
||||
.limit(1)
|
||||
|
||||
if (!entry) {
|
||||
set.status = 404
|
||||
return { error: "Performance entry not found" }
|
||||
}
|
||||
|
||||
if (entry.verifiedAt) {
|
||||
set.status = 409
|
||||
return { error: "Entry already verified" }
|
||||
}
|
||||
|
||||
const [updated] = await db
|
||||
.update(performanceEntries)
|
||||
.set({
|
||||
verifiedAt: new Date(),
|
||||
verifiedBy: guard.user.id,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(performanceEntries.id, params.id))
|
||||
.returning()
|
||||
|
||||
return updated
|
||||
},
|
||||
{
|
||||
params: t.Object({ id: t.String() }),
|
||||
},
|
||||
)
|
||||
// ── Upvote ────────────────────────────────────────────────────────
|
||||
.post(
|
||||
"/:id/upvote",
|
||||
async ({ params, request, set }) => {
|
||||
const guard = await requireRole(request.headers, [
|
||||
"user",
|
||||
"contributor",
|
||||
"admin",
|
||||
])
|
||||
if (!guard.ok) {
|
||||
set.status = guard.status
|
||||
return { error: guard.error }
|
||||
}
|
||||
|
||||
const [updated] = await db
|
||||
.update(performanceEntries)
|
||||
.set({
|
||||
upvotes: sql`${performanceEntries.upvotes} + 1`,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(performanceEntries.id, params.id),
|
||||
eq(performanceEntries.isRemoved, false),
|
||||
),
|
||||
)
|
||||
.returning()
|
||||
|
||||
if (!updated) {
|
||||
set.status = 404
|
||||
return { error: "Performance entry not found" }
|
||||
}
|
||||
|
||||
// Auto-pin check (fire and forget, result doesn't affect response)
|
||||
checkAndAutoPin(updated.id).catch((err) =>
|
||||
console.error("Auto-pin check failed:", err),
|
||||
)
|
||||
|
||||
return updated
|
||||
},
|
||||
{
|
||||
params: t.Object({ id: t.String() }),
|
||||
},
|
||||
)
|
||||
// ── Downvote ──────────────────────────────────────────────────────
|
||||
.post(
|
||||
"/:id/downvote",
|
||||
async ({ params, request, set }) => {
|
||||
const guard = await requireRole(request.headers, [
|
||||
"user",
|
||||
"contributor",
|
||||
"admin",
|
||||
])
|
||||
if (!guard.ok) {
|
||||
set.status = guard.status
|
||||
return { error: guard.error }
|
||||
}
|
||||
|
||||
const [updated] = await db
|
||||
.update(performanceEntries)
|
||||
.set({
|
||||
downvotes: sql`${performanceEntries.downvotes} + 1`,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(performanceEntries.id, params.id),
|
||||
eq(performanceEntries.isRemoved, false),
|
||||
),
|
||||
)
|
||||
.returning()
|
||||
|
||||
if (!updated) {
|
||||
set.status = 404
|
||||
return { error: "Performance entry not found" }
|
||||
}
|
||||
|
||||
// Auto-pin check (fire and forget, result doesn't affect response)
|
||||
checkAndAutoPin(updated.id).catch((err) =>
|
||||
console.error("Auto-pin check failed:", err),
|
||||
)
|
||||
|
||||
return updated
|
||||
},
|
||||
{
|
||||
params: t.Object({ id: t.String() }),
|
||||
},
|
||||
)
|
||||
// ── Pin a preset (admin only) ──────────────────────────────────────
|
||||
.post(
|
||||
"/:id/pin",
|
||||
async ({ request, params, set }) => {
|
||||
const guard = await requireRole(request.headers, ["admin"])
|
||||
if (!guard.ok) { set.status = guard.status; return { error: guard.error } }
|
||||
|
||||
const [entry] = await db
|
||||
.update(performanceEntries)
|
||||
.set({ isPinned: true, pinnedAt: new Date() })
|
||||
.where(eq(performanceEntries.id, params.id))
|
||||
.returning()
|
||||
|
||||
if (!entry) { set.status = 404; return { error: "Entry not found" } }
|
||||
return { entry }
|
||||
},
|
||||
{ params: t.Object({ id: t.String() }) },
|
||||
)
|
||||
// ── Unpin a preset (admin only) ────────────────────────────────────
|
||||
.delete(
|
||||
"/:id/pin",
|
||||
async ({ request, params, set }) => {
|
||||
const guard = await requireRole(request.headers, ["admin"])
|
||||
if (!guard.ok) { set.status = guard.status; return { error: guard.error } }
|
||||
|
||||
const [entry] = await db
|
||||
.update(performanceEntries)
|
||||
.set({ isPinned: false, pinnedAt: null })
|
||||
.where(eq(performanceEntries.id, params.id))
|
||||
.returning()
|
||||
|
||||
if (!entry) { set.status = 404; return { error: "Entry not found" } }
|
||||
return { entry }
|
||||
},
|
||||
{ params: t.Object({ id: t.String() }) },
|
||||
)
|
||||
// ── User-scoped soft delete (owner or admin) ────────────────────
|
||||
.delete(
|
||||
"/:id/user-delete",
|
||||
async ({ params, body, request, set }) => {
|
||||
const guard = await requireRole(request.headers, [
|
||||
"user",
|
||||
"contributor",
|
||||
"admin",
|
||||
])
|
||||
if (!guard.ok) {
|
||||
set.status = guard.status
|
||||
return { error: guard.error }
|
||||
}
|
||||
|
||||
const [entry] = await db
|
||||
.select({
|
||||
id: performanceEntries.id,
|
||||
userId: performanceEntries.userId,
|
||||
})
|
||||
.from(performanceEntries)
|
||||
.where(eq(performanceEntries.id, params.id))
|
||||
.limit(1)
|
||||
|
||||
if (!entry) {
|
||||
set.status = 404
|
||||
return { error: "Performance entry not found" }
|
||||
}
|
||||
|
||||
if (entry.userId !== guard.user.id && guard.user.role !== "admin") {
|
||||
set.status = 403
|
||||
return { error: "Not authorized to delete this entry" }
|
||||
}
|
||||
|
||||
const reason = body?.reason as string | undefined
|
||||
|
||||
const [updated] = await db
|
||||
.update(performanceEntries)
|
||||
.set({
|
||||
isRemoved: true,
|
||||
removedReason: reason ?? "User deleted",
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(performanceEntries.id, params.id))
|
||||
.returning()
|
||||
|
||||
if (!updated) {
|
||||
set.status = 404
|
||||
return { error: "Performance entry not found" }
|
||||
}
|
||||
|
||||
return { success: true }
|
||||
},
|
||||
{
|
||||
params: t.Object({ id: t.String() }),
|
||||
body: t.Optional(t.Object({ reason: t.Optional(t.String()) })),
|
||||
},
|
||||
)
|
||||
// ── Edit entry (owner or admin) ───────────────────────────────────
|
||||
.patch(
|
||||
"/:id/edit",
|
||||
async ({ params, request, set }) => {
|
||||
const guard = await requireRole(request.headers, [
|
||||
"user",
|
||||
"contributor",
|
||||
"admin",
|
||||
])
|
||||
if (!guard.ok) {
|
||||
set.status = guard.status
|
||||
return { error: guard.error }
|
||||
}
|
||||
|
||||
const [entry] = await db
|
||||
.select({
|
||||
id: performanceEntries.id,
|
||||
userId: performanceEntries.userId,
|
||||
})
|
||||
.from(performanceEntries)
|
||||
.where(eq(performanceEntries.id, params.id))
|
||||
.limit(1)
|
||||
|
||||
if (!entry) {
|
||||
set.status = 404
|
||||
return { error: "Performance entry not found" }
|
||||
}
|
||||
|
||||
if (entry.userId !== guard.user.id && guard.user.role !== "admin") {
|
||||
set.status = 403
|
||||
return { error: "Not authorized to edit this entry" }
|
||||
}
|
||||
|
||||
// Parse multipart/form-data
|
||||
let formData: FormData
|
||||
try {
|
||||
formData = await request.formData()
|
||||
} catch {
|
||||
set.status = 400
|
||||
return { error: "Invalid multipart/form-data" }
|
||||
}
|
||||
|
||||
// Extract and parse the JSON payload field
|
||||
const payloadStr = formData.get("payload")
|
||||
if (!payloadStr || typeof payloadStr !== "string") {
|
||||
set.status = 400
|
||||
return { error: "Missing or invalid payload field" }
|
||||
}
|
||||
|
||||
let payload: Record<string, unknown>
|
||||
try {
|
||||
payload = JSON.parse(payloadStr)
|
||||
} catch {
|
||||
set.status = 400
|
||||
return { error: "Invalid JSON in payload field" }
|
||||
}
|
||||
|
||||
// Extract screenshot files
|
||||
const screenshotFiles = formData.getAll("screenshots").filter((f): f is File => f instanceof File)
|
||||
|
||||
// ── Process new screenshots with sharp BEFORE any DB changes ──
|
||||
const processedScreenshots: Array<{
|
||||
buffer: Buffer
|
||||
width: number
|
||||
height: number
|
||||
mimeType: string
|
||||
size: number
|
||||
originalName: string | null
|
||||
}> = []
|
||||
|
||||
if (screenshotFiles.length > MAX_SCREENSHOTS_PER_ENTRY) {
|
||||
set.status = 400
|
||||
return { error: `Maximum ${MAX_SCREENSHOTS_PER_ENTRY} screenshots allowed` }
|
||||
}
|
||||
|
||||
for (const file of screenshotFiles) {
|
||||
if (file.size > MAX_UPLOAD_SIZE) {
|
||||
set.status = 400
|
||||
return { error: `Screenshot "${file.name}" exceeds 10 MB limit` }
|
||||
}
|
||||
|
||||
if (!isAllowedMimeType(file.type)) {
|
||||
set.status = 400
|
||||
return { error: `Screenshot "${file.name}" has unsupported MIME type: ${file.type}` }
|
||||
}
|
||||
|
||||
const arrayBuffer = await file.arrayBuffer()
|
||||
const rawBuffer = Buffer.from(arrayBuffer)
|
||||
|
||||
if (!validateMagicBytes(rawBuffer, file.type)) {
|
||||
set.status = 400
|
||||
return { error: `Screenshot "${file.name}" content does not match declared type` }
|
||||
}
|
||||
|
||||
try {
|
||||
const processed = await processScreenshot(rawBuffer, file.type)
|
||||
processedScreenshots.push({
|
||||
...processed,
|
||||
originalName: file.name || null,
|
||||
})
|
||||
} catch (err) {
|
||||
set.status = 400
|
||||
return { error: `Failed to process screenshot "${file.name}": ${err instanceof Error ? err.message : "Unknown error"}` }
|
||||
}
|
||||
}
|
||||
|
||||
// ── Build updateData from payload ────────────────────────────────
|
||||
const updateData: Partial<typeof performanceEntries.$inferInsert> = {
|
||||
updatedAt: new Date(),
|
||||
}
|
||||
|
||||
if (payload.fpsAvg !== undefined) updateData.fpsAvg = payload.fpsAvg as number | undefined
|
||||
if (payload.fpsOnePercentLow !== undefined) updateData.fpsOnePercentLow = payload.fpsOnePercentLow as number | undefined
|
||||
if (payload.fpsLow !== undefined) updateData.fpsLow = payload.fpsLow as number | undefined
|
||||
if (payload.fpsHigh !== undefined) updateData.fpsHigh = payload.fpsHigh as number | undefined
|
||||
if (payload.protonVersion !== undefined)
|
||||
updateData.protonVersion = payload.protonVersion as string | null
|
||||
if (payload.osVersion !== undefined)
|
||||
updateData.osVersion = payload.osVersion as string | null
|
||||
if (payload.upscalerType !== undefined)
|
||||
updateData.upscalerType = (payload.upscalerType as "none" | "fsr" | "dlss" | "xess" | "lsfg" | "other" | null) ?? "none"
|
||||
if (payload.upscalerVersion !== undefined)
|
||||
updateData.upscalerVersion = payload.upscalerVersion as string | null
|
||||
if (payload.frameGenMethod !== undefined)
|
||||
updateData.frameGenMethod = (payload.frameGenMethod as "none" | "fsr_fg" | "dlss_fg" | "lsfg" | "other" | null) ?? "none"
|
||||
if (payload.launchOptions !== undefined)
|
||||
updateData.launchOptions = payload.launchOptions as string | null
|
||||
if (payload.settingsJson !== undefined)
|
||||
updateData.settingsJson = payload.settingsJson as typeof performanceEntries.$inferInsert["settingsJson"]
|
||||
if (payload.userNotes !== undefined)
|
||||
updateData.userNotes = payload.userNotes as string | null
|
||||
if (payload.tdpWatts !== undefined) updateData.tdpWatts = payload.tdpWatts as number | undefined
|
||||
if (payload.youtubeVideoId !== undefined) {
|
||||
const ytIdRaw = payload.youtubeVideoId as string | null
|
||||
if (ytIdRaw !== null) {
|
||||
const ytId = ytIdRaw.trim()
|
||||
if (!/^[a-zA-Z0-9_-]{11}$/.test(ytId)) {
|
||||
set.status = 400
|
||||
return { error: "Invalid YouTube video ID format" }
|
||||
}
|
||||
updateData.youtubeVideoId = ytId
|
||||
} else {
|
||||
updateData.youtubeVideoId = null
|
||||
}
|
||||
}
|
||||
|
||||
// ── Update the performance entry ──────────────────────────────────
|
||||
const [updated] = await db
|
||||
.update(performanceEntries)
|
||||
.set(updateData)
|
||||
.where(eq(performanceEntries.id, params.id))
|
||||
.returning()
|
||||
|
||||
// ── Remove screenshots marked for deletion ────────────────────────
|
||||
const removedIds: string[] = Array.isArray(payload.removedScreenshotIds)
|
||||
? payload.removedScreenshotIds
|
||||
: []
|
||||
|
||||
if (removedIds.length > 0) {
|
||||
// Fetch storage keys before deleting rows
|
||||
const toRemove = await db
|
||||
.select({ id: entryScreenshots.id, storageKey: entryScreenshots.storageKey })
|
||||
.from(entryScreenshots)
|
||||
.where(
|
||||
and(
|
||||
eq(entryScreenshots.entryId, params.id),
|
||||
sql`${entryScreenshots.id} = ANY(${removedIds})`,
|
||||
),
|
||||
)
|
||||
|
||||
for (const ss of toRemove) {
|
||||
try { await deleteObject(ss.storageKey) } catch { /* best-effort */ }
|
||||
try {
|
||||
await db.delete(storageObjects).where(eq(storageObjects.key, ss.storageKey))
|
||||
} catch { /* best-effort */ }
|
||||
}
|
||||
|
||||
// Delete the screenshot rows
|
||||
if (toRemove.length > 0) {
|
||||
await db.delete(entryScreenshots).where(
|
||||
sql`${entryScreenshots.id} = ANY(${toRemove.map((s) => s.id)})`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Atomic screenshot replacement ─────────────────────────────────
|
||||
if (screenshotFiles.length > 0) {
|
||||
// a. Upload all new screenshots to R2 FIRST (before deleting old ones)
|
||||
const uploadedKeys: string[] = []
|
||||
const uploadedStorageIds: string[] = []
|
||||
const uploadedScreenshotIds: string[] = []
|
||||
|
||||
let uploadFailed = false
|
||||
let uploadError = ""
|
||||
|
||||
for (let i = 0; i < processedScreenshots.length; i++) {
|
||||
const shot = processedScreenshots[i]
|
||||
const r2Key = `screenshots/${entry.id}/${crypto.randomUUID()}.jpg`
|
||||
|
||||
try {
|
||||
if (!isR2Configured()) {
|
||||
throw new Error("R2 storage is not configured")
|
||||
}
|
||||
await uploadObject(r2Key, shot.buffer, shot.mimeType, {
|
||||
entryId: entry.id,
|
||||
orderIndex: String(i),
|
||||
})
|
||||
uploadedKeys.push(r2Key)
|
||||
|
||||
// Insert into storageObjects
|
||||
const [storageObj] = await db
|
||||
.insert(storageObjects)
|
||||
.values({
|
||||
key: r2Key,
|
||||
bucket: "deckyvault",
|
||||
size: shot.size,
|
||||
mimeType: shot.mimeType,
|
||||
entityType: "entry_screenshot",
|
||||
entityId: entry.id,
|
||||
uploadedBy: guard.user.id,
|
||||
})
|
||||
.returning()
|
||||
uploadedStorageIds.push(storageObj.id)
|
||||
|
||||
// Insert into entryScreenshots
|
||||
const [screenshotRow] = await db
|
||||
.insert(entryScreenshots)
|
||||
.values({
|
||||
entryId: entry.id,
|
||||
storageKey: r2Key,
|
||||
orderIndex: i,
|
||||
mimeType: shot.mimeType,
|
||||
width: shot.width,
|
||||
height: shot.height,
|
||||
originalName: shot.originalName,
|
||||
})
|
||||
.returning()
|
||||
uploadedScreenshotIds.push(screenshotRow.id)
|
||||
} catch (err) {
|
||||
uploadFailed = true
|
||||
uploadError = err instanceof Error ? err.message : "Upload failed"
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (uploadFailed) {
|
||||
// Roll back: delete just-uploaded R2 objects, storageObjects rows, entryScreenshots rows
|
||||
for (const key of uploadedKeys) {
|
||||
try { await deleteObject(key) } catch { /* best-effort */ }
|
||||
}
|
||||
for (const id of uploadedStorageIds) {
|
||||
try { await db.delete(storageObjects).where(eq(storageObjects.id, id)) } catch { /* best-effort */ }
|
||||
}
|
||||
for (const id of uploadedScreenshotIds) {
|
||||
try { await db.delete(entryScreenshots).where(eq(entryScreenshots.id, id)) } catch { /* best-effort */ }
|
||||
}
|
||||
|
||||
set.status = 500
|
||||
return { error: `Screenshot upload failed: ${uploadError}` }
|
||||
}
|
||||
|
||||
// b. All new uploads succeeded — now safe to delete old screenshots
|
||||
const oldScreenshots = await db
|
||||
.select()
|
||||
.from(entryScreenshots)
|
||||
.where(eq(entryScreenshots.entryId, entry.id))
|
||||
|
||||
for (const old of oldScreenshots) {
|
||||
// Skip any that were just uploaded (shouldn't overlap, but safety check)
|
||||
if (uploadedScreenshotIds.includes(old.id)) continue
|
||||
|
||||
try { await deleteObject(old.storageKey) } catch { /* best-effort R2 delete */ }
|
||||
try { await db.delete(storageObjects).where(eq(storageObjects.key, old.storageKey)) } catch { /* best-effort */ }
|
||||
}
|
||||
|
||||
// Delete old entryScreenshots rows (excluding newly inserted ones)
|
||||
if (oldScreenshots.length > 0) {
|
||||
const oldIds = oldScreenshots
|
||||
.filter((s) => !uploadedScreenshotIds.includes(s.id))
|
||||
.map((s) => s.id)
|
||||
if (oldIds.length > 0) {
|
||||
await db.delete(entryScreenshots).where(
|
||||
sql`${entryScreenshots.id} = ANY(${oldIds})`
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Update gamePlatformSupport anti-cheat info if provided ──────
|
||||
if (
|
||||
payload.antiCheatRelevant !== undefined ||
|
||||
payload.antiCheatName !== undefined ||
|
||||
payload.antiCheatStatus !== undefined
|
||||
) {
|
||||
// Need versionId to resolve gameId
|
||||
const [entryVersion] = await db
|
||||
.select({ versionId: performanceEntries.versionId })
|
||||
.from(performanceEntries)
|
||||
.where(eq(performanceEntries.id, params.id))
|
||||
.limit(1)
|
||||
|
||||
if (entryVersion) {
|
||||
const [gv] = await db
|
||||
.select({ gameId: gameVersions.gameId })
|
||||
.from(gameVersions)
|
||||
.where(eq(gameVersions.id, entryVersion.versionId))
|
||||
.limit(1)
|
||||
|
||||
if (gv) {
|
||||
const [existingSupport] = await db
|
||||
.select()
|
||||
.from(gamePlatformSupport)
|
||||
.where(
|
||||
and(
|
||||
eq(gamePlatformSupport.gameId, gv.gameId),
|
||||
eq(gamePlatformSupport.hardwareSlug, updated.hardwareSlug),
|
||||
),
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
if (existingSupport) {
|
||||
await db
|
||||
.update(gamePlatformSupport)
|
||||
.set({
|
||||
antiCheatRelevant:
|
||||
payload.antiCheatRelevant !== undefined
|
||||
? (payload.antiCheatRelevant as boolean)
|
||||
: existingSupport.antiCheatRelevant,
|
||||
antiCheatName:
|
||||
payload.antiCheatName !== undefined
|
||||
? (payload.antiCheatName as string | null)
|
||||
: existingSupport.antiCheatName,
|
||||
antiCheatStatus:
|
||||
payload.antiCheatStatus !== undefined
|
||||
? ((payload.antiCheatStatus ?? "unknown") as "none" | "supported" | "unsupported" | "unknown")
|
||||
: existingSupport.antiCheatStatus,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(gamePlatformSupport.id, existingSupport.id))
|
||||
} else {
|
||||
await db.insert(gamePlatformSupport).values({
|
||||
gameId: gv.gameId,
|
||||
hardwareSlug: updated.hardwareSlug,
|
||||
isSupported: true,
|
||||
protonStatus: "unknown",
|
||||
antiCheatRelevant: (payload.antiCheatRelevant as boolean) ?? false,
|
||||
antiCheatName: (payload.antiCheatName as string | null) ?? null,
|
||||
antiCheatStatus: (payload.antiCheatStatus ?? "unknown") as "none" | "supported" | "unsupported" | "unknown",
|
||||
playabilityStatus: "unknown",
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return updated
|
||||
},
|
||||
{
|
||||
params: t.Object({ id: t.String() }),
|
||||
},
|
||||
)
|
||||
// ── Best entry: highest-rated for latest version ──────────────────
|
||||
.get(
|
||||
"/best",
|
||||
async ({ query, set }) => {
|
||||
const { gameId, hardwareSlug } = query as {
|
||||
gameId?: string
|
||||
hardwareSlug?: string
|
||||
}
|
||||
|
||||
if (!gameId) {
|
||||
set.status = 400
|
||||
return { error: "gameId query parameter is required" }
|
||||
}
|
||||
|
||||
// Find the latest version for this game
|
||||
const [latestVersion] = await db
|
||||
.select()
|
||||
.from(gameVersions)
|
||||
.where(
|
||||
and(eq(gameVersions.gameId, gameId), eq(gameVersions.isLatest, true)),
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
if (!latestVersion) {
|
||||
set.status = 404
|
||||
return { error: "No versions found for this game" }
|
||||
}
|
||||
|
||||
const conditions = [
|
||||
eq(performanceEntries.versionId, latestVersion.id),
|
||||
eq(performanceEntries.isRemoved, false),
|
||||
]
|
||||
|
||||
if (hardwareSlug) {
|
||||
conditions.push(eq(performanceEntries.hardwareSlug, hardwareSlug))
|
||||
}
|
||||
|
||||
const [bestEntry] = await db
|
||||
.select({
|
||||
id: performanceEntries.id,
|
||||
versionId: performanceEntries.versionId,
|
||||
hardwareSlug: performanceEntries.hardwareSlug,
|
||||
fpsAvg: performanceEntries.fpsAvg,
|
||||
fpsLow: performanceEntries.fpsLow,
|
||||
fpsHigh: performanceEntries.fpsHigh,
|
||||
upscalerType: performanceEntries.upscalerType,
|
||||
upscalerVersion: performanceEntries.upscalerVersion,
|
||||
frameGenMethod: performanceEntries.frameGenMethod,
|
||||
settingsJson: performanceEntries.settingsJson,
|
||||
userNotes: performanceEntries.userNotes,
|
||||
upvotes: performanceEntries.upvotes,
|
||||
downvotes: performanceEntries.downvotes,
|
||||
verifiedAt: performanceEntries.verifiedAt,
|
||||
createdAt: performanceEntries.createdAt,
|
||||
userName: user.name,
|
||||
userImage: user.image,
|
||||
hardwareName: hardware.name,
|
||||
versionString: gameVersions.versionString,
|
||||
})
|
||||
.from(performanceEntries)
|
||||
.innerJoin(user, eq(performanceEntries.userId, user.id))
|
||||
.innerJoin(hardware, eq(performanceEntries.hardwareSlug, hardware.slug))
|
||||
.innerJoin(gameVersions, eq(performanceEntries.versionId, gameVersions.id))
|
||||
.where(and(...conditions))
|
||||
.orderBy(
|
||||
desc(
|
||||
sql`${performanceEntries.upvotes} - ${performanceEntries.downvotes}`,
|
||||
),
|
||||
desc(performanceEntries.upvotes),
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
if (!bestEntry) {
|
||||
set.status = 404
|
||||
return { error: "No performance entries found" }
|
||||
}
|
||||
|
||||
return bestEntry
|
||||
},
|
||||
{
|
||||
query: t.Object({
|
||||
gameId: t.String(),
|
||||
hardwareSlug: t.Optional(t.String()),
|
||||
}),
|
||||
},
|
||||
)
|
||||
// ── Stats endpoint: aggregated performance for a game+hardware combo ──
|
||||
.get(
|
||||
"/stats",
|
||||
async ({ query, set }) => {
|
||||
const { gameId, hardwareSlug, upscalerType, upscalerVersion } = query as {
|
||||
gameId?: string
|
||||
hardwareSlug?: string
|
||||
upscalerType?: string
|
||||
upscalerVersion?: string
|
||||
}
|
||||
|
||||
if (!gameId) {
|
||||
set.status = 400
|
||||
return { error: "gameId query parameter is required" }
|
||||
}
|
||||
|
||||
const conditions = [
|
||||
eq(games.id, gameId),
|
||||
eq(performanceEntries.isRemoved, false),
|
||||
]
|
||||
|
||||
if (hardwareSlug) {
|
||||
conditions.push(eq(performanceEntries.hardwareSlug, hardwareSlug))
|
||||
}
|
||||
if (upscalerType) {
|
||||
conditions.push(eq(performanceEntries.upscalerType, upscalerType as any)) // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
}
|
||||
if (upscalerVersion) {
|
||||
conditions.push(eq(performanceEntries.upscalerVersion, upscalerVersion))
|
||||
}
|
||||
|
||||
// Join through gameVersions to get to games
|
||||
const stats = await db
|
||||
.select({
|
||||
count: sql<number>`count(*)::int`,
|
||||
fpsAvg: sql<number>`avg(${performanceEntries.fpsAvg})::real`,
|
||||
fpsLow: sql<number>`percentile_cont(0.1) within group (order by ${performanceEntries.fpsAvg})::real`,
|
||||
fpsHigh: sql<number>`percentile_cont(0.9) within group (order by ${performanceEntries.fpsAvg})::real`,
|
||||
})
|
||||
.from(performanceEntries)
|
||||
.innerJoin(
|
||||
gameVersions,
|
||||
eq(performanceEntries.versionId, gameVersions.id),
|
||||
)
|
||||
.innerJoin(games, eq(gameVersions.gameId, games.id))
|
||||
.where(and(...conditions))
|
||||
|
||||
return stats[0] ?? { count: 0, fpsAvg: null, fpsLow: null, fpsHigh: null }
|
||||
},
|
||||
{
|
||||
query: t.Object({
|
||||
gameId: t.String(),
|
||||
hardwareSlug: t.Optional(t.String()),
|
||||
upscalerType: t.Optional(t.String()),
|
||||
upscalerVersion: t.Optional(t.String()),
|
||||
}),
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,222 @@
|
||||
import { Elysia } from "elysia";
|
||||
import { db } from "@/lib/db/index";
|
||||
import { games, gamePlatformSupport, performanceEntries, gameVersions, playabilityStatusEnum } from "@/lib/db/schema";
|
||||
import { eq, and, avg, count, sql } from "drizzle-orm";
|
||||
import { requireContributorOrAdmin } from "@/lib/auth/guard";
|
||||
|
||||
/**
|
||||
* Playability calculation rules:
|
||||
* - great: avg FPS >= 55, no upscaler/frame-gen dependency
|
||||
* - playable: avg FPS >= 30, or >= 55 with upscaler dependency
|
||||
* - needs_tweaks: avg FPS >= 20 but < 30
|
||||
* - unplayable: avg FPS < 20, OR anti-cheat is relevant AND unsupported
|
||||
* - unknown: no benchmark data
|
||||
*
|
||||
* IMPORTANT: Anti-cheat only blocks playability if the game actually uses anti-cheat
|
||||
* (antiCheatRelevant = true). Games without anti-cheat are unaffected.
|
||||
*/
|
||||
function calculatePlayability(stats: {
|
||||
avgFps: number | null;
|
||||
antiCheatRelevant: boolean;
|
||||
antiCheatStatus: string | null;
|
||||
hasUpscalerDependency: boolean;
|
||||
entryCount: number;
|
||||
}): "great" | "playable" | "needs_tweaks" | "unplayable" | "unknown" {
|
||||
if (!stats.avgFps || stats.entryCount === 0) return "unknown";
|
||||
|
||||
// Anti-cheat unsupported = unplayable ONLY if the game actually uses anti-cheat
|
||||
if (stats.antiCheatRelevant && stats.antiCheatStatus === "unsupported") {
|
||||
return "unplayable";
|
||||
}
|
||||
|
||||
const fps = stats.avgFps;
|
||||
|
||||
if (fps >= 55 && !stats.hasUpscalerDependency) return "great";
|
||||
if (fps >= 55 && stats.hasUpscalerDependency) return "playable";
|
||||
if (fps >= 30) return "playable";
|
||||
if (fps >= 20) return "needs_tweaks";
|
||||
return "unplayable";
|
||||
}
|
||||
|
||||
/**
|
||||
* Recalculate playability for a game (all devices).
|
||||
* Called automatically after benchmark submission and Steam sync.
|
||||
* Skips devices with manual overrides.
|
||||
*/
|
||||
export async function recalculatePlayability(gameId: string): Promise<{
|
||||
gamePlayability: string;
|
||||
deviceResults: Array<{ hardwareSlug: string; playabilityStatus: string }>;
|
||||
}> {
|
||||
// Get all platform support entries for this game
|
||||
const platformEntries = await db
|
||||
.select()
|
||||
.from(gamePlatformSupport)
|
||||
.where(eq(gamePlatformSupport.gameId, gameId));
|
||||
|
||||
// Get performance stats per device
|
||||
const deviceStats = await db
|
||||
.select({
|
||||
hardwareSlug: performanceEntries.hardwareSlug,
|
||||
avgFps: avg(performanceEntries.fpsAvg).mapWith(Number),
|
||||
entryCount: count(performanceEntries.id),
|
||||
upscalerEntries:
|
||||
sql<number>`count(case when ${performanceEntries.upscalerType} != 'none' then 1 end)`.mapWith(
|
||||
Number,
|
||||
),
|
||||
frameGenEntries:
|
||||
sql<number>`count(case when ${performanceEntries.frameGenMethod} != 'none' then 1 end)`.mapWith(
|
||||
Number,
|
||||
),
|
||||
})
|
||||
.from(performanceEntries)
|
||||
.innerJoin(gameVersions, eq(performanceEntries.versionId, gameVersions.id))
|
||||
.where(and(eq(gameVersions.gameId, gameId), eq(performanceEntries.isRemoved, false)))
|
||||
.groupBy(performanceEntries.hardwareSlug);
|
||||
|
||||
const results: Array<{ hardwareSlug: string; playabilityStatus: string }> = [];
|
||||
|
||||
for (const stat of deviceStats) {
|
||||
const platformEntry = platformEntries.find((p) => p.hardwareSlug === stat.hardwareSlug);
|
||||
|
||||
const hasUpscalerDependency =
|
||||
stat.upscalerEntries > stat.entryCount * 0.5 ||
|
||||
stat.frameGenEntries > stat.entryCount * 0.5;
|
||||
|
||||
const status = calculatePlayability({
|
||||
avgFps: stat.avgFps,
|
||||
antiCheatRelevant: platformEntry?.antiCheatRelevant ?? false,
|
||||
antiCheatStatus: platformEntry?.antiCheatStatus ?? null,
|
||||
hasUpscalerDependency,
|
||||
entryCount: stat.entryCount,
|
||||
});
|
||||
|
||||
// Only update if not manually overridden
|
||||
if (platformEntry && !platformEntry.playabilityOverride) {
|
||||
await db
|
||||
.update(gamePlatformSupport)
|
||||
.set({
|
||||
playabilityStatus: status as typeof playabilityStatusEnum.enumValues[number],
|
||||
playabilityCalculatedAt: new Date(),
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(gamePlatformSupport.gameId, gameId),
|
||||
eq(gamePlatformSupport.hardwareSlug, stat.hardwareSlug),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
results.push({ hardwareSlug: stat.hardwareSlug, playabilityStatus: status });
|
||||
}
|
||||
|
||||
// Update aggregate game-level playability (worst of all devices)
|
||||
const priority = { unplayable: 0, needs_tweaks: 1, playable: 2, great: 3, unknown: 4 };
|
||||
let worstStatus: typeof playabilityStatusEnum.enumValues[number] = "unknown";
|
||||
for (const r of results) {
|
||||
if (
|
||||
priority[r.playabilityStatus as keyof typeof priority] <
|
||||
priority[worstStatus as keyof typeof priority]
|
||||
) {
|
||||
worstStatus = r.playabilityStatus as typeof playabilityStatusEnum.enumValues[number];
|
||||
}
|
||||
}
|
||||
|
||||
// Only update game-level if not manually overridden
|
||||
const [game] = await db
|
||||
.select({ playabilityOverride: games.playabilityOverride })
|
||||
.from(games)
|
||||
.where(eq(games.id, gameId))
|
||||
.limit(1);
|
||||
|
||||
if (!game?.playabilityOverride) {
|
||||
await db
|
||||
.update(games)
|
||||
.set({
|
||||
playabilityStatus: worstStatus as typeof playabilityStatusEnum.enumValues[number],
|
||||
playabilityCalculatedAt: new Date(),
|
||||
})
|
||||
.where(eq(games.id, gameId));
|
||||
}
|
||||
|
||||
return { gamePlayability: worstStatus, deviceResults: results };
|
||||
}
|
||||
|
||||
export const playabilityRoutes = new Elysia({ prefix: "/playability", detail: { tags: ["Games"] } })
|
||||
// Manual trigger for recalculation (admin/contributor)
|
||||
.post("/calculate/:gameId", async ({ params, request, set }) => {
|
||||
const guard = await requireContributorOrAdmin(request.headers);
|
||||
if (!guard.ok) {
|
||||
set.status = guard.status;
|
||||
return { error: guard.error };
|
||||
}
|
||||
|
||||
return recalculatePlayability(params.gameId);
|
||||
})
|
||||
|
||||
// Manual override for a game (admin/contributor)
|
||||
.post("/override/:gameId", async ({ params, body, request, set }) => {
|
||||
const guard = await requireContributorOrAdmin(request.headers);
|
||||
if (!guard.ok) {
|
||||
set.status = guard.status;
|
||||
return { error: guard.error };
|
||||
}
|
||||
|
||||
const { status, hardwareSlug } = body as {
|
||||
status: string;
|
||||
hardwareSlug?: string;
|
||||
};
|
||||
|
||||
if (!["great", "playable", "needs_tweaks", "unplayable"].includes(status)) {
|
||||
set.status = 400;
|
||||
return { error: "Invalid playability status" };
|
||||
}
|
||||
|
||||
if (hardwareSlug) {
|
||||
await db
|
||||
.update(gamePlatformSupport)
|
||||
.set({ playabilityStatus: status as typeof playabilityStatusEnum.enumValues[number], playabilityOverride: true })
|
||||
.where(
|
||||
and(
|
||||
eq(gamePlatformSupport.gameId, params.gameId),
|
||||
eq(gamePlatformSupport.hardwareSlug, hardwareSlug),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
await db
|
||||
.update(games)
|
||||
.set({ playabilityStatus: status as typeof playabilityStatusEnum.enumValues[number], playabilityOverride: true })
|
||||
.where(eq(games.id, params.gameId));
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
})
|
||||
|
||||
// Clear override (revert to auto-calculated)
|
||||
.post("/clear-override/:gameId", async ({ params, body, request, set }) => {
|
||||
const guard = await requireContributorOrAdmin(request.headers);
|
||||
if (!guard.ok) {
|
||||
set.status = guard.status;
|
||||
return { error: guard.error };
|
||||
}
|
||||
|
||||
const { hardwareSlug } = body as { hardwareSlug?: string };
|
||||
|
||||
if (hardwareSlug) {
|
||||
await db
|
||||
.update(gamePlatformSupport)
|
||||
.set({ playabilityOverride: false })
|
||||
.where(
|
||||
and(
|
||||
eq(gamePlatformSupport.gameId, params.gameId),
|
||||
eq(gamePlatformSupport.hardwareSlug, hardwareSlug),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
await db
|
||||
.update(games)
|
||||
.set({ playabilityOverride: false })
|
||||
.where(eq(games.id, params.gameId));
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
});
|
||||
@@ -0,0 +1,171 @@
|
||||
import { Elysia } from "elysia"
|
||||
import { db } from "@/lib/db/index"
|
||||
import { user } from "@/lib/db/schema"
|
||||
import { eq } from "drizzle-orm"
|
||||
import { requireAuth } from "@/lib/auth/guard"
|
||||
import {
|
||||
uploadObject,
|
||||
deleteObject,
|
||||
isR2Configured,
|
||||
getR2PublicUrl,
|
||||
isR2Url,
|
||||
} from "@/lib/storage"
|
||||
import { storageObjects } from "@/lib/db/schema"
|
||||
|
||||
const ALLOWED_MIME_TYPES = ["image/jpeg", "image/png", "image/webp"]
|
||||
const MAX_FILE_SIZE = 5 * 1024 * 1024 // 5MB
|
||||
|
||||
// Magic byte signatures for file type validation
|
||||
const MAGIC_BYTES: Record<string, number[]> = {
|
||||
"image/jpeg": [0xff, 0xd8, 0xff],
|
||||
"image/png": [0x89, 0x50, 0x4e, 0x47],
|
||||
"image/webp": [0x52, 0x49, 0x46, 0x46], // RIFF header (WEBP container)
|
||||
}
|
||||
|
||||
function validateMagicBytes(buffer: Buffer, declaredMime: string): boolean {
|
||||
const expected = MAGIC_BYTES[declaredMime]
|
||||
if (!expected) return false
|
||||
if (buffer.length < expected.length) return false
|
||||
return expected.every((byte, i) => buffer[i] === byte)
|
||||
}
|
||||
|
||||
export const profilePhotoRoutes = new Elysia({ prefix: "/user", detail: { tags: ["Users"] } })
|
||||
|
||||
// ── Upload Profile Photo ──────────────────────────────────────────
|
||||
.post(
|
||||
"/profile-photo",
|
||||
async ({ request, set }) => {
|
||||
const guard = await requireAuth(request.headers)
|
||||
if (!guard.ok) {
|
||||
set.status = guard.status
|
||||
return { error: guard.error }
|
||||
}
|
||||
|
||||
if (!isR2Configured()) {
|
||||
set.status = 503
|
||||
return { error: "Storage not configured" }
|
||||
}
|
||||
|
||||
// Parse multipart form data
|
||||
const formData = await request.formData()
|
||||
const file = formData.get("photo")
|
||||
if (!file || !(file instanceof File)) {
|
||||
set.status = 400
|
||||
return { error: "No file provided" }
|
||||
}
|
||||
|
||||
// Validate MIME type
|
||||
if (!ALLOWED_MIME_TYPES.includes(file.type)) {
|
||||
set.status = 400
|
||||
return { error: `Invalid file type. Allowed: ${ALLOWED_MIME_TYPES.join(", ")}` }
|
||||
}
|
||||
|
||||
// Validate file size
|
||||
if (file.size > MAX_FILE_SIZE) {
|
||||
set.status = 400
|
||||
return { error: `File too large. Maximum size: ${MAX_FILE_SIZE / 1024 / 1024}MB` }
|
||||
}
|
||||
|
||||
// Read file buffer and validate magic bytes
|
||||
const arrayBuffer = await file.arrayBuffer()
|
||||
const buffer = Buffer.from(arrayBuffer)
|
||||
|
||||
if (!validateMagicBytes(buffer, file.type)) {
|
||||
set.status = 400
|
||||
return { error: "File content does not match declared type" }
|
||||
}
|
||||
|
||||
// Generate unique key
|
||||
const timestamp = Date.now()
|
||||
const ext = file.type === "image/png" ? "png" : file.type === "image/webp" ? "webp" : "jpg"
|
||||
const actualKey = `avatars/${guard.user.id}-${timestamp}.${ext}`
|
||||
const publicUrl = await uploadObject(actualKey, buffer, file.type, {
|
||||
userId: guard.user.id,
|
||||
entityType: "avatar",
|
||||
})
|
||||
|
||||
// Track in storage_objects
|
||||
await db.insert(storageObjects).values({
|
||||
key: actualKey,
|
||||
bucket: process.env.R2_BUCKET_NAME ?? "deckyvault",
|
||||
size: buffer.length,
|
||||
mimeType: file.type,
|
||||
entityType: "avatar",
|
||||
entityId: guard.user.id,
|
||||
uploadedBy: guard.user.id,
|
||||
})
|
||||
|
||||
// Check if user had a previous custom avatar and delete it
|
||||
const [currentUser] = await db
|
||||
.select({ image: user.image })
|
||||
.from(user)
|
||||
.where(eq(user.id, guard.user.id))
|
||||
.limit(1)
|
||||
|
||||
if (currentUser?.image && isR2Url(currentUser.image)) {
|
||||
// Extract the key from the URL (everything after R2_PUBLIC_URL/)
|
||||
const oldKey = currentUser.image.replace(`${getR2PublicUrl()}/`, "")
|
||||
try {
|
||||
await deleteObject(oldKey)
|
||||
} catch {
|
||||
// Log but don't block — daily cron will clean up orphaned objects
|
||||
console.warn(`Failed to delete old avatar: ${oldKey}`)
|
||||
}
|
||||
// Remove old tracking record
|
||||
await db
|
||||
.delete(storageObjects)
|
||||
.where(eq(storageObjects.key, oldKey))
|
||||
}
|
||||
|
||||
// Update user.image
|
||||
await db
|
||||
.update(user)
|
||||
.set({ image: publicUrl, updatedAt: new Date() })
|
||||
.where(eq(user.id, guard.user.id))
|
||||
|
||||
return { url: publicUrl }
|
||||
},
|
||||
)
|
||||
|
||||
// ── Delete Profile Photo ──────────────────────────────────────────
|
||||
.delete(
|
||||
"/profile-photo",
|
||||
async ({ request, set }) => {
|
||||
const guard = await requireAuth(request.headers)
|
||||
if (!guard.ok) {
|
||||
set.status = guard.status
|
||||
return { error: guard.error }
|
||||
}
|
||||
|
||||
const [currentUser] = await db
|
||||
.select({ image: user.image })
|
||||
.from(user)
|
||||
.where(eq(user.id, guard.user.id))
|
||||
.limit(1)
|
||||
|
||||
if (!currentUser?.image || !isR2Url(currentUser.image)) {
|
||||
return { success: true, message: "No custom photo to delete" }
|
||||
}
|
||||
|
||||
// Delete from R2
|
||||
const oldKey = currentUser.image.replace(`${getR2PublicUrl()}/`, "")
|
||||
try {
|
||||
await deleteObject(oldKey)
|
||||
} catch {
|
||||
console.warn(`Failed to delete avatar from R2: ${oldKey}`)
|
||||
}
|
||||
|
||||
// Remove tracking record
|
||||
await db
|
||||
.delete(storageObjects)
|
||||
.where(eq(storageObjects.key, oldKey))
|
||||
|
||||
// Clear user.image
|
||||
await db
|
||||
.update(user)
|
||||
.set({ image: null, updatedAt: new Date() })
|
||||
.where(eq(user.id, guard.user.id))
|
||||
|
||||
return { success: true }
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,61 @@
|
||||
import { Elysia, t } from "elysia"
|
||||
import { db } from "@/lib/db/index"
|
||||
import { reports } from "@/lib/db/schema"
|
||||
import { eq, and } from "drizzle-orm"
|
||||
import { requireRole } from "@/lib/auth/guard"
|
||||
|
||||
export const reportRoutes = new Elysia({ prefix: "/performance", detail: { tags: ["Reports"] } }).post(
|
||||
"/:id/report",
|
||||
async ({ params, body, request, set }) => {
|
||||
const guard = await requireRole(request.headers, [
|
||||
"user",
|
||||
"contributor",
|
||||
"admin",
|
||||
])
|
||||
if (!guard.ok) {
|
||||
set.status = guard.status
|
||||
return { error: guard.error }
|
||||
}
|
||||
|
||||
// Check if user already reported this entry
|
||||
const [existing] = await db
|
||||
.select()
|
||||
.from(reports)
|
||||
.where(
|
||||
and(
|
||||
eq(reports.entryId, params.id),
|
||||
eq(reports.reporterId, guard.user.id),
|
||||
),
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
if (existing) {
|
||||
set.status = 409
|
||||
return { error: "You have already reported this entry" }
|
||||
}
|
||||
|
||||
const [created] = await db
|
||||
.insert(reports)
|
||||
.values({
|
||||
entryId: params.id,
|
||||
reporterId: guard.user.id,
|
||||
reason: body.reason,
|
||||
details: body.details ?? null,
|
||||
})
|
||||
.returning()
|
||||
|
||||
return created
|
||||
},
|
||||
{
|
||||
params: t.Object({ id: t.String() }),
|
||||
body: t.Object({
|
||||
reason: t.Union([
|
||||
t.Literal("inaccurate"),
|
||||
t.Literal("spam"),
|
||||
t.Literal("inappropriate"),
|
||||
t.Literal("other"),
|
||||
]),
|
||||
details: t.Optional(t.String()),
|
||||
}),
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,139 @@
|
||||
import { Elysia, t } from "elysia"
|
||||
import { db } from "@/lib/db/index"
|
||||
import { savedFilters } from "@/lib/db/schema"
|
||||
import { eq, and, desc } from "drizzle-orm"
|
||||
import { requireAuth } from "@/lib/auth/guard"
|
||||
|
||||
export const savedFilterRoutes = new Elysia({ prefix: "/saved-filters", detail: { tags: ["Games"] } })
|
||||
|
||||
// Get user's saved filters
|
||||
.get("/", async ({ request, set }) => {
|
||||
const guard = await requireAuth(request.headers)
|
||||
if (!guard.ok) {
|
||||
set.status = guard.status
|
||||
return { error: guard.error }
|
||||
}
|
||||
|
||||
const filters = await db
|
||||
.select()
|
||||
.from(savedFilters)
|
||||
.where(eq(savedFilters.userId, guard.user.id))
|
||||
.orderBy(desc(savedFilters.updatedAt))
|
||||
|
||||
return filters
|
||||
})
|
||||
|
||||
// Create a saved filter
|
||||
.post(
|
||||
"/",
|
||||
async ({ body, request, set }) => {
|
||||
const guard = await requireAuth(request.headers)
|
||||
if (!guard.ok) {
|
||||
set.status = guard.status
|
||||
return { error: guard.error }
|
||||
}
|
||||
|
||||
const { name, filters: filterData } = body
|
||||
|
||||
const [saved] = await db
|
||||
.insert(savedFilters)
|
||||
.values({
|
||||
userId: guard.user.id,
|
||||
name,
|
||||
filters: filterData,
|
||||
})
|
||||
.returning()
|
||||
|
||||
return saved
|
||||
},
|
||||
{
|
||||
body: t.Object({
|
||||
name: t.String(),
|
||||
filters: t.Record(t.String(), t.Any()),
|
||||
}),
|
||||
},
|
||||
)
|
||||
|
||||
// Update a saved filter
|
||||
.patch(
|
||||
"/:id",
|
||||
async ({ params, body, request, set }) => {
|
||||
const guard = await requireAuth(request.headers)
|
||||
if (!guard.ok) {
|
||||
set.status = guard.status
|
||||
return { error: guard.error }
|
||||
}
|
||||
|
||||
const { name, filters: filterData } = body
|
||||
|
||||
// Verify ownership
|
||||
const [existing] = await db
|
||||
.select()
|
||||
.from(savedFilters)
|
||||
.where(
|
||||
and(
|
||||
eq(savedFilters.id, params.id),
|
||||
eq(savedFilters.userId, guard.user.id),
|
||||
),
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
if (!existing) {
|
||||
set.status = 404
|
||||
return { error: "Saved filter not found" }
|
||||
}
|
||||
|
||||
const [updated] = await db
|
||||
.update(savedFilters)
|
||||
.set({
|
||||
...(name && { name }),
|
||||
...(filterData && { filters: filterData }),
|
||||
})
|
||||
.where(eq(savedFilters.id, params.id))
|
||||
.returning()
|
||||
|
||||
return updated
|
||||
},
|
||||
{
|
||||
params: t.Object({ id: t.String() }),
|
||||
body: t.Object({
|
||||
name: t.Optional(t.String()),
|
||||
filters: t.Optional(t.Record(t.String(), t.Any())),
|
||||
}),
|
||||
},
|
||||
)
|
||||
|
||||
// Delete a saved filter
|
||||
.delete(
|
||||
"/:id",
|
||||
async ({ params, request, set }) => {
|
||||
const guard = await requireAuth(request.headers)
|
||||
if (!guard.ok) {
|
||||
set.status = guard.status
|
||||
return { error: guard.error }
|
||||
}
|
||||
|
||||
const [existing] = await db
|
||||
.select()
|
||||
.from(savedFilters)
|
||||
.where(
|
||||
and(
|
||||
eq(savedFilters.id, params.id),
|
||||
eq(savedFilters.userId, guard.user.id),
|
||||
),
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
if (!existing) {
|
||||
set.status = 404
|
||||
return { error: "Saved filter not found" }
|
||||
}
|
||||
|
||||
await db.delete(savedFilters).where(eq(savedFilters.id, params.id))
|
||||
|
||||
return { success: true }
|
||||
},
|
||||
{
|
||||
params: t.Object({ id: t.String() }),
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,162 @@
|
||||
import { Elysia, t } from "elysia"
|
||||
import { auth } from "@/lib/auth"
|
||||
import { db } from "@/lib/db/index"
|
||||
import { savedGames, games } from "@/lib/db/schema"
|
||||
import { eq, and, sql } from "drizzle-orm"
|
||||
|
||||
export const savedGamesRoutes = new Elysia({ prefix: "/user/me/saved-games", detail: { tags: ["Games"] } })
|
||||
.post(
|
||||
"/",
|
||||
async ({ request, body, set }) => {
|
||||
const session = await auth.api.getSession({
|
||||
headers: request.headers,
|
||||
})
|
||||
|
||||
if (!session) {
|
||||
set.status = 401
|
||||
return { error: "Unauthorized" }
|
||||
}
|
||||
|
||||
// Check if game exists
|
||||
const [game] = await db
|
||||
.select({ id: games.id })
|
||||
.from(games)
|
||||
.where(eq(games.id, body.gameId))
|
||||
.limit(1)
|
||||
|
||||
if (!game) {
|
||||
set.status = 404
|
||||
return { error: "Game not found" }
|
||||
}
|
||||
|
||||
// Check if already saved
|
||||
const [existing] = await db
|
||||
.select({ id: savedGames.id })
|
||||
.from(savedGames)
|
||||
.where(
|
||||
and(
|
||||
eq(savedGames.userId, session.user.id),
|
||||
eq(savedGames.gameId, body.gameId),
|
||||
),
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
if (existing) {
|
||||
set.status = 409
|
||||
return { error: "Game already saved" }
|
||||
}
|
||||
|
||||
const [saved] = await db
|
||||
.insert(savedGames)
|
||||
.values({
|
||||
userId: session.user.id,
|
||||
gameId: body.gameId,
|
||||
})
|
||||
.returning()
|
||||
|
||||
return { id: saved.id, gameId: saved.gameId, createdAt: saved.createdAt.toISOString() }
|
||||
},
|
||||
{
|
||||
body: t.Object({
|
||||
gameId: t.String(),
|
||||
}),
|
||||
},
|
||||
)
|
||||
.delete(
|
||||
"/:gameId",
|
||||
async ({ request, params, set }) => {
|
||||
const session = await auth.api.getSession({
|
||||
headers: request.headers,
|
||||
})
|
||||
|
||||
if (!session) {
|
||||
set.status = 401
|
||||
return { error: "Unauthorized" }
|
||||
}
|
||||
|
||||
const [deleted] = await db
|
||||
.delete(savedGames)
|
||||
.where(
|
||||
and(
|
||||
eq(savedGames.userId, session.user.id),
|
||||
eq(savedGames.gameId, params.gameId),
|
||||
),
|
||||
)
|
||||
.returning()
|
||||
|
||||
if (!deleted) {
|
||||
set.status = 404
|
||||
return { error: "Saved game not found" }
|
||||
}
|
||||
|
||||
return { success: true }
|
||||
},
|
||||
{
|
||||
params: t.Object({
|
||||
gameId: t.String(),
|
||||
}),
|
||||
},
|
||||
)
|
||||
.get(
|
||||
"/",
|
||||
async ({ request, set }) => {
|
||||
const session = await auth.api.getSession({
|
||||
headers: request.headers,
|
||||
})
|
||||
|
||||
if (!session) {
|
||||
set.status = 401
|
||||
return { error: "Unauthorized" }
|
||||
}
|
||||
|
||||
const saved = await db
|
||||
.select({
|
||||
id: savedGames.id,
|
||||
gameId: savedGames.gameId,
|
||||
createdAt: savedGames.createdAt,
|
||||
gameTitle: games.title,
|
||||
gameHeaderImage: games.headerImage,
|
||||
gameCapsuleImage: games.capsuleImage,
|
||||
gameSteamAppId: games.steamAppId,
|
||||
})
|
||||
.from(savedGames)
|
||||
.innerJoin(games, eq(savedGames.gameId, games.id))
|
||||
.where(eq(savedGames.userId, session.user.id))
|
||||
.orderBy(sql`${savedGames.createdAt} DESC`)
|
||||
|
||||
return saved.map((s) => ({
|
||||
...s,
|
||||
createdAt: s.createdAt.toISOString(),
|
||||
}))
|
||||
},
|
||||
)
|
||||
.get(
|
||||
"/check/:gameId",
|
||||
async ({ request, params }) => {
|
||||
const session = await auth.api.getSession({
|
||||
headers: request.headers,
|
||||
})
|
||||
|
||||
if (!session) {
|
||||
return { saved: false }
|
||||
}
|
||||
|
||||
const [existing] = await db
|
||||
.select({ id: savedGames.id })
|
||||
.from(savedGames)
|
||||
.where(
|
||||
and(
|
||||
eq(savedGames.userId, session.user.id),
|
||||
eq(savedGames.gameId, params.gameId),
|
||||
),
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
return { saved: !!existing }
|
||||
},
|
||||
{
|
||||
params: t.Object({
|
||||
gameId: t.String(),
|
||||
}),
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,257 @@
|
||||
import { Elysia, t } from "elysia"
|
||||
import { db } from "@/lib/db/index"
|
||||
import { performanceEntries, entryScreenshots, storageObjects } from "@/lib/db/schema"
|
||||
import { eq, sql } from "drizzle-orm"
|
||||
import { requireRole } from "@/lib/auth/guard"
|
||||
import { uploadObject, deleteObject, getR2PublicUrl, isR2Configured } from "@/lib/storage"
|
||||
import { processScreenshot, isAllowedMimeType } from "@/lib/image-processing"
|
||||
|
||||
const MAX_SCREENSHOTS_PER_ENTRY = 2
|
||||
const MAX_UPLOAD_SIZE = 10 * 1024 * 1024 // 10 MB raw
|
||||
|
||||
export const screenshotRoutes = new Elysia({ prefix: "/performance", detail: { tags: ["Performance"] } })
|
||||
|
||||
// ── Upload screenshots ─────────────────────────────────────────
|
||||
.post(
|
||||
"/:id/screenshots",
|
||||
async ({ params, request, set }) => {
|
||||
const guard = await requireRole(request.headers, ["user", "contributor", "admin"])
|
||||
if (!guard.ok) {
|
||||
set.status = guard.status
|
||||
return { error: guard.error }
|
||||
}
|
||||
|
||||
if (!isR2Configured()) {
|
||||
set.status = 503
|
||||
return { error: "Storage not configured" }
|
||||
}
|
||||
|
||||
// Verify entry exists and user is owner or admin
|
||||
const [entry] = await db
|
||||
.select({
|
||||
id: performanceEntries.id,
|
||||
userId: performanceEntries.userId,
|
||||
})
|
||||
.from(performanceEntries)
|
||||
.where(eq(performanceEntries.id, params.id))
|
||||
.limit(1)
|
||||
|
||||
if (!entry) {
|
||||
set.status = 404
|
||||
return { error: "Performance entry not found" }
|
||||
}
|
||||
|
||||
if (entry.userId !== guard.user.id && guard.user.role !== "admin") {
|
||||
set.status = 403
|
||||
return { error: "Not authorized to upload screenshots for this entry" }
|
||||
}
|
||||
|
||||
// Check existing screenshot count
|
||||
const [countResult] = await db
|
||||
.select({ count: sql<number>`count(*)::int` })
|
||||
.from(entryScreenshots)
|
||||
.where(eq(entryScreenshots.entryId, params.id))
|
||||
|
||||
if ((countResult?.count ?? 0) >= MAX_SCREENSHOTS_PER_ENTRY) {
|
||||
set.status = 400
|
||||
return { error: `Maximum ${MAX_SCREENSHOTS_PER_ENTRY} screenshots per entry` }
|
||||
}
|
||||
|
||||
// Parse multipart form data
|
||||
const formData = await request.formData()
|
||||
const files = formData.getAll("screenshots")
|
||||
|
||||
if (!files || files.length === 0) {
|
||||
set.status = 400
|
||||
return { error: "No files provided" }
|
||||
}
|
||||
|
||||
// Remaining slots
|
||||
const remaining = MAX_SCREENSHOTS_PER_ENTRY - (countResult?.count ?? 0)
|
||||
const toProcess = files.slice(0, remaining).filter((f): f is File => f instanceof File)
|
||||
|
||||
if (toProcess.length === 0) {
|
||||
set.status = 400
|
||||
return { error: "No valid files provided or entry already has maximum screenshots" }
|
||||
}
|
||||
|
||||
// Get existing max order index
|
||||
const [maxOrder] = await db
|
||||
.select({ maxIndex: sql<number>`coalesce(max(${entryScreenshots.orderIndex}), -1)` })
|
||||
.from(entryScreenshots)
|
||||
.where(eq(entryScreenshots.entryId, params.id))
|
||||
|
||||
const startIndex = (maxOrder?.maxIndex ?? -1) + 1
|
||||
|
||||
const results = []
|
||||
|
||||
for (let i = 0; i < toProcess.length; i++) {
|
||||
const file = toProcess[i]
|
||||
|
||||
// Validate MIME type
|
||||
if (!isAllowedMimeType(file.type)) {
|
||||
continue // Skip invalid types
|
||||
}
|
||||
|
||||
// Validate file size
|
||||
if (file.size > MAX_UPLOAD_SIZE) {
|
||||
continue // Skip oversized files
|
||||
}
|
||||
|
||||
try {
|
||||
const arrayBuffer = await file.arrayBuffer()
|
||||
const inputBuffer = Buffer.from(arrayBuffer)
|
||||
|
||||
// Process and compress
|
||||
const processed = await processScreenshot(inputBuffer, file.type)
|
||||
|
||||
// Upload to R2
|
||||
const storageKey = `screenshots/${params.id}/${crypto.randomUUID()}.jpg`
|
||||
const publicUrl = await uploadObject(
|
||||
storageKey,
|
||||
processed.buffer,
|
||||
processed.mimeType,
|
||||
{ userId: guard.user.id, entityType: "entry_screenshot", entityId: params.id },
|
||||
)
|
||||
|
||||
// Track in storage_objects
|
||||
await db.insert(storageObjects).values({
|
||||
key: storageKey,
|
||||
bucket: process.env.R2_BUCKET_NAME ?? "deckyvault",
|
||||
size: processed.size,
|
||||
mimeType: processed.mimeType,
|
||||
entityType: "entry_screenshot",
|
||||
entityId: params.id,
|
||||
uploadedBy: guard.user.id,
|
||||
})
|
||||
|
||||
// Create screenshot record
|
||||
const [screenshot] = await db
|
||||
.insert(entryScreenshots)
|
||||
.values({
|
||||
entryId: params.id,
|
||||
storageKey,
|
||||
orderIndex: startIndex + i,
|
||||
mimeType: processed.mimeType,
|
||||
width: processed.width,
|
||||
height: processed.height,
|
||||
originalName: file.name || null,
|
||||
})
|
||||
.returning()
|
||||
|
||||
results.push({
|
||||
id: screenshot.id,
|
||||
entryId: screenshot.entryId,
|
||||
url: publicUrl,
|
||||
orderIndex: screenshot.orderIndex,
|
||||
width: processed.width,
|
||||
height: processed.height,
|
||||
})
|
||||
} catch (err) {
|
||||
console.error(`Failed to process screenshot ${i}:`, err)
|
||||
}
|
||||
}
|
||||
|
||||
if (results.length === 0) {
|
||||
set.status = 500
|
||||
return { error: "Failed to process any screenshots" }
|
||||
}
|
||||
|
||||
return { data: results }
|
||||
},
|
||||
{
|
||||
params: t.Object({ id: t.String() }),
|
||||
},
|
||||
)
|
||||
|
||||
// ── List screenshots ────────────────────────────────────────────
|
||||
.get(
|
||||
"/:id/screenshots",
|
||||
async ({ params }) => {
|
||||
const screenshots = await db
|
||||
.select({
|
||||
id: entryScreenshots.id,
|
||||
entryId: entryScreenshots.entryId,
|
||||
storageKey: entryScreenshots.storageKey,
|
||||
orderIndex: entryScreenshots.orderIndex,
|
||||
mimeType: entryScreenshots.mimeType,
|
||||
width: entryScreenshots.width,
|
||||
height: entryScreenshots.height,
|
||||
originalName: entryScreenshots.originalName,
|
||||
createdAt: entryScreenshots.createdAt,
|
||||
})
|
||||
.from(entryScreenshots)
|
||||
.where(eq(entryScreenshots.entryId, params.id))
|
||||
.orderBy(entryScreenshots.orderIndex)
|
||||
|
||||
const publicUrl = getR2PublicUrl()
|
||||
|
||||
return {
|
||||
data: screenshots.map((s) => ({
|
||||
...s,
|
||||
url: `${publicUrl}/${s.storageKey}`,
|
||||
})),
|
||||
}
|
||||
},
|
||||
{
|
||||
params: t.Object({ id: t.String() }),
|
||||
},
|
||||
)
|
||||
|
||||
// ── Delete screenshot ────────────────────────────────────────────
|
||||
.delete(
|
||||
"/:id/screenshots/:sid",
|
||||
async ({ params, request, set }) => {
|
||||
const guard = await requireRole(request.headers, ["user", "contributor", "admin"])
|
||||
if (!guard.ok) {
|
||||
set.status = guard.status
|
||||
return { error: guard.error }
|
||||
}
|
||||
|
||||
// Find the screenshot
|
||||
const [screenshot] = await db
|
||||
.select()
|
||||
.from(entryScreenshots)
|
||||
.where(eq(entryScreenshots.id, params.sid))
|
||||
.limit(1)
|
||||
|
||||
if (!screenshot) {
|
||||
set.status = 404
|
||||
return { error: "Screenshot not found" }
|
||||
}
|
||||
|
||||
// Verify ownership or admin
|
||||
const [entry] = await db
|
||||
.select({ userId: performanceEntries.userId })
|
||||
.from(performanceEntries)
|
||||
.where(eq(performanceEntries.id, screenshot.entryId))
|
||||
.limit(1)
|
||||
|
||||
if (entry && entry.userId !== guard.user.id && guard.user.role !== "admin") {
|
||||
set.status = 403
|
||||
return { error: "Not authorized to delete this screenshot" }
|
||||
}
|
||||
|
||||
// Delete from R2
|
||||
try {
|
||||
await deleteObject(screenshot.storageKey)
|
||||
} catch {
|
||||
console.warn(`Failed to delete screenshot from R2: ${screenshot.storageKey}`)
|
||||
}
|
||||
|
||||
// Delete storage_objects record
|
||||
await db
|
||||
.delete(storageObjects)
|
||||
.where(eq(storageObjects.key, screenshot.storageKey))
|
||||
|
||||
// Delete screenshot record
|
||||
await db
|
||||
.delete(entryScreenshots)
|
||||
.where(eq(entryScreenshots.id, params.sid))
|
||||
|
||||
return { success: true }
|
||||
},
|
||||
{
|
||||
params: t.Object({ id: t.String(), sid: t.String() }),
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,540 @@
|
||||
import { Elysia, t } from "elysia"
|
||||
import { db } from "@/lib/db/index"
|
||||
import {
|
||||
games,
|
||||
gameVersions,
|
||||
performanceEntries,
|
||||
gameComments,
|
||||
hardware,
|
||||
} from "@/lib/db/schema"
|
||||
import { ilike, or, sql, eq, inArray, and, gte, desc } from "drizzle-orm"
|
||||
import { fuzzySearchTerm } from "@/lib/db/search"
|
||||
|
||||
interface SteamSearchItem {
|
||||
id: number
|
||||
name: string
|
||||
tiny_image: string
|
||||
metascore: string
|
||||
price?: { currency: string; initial: number; final: number }
|
||||
platforms: { windows: boolean; mac: boolean; linux: boolean }
|
||||
controller_support?: string
|
||||
}
|
||||
|
||||
interface SteamSearchResponse {
|
||||
items: SteamSearchItem[]
|
||||
total: number
|
||||
}
|
||||
|
||||
export const searchUnifiedRoutes = new Elysia({ prefix: "/search", detail: { tags: ["Search"] } }).get(
|
||||
"/unified",
|
||||
async ({ query, set }) => {
|
||||
if (!query.q || query.q.length < 2) {
|
||||
set.status = 400
|
||||
return { error: "Query must be at least 2 characters" }
|
||||
}
|
||||
|
||||
const titleTerm = fuzzySearchTerm(query.q)
|
||||
const term = `%${query.q}%`
|
||||
|
||||
// ── Build filter conditions for columns on the games table ────
|
||||
const baseFilterConditions = [
|
||||
or(
|
||||
ilike(games.title, titleTerm),
|
||||
ilike(games.developer, term),
|
||||
ilike(games.publisher, term),
|
||||
),
|
||||
]
|
||||
|
||||
if (query.playabilityStatus) {
|
||||
baseFilterConditions.push(sql`${games.playabilityStatus} = ${query.playabilityStatus}`)
|
||||
}
|
||||
if (query.steamReviewScore) {
|
||||
const minScore = parseInt(query.steamReviewScore, 10)
|
||||
if (!isNaN(minScore)) {
|
||||
baseFilterConditions.push(gte(games.steamReviewScore, minScore))
|
||||
}
|
||||
}
|
||||
if (query.isFree === "true") {
|
||||
baseFilterConditions.push(eq(games.isFree, true))
|
||||
}
|
||||
if (query.hasMultiplayer === "true") {
|
||||
baseFilterConditions.push(sql`${games.onlineMultiplayerStatus} = 'supported'`)
|
||||
}
|
||||
|
||||
// ── 1. Search local database ────────────────────────────────────
|
||||
const localGames = await db
|
||||
.select()
|
||||
.from(games)
|
||||
.where(and(...baseFilterConditions))
|
||||
.limit(40)
|
||||
|
||||
// ── 1b. Post-process filters requiring joins ───────────────────
|
||||
let filteredGameIds = new Set(localGames.map((g) => g.id))
|
||||
|
||||
// Device filter: keep only games that have at least one benchmark for the device
|
||||
if (query.device && filteredGameIds.size > 0) {
|
||||
const matchingGameIds = await db
|
||||
.select({ gameId: gameVersions.gameId })
|
||||
.from(performanceEntries)
|
||||
.innerJoin(gameVersions, eq(performanceEntries.versionId, gameVersions.id))
|
||||
.where(
|
||||
and(
|
||||
inArray(gameVersions.gameId, [...filteredGameIds]),
|
||||
eq(performanceEntries.hardwareSlug, query.device),
|
||||
eq(performanceEntries.isRemoved, false),
|
||||
),
|
||||
)
|
||||
.groupBy(gameVersions.gameId)
|
||||
filteredGameIds = new Set(matchingGameIds.map((r) => r.gameId))
|
||||
}
|
||||
|
||||
// FSR support filter: keep games with at least one benchmark using upscaler !== 'none'
|
||||
if (query.fsrSupport === "true" && filteredGameIds.size > 0) {
|
||||
const matchingGameIds = await db
|
||||
.select({ gameId: gameVersions.gameId })
|
||||
.from(performanceEntries)
|
||||
.innerJoin(gameVersions, eq(performanceEntries.versionId, gameVersions.id))
|
||||
.where(
|
||||
and(
|
||||
inArray(gameVersions.gameId, [...filteredGameIds]),
|
||||
sql`${performanceEntries.upscalerType} != 'none'`,
|
||||
eq(performanceEntries.isRemoved, false),
|
||||
),
|
||||
)
|
||||
.groupBy(gameVersions.gameId)
|
||||
filteredGameIds = new Set(matchingGameIds.map((r) => r.gameId))
|
||||
}
|
||||
|
||||
// FPS range filter: keep games whose bestFps falls within [minFps, maxFps]
|
||||
const minFps = query.minFps ? parseInt(query.minFps, 10) : undefined
|
||||
const maxFps = query.maxFps ? parseInt(query.maxFps, 10) : undefined
|
||||
if ((minFps !== undefined || maxFps !== undefined) && filteredGameIds.size > 0) {
|
||||
const fpsStats = await db
|
||||
.select({
|
||||
gameId: gameVersions.gameId,
|
||||
bestFps: sql<number>`MAX(${performanceEntries.fpsAvg})::real`,
|
||||
})
|
||||
.from(performanceEntries)
|
||||
.innerJoin(gameVersions, eq(performanceEntries.versionId, gameVersions.id))
|
||||
.where(
|
||||
and(
|
||||
inArray(gameVersions.gameId, [...filteredGameIds]),
|
||||
eq(performanceEntries.isRemoved, false),
|
||||
),
|
||||
)
|
||||
.groupBy(gameVersions.gameId)
|
||||
|
||||
const fpsMatchIds = new Set<string>()
|
||||
for (const row of fpsStats) {
|
||||
if (minFps !== undefined && row.bestFps < minFps) continue
|
||||
if (maxFps !== undefined && row.bestFps > maxFps) continue
|
||||
fpsMatchIds.add(row.gameId)
|
||||
}
|
||||
filteredGameIds = fpsMatchIds
|
||||
}
|
||||
|
||||
// ── Filter local games to only those that passed all filters so far ──
|
||||
const filteredLocalGames = localGames.filter((g) => filteredGameIds.has(g.id))
|
||||
const filteredIds = filteredLocalGames.map((g) => g.id)
|
||||
|
||||
// Fetch platform support + anti-cheat for local games
|
||||
const platformSupportMap = new Map<
|
||||
string,
|
||||
{
|
||||
isSupported: boolean
|
||||
protonStatus: string
|
||||
antiCheatRelevant: boolean
|
||||
antiCheatName: string | null
|
||||
antiCheatStatus: string
|
||||
}
|
||||
>()
|
||||
if (filteredIds.length > 0) {
|
||||
const { gamePlatformSupport } = await import("@/lib/db/schema")
|
||||
const supportRows = await db
|
||||
.select({
|
||||
gameId: gamePlatformSupport.gameId,
|
||||
isSupported: gamePlatformSupport.isSupported,
|
||||
protonStatus: gamePlatformSupport.protonStatus,
|
||||
antiCheatRelevant: gamePlatformSupport.antiCheatRelevant,
|
||||
antiCheatName: gamePlatformSupport.antiCheatName,
|
||||
antiCheatStatus: gamePlatformSupport.antiCheatStatus,
|
||||
})
|
||||
.from(gamePlatformSupport)
|
||||
.where(inArray(gamePlatformSupport.gameId, filteredIds))
|
||||
|
||||
for (const row of supportRows) {
|
||||
platformSupportMap.set(row.gameId, {
|
||||
isSupported: row.isSupported,
|
||||
protonStatus: row.protonStatus,
|
||||
antiCheatRelevant: row.antiCheatRelevant,
|
||||
antiCheatName: row.antiCheatName,
|
||||
antiCheatStatus: row.antiCheatStatus,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ── 1c. Proton/Native and Anti-cheat post-filters ─────────────
|
||||
if (query.protonNative && query.protonNative !== "any" && filteredIds.length > 0) {
|
||||
const protonMatchIds = new Set<string>()
|
||||
for (const g of filteredLocalGames) {
|
||||
const platform = platformSupportMap.get(g.id)
|
||||
const protonStatus = platform
|
||||
? platform.protonStatus
|
||||
: g.platforms?.linux
|
||||
? "native"
|
||||
: g.platforms?.windows
|
||||
? "proton"
|
||||
: "unknown"
|
||||
if (
|
||||
(query.protonNative === "native" && protonStatus === "native") ||
|
||||
(query.protonNative === "proton" && protonStatus === "proton")
|
||||
) {
|
||||
protonMatchIds.add(g.id)
|
||||
}
|
||||
}
|
||||
filteredGameIds = protonMatchIds
|
||||
}
|
||||
|
||||
if (query.antiCheatStatus && query.antiCheatStatus !== "any" && filteredIds.length > 0) {
|
||||
const acMatchIds = new Set<string>()
|
||||
for (const g of filteredLocalGames) {
|
||||
const platform = platformSupportMap.get(g.id)
|
||||
const acStatus = platform ? platform.antiCheatStatus : "unknown"
|
||||
if (acStatus === query.antiCheatStatus) {
|
||||
acMatchIds.add(g.id)
|
||||
}
|
||||
}
|
||||
filteredGameIds = acMatchIds
|
||||
}
|
||||
|
||||
// Final local games after all filters
|
||||
const finalLocalGames = filteredLocalGames.filter((g) => filteredGameIds.has(g.id))
|
||||
const finalIds = finalLocalGames.map((g) => g.id)
|
||||
const finalSteamAppIds = new Set(
|
||||
finalLocalGames.map((g) => g.steamAppId).filter(Boolean),
|
||||
)
|
||||
|
||||
// ── 2. Count related data for local games ───────────────────────
|
||||
let benchmarkCounts: { gameId: string; count: number }[] = []
|
||||
let presetCounts: { gameId: string; count: number }[] = []
|
||||
let commentCounts: { gameId: string; count: number }[] = []
|
||||
|
||||
if (finalIds.length > 0) {
|
||||
const [bCounts, pCounts, cCounts] = await Promise.all([
|
||||
db
|
||||
.select({
|
||||
gameId: gameVersions.gameId,
|
||||
count: sql<number>`count(*)::int`,
|
||||
})
|
||||
.from(performanceEntries)
|
||||
.innerJoin(
|
||||
gameVersions,
|
||||
eq(performanceEntries.versionId, gameVersions.id),
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
inArray(gameVersions.gameId, finalIds),
|
||||
eq(performanceEntries.isRemoved, false),
|
||||
),
|
||||
)
|
||||
.groupBy(gameVersions.gameId),
|
||||
db
|
||||
.select({
|
||||
gameId: gameVersions.gameId,
|
||||
count: sql<number>`count(*)::int`,
|
||||
})
|
||||
.from(performanceEntries)
|
||||
.innerJoin(
|
||||
gameVersions,
|
||||
eq(performanceEntries.versionId, gameVersions.id),
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
inArray(gameVersions.gameId, finalIds),
|
||||
eq(performanceEntries.isRemoved, false),
|
||||
sql`${performanceEntries.settingsJson} IS NOT NULL`,
|
||||
),
|
||||
)
|
||||
.groupBy(gameVersions.gameId),
|
||||
db
|
||||
.select({
|
||||
gameId: gameComments.gameId,
|
||||
count: sql<number>`count(*)::int`,
|
||||
})
|
||||
.from(gameComments)
|
||||
.where(inArray(gameComments.gameId, finalIds))
|
||||
.groupBy(gameComments.gameId),
|
||||
])
|
||||
benchmarkCounts = bCounts
|
||||
presetCounts = pCounts
|
||||
commentCounts = cCounts
|
||||
}
|
||||
|
||||
const countMap = new Map<
|
||||
string,
|
||||
{ benchmarks: number; presets: number; comments: number }
|
||||
>()
|
||||
for (const g of finalLocalGames) {
|
||||
countMap.set(g.id, { benchmarks: 0, presets: 0, comments: 0 })
|
||||
}
|
||||
for (const c of benchmarkCounts) {
|
||||
countMap.get(c.gameId)!.benchmarks = c.count
|
||||
}
|
||||
for (const c of presetCounts) {
|
||||
countMap.get(c.gameId)!.presets = c.count
|
||||
}
|
||||
for (const c of commentCounts) {
|
||||
countMap.get(c.gameId)!.comments = c.count
|
||||
}
|
||||
|
||||
// ── 2b. Raw Performer + Poor Performance + best FPS ────────────
|
||||
const rawPerformerMap = new Map<string, boolean>()
|
||||
const poorPerformerMap = new Map<string, boolean>()
|
||||
const bestFpsMap = new Map<string, number>()
|
||||
|
||||
if (finalIds.length > 0) {
|
||||
const perfStats = await db
|
||||
.select({
|
||||
gameId: gameVersions.gameId,
|
||||
bestFps: sql<number>`MAX(${performanceEntries.fpsAvg})::real`,
|
||||
isRawPerformer: sql<boolean>`BOOL_OR(
|
||||
${performanceEntries.fpsAvg} >= 60
|
||||
AND ${performanceEntries.upscalerType} = 'none'
|
||||
AND ${performanceEntries.frameGenMethod} = 'none'
|
||||
)`,
|
||||
isPoorPerformance: sql<boolean>`BOOL_OR(${performanceEntries.fpsAvg} < 30)`,
|
||||
})
|
||||
.from(performanceEntries)
|
||||
.innerJoin(
|
||||
gameVersions,
|
||||
eq(performanceEntries.versionId, gameVersions.id),
|
||||
)
|
||||
.innerJoin(hardware, and(
|
||||
eq(performanceEntries.hardwareSlug, hardware.slug),
|
||||
eq(hardware.deviceType, "handheld"),
|
||||
))
|
||||
.where(
|
||||
and(
|
||||
inArray(gameVersions.gameId, finalIds),
|
||||
eq(performanceEntries.isRemoved, false),
|
||||
),
|
||||
)
|
||||
.groupBy(gameVersions.gameId)
|
||||
|
||||
for (const row of perfStats) {
|
||||
bestFpsMap.set(row.gameId, row.bestFps)
|
||||
rawPerformerMap.set(row.gameId, row.isRawPerformer)
|
||||
poorPerformerMap.set(row.gameId, row.isPoorPerformance)
|
||||
}
|
||||
}
|
||||
|
||||
// ── 2b2. Battery estimate for handheld devices ────────────────────
|
||||
const batteryMinMap = new Map<string, number>()
|
||||
|
||||
if (finalIds.length > 0) {
|
||||
const { hardware: hardwareTable } = await import("@/lib/db/schema")
|
||||
|
||||
const batteryStats = await db
|
||||
.select({
|
||||
gameId: gameVersions.gameId,
|
||||
estimatedBatteryMin: sql<number>`ROUND(
|
||||
(${hardwareTable.wattHours}::real / ${performanceEntries.tdpWatts}) * 60
|
||||
)::int`,
|
||||
})
|
||||
.from(performanceEntries)
|
||||
.innerJoin(gameVersions, eq(performanceEntries.versionId, gameVersions.id))
|
||||
.innerJoin(hardwareTable, eq(performanceEntries.hardwareSlug, hardwareTable.slug))
|
||||
.where(
|
||||
and(
|
||||
inArray(gameVersions.gameId, finalIds),
|
||||
eq(performanceEntries.isRemoved, false),
|
||||
eq(hardwareTable.deviceType, "handheld"),
|
||||
sql`${performanceEntries.tdpWatts} IS NOT NULL AND ${performanceEntries.tdpWatts} > 0`,
|
||||
sql`${hardwareTable.wattHours} IS NOT NULL`,
|
||||
),
|
||||
)
|
||||
.orderBy(desc(performanceEntries.fpsAvg))
|
||||
|
||||
// Deduplicate — keep only the first (best fps) entry per game
|
||||
const seenGames = new Set<string>()
|
||||
for (const row of batteryStats) {
|
||||
if (!seenGames.has(row.gameId)) {
|
||||
seenGames.add(row.gameId)
|
||||
batteryMinMap.set(row.gameId, row.estimatedBatteryMin)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── 2c. Latest version ──────────────────────────────────────────
|
||||
const latestVersionMap = new Map<string, string>()
|
||||
|
||||
if (finalIds.length > 0) {
|
||||
const versionRows = await db
|
||||
.select({
|
||||
gameId: gameVersions.gameId,
|
||||
versionString: gameVersions.versionString,
|
||||
})
|
||||
.from(gameVersions)
|
||||
.where(
|
||||
and(
|
||||
inArray(gameVersions.gameId, finalIds),
|
||||
eq(gameVersions.isLatest, true),
|
||||
),
|
||||
)
|
||||
|
||||
for (const row of versionRows) {
|
||||
if (row.versionString) {
|
||||
latestVersionMap.set(row.gameId, row.versionString)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── 3. Search Steam ─────────────────────────────────────────────
|
||||
let steamItems: SteamSearchItem[] = []
|
||||
try {
|
||||
const url = new URL("https://store.steampowered.com/api/storesearch/")
|
||||
url.searchParams.set("term", query.q)
|
||||
url.searchParams.set("cc", "US")
|
||||
url.searchParams.set("l", "en")
|
||||
|
||||
const res = await fetch(url.toString(), {
|
||||
headers: { Accept: "application/json" },
|
||||
})
|
||||
if (res.ok) {
|
||||
const data = (await res.json()) as SteamSearchResponse
|
||||
steamItems = (data.items || []).filter((item) => {
|
||||
const name = item.name.toLowerCase()
|
||||
const exclude = [
|
||||
"soundtrack",
|
||||
" original soundtrack",
|
||||
" ost",
|
||||
" - ost",
|
||||
"dlc",
|
||||
"expansion",
|
||||
"season pass",
|
||||
" deluxe edition",
|
||||
" ultimate edition",
|
||||
" premium edition",
|
||||
" demo",
|
||||
" trial",
|
||||
" playtest",
|
||||
" beta",
|
||||
" artbook",
|
||||
" soundtrack bundle",
|
||||
]
|
||||
return !exclude.some((kw) => name.includes(kw))
|
||||
})
|
||||
}
|
||||
} catch {
|
||||
// Steam search failure is non-fatal
|
||||
}
|
||||
|
||||
// ── 4. Build unified results ────────────────────────────────────
|
||||
const results = []
|
||||
|
||||
// Add local games
|
||||
for (const g of finalLocalGames) {
|
||||
const counts = countMap.get(g.id)!
|
||||
const platform = platformSupportMap.get(g.id)
|
||||
results.push({
|
||||
kind: "local" as const,
|
||||
id: g.id,
|
||||
appId: g.steamAppId,
|
||||
title: g.title,
|
||||
image: g.capsuleImage || g.headerImage,
|
||||
tinyImage: g.capsuleImage || g.headerImage || null,
|
||||
developer: g.developer,
|
||||
publisher: g.publisher,
|
||||
description: g.description,
|
||||
genres: g.genres,
|
||||
source: g.source,
|
||||
counts,
|
||||
platforms: g.platforms,
|
||||
platformSupport: platform
|
||||
? {
|
||||
isSupported: platform.isSupported,
|
||||
protonStatus: platform.protonStatus,
|
||||
antiCheatRelevant: platform.antiCheatRelevant,
|
||||
antiCheatName: platform.antiCheatName,
|
||||
antiCheatStatus: platform.antiCheatStatus,
|
||||
}
|
||||
: g.platforms
|
||||
? {
|
||||
isSupported: g.platforms.linux || g.platforms.windows || false,
|
||||
protonStatus: g.platforms.linux
|
||||
? "native"
|
||||
: g.platforms.windows
|
||||
? "proton"
|
||||
: "unsupported",
|
||||
antiCheatRelevant: false,
|
||||
antiCheatName: null,
|
||||
antiCheatStatus: "unknown",
|
||||
}
|
||||
: null,
|
||||
isRawPerformer: rawPerformerMap.get(g.id) ?? false,
|
||||
isPoorPerformance: poorPerformerMap.get(g.id) ?? false,
|
||||
bestFps: bestFpsMap.get(g.id) ?? null,
|
||||
estimatedBatteryMin: batteryMinMap.get(g.id) ?? null,
|
||||
latestVersion: latestVersionMap.get(g.id) ?? null,
|
||||
playabilityStatus: g.playabilityStatus,
|
||||
steamReviewScore: g.steamReviewScore,
|
||||
steamReviewSentiment: g.steamReviewSentiment,
|
||||
antiCheatRelevant: platform?.antiCheatRelevant ?? null,
|
||||
antiCheatStatus: platform?.antiCheatStatus ?? null,
|
||||
antiCheatName: platform?.antiCheatName ?? null,
|
||||
})
|
||||
}
|
||||
|
||||
// Add Steam-only games
|
||||
for (const item of steamItems) {
|
||||
if (finalSteamAppIds.has(item.id)) continue
|
||||
results.push({
|
||||
kind: "steam" as const,
|
||||
appId: item.id,
|
||||
title: item.name,
|
||||
image: `https://cdn.akamai.steamstatic.com/steam/apps/${item.id}/library_600x900.jpg`,
|
||||
tinyImage: item.tiny_image || null,
|
||||
developer: null,
|
||||
publisher: null,
|
||||
description: null,
|
||||
genres: null,
|
||||
source: "steam" as const,
|
||||
counts: null,
|
||||
platformSupport: null,
|
||||
metascore: item.metascore || null,
|
||||
price: item.price
|
||||
? {
|
||||
currency: item.price.currency,
|
||||
initial: item.price.initial,
|
||||
final: item.price.final,
|
||||
}
|
||||
: null,
|
||||
platforms: item.platforms,
|
||||
controllerSupport: item.controller_support || null,
|
||||
isRawPerformer: false,
|
||||
bestFps: null,
|
||||
latestVersion: null,
|
||||
})
|
||||
}
|
||||
|
||||
return { results, total: results.length }
|
||||
},
|
||||
{
|
||||
query: t.Object({
|
||||
q: t.String(),
|
||||
device: t.Optional(t.String()),
|
||||
minFps: t.Optional(t.String()),
|
||||
maxFps: t.Optional(t.String()),
|
||||
fsrSupport: t.Optional(t.String()),
|
||||
protonNative: t.Optional(t.String()),
|
||||
antiCheatStatus: t.Optional(t.String()),
|
||||
playabilityStatus: t.Optional(t.String()),
|
||||
steamReviewScore: t.Optional(t.String()),
|
||||
isFree: t.Optional(t.String()),
|
||||
hasMultiplayer: t.Optional(t.String()),
|
||||
}),
|
||||
detail: {
|
||||
description: "Unified search across local database and Steam store. Returns both synced games (with performance data) and Steam-only results.",
|
||||
},
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,108 @@
|
||||
import { Elysia } from "elysia"
|
||||
import { db } from "@/lib/db/index"
|
||||
import { games } from "@/lib/db/schema"
|
||||
import { eq } from "drizzle-orm"
|
||||
|
||||
// In-memory cache for reviews (key -> { data, expires })
|
||||
const reviewCache = new Map<
|
||||
string,
|
||||
{ data: SteamReviewResponse; expires: number }
|
||||
>()
|
||||
|
||||
const CACHE_TTL = 60 * 60 * 1000 // 1 hour
|
||||
|
||||
interface SteamReview {
|
||||
recommendationid: string
|
||||
author: {
|
||||
steamid: string
|
||||
num_games_owned: number
|
||||
num_reviews: number
|
||||
playtime_forever: number
|
||||
playtime_last_two_weeks: number
|
||||
playtime_at_review: number
|
||||
last_played: number
|
||||
}
|
||||
language: string
|
||||
review: string
|
||||
timestamp_created: number
|
||||
timestamp_updated: number
|
||||
voted_up: boolean
|
||||
votes_up: number
|
||||
votes_funny: number
|
||||
comment_count: number
|
||||
steam_purchase: boolean
|
||||
received_for_free: boolean
|
||||
written_during_early_access: boolean
|
||||
}
|
||||
|
||||
interface SteamReviewResponse {
|
||||
success: number
|
||||
query_summary: {
|
||||
num_reviews: number
|
||||
review_score_desc: string
|
||||
total_positive: number
|
||||
total_negative: number
|
||||
total_reviews: number
|
||||
}
|
||||
reviews: SteamReview[]
|
||||
}
|
||||
|
||||
export const steamReviewRoutes = new Elysia({ prefix: "/steam-reviews", detail: { tags: ["Steam"] } })
|
||||
|
||||
// Get embedded Steam reviews for a game
|
||||
.get(
|
||||
"/:gameId",
|
||||
async ({ params, query, set }) => {
|
||||
const gameId = params.gameId
|
||||
const offset = Number(query.offset) || 0
|
||||
const limit = Math.min(Number(query.limit) || 10, 20)
|
||||
const language = query.language || "english"
|
||||
|
||||
// Get Steam App ID
|
||||
const [game] = await db
|
||||
.select({ steamAppId: games.steamAppId })
|
||||
.from(games)
|
||||
.where(eq(games.id, gameId))
|
||||
.limit(1)
|
||||
|
||||
if (!game?.steamAppId) {
|
||||
set.status = 404
|
||||
return { error: "Game not found or has no Steam App ID" }
|
||||
}
|
||||
|
||||
const cacheKey = `${game.steamAppId}-${language}-${offset}-${limit}`
|
||||
const cached = reviewCache.get(cacheKey)
|
||||
if (cached && cached.expires > Date.now()) {
|
||||
return cached.data
|
||||
}
|
||||
|
||||
try {
|
||||
// Steam API uses cursor-based pagination, but for simplicity we fetch more and slice
|
||||
const response = await fetch(
|
||||
`https://store.steampowered.com/appreviews/${game.steamAppId}?json=1&language=${language}&purchase_type=all&num_per_page=${offset + limit}&filter=recent&review_type=all`,
|
||||
{ signal: AbortSignal.timeout(10000) }
|
||||
)
|
||||
|
||||
if (!response.ok) {
|
||||
set.status = 502
|
||||
return { error: "Failed to fetch Steam reviews" }
|
||||
}
|
||||
|
||||
const data: SteamReviewResponse = await response.json()
|
||||
|
||||
// Slice reviews for the requested page
|
||||
const slicedData = {
|
||||
...data,
|
||||
reviews: (data.reviews ?? []).slice(offset, offset + limit),
|
||||
}
|
||||
|
||||
// Cache the result
|
||||
reviewCache.set(cacheKey, { data: slicedData, expires: Date.now() + CACHE_TTL })
|
||||
|
||||
return slicedData
|
||||
} catch {
|
||||
set.status = 502
|
||||
return { error: "Steam review API unavailable" }
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,87 @@
|
||||
import { Elysia, t } from "elysia"
|
||||
|
||||
interface SteamSearchItem {
|
||||
id: number
|
||||
name: string
|
||||
tiny_image: string
|
||||
metascore: string
|
||||
platforms: {
|
||||
windows: boolean
|
||||
mac: boolean
|
||||
linux: boolean
|
||||
}
|
||||
}
|
||||
|
||||
export const steamSearchRoutes = new Elysia({ prefix: "/search", detail: { tags: ["Search"] } })
|
||||
.get(
|
||||
"/steam",
|
||||
async ({ query, set }) => {
|
||||
if (!query.q || query.q.length < 2) {
|
||||
set.status = 400
|
||||
return { error: "Query must be at least 2 characters" }
|
||||
}
|
||||
|
||||
try {
|
||||
const url = new URL("https://store.steampowered.com/api/storesearch/")
|
||||
url.searchParams.set("term", query.q)
|
||||
url.searchParams.set("cc", "US")
|
||||
url.searchParams.set("l", "en")
|
||||
|
||||
const res = await fetch(url.toString(), {
|
||||
headers: { Accept: "application/json" },
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
set.status = 502
|
||||
return { error: "Failed to fetch from Steam" }
|
||||
}
|
||||
|
||||
const data = await res.json()
|
||||
|
||||
const items: SteamSearchItem[] = (data.items || []).filter(
|
||||
(item: SteamSearchItem) => {
|
||||
const name = item.name.toLowerCase()
|
||||
const exclude = [
|
||||
"soundtrack",
|
||||
" original soundtrack",
|
||||
" ost",
|
||||
" - ost",
|
||||
"dlc",
|
||||
"expansion",
|
||||
"season pass",
|
||||
" deluxe edition",
|
||||
" ultimate edition",
|
||||
" premium edition",
|
||||
" demo",
|
||||
" trial",
|
||||
" playtest",
|
||||
" beta",
|
||||
" artbook",
|
||||
" soundtrack bundle",
|
||||
]
|
||||
return !exclude.some((kw) => name.includes(kw))
|
||||
},
|
||||
)
|
||||
|
||||
return {
|
||||
items: items.map((item) => ({
|
||||
appId: item.id,
|
||||
title: item.name,
|
||||
image: `https://cdn.akamai.steamstatic.com/steam/apps/${item.id}/library_600x900.jpg`,
|
||||
platforms: item.platforms,
|
||||
metascore: item.metascore,
|
||||
})),
|
||||
total: data.total || 0,
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Steam search error:", err)
|
||||
set.status = 500
|
||||
return { error: "Internal server error" }
|
||||
}
|
||||
},
|
||||
{
|
||||
query: t.Object({
|
||||
q: t.String(),
|
||||
}),
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,172 @@
|
||||
import { Elysia, t } from "elysia"
|
||||
import { db } from "@/lib/db/index"
|
||||
import { games } from "@/lib/db/schema"
|
||||
import { eq } from "drizzle-orm"
|
||||
import { fetchAllVersions, SERVER_STRATEGIES, CLIENT_STRATEGIES, type VersionFetchResult } from "@/lib/version-fetchers/index"
|
||||
|
||||
export const steamdbVersionRoutes = new Elysia({
|
||||
prefix: "/games/:gameId",
|
||||
detail: { tags: ["Games"] },
|
||||
}).get(
|
||||
"/steamdb-version",
|
||||
async ({ params, set }) => {
|
||||
// Look up game — supports both DB UUID and numeric Steam App ID
|
||||
const isNumeric = /^\d+$/.test(params.gameId)
|
||||
let steamAppId: number | null = null
|
||||
|
||||
if (isNumeric) {
|
||||
// Already a Steam App ID — use directly
|
||||
steamAppId = Number(params.gameId)
|
||||
} else {
|
||||
// Look up by DB UUID
|
||||
const [game] = await db
|
||||
.select({ steamAppId: games.steamAppId })
|
||||
.from(games)
|
||||
.where(eq(games.id, params.gameId))
|
||||
.limit(1)
|
||||
|
||||
if (!game) {
|
||||
set.status = 404
|
||||
return { error: "Game not found" }
|
||||
}
|
||||
steamAppId = game.steamAppId
|
||||
}
|
||||
|
||||
if (steamAppId === null) {
|
||||
return { unavailable: true, reason: "no_steam_app_id" }
|
||||
}
|
||||
|
||||
// Run ONLY server-safe strategies (no client-preferred ones)
|
||||
// Client-side strategies should be called from the browser
|
||||
const { best, all } = await fetchAllVersions(
|
||||
steamAppId,
|
||||
SERVER_STRATEGIES,
|
||||
)
|
||||
|
||||
// If server strategies found nothing, tell the client which strategies to try
|
||||
const clientStrategies = best.versionString || best.buildId
|
||||
? [] // Server got something, client doesn't need to try more
|
||||
: CLIENT_STRATEGIES.map((s) => s.name) // Server got nothing, suggest client try these
|
||||
|
||||
if (best.versionString === null && best.buildId === null && clientStrategies.length === 0) {
|
||||
return {
|
||||
unavailable: true,
|
||||
reason: "not_found",
|
||||
results: all.map((r) => ({
|
||||
source: r.source,
|
||||
success: r.success,
|
||||
error: r.error,
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
versionString: best.versionString,
|
||||
buildId: best.buildId,
|
||||
steamAppId,
|
||||
source: best.source,
|
||||
clientStrategies,
|
||||
// Include detailed results for debugging
|
||||
results: all.map((r) => ({
|
||||
source: r.source,
|
||||
versionString: r.versionString,
|
||||
buildId: r.buildId,
|
||||
success: r.success,
|
||||
error: r.error,
|
||||
})),
|
||||
// If server didn't find named version, tell client to try
|
||||
needsClientFetch: clientStrategies.length > 0,
|
||||
}
|
||||
},
|
||||
{
|
||||
params: t.Object({ gameId: t.String() }),
|
||||
},
|
||||
)
|
||||
|
||||
/**
|
||||
* Client-friendly version fetch endpoint.
|
||||
* POST /api/games/:gameId/fetch-version-client
|
||||
*
|
||||
* Accepts results from client-side strategies and merges with server results.
|
||||
* The client calls this after running client-side strategies in the browser.
|
||||
*/
|
||||
export const clientVersionRoutes = new Elysia({
|
||||
prefix: "/games/:gameId",
|
||||
detail: { tags: ["Games"] },
|
||||
}).post(
|
||||
"/fetch-version-client",
|
||||
async ({ params, body, set }) => {
|
||||
// Look up game — supports both DB UUID and numeric Steam App ID
|
||||
const isNumeric = /^\d+$/.test(params.gameId)
|
||||
let steamAppId: number | null = null
|
||||
|
||||
if (isNumeric) {
|
||||
steamAppId = Number(params.gameId)
|
||||
} else {
|
||||
const [game] = await db
|
||||
.select({ steamAppId: games.steamAppId })
|
||||
.from(games)
|
||||
.where(eq(games.id, params.gameId))
|
||||
.limit(1)
|
||||
|
||||
if (!game) {
|
||||
set.status = 404
|
||||
return { error: "Game not found" }
|
||||
}
|
||||
steamAppId = game.steamAppId
|
||||
}
|
||||
|
||||
if (steamAppId === null) {
|
||||
return { unavailable: true, reason: "no_steam_app_id" }
|
||||
}
|
||||
|
||||
// Run server strategies
|
||||
const { best: serverBest } = await fetchAllVersions(
|
||||
steamAppId,
|
||||
SERVER_STRATEGIES,
|
||||
)
|
||||
|
||||
// Merge with client results
|
||||
const clientResults: VersionFetchResult[] = (body.clientResults ?? []).map(
|
||||
(r: { source: string; versionString: string | null; buildId: string | null; success: boolean; error?: string }) => ({
|
||||
...r,
|
||||
}),
|
||||
)
|
||||
|
||||
const allResults = [
|
||||
...SERVER_STRATEGIES.map((s) => {
|
||||
const existing = clientResults.find((r) => r.source === s.name)
|
||||
return existing ?? { versionString: null, buildId: null, source: s.name, success: false }
|
||||
}),
|
||||
...clientResults.filter((r) => !SERVER_STRATEGIES.some((s) => s.name === r.source)),
|
||||
]
|
||||
|
||||
// Find best: prefer versionString > buildId
|
||||
let best: VersionFetchResult = serverBest
|
||||
for (const r of allResults) {
|
||||
if (r.versionString && !best.versionString) best = r
|
||||
if (!best.versionString && !best.buildId && r.buildId) best = r
|
||||
}
|
||||
|
||||
return {
|
||||
versionString: best.versionString,
|
||||
buildId: best.buildId,
|
||||
source: best.source,
|
||||
allResults,
|
||||
}
|
||||
},
|
||||
{
|
||||
params: t.Object({ gameId: t.String() }),
|
||||
body: t.Object({
|
||||
clientResults: t.Array(
|
||||
t.Object({
|
||||
source: t.String(),
|
||||
versionString: t.Nullable(t.String()),
|
||||
buildId: t.Nullable(t.String()),
|
||||
success: t.Boolean(),
|
||||
error: t.Optional(t.String()),
|
||||
}),
|
||||
),
|
||||
}),
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,80 @@
|
||||
import { Elysia, t } from "elysia"
|
||||
|
||||
const STEAMGRIDDB_BASE = "https://www.steamgriddb.com/api/v2"
|
||||
|
||||
export const steamgridProxyRoutes = new Elysia({ prefix: "/steamgrid", detail: { tags: ["Steam"] } })
|
||||
.get(
|
||||
"/search",
|
||||
async ({ query, set }) => {
|
||||
const apiKey = process.env.STEAMGRIDDB_API_KEY
|
||||
if (!apiKey) {
|
||||
set.status = 503
|
||||
return { error: "SteamGridDB API key not configured" }
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(
|
||||
`${STEAMGRIDDB_BASE}/search/autocomplete/${encodeURIComponent(query.q)}`,
|
||||
{
|
||||
headers: { Authorization: `Bearer ${apiKey}` },
|
||||
}
|
||||
)
|
||||
|
||||
if (!res.ok) {
|
||||
set.status = res.status
|
||||
return { error: "SteamGridDB search failed" }
|
||||
}
|
||||
|
||||
const data = await res.json()
|
||||
return data
|
||||
} catch (err) {
|
||||
console.error("SteamGridDB search error:", err)
|
||||
set.status = 500
|
||||
return { error: "SteamGridDB search failed" }
|
||||
}
|
||||
},
|
||||
{
|
||||
query: t.Object({ q: t.String({ minLength: 2 }) }),
|
||||
}
|
||||
)
|
||||
.get(
|
||||
"/grids/:gameId",
|
||||
async ({ params, query, set }) => {
|
||||
const apiKey = process.env.STEAMGRIDDB_API_KEY
|
||||
if (!apiKey) {
|
||||
set.status = 503
|
||||
return { error: "SteamGridDB API key not configured" }
|
||||
}
|
||||
|
||||
try {
|
||||
const url = new URL(`${STEAMGRIDDB_BASE}/grids/game/${params.gameId}`)
|
||||
// Add dimensions filter for capsule-style images
|
||||
url.searchParams.set("dimensions", "600x900,342x482")
|
||||
if (query.styles) {
|
||||
url.searchParams.set("styles", query.styles)
|
||||
}
|
||||
|
||||
const res = await fetch(url.toString(), {
|
||||
headers: { Authorization: `Bearer ${apiKey}` },
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
set.status = res.status
|
||||
return { error: "SteamGridDB grids fetch failed" }
|
||||
}
|
||||
|
||||
const data = await res.json()
|
||||
return data
|
||||
} catch (err) {
|
||||
console.error("SteamGridDB grids fetch error:", err)
|
||||
set.status = 500
|
||||
return { error: "SteamGridDB grids fetch failed" }
|
||||
}
|
||||
},
|
||||
{
|
||||
params: t.Object({ gameId: t.String() }),
|
||||
query: t.Object({
|
||||
styles: t.Optional(t.String()),
|
||||
}),
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,327 @@
|
||||
import { Elysia, t } from "elysia"
|
||||
import { auth } from "@/lib/auth"
|
||||
import { db } from "@/lib/db/index"
|
||||
import { user, performanceEntries, games, gameVersions, hardware, account, passkey } from "@/lib/db/schema"
|
||||
import { eq, sql, and, desc } from "drizzle-orm"
|
||||
import { hashPassword } from "better-auth/crypto"
|
||||
|
||||
export const userRoutes = new Elysia({ prefix: "/user", detail: { tags: ["Users"] } })
|
||||
.get(
|
||||
"/profile/:id",
|
||||
async ({ params, set }) => {
|
||||
const [profile] = await db
|
||||
.select({
|
||||
id: user.id,
|
||||
name: user.name,
|
||||
image: user.image,
|
||||
role: user.role,
|
||||
createdAt: user.createdAt,
|
||||
emailVerified: user.emailVerified,
|
||||
})
|
||||
.from(user)
|
||||
.where(eq(user.id, params.id))
|
||||
.limit(1)
|
||||
|
||||
if (!profile) {
|
||||
set.status = 404
|
||||
return { error: "User not found" }
|
||||
}
|
||||
|
||||
// Count contributions (performance entries)
|
||||
const [{ count: contributions }] = await db
|
||||
.select({ count: sql<number>`count(*)::int` })
|
||||
.from(performanceEntries)
|
||||
.where(eq(performanceEntries.userId, params.id))
|
||||
|
||||
// Count verified entries
|
||||
const [{ count: verifiedEntries }] = await db
|
||||
.select({ count: sql<number>`count(*)::int` })
|
||||
.from(performanceEntries)
|
||||
.where(
|
||||
sql`${performanceEntries.userId} = ${params.id} AND ${performanceEntries.verifiedAt} IS NOT NULL`
|
||||
)
|
||||
|
||||
// Reputation = contributions * 10 (simple formula for now)
|
||||
const reputation = contributions * 10
|
||||
|
||||
const { emailVerified, ...publicProfile } = profile
|
||||
return {
|
||||
...publicProfile,
|
||||
createdAt: publicProfile.createdAt.toISOString(),
|
||||
contributions,
|
||||
verifiedEntries,
|
||||
reputation,
|
||||
verified: !!emailVerified,
|
||||
}
|
||||
},
|
||||
{
|
||||
params: t.Object({
|
||||
id: t.String(),
|
||||
}),
|
||||
},
|
||||
)
|
||||
.get(
|
||||
"/me",
|
||||
async ({ request, set }) => {
|
||||
const session = await auth.api.getSession({
|
||||
headers: request.headers,
|
||||
})
|
||||
|
||||
if (!session) {
|
||||
set.status = 401
|
||||
return { error: "Unauthorized" }
|
||||
}
|
||||
|
||||
const [profile] = await db
|
||||
.select({
|
||||
id: user.id,
|
||||
name: user.name,
|
||||
email: user.email,
|
||||
image: user.image,
|
||||
role: user.role,
|
||||
createdAt: user.createdAt,
|
||||
emailVerified: user.emailVerified,
|
||||
})
|
||||
.from(user)
|
||||
.where(eq(user.id, session.user.id))
|
||||
.limit(1)
|
||||
|
||||
if (!profile) {
|
||||
set.status = 404
|
||||
return { error: "User not found" }
|
||||
}
|
||||
|
||||
const [{ count: contributions }] = await db
|
||||
.select({ count: sql<number>`count(*)::int` })
|
||||
.from(performanceEntries)
|
||||
.where(eq(performanceEntries.userId, session.user.id))
|
||||
|
||||
// Count verified entries
|
||||
const [{ count: verifiedEntries }] = await db
|
||||
.select({ count: sql<number>`count(*)::int` })
|
||||
.from(performanceEntries)
|
||||
.where(
|
||||
sql`${performanceEntries.userId} = ${session.user.id} AND ${performanceEntries.verifiedAt} IS NOT NULL`
|
||||
)
|
||||
|
||||
return {
|
||||
id: profile.id,
|
||||
name: profile.name,
|
||||
email: profile.email,
|
||||
image: profile.image,
|
||||
role: profile.role,
|
||||
createdAt: profile.createdAt.toISOString(),
|
||||
contributions,
|
||||
verifiedEntries,
|
||||
reputation: contributions * 10,
|
||||
verified: !!profile.emailVerified,
|
||||
}
|
||||
},
|
||||
)
|
||||
.get(
|
||||
"/me/sessions",
|
||||
async ({ request, set }) => {
|
||||
const session = await auth.api.getSession({
|
||||
headers: request.headers,
|
||||
})
|
||||
|
||||
if (!session) {
|
||||
set.status = 401
|
||||
return { error: "Unauthorized" }
|
||||
}
|
||||
|
||||
const sessions = await auth.api.listSessions({
|
||||
headers: request.headers,
|
||||
})
|
||||
|
||||
return sessions
|
||||
},
|
||||
)
|
||||
.get(
|
||||
"/me/auth-methods",
|
||||
async ({ request, set }) => {
|
||||
const session = await auth.api.getSession({
|
||||
headers: request.headers,
|
||||
})
|
||||
|
||||
if (!session) {
|
||||
set.status = 401
|
||||
return { error: "Unauthorized" }
|
||||
}
|
||||
|
||||
// Count accounts by provider
|
||||
const accounts = await db
|
||||
.select({ providerId: account.providerId, id: account.id })
|
||||
.from(account)
|
||||
.where(eq(account.userId, session.user.id))
|
||||
|
||||
// Count passkeys via direct DB query (avoids auth.api.listPasskeys hanging)
|
||||
const passkeys = await db
|
||||
.select({ id: passkey.id })
|
||||
.from(passkey)
|
||||
.where(eq(passkey.userId, session.user.id))
|
||||
|
||||
// Check if user has a password (from accounts where providerId is "credential")
|
||||
const hasPassword = accounts.some((a) => a.providerId === "credential")
|
||||
|
||||
// OAuth providers
|
||||
const oauthProviders = accounts
|
||||
.filter((a) => a.providerId !== "credential")
|
||||
.map((a) => ({
|
||||
providerId: a.providerId,
|
||||
id: a.id,
|
||||
}))
|
||||
|
||||
// Total auth methods = passwords + passkeys + oauth accounts
|
||||
const passkeyCount = passkeys.length
|
||||
const totalAuthMethods =
|
||||
(hasPassword ? 1 : 0) + passkeyCount + oauthProviders.length
|
||||
|
||||
return {
|
||||
hasPassword,
|
||||
passkeyCount,
|
||||
oauthProviders,
|
||||
totalAuthMethods,
|
||||
}
|
||||
},
|
||||
)
|
||||
.post(
|
||||
"/me/set-password",
|
||||
async ({ request, body, set }) => {
|
||||
// Reject non-JSON content types
|
||||
const contentType = request.headers.get("content-type") || ""
|
||||
if (!contentType.includes("application/json")) {
|
||||
set.status = 415
|
||||
return { error: "Content-Type must be application/json" }
|
||||
}
|
||||
|
||||
const session = await auth.api.getSession({
|
||||
headers: request.headers,
|
||||
})
|
||||
|
||||
if (!session) {
|
||||
set.status = 401
|
||||
return { error: "Unauthorized" }
|
||||
}
|
||||
|
||||
// Check if user already has a password
|
||||
const existing = await db
|
||||
.select({ id: account.id })
|
||||
.from(account)
|
||||
.where(
|
||||
and(
|
||||
eq(account.userId, session.user.id),
|
||||
eq(account.providerId, "credential")
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
if (existing.length > 0) {
|
||||
set.status = 400
|
||||
return { error: "Password already set" }
|
||||
}
|
||||
|
||||
const hashed = await hashPassword(body.newPassword)
|
||||
|
||||
await db.insert(account).values({
|
||||
id: crypto.randomUUID(),
|
||||
userId: session.user.id,
|
||||
providerId: "credential",
|
||||
accountId: session.user.id,
|
||||
password: hashed,
|
||||
})
|
||||
|
||||
return { success: true }
|
||||
},
|
||||
{
|
||||
body: t.Object({
|
||||
newPassword: t.String({ minLength: 10 }),
|
||||
}),
|
||||
detail: {
|
||||
description: "Set a password for the authenticated user. Requires JSON body.",
|
||||
tags: ["Users"],
|
||||
},
|
||||
},
|
||||
)
|
||||
.get(
|
||||
"/profile/:id/contributions",
|
||||
async ({ params, query, set }) => {
|
||||
const [profile] = await db
|
||||
.select({ id: user.id })
|
||||
.from(user)
|
||||
.where(eq(user.id, params.id))
|
||||
.limit(1)
|
||||
|
||||
if (!profile) {
|
||||
set.status = 404
|
||||
return { error: "User not found" }
|
||||
}
|
||||
|
||||
const limit = Math.min(Number(query.limit) || 10, 50)
|
||||
const offset = Number(query.offset) || 0
|
||||
|
||||
const entries = await db
|
||||
.select({
|
||||
id: performanceEntries.id,
|
||||
fpsAvg: performanceEntries.fpsAvg,
|
||||
fpsLow: performanceEntries.fpsLow,
|
||||
fpsHigh: performanceEntries.fpsHigh,
|
||||
hardwareSlug: performanceEntries.hardwareSlug,
|
||||
hardwareName: hardware.name,
|
||||
upscalerType: performanceEntries.upscalerType,
|
||||
upscalerVersion: performanceEntries.upscalerVersion,
|
||||
frameGenMethod: performanceEntries.frameGenMethod,
|
||||
verifiedAt: performanceEntries.verifiedAt,
|
||||
createdAt: performanceEntries.createdAt,
|
||||
gameTitle: games.title,
|
||||
gameId: games.id,
|
||||
gameHeaderImage: games.headerImage,
|
||||
})
|
||||
.from(performanceEntries)
|
||||
.innerJoin(
|
||||
gameVersions,
|
||||
eq(performanceEntries.versionId, gameVersions.id),
|
||||
)
|
||||
.innerJoin(games, eq(gameVersions.gameId, games.id))
|
||||
.innerJoin(
|
||||
hardware,
|
||||
eq(performanceEntries.hardwareSlug, hardware.slug),
|
||||
)
|
||||
.where(and(
|
||||
eq(performanceEntries.userId, params.id),
|
||||
eq(performanceEntries.isRemoved, false)
|
||||
))
|
||||
.orderBy(desc(performanceEntries.createdAt))
|
||||
.limit(limit)
|
||||
.offset(offset)
|
||||
|
||||
const [{ count: total }] = await db
|
||||
.select({ count: sql<number>`count(*)::int` })
|
||||
.from(performanceEntries)
|
||||
.where(and(
|
||||
eq(performanceEntries.userId, params.id),
|
||||
eq(performanceEntries.isRemoved, false)
|
||||
))
|
||||
|
||||
return {
|
||||
data: entries.map((e) => ({
|
||||
...e,
|
||||
createdAt: e.createdAt.toISOString(),
|
||||
verifiedAt: e.verifiedAt?.toISOString() ?? null,
|
||||
})),
|
||||
total,
|
||||
limit,
|
||||
offset,
|
||||
}
|
||||
},
|
||||
{
|
||||
params: t.Object({
|
||||
id: t.String(),
|
||||
}),
|
||||
query: t.Object({
|
||||
limit: t.Optional(t.String()),
|
||||
offset: t.Optional(t.String()),
|
||||
}),
|
||||
},
|
||||
)
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
/**
|
||||
* Version Fetcher Test API
|
||||
*
|
||||
* Two endpoints:
|
||||
* 1. GET /api/games/:gameId/test-version-fetchers
|
||||
* Uses DB game ID (UUID or numeric Steam App ID) to look up the game,
|
||||
* then runs all strategies.
|
||||
*
|
||||
* 2. GET /api/version-test?steamAppId=730
|
||||
* Runs all strategies directly against a Steam App ID — no DB lookup needed.
|
||||
* This is the preferred testing endpoint.
|
||||
*/
|
||||
import { Elysia, t } from "elysia"
|
||||
import { db } from "@/lib/db/index"
|
||||
import { games } from "@/lib/db/schema"
|
||||
import { eq } from "drizzle-orm"
|
||||
import { ALL_STRATEGIES } from "@/lib/version-fetchers/index"
|
||||
|
||||
async function resolveGame(gameId: string): Promise<{
|
||||
steamAppId: number
|
||||
title: string | null
|
||||
dbId: string | null
|
||||
} | null> {
|
||||
// Try numeric (Steam App ID) first
|
||||
const isNumeric = /^\d+$/.test(gameId)
|
||||
const numId = isNumeric ? Number(gameId) : null
|
||||
|
||||
if (numId) {
|
||||
// Look up by Steam App ID in DB
|
||||
const [game] = await db
|
||||
.select({ id: games.id, steamAppId: games.steamAppId, title: games.title })
|
||||
.from(games)
|
||||
.where(eq(games.steamAppId, numId))
|
||||
.limit(1)
|
||||
|
||||
return {
|
||||
steamAppId: numId,
|
||||
title: game?.title ?? null,
|
||||
dbId: game?.id ?? null,
|
||||
}
|
||||
}
|
||||
|
||||
// Try UUID
|
||||
const [game] = await db
|
||||
.select({ id: games.id, steamAppId: games.steamAppId, title: games.title })
|
||||
.from(games)
|
||||
.where(eq(games.id, gameId))
|
||||
.limit(1)
|
||||
|
||||
if (!game || game.steamAppId === null) return null
|
||||
|
||||
return {
|
||||
steamAppId: game.steamAppId,
|
||||
title: game.title,
|
||||
dbId: game.id,
|
||||
}
|
||||
}
|
||||
|
||||
// ── Nested route: /api/games/:gameId/test-version-fetchers ──────
|
||||
export const versionTestRoutes = new Elysia({
|
||||
prefix: "/games/:gameId",
|
||||
detail: { tags: ["Games"] },
|
||||
}).get(
|
||||
"/test-version-fetchers",
|
||||
async ({ params, query, set }) => {
|
||||
// Support direct steamAppId override via query param
|
||||
let steamAppId: number
|
||||
let title: string | null = null
|
||||
let dbId: string | null = null
|
||||
|
||||
if (query.steamAppId) {
|
||||
// Use provided Steam App ID directly
|
||||
steamAppId = Number(query.steamAppId)
|
||||
} else {
|
||||
const resolved = await resolveGame(params.gameId)
|
||||
if (!resolved) {
|
||||
set.status = 404
|
||||
return { error: "Game not found. Try passing ?steamAppId=730 directly." }
|
||||
}
|
||||
steamAppId = resolved.steamAppId
|
||||
title = resolved.title
|
||||
dbId = resolved.dbId
|
||||
}
|
||||
|
||||
// Run all strategies
|
||||
const results = await Promise.all(
|
||||
ALL_STRATEGIES.map(async (s) => {
|
||||
const result = await s.fn(steamAppId)
|
||||
return {
|
||||
strategy: s.name,
|
||||
versionString: result.versionString,
|
||||
buildId: result.buildId,
|
||||
success: result.success,
|
||||
error: result.error ?? null,
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
// Determine best
|
||||
const withVersion = results.find((r) => r.versionString)
|
||||
const withBuild = results.find((r) => r.buildId)
|
||||
const best = withVersion ?? withBuild ?? null
|
||||
|
||||
return {
|
||||
game: {
|
||||
id: dbId ?? params.gameId,
|
||||
title: title ?? `Steam App ${steamAppId}`,
|
||||
steamAppId,
|
||||
},
|
||||
results,
|
||||
best: best
|
||||
? {
|
||||
strategy: best.strategy,
|
||||
versionString: best.versionString,
|
||||
buildId: best.buildId,
|
||||
}
|
||||
: null,
|
||||
}
|
||||
},
|
||||
{
|
||||
params: t.Object({ gameId: t.String() }),
|
||||
query: t.Object({
|
||||
steamAppId: t.Optional(t.String()),
|
||||
}),
|
||||
},
|
||||
)
|
||||
|
||||
// ── Standalone route: /api/version-test?steamAppId=730 ──────────
|
||||
export const standaloneVersionTestRoutes = new Elysia({
|
||||
prefix: "/version-test",
|
||||
detail: { tags: ["Games"] },
|
||||
}).get(
|
||||
"/",
|
||||
async ({ query }) => {
|
||||
const steamAppId = Number(query.steamAppId)
|
||||
if (!steamAppId || isNaN(steamAppId)) {
|
||||
return { error: "steamAppId query parameter is required (e.g., ?steamAppId=730)" }
|
||||
}
|
||||
|
||||
const results = await Promise.all(
|
||||
ALL_STRATEGIES.map(async (s) => {
|
||||
const result = await s.fn(steamAppId)
|
||||
return {
|
||||
strategy: s.name,
|
||||
versionString: result.versionString,
|
||||
buildId: result.buildId,
|
||||
success: result.success,
|
||||
error: result.error ?? null,
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
const withVersion = results.find((r) => r.versionString)
|
||||
const withBuild = results.find((r) => r.buildId)
|
||||
const best = withVersion ?? withBuild ?? null
|
||||
|
||||
return {
|
||||
steamAppId,
|
||||
results,
|
||||
best: best
|
||||
? {
|
||||
strategy: best.strategy,
|
||||
versionString: best.versionString,
|
||||
buildId: best.buildId,
|
||||
}
|
||||
: null,
|
||||
}
|
||||
},
|
||||
{
|
||||
query: t.Object({
|
||||
steamAppId: t.String(),
|
||||
}),
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,15 @@
|
||||
import { createAuthClient } from 'better-auth/react'
|
||||
import { adminClient, emailOTPClient, lastLoginMethodClient } from 'better-auth/client/plugins'
|
||||
import { passkeyClient } from '@better-auth/passkey/client'
|
||||
|
||||
export const authClient = createAuthClient({
|
||||
baseURL: process.env.NEXT_PUBLIC_SITE_URL || "https://localhost:3000",
|
||||
plugins: [
|
||||
emailOTPClient(),
|
||||
passkeyClient(),
|
||||
lastLoginMethodClient(),
|
||||
adminClient()
|
||||
]
|
||||
})
|
||||
|
||||
export const { signIn, signOut, signUp, getSession, useSession } = authClient
|
||||
@@ -0,0 +1,109 @@
|
||||
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 { 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'],
|
||||
}),
|
||||
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,
|
||||
},
|
||||
})
|
||||
@@ -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")
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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"])
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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]
|
||||
@@ -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
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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) })
|
||||
@@ -0,0 +1,137 @@
|
||||
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],
|
||||
}),
|
||||
}))
|
||||
@@ -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),
|
||||
],
|
||||
)
|
||||
@@ -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),
|
||||
]
|
||||
)
|
||||
@@ -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),
|
||||
]
|
||||
)
|
||||
@@ -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),
|
||||
],
|
||||
)
|
||||
@@ -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(),
|
||||
})
|
||||
@@ -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()),
|
||||
],
|
||||
)
|
||||
@@ -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),
|
||||
])
|
||||
@@ -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),
|
||||
],
|
||||
)
|
||||
@@ -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),
|
||||
],
|
||||
)
|
||||
@@ -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),
|
||||
],
|
||||
)
|
||||
@@ -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}%`
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
},
|
||||
]
|
||||
@@ -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")
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
@@ -0,0 +1,11 @@
|
||||
export {
|
||||
uploadObject,
|
||||
deleteObject,
|
||||
listObjects,
|
||||
getObjectMetadata,
|
||||
isR2Configured,
|
||||
getR2PublicUrl,
|
||||
isR2Url,
|
||||
getR2ConfigStatus,
|
||||
} from "./r2-client"
|
||||
export type { ObjectInfo, ObjectMetadata } from "./r2-client"
|
||||
@@ -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 }
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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(" ");
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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("")
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* Truncate text at a word boundary, appending ellipsis if cut.
|
||||
* Guarantees the result never breaks a word in half.
|
||||
*/
|
||||
export function smartTruncate(text: string, maxLen: number): string {
|
||||
if (text.length <= maxLen) return text
|
||||
const truncated = text.slice(0, maxLen)
|
||||
const lastSpace = truncated.lastIndexOf(" ")
|
||||
return lastSpace > 0 ? truncated.slice(0, lastSpace) + "..." : truncated + "..."
|
||||
}
|
||||
|
||||
/** Breadcrumb segment — name and absolute URL */
|
||||
export interface BreadcrumbSegment {
|
||||
name: string
|
||||
url: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a Schema.org BreadcrumbList from ordered segments.
|
||||
* Position is 1-based per Schema.org spec.
|
||||
*/
|
||||
export function buildBreadcrumbList(segments: BreadcrumbSegment[]) {
|
||||
return {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "BreadcrumbList",
|
||||
itemListElement: segments.map((seg, i) => ({
|
||||
"@type": "ListItem",
|
||||
position: i + 1,
|
||||
name: seg.name,
|
||||
item: seg.url,
|
||||
})),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
export function generateSlug(title: string): string {
|
||||
return title
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/-+/g, "-")
|
||||
.replace(/^-|-$/g, "")
|
||||
.slice(0, 80)
|
||||
.replace(/-$/g, "")
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
/**
|
||||
* Strategy 3: Steam Community Hub Scraping
|
||||
*
|
||||
* Fetches steamcommunity.com/app/{appid} and extracts version/build info
|
||||
* from update announcements, embedded data, and the app sidebar.
|
||||
*
|
||||
* Community hub is less aggressively rate-limited than the store.
|
||||
*/
|
||||
import type { VersionFetchResult } from "./types"
|
||||
|
||||
const COMMUNITY_URL = "https://steamcommunity.com/app"
|
||||
|
||||
export async function fetchCommunityHub(
|
||||
steamAppId: number,
|
||||
): Promise<VersionFetchResult> {
|
||||
const source = "Community Hub Scrape"
|
||||
|
||||
try {
|
||||
const res = await fetch(`${COMMUNITY_URL}/${steamAppId}`, {
|
||||
headers: {
|
||||
"User-Agent":
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
|
||||
Accept: "text/html",
|
||||
},
|
||||
signal: AbortSignal.timeout(10000),
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
return {
|
||||
versionString: null,
|
||||
buildId: null,
|
||||
source,
|
||||
success: false,
|
||||
error: `HTTP ${res.status}`,
|
||||
}
|
||||
}
|
||||
|
||||
const html = await res.text()
|
||||
|
||||
let versionString: string | null = null
|
||||
let buildId: string | null = null
|
||||
|
||||
// ── Extract from embedded community data ──────────────────
|
||||
// The community hub embeds JSON in data attributes
|
||||
const communityDataMatch = html.match(
|
||||
/data-community="([^"]+)"/i,
|
||||
)
|
||||
if (communityDataMatch) {
|
||||
try {
|
||||
const decoded = communityDataMatch[1]
|
||||
.replace(/"/g, '"')
|
||||
.replace(/&/g, "&")
|
||||
const data = JSON.parse(decoded)
|
||||
// Community data usually has CLANSTEAMID, APPID, IS_OGG, etc.
|
||||
// Not version info, but checked for completeness
|
||||
if (data.APPID && !buildId) {
|
||||
// APPID is just the same app ID — not useful
|
||||
}
|
||||
} catch {
|
||||
// Non-fatal
|
||||
}
|
||||
}
|
||||
|
||||
// ── Extract from "View Stats" link ────────────────────────
|
||||
// Sometimes game stats pages have more info
|
||||
|
||||
// ── Parse update announcements for version numbers ────────
|
||||
// Look for patterns like "Counter-Strike 2 Update" or "Game Update 1.4.2"
|
||||
const updateTitlePatterns = [
|
||||
// "Game Name Update" — generic, skip
|
||||
// "Update 1.4.2" pattern
|
||||
/(?:Update|Patch)\s+(\d+\.\d+(?:\.\d+)?(?:[a-z]\d*)?)/gi,
|
||||
// "Version 1.4.2" pattern
|
||||
/Version\s+(\d+\.\d+(?:\.\d+)?(?:[a-z]\d*)?)/gi,
|
||||
// "Build 12345678" pattern
|
||||
/Build\s+(\d{5,10})/gi,
|
||||
]
|
||||
|
||||
for (const pattern of updateTitlePatterns) {
|
||||
const match = pattern.exec(html)
|
||||
if (match) {
|
||||
const val = match[1]
|
||||
if (/^\d{5,10}$/.test(val)) {
|
||||
if (!buildId) buildId = val
|
||||
} else if (/^\d+\.\d+/.test(val)) {
|
||||
if (!versionString) versionString = val
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Extract build ID from JS globals ──────────────────────
|
||||
const buildTimestampMatch = html.match(
|
||||
/"BUILD_TIMESTAMP"[:\s]+(\d{9,10})/,
|
||||
)
|
||||
if (buildTimestampMatch && !buildId) {
|
||||
// BUILD_TIMESTAMP is the page build time, not game build
|
||||
// But if we have nothing else, it's a signal
|
||||
// Skipping — too noisy
|
||||
}
|
||||
|
||||
// ── Try apphub sidebar for build/version info ──────────────
|
||||
const apphubMatch = html.match(
|
||||
/apphub_AppInfo[^>]*>([\s\S]*?)<\/div>/i,
|
||||
)
|
||||
if (apphubMatch) {
|
||||
const apphubHtml = apphubMatch[1]
|
||||
// Look for "Build ID" or "Current version" labels
|
||||
const buildLabelMatch = apphubHtml.match(
|
||||
/Build\s*ID[:\s]*<\/span>\s*([\d,]+)/i,
|
||||
)
|
||||
if (buildLabelMatch && !buildId) {
|
||||
buildId = buildLabelMatch[1].replace(/,/g, "")
|
||||
}
|
||||
|
||||
const versionLabelMatch = apphubHtml.match(
|
||||
/(?:Current|Latest)\s*Version[:\s]*<\/span>\s*([\d.]+)/i,
|
||||
)
|
||||
if (versionLabelMatch && !versionString) {
|
||||
versionString = versionLabelMatch[1]
|
||||
}
|
||||
}
|
||||
|
||||
const success = !!(versionString || buildId)
|
||||
return {
|
||||
versionString,
|
||||
buildId,
|
||||
source,
|
||||
success,
|
||||
error: success ? undefined : "No version data found on community hub",
|
||||
}
|
||||
} catch (err) {
|
||||
return {
|
||||
versionString: null,
|
||||
buildId: null,
|
||||
source,
|
||||
success: false,
|
||||
error: err instanceof Error ? err.message : "Unknown error",
|
||||
}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user