From f9617bf586c733925e8483e8de6745afabf78af8 Mon Sep 17 00:00:00 2001 From: Adrian Bonpin Date: Thu, 30 Apr 2026 19:32:35 +0800 Subject: [PATCH] feat: add Steam Reviews display API with caching - Create lib/api/steam-reviews.ts with in-memory cache (1hr TTL) - Proxy endpoint for fetching Steam reviews by game ID - Supports offset/limit/language query params - Registers route in API index and app router --- app/api/[[...slugs]]/route.ts | 3 + lib/api/index.ts | 1 + lib/api/steam-reviews.ts | 101 ++++++++++++++++++++++++++++++++++ 3 files changed, 105 insertions(+) create mode 100644 lib/api/steam-reviews.ts diff --git a/app/api/[[...slugs]]/route.ts b/app/api/[[...slugs]]/route.ts index fa1ab96..13fedda 100644 --- a/app/api/[[...slugs]]/route.ts +++ b/app/api/[[...slugs]]/route.ts @@ -27,6 +27,7 @@ import { gameStatsRoutes } from "@/lib/api/game-stats" 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 { gamesListingRoutes } from "@/lib/api/games-listing" import { steamgridProxyRoutes } from "@/lib/api/steamgrid-proxy" @@ -104,6 +105,8 @@ export const app = new Elysia({ prefix: "/api" }) .use(compareRoutes) // Playability .use(playabilityRoutes) + // Steam reviews + .use(steamReviewRoutes) // Root .get("/", () => ({ name: "DeckyVault API", diff --git a/lib/api/index.ts b/lib/api/index.ts index 8efaaac..a00af55 100644 --- a/lib/api/index.ts +++ b/lib/api/index.ts @@ -18,3 +18,4 @@ export { steamgridProxyRoutes } from "./steamgrid-proxy" export { gamesManualRoutes } from "./games-manual" export { compareRoutes } from "./compare" export { playabilityRoutes } from "./playability" +export { steamReviewRoutes } from "./steam-reviews" diff --git a/lib/api/steam-reviews.ts b/lib/api/steam-reviews.ts new file mode 100644 index 0000000..2cf0514 --- /dev/null +++ b/lib/api/steam-reviews.ts @@ -0,0 +1,101 @@ +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: any; 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" }) + + // 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 { + const response = await fetch( + `https://store.steampowered.com/appreviews/${game.steamAppId}?json=1&language=${language}&purchase_type=all&num_per_page=${limit}&cursor=${encodeURIComponent(Buffer.from(`${offset}`).toString("base64"))}&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() + + // Cache the result + reviewCache.set(cacheKey, { data, expires: Date.now() + CACHE_TTL }) + + return data + } catch (error) { + set.status = 502 + return { error: "Steam review API unavailable" } + } + } + ) \ No newline at end of file