diff --git a/app/__tests__/sitemap.test.ts b/app/__tests__/sitemap.test.ts new file mode 100644 index 0000000..9338bad --- /dev/null +++ b/app/__tests__/sitemap.test.ts @@ -0,0 +1,116 @@ +import { describe, it, expect, vi, beforeEach } from "vitest" + +// ── Mock data ────────────────────────────────────────────────────────── +let mockGameRows: Array<{ id: string; updatedAt: Date | null; capsuleImage: string | null }> = [] +let mockDeviceRows: Array<{ slug: string; createdAt: Date | null }> = [] + +// ── Chainable query builder mock ────────────────────────────────────── +// Drizzle ORM pattern: db.select().from(table).where(...).limit(...).offset(...) +// or without .where(): db.select().from(table) +// Both resolve as promises. + +function createChainableQuery(resolveWith: unknown[]) { + const then = (resolve: (v: unknown) => unknown, reject: (e: unknown) => unknown) => + Promise.resolve(resolveWith).then(resolve, reject) + + const chain: Record = { + where: vi.fn(() => ({ then, [Symbol.toPrimitive]: () => resolveWith })), + limit: vi.fn(() => ({ then, [Symbol.toPrimitive]: () => resolveWith })), + offset: vi.fn(() => ({ + then, + where: chain.where, + limit: chain.limit, + [Symbol.toPrimitive]: () => resolveWith, + })), + then, + [Symbol.toPrimitive]: () => resolveWith, + } + + return chain +} + +let devicesCallCount = 0 + +vi.mock("@/lib/db/index", () => ({ + db: { + select: vi.fn(() => ({ + from: vi.fn(() => { + // Alternate between games and hardware queries based on call order + // Games query always comes first, then hardware + devicesCallCount++ + const data = devicesCallCount % 2 === 1 ? mockGameRows : mockDeviceRows + return createChainableQuery(data) + }), + })), + }, +})) + +vi.mock("@/lib/db/schema", () => ({ + games: { + id: "id", + updatedAt: "updatedAt", + capsuleImage: "capsuleImage", + syncStatus: "syncStatus", + }, + hardware: { slug: "slug", createdAt: "createdAt" }, +})) + +vi.mock("drizzle-orm", () => ({ + or: vi.fn((...args: unknown[]) => args[0]), + ne: vi.fn((col: unknown) => col), + isNull: vi.fn((col: unknown) => col), +})) + +describe("Sitemap Generator (app/sitemap.ts)", () => { + beforeEach(() => { + vi.clearAllMocks() + mockGameRows = [] + mockDeviceRows = [] + gamesCallCount = 0 + devicesCallCount = 0 + }) + + it("has ISR revalidation configured", async () => { + const mod = await import("@/app/sitemap") + expect(mod.revalidate).toBe(3600) + }) + + it("returns static pages even with empty DB results", async () => { + const mod = await import("@/app/sitemap") + const result = await mod.default() + + expect(Array.isArray(result)).toBe(true) + // At minimum: 6 static pages (games and devices are empty) + expect(result.length).toBeGreaterThanOrEqual(6) + // First entry should be the homepage with priority 1 + expect(result[0].url).toContain("deckyvault.xyz") + expect(result[0].priority).toBe(1) + }) + + it("includes game entries from DB", async () => { + mockGameRows = [ + { id: "123456", updatedAt: new Date("2025-01-01"), capsuleImage: "https://cdn.example.com/img.jpg" }, + ] + mockDeviceRows = [] + + const mod = await import("@/app/sitemap") + const result = await mod.default() + + const gameEntry = result.find((e: { url: string }) => e.url.includes("/game/123456")) + expect(gameEntry).toBeDefined() + expect(gameEntry!.url).toContain("deckyvault.xyz/game/123456") + expect(gameEntry!.priority).toBe(0.7) + }) + + it("always uses production URL when env is localhost", async () => { + process.env.NEXT_PUBLIC_SITE_URL = "http://localhost:3000" + + const mod = await import("@/app/sitemap") + const result = await mod.default() + + expect(result[0].url).toContain("deckyvault.xyz") + expect(result[0].url).not.toContain("localhost") + + delete process.env.NEXT_PUBLIC_SITE_URL + }) +}) \ No newline at end of file diff --git a/app/dashboard/page.tsx b/app/dashboard/page.tsx index 31b5ce5..32f4647 100644 --- a/app/dashboard/page.tsx +++ b/app/dashboard/page.tsx @@ -131,7 +131,7 @@ export default async function DashboardPage() { `) // Serialize rows for the client component - const serializedTrending = trending.rows.map((row: any) => ({ + const serializedTrending = trending.rows.map((row) => ({ id: String(row.id), title: String(row.title), capsule_image: row.capsule_image ? String(row.capsule_image) : null, @@ -143,19 +143,19 @@ export default async function DashboardPage() { activity_score: Number(row.activity_score ?? 0), })) - const serializedBestNew = bestNew.rows.map((row: any) => ({ + const serializedBestNew = bestNew.rows.map((row) => ({ id: String(row.id), title: String(row.title), capsule_image: row.capsule_image ? String(row.capsule_image) : null, header_image: row.header_image ? String(row.header_image) : null, - release_date: row.release_date ? new Date(row.release_date).toISOString() : null, - created_at: row.created_at ? new Date(row.created_at).toISOString() : null, + release_date: row.release_date ? new Date(String(row.release_date)).toISOString() : null, + created_at: row.created_at ? new Date(String(row.created_at)).toISOString() : null, playability_status: row.playability_status ? String(row.playability_status) : null, avg_fps: row.avg_fps ? Number(row.avg_fps) : null, benchmark_count: Number(row.benchmark_count ?? 0), })) - const serializedMostTested = mostTested.rows.map((row: any) => ({ + const serializedMostTested = mostTested.rows.map((row) => ({ id: String(row.id), title: String(row.title), capsule_image: row.capsule_image ? String(row.capsule_image) : null, @@ -164,7 +164,7 @@ export default async function DashboardPage() { benchmark_count: Number(row.benchmark_count ?? 0), })) - const serializedMostReported = mostReported.rows.map((row: any) => ({ + const serializedMostReported = mostReported.rows.map((row) => ({ id: String(row.id), title: String(row.title), capsule_image: row.capsule_image ? String(row.capsule_image) : null, diff --git a/app/game/[id]/game-page-client.tsx b/app/game/[id]/game-page-client.tsx index cb6b333..dbd157a 100644 --- a/app/game/[id]/game-page-client.tsx +++ b/app/game/[id]/game-page-client.tsx @@ -1476,17 +1476,6 @@ function Badge({ ) } -function MetaItem({ label, value }: { label: string; value: string }) { - return ( -
- - {label} - - {value} -
- ) -} - function StatCard({ label, value, diff --git a/app/sitemap.ts b/app/sitemap.ts index ba95515..00f164e 100644 --- a/app/sitemap.ts +++ b/app/sitemap.ts @@ -3,164 +3,89 @@ import { db } from "@/lib/db/index" import { games, hardware } from "@/lib/db/schema" import { or, ne, isNull } from "drizzle-orm" +// ── Constants ──────────────────────────────────────────────────────── const PRODUCTION_URL = "https://deckyvault.xyz" -const MAX_URLS_PER_SITEMAP = 45_000 + +/** Revalidate sitemap every hour via ISR */ +export const revalidate = 3600 function getBaseUrl(): string { const envUrl = process.env.NEXT_PUBLIC_SITE_URL - // Never use localhost for sitemaps — they're for production search engines if (envUrl && !envUrl.includes("localhost") && !envUrl.includes("127.0.0.1")) { return envUrl.replace(/\/$/, "") } return PRODUCTION_URL } +// ── Static pages with known priorities ─────────────────────────────── const STATIC_ENTRIES: Array<{ - url: string + urlPath: string changeFrequency: MetadataRoute.Sitemap[number]["changeFrequency"] priority: number }> = [ - { url: "", changeFrequency: "weekly", priority: 1 }, - { url: "/games", changeFrequency: "daily", priority: 0.8 }, - { url: "/dashboard", changeFrequency: "daily", priority: 0.7 }, - { url: "/devices", changeFrequency: "monthly", priority: 0.6 }, - { url: "/updates", changeFrequency: "weekly", priority: 0.5 }, - { url: "/contact", changeFrequency: "yearly", priority: 0.3 }, + { urlPath: "", changeFrequency: "weekly", priority: 1 }, + { urlPath: "/games", changeFrequency: "daily", priority: 0.8 }, + { urlPath: "/dashboard", changeFrequency: "daily", priority: 0.7 }, + { urlPath: "/devices", changeFrequency: "monthly", priority: 0.6 }, + { urlPath: "/updates", changeFrequency: "weekly", priority: 0.5 }, + { urlPath: "/contact", changeFrequency: "yearly", priority: 0.3 }, ] -export async function generateSitemaps(): Promise<{ id: string }[]> { - const gameCount = await db.$count( - games, - or(ne(games.syncStatus, "failed"), isNull(games.syncStatus)), - ) - const deviceCount = await db.$count(hardware) - const total = STATIC_ENTRIES.length + gameCount + deviceCount - - const count = Math.ceil(total / MAX_URLS_PER_SITEMAP) - return Array.from({ length: count }, (_, i) => ({ id: String(i) })) -} - -export default async function sitemap({ - id, -}: { - id: string -}): Promise { +// ── Default export: build the sitemap ──────────────────────────────── +export default async function sitemap(): Promise { const baseUrl = getBaseUrl() - const chunkIndex = Number(id) - const offset = chunkIndex * MAX_URLS_PER_SITEMAP - if (chunkIndex === 0) { - // First chunk: static pages + some games/devices - const staticEntries: MetadataRoute.Sitemap = STATIC_ENTRIES.map((s) => ({ - url: s.url ? `${baseUrl}${s.url}` : baseUrl, - changeFrequency: s.changeFrequency, - priority: s.priority, - })) + // Static pages + const staticEntries: MetadataRoute.Sitemap = STATIC_ENTRIES.map((s) => ({ + url: s.urlPath ? `${baseUrl}${s.urlPath}` : baseUrl, + changeFrequency: s.changeFrequency, + priority: s.priority, + })) - const remaining = MAX_URLS_PER_SITEMAP - staticEntries.length + // Game pages + const gameRows = await db + .select({ + id: games.id, + updatedAt: games.updatedAt, + capsuleImage: games.capsuleImage, + }) + .from(games) + .where(or(ne(games.syncStatus, "failed"), isNull(games.syncStatus))) - const gameRows = await db - .select({ - id: games.id, - updatedAt: games.updatedAt, - capsuleImage: games.capsuleImage, - }) - .from(games) - .where(or(ne(games.syncStatus, "failed"), isNull(games.syncStatus))) - .limit(remaining) - .offset(0) + const gameEntries: MetadataRoute.Sitemap = gameRows.map((row) => ({ + url: `${baseUrl}/game/${row.id}`, + lastModified: row.updatedAt ?? undefined, + changeFrequency: "weekly" as const, + priority: 0.7, + ...buildImageEntry(row.capsuleImage), + })) - const gameEntries: MetadataRoute.Sitemap = gameRows.map((row) => ({ - url: `${baseUrl}/game/${row.id}`, - lastModified: row.updatedAt ?? undefined, - changeFrequency: "weekly", - priority: 0.7, - ...(row.capsuleImage && - typeof row.capsuleImage === "string" && - row.capsuleImage.trim().startsWith("https://") && - row.capsuleImage.trim().length <= 2048 - ? { images: [row.capsuleImage.trim()] } - : {}), - })) + // Device pages + const deviceRows = await db + .select({ slug: hardware.slug, createdAt: hardware.createdAt }) + .from(hardware) - const stillRemaining = remaining - gameRows.length - let deviceEntries: MetadataRoute.Sitemap = [] + const deviceEntries: MetadataRoute.Sitemap = deviceRows.map((row) => ({ + url: `${baseUrl}/devices/${row.slug}`, + lastModified: row.createdAt ?? undefined, + changeFrequency: "monthly" as const, + priority: 0.5, + })) - if (stillRemaining > 0) { - const deviceRows = await db - .select({ slug: hardware.slug, createdAt: hardware.createdAt }) - .from(hardware) - .limit(stillRemaining) - .offset(0) - - deviceEntries = deviceRows.map((row) => ({ - url: `${baseUrl}/devices/${row.slug}`, - lastModified: row.createdAt ?? undefined, - changeFrequency: "monthly", - priority: 0.5, - })) - } - - return [...staticEntries, ...gameEntries, ...deviceEntries] - } - - // Subsequent chunks: games and devices only - const gameCount = await db.$count( - games, - or(ne(games.syncStatus, "failed"), isNull(games.syncStatus)), - ) - const dynamicOffset = offset - STATIC_ENTRIES.length - - let allEntries: MetadataRoute.Sitemap = [] - - if (dynamicOffset < gameCount) { - const gameOffset = dynamicOffset - const gameLimit = Math.min(MAX_URLS_PER_SITEMAP, gameCount - gameOffset) - - const gameRows = await db - .select({ - id: games.id, - updatedAt: games.updatedAt, - capsuleImage: games.capsuleImage, - }) - .from(games) - .where(or(ne(games.syncStatus, "failed"), isNull(games.syncStatus))) - .limit(gameLimit) - .offset(gameOffset) - - allEntries = gameRows.map((row) => ({ - url: `${baseUrl}/game/${row.id}`, - lastModified: row.updatedAt ?? undefined, - changeFrequency: "weekly", - priority: 0.7, - ...(row.capsuleImage && - typeof row.capsuleImage === "string" && - row.capsuleImage.trim().startsWith("https://") && - row.capsuleImage.trim().length <= 2048 - ? { images: [row.capsuleImage.trim()] } - : {}), - })) - } - - const remainingInChunk = MAX_URLS_PER_SITEMAP - allEntries.length - if (remainingInChunk > 0) { - const deviceOffset = Math.max(0, dynamicOffset - gameCount) - - const deviceRows = await db - .select({ slug: hardware.slug, createdAt: hardware.createdAt }) - .from(hardware) - .limit(remainingInChunk) - .offset(deviceOffset) - - const deviceEntries = deviceRows.map((row) => ({ - url: `${baseUrl}/devices/${row.slug}`, - lastModified: row.createdAt ?? undefined, - changeFrequency: "monthly", - priority: 0.5, - })) - - allEntries = [...allEntries, ...deviceEntries] - } - - return allEntries + return [...staticEntries, ...gameEntries, ...deviceEntries] } + +// ── Helpers ────────────────────────────────────────────────────────── +/** Build a valid image sitemap entry from a capsule image URL */ +function buildImageEntry( + capsuleImage: unknown, +): { images: string[] } | Record { + if ( + typeof capsuleImage === "string" && + capsuleImage.trim().startsWith("https://") && + capsuleImage.trim().length <= 2048 + ) { + return { images: [capsuleImage.trim()] } + } + return {} +} \ No newline at end of file diff --git a/app/sw.ts b/app/sw.ts index a088709..1d796bb 100644 --- a/app/sw.ts +++ b/app/sw.ts @@ -64,9 +64,9 @@ const serwist = new Serwist({ ], }), }, - // Navigation fallback: offline.html for uncached pages + // Navigation fallback for HTML pages (not XML/JSON/etc.) { - matcher: ({ request }) => request.mode === "navigate", + matcher: ({ request }) => request.mode === "navigate" && !request.url.endsWith(".xml"), handler: new NetworkFirst({ cacheName: "navigation", plugins: [ diff --git a/components/dashboard/best-releases-chart.tsx b/components/dashboard/best-releases-chart.tsx index e7b3fd0..bb8d440 100644 --- a/components/dashboard/best-releases-chart.tsx +++ b/components/dashboard/best-releases-chart.tsx @@ -37,9 +37,10 @@ function buildOption(games: BestRelease[]): EChartsOption { backgroundColor: "#1a1225", borderColor: CHART_THEME.border, textStyle: { color: CHART_THEME.text }, - formatter: (params: any) => { - const v = params.value - return `
${params.name}
+ formatter: (params: unknown) => { + const p = params as { name: string; value: number[] } + const v = p.value + return `
${p.name}
Avg FPS: ${v[1]}
Benchmarks: ${v[2]}
` }, @@ -78,7 +79,7 @@ function buildOption(games: BestRelease[]): EChartsOption { symbolSize: (val: number[]) => Math.max(12, Math.min(40, val[2] * 3)), label: { show: true, - formatter: (p: any) => p.name, + formatter: (p: unknown) => (p as { name: string }).name, position: "top", color: CHART_THEME.text, fontSize: 10, diff --git a/components/dashboard/trending-games-chart.tsx b/components/dashboard/trending-games-chart.tsx index 98a5f3c..bd5d2a0 100644 --- a/components/dashboard/trending-games-chart.tsx +++ b/components/dashboard/trending-games-chart.tsx @@ -17,8 +17,6 @@ function buildOption(games: TrendingGame[]): EChartsOption { const titles = sorted.map((g) => g.title) const scores = sorted.map((g) => g.activity_score) const benchmarks = sorted.map((g) => g.benchmark_count) - const comments = sorted.map((g) => g.comment_count) - const upvotes = sorted.map((g) => g.upvote_count) return { backgroundColor: CHART_THEME.bg, @@ -28,8 +26,9 @@ function buildOption(games: TrendingGame[]): EChartsOption { backgroundColor: "#1a1225", borderColor: CHART_THEME.border, textStyle: { color: CHART_THEME.text }, - formatter: (params: any) => { - const idx = params[0].dataIndex + formatter: (params: unknown) => { + const p = params as { dataIndex: number }[] + const idx = p[0].dataIndex const g = sorted[idx] return `
${g.title}
Activity Score: ${g.activity_score}
diff --git a/components/profile/profile-header.tsx b/components/profile/profile-header.tsx index fec34b0..251bcde 100644 --- a/components/profile/profile-header.tsx +++ b/components/profile/profile-header.tsx @@ -1,6 +1,6 @@ "use client" -import { Shield, Crown, CheckCircle, Mail, User } from "lucide-react" +import { Shield, Crown, CheckCircle, Mail } from "lucide-react" import { motion } from "motion/react" interface ProfileHeaderProps { @@ -44,6 +44,7 @@ export function ProfileHeader({ name, email, role, verified, createdAt, image }:
{image ? (
+ {/* eslint-disable-next-line @next/next/no-img-element */} {`${name}'s
) : ( diff --git a/components/profile/profile-photo-upload.tsx b/components/profile/profile-photo-upload.tsx index 6ad113b..f4b7520 100644 --- a/components/profile/profile-photo-upload.tsx +++ b/components/profile/profile-photo-upload.tsx @@ -23,7 +23,7 @@ function getInitials(name: string): string { return name.charAt(0).toUpperCase() } -export function ProfilePhotoUpload({ currentImage, userName, userId, onImageChange }: ProfilePhotoUploadProps) { +export function ProfilePhotoUpload({ currentImage, userName, onImageChange }: ProfilePhotoUploadProps) { const [previewUrl, setPreviewUrl] = useState(currentImage) const [uploadState, setUploadState] = useState<"idle" | "uploading" | "success" | "error">("idle") const [errorMessage, setErrorMessage] = useState(null) @@ -42,7 +42,9 @@ export function ProfilePhotoUpload({ currentImage, userName, userId, onImageChan return cleanupTempUrl }, []) + // Sync previewUrl when parent updates currentImage externally useEffect(() => { + // eslint-disable-next-line react-hooks/set-state-in-effect setPreviewUrl(currentImage) }, [currentImage]) @@ -56,7 +58,7 @@ export function ProfilePhotoUpload({ currentImage, userName, userId, onImageChan return null } - const handleFile = async (file: File) => { + const handleFile = useCallback(async (file: File) => { const validationError = validateFile(file) if (validationError) { setErrorMessage(validationError) @@ -99,7 +101,7 @@ export function ProfilePhotoUpload({ currentImage, userName, userId, onImageChan setPreviewUrl(currentImage) cleanupTempUrl() } - } + }, [currentImage, onImageChange]) const handleInputChange = (e: React.ChangeEvent) => { const file = e.target.files?.[0] @@ -112,7 +114,7 @@ export function ProfilePhotoUpload({ currentImage, userName, userId, onImageChan setIsDragging(false) const file = e.dataTransfer.files?.[0] if (file) handleFile(file) - }, [currentImage]) + }, [handleFile]) const handleDragOver = useCallback((e: React.DragEvent) => { e.preventDefault() @@ -179,6 +181,7 @@ export function ProfilePhotoUpload({ currentImage, userName, userId, onImageChan }} > {previewUrl ? ( + // eslint-disable-next-line @next/next/no-img-element {`${userName}'s ({ name: "DeckyVault API", diff --git a/lib/api/cron.ts b/lib/api/cron.ts index 7b7ab80..8334329 100644 --- a/lib/api/cron.ts +++ b/lib/api/cron.ts @@ -1,7 +1,7 @@ import { Elysia, t } from "elysia" import { db } from "@/lib/db/index" import { storageObjects } from "@/lib/db/schema" -import { eq, and, isNull, sql } from "drizzle-orm" +import { eq, sql } from "drizzle-orm" import { deleteObject, isR2Configured } from "@/lib/storage" // ── Task Result Type ──────────────────────────────────────────────── @@ -110,21 +110,6 @@ registerCronTask("orphan_detection", async () => { } }) -// ── Sitemap Regeneration Task ──────────────────────────────────────── -registerCronTask("sitemap_regeneration", async () => { - const start = Date.now() - const details: Record = {} - try { - const { generateSitemaps } = await import("@/lib/sitemap/generate-static") - await generateSitemaps() - details.regenerated = true - return { name: "sitemap_regeneration", status: "success" as const, durationMs: Date.now() - start, details } - } catch (err) { - details.error = err instanceof Error ? err.message : String(err) - return { name: "sitemap_regeneration", status: "error" as const, durationMs: Date.now() - start, details } - } -}) - // ── Cron Route ────────────────────────────────────────────────────── export const cronRoutes = new Elysia({ prefix: "/cron" }).post( "/daily", diff --git a/lib/api/dashboard-public.ts b/lib/api/dashboard-public.ts index af7d865..addf7c1 100644 --- a/lib/api/dashboard-public.ts +++ b/lib/api/dashboard-public.ts @@ -1,16 +1,8 @@ -import { Elysia, t } from "elysia" +import { Elysia } from "elysia" import { db } from "@/lib/db/index" -import { - games, - gameVersions, - performanceEntries, - gameComments, - reports, -} from "@/lib/db/schema" -import { eq, and, gte, sql, desc, count } from "drizzle-orm" +import { sql } from "drizzle-orm" const SEVEN_DAYS_AGO = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000) -const NINETY_DAYS_AGO = new Date(Date.now() - 90 * 24 * 60 * 60 * 1000) export const dashboardPublicRoutes = new Elysia({ prefix: "/dashboard" }) diff --git a/lib/api/index.ts b/lib/api/index.ts index e6a7c16..13e0561 100644 --- a/lib/api/index.ts +++ b/lib/api/index.ts @@ -23,6 +23,5 @@ export { communitySuggestionRoutes } from "./community-suggestions" export { savedFilterRoutes } from "./saved-filters" export { dashboardRoutes } from "./dashboard" export { dashboardPublicRoutes } from "./dashboard-public" -export { sitemapRegenerateRoutes } from "./sitemap-regenerate" export { cronRoutes } from "./cron" export { profilePhotoRoutes } from "./profile-photo" diff --git a/lib/api/profile-photo.ts b/lib/api/profile-photo.ts index 050910b..a83aec6 100644 --- a/lib/api/profile-photo.ts +++ b/lib/api/profile-photo.ts @@ -1,4 +1,4 @@ -import { Elysia, t } from "elysia" +import { Elysia } from "elysia" import { db } from "@/lib/db/index" import { user } from "@/lib/db/schema" import { eq } from "drizzle-orm" @@ -14,7 +14,6 @@ import { storageObjects } from "@/lib/db/schema" const ALLOWED_MIME_TYPES = ["image/jpeg", "image/png", "image/webp"] const MAX_FILE_SIZE = 5 * 1024 * 1024 // 5MB -const AVATAR_SIZE = 256 // Magic byte signatures for file type validation const MAGIC_BYTES: Record = { @@ -78,10 +77,6 @@ export const profilePhotoRoutes = new Elysia({ prefix: "/user" }) // Generate unique key const timestamp = Date.now() - const key = `avatars/${guard.user.id}-${timestamp}.webp` - - // Upload to R2 (store original; client-side resize handled later or store as-is) - // For MVP: store the original file as-is with its original MIME type 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, { diff --git a/lib/api/search-unified.ts b/lib/api/search-unified.ts index d4fb410..5cb6659 100644 --- a/lib/api/search-unified.ts +++ b/lib/api/search-unified.ts @@ -135,9 +135,6 @@ export const searchUnifiedRoutes = new Elysia({ prefix: "/search" }).get( // ── 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) - const localSteamAppIds = new Set( - filteredLocalGames.map((g) => g.steamAppId).filter(Boolean), - ) // Fetch platform support + anti-cheat for local games const platformSupportMap = new Map< diff --git a/lib/api/sitemap-regenerate.ts b/lib/api/sitemap-regenerate.ts deleted file mode 100644 index 58ec46f..0000000 --- a/lib/api/sitemap-regenerate.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { Elysia } from "elysia" -import { requireAdmin } from "@/lib/auth/guard" -import { generateSitemaps } from "@/lib/sitemap/generate-static" - -export const sitemapRegenerateRoutes = new Elysia({ prefix: "/sitemap" }) - - .post( - "/regenerate", - async ({ request, set }) => { - const guard = await requireAdmin(request.headers) - if (!guard.ok) { - set.status = guard.status - return { error: guard.error } - } - - try { - await generateSitemaps() - return { success: true, message: "Sitemap regenerated successfully" } - } catch (err) { - set.status = 500 - return { error: "Failed to regenerate sitemap", details: err instanceof Error ? err.message : String(err) } - } - }, - ) \ No newline at end of file diff --git a/lib/sitemap/__tests__/sitemap.test.ts b/lib/sitemap/__tests__/sitemap.test.ts deleted file mode 100644 index 482709e..0000000 --- a/lib/sitemap/__tests__/sitemap.test.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { describe, it, expect, vi } from "vitest" - -// Helper to build a chainable mock that returns [] -function chainableMock() { - const mock = { - where: vi.fn().mockReturnThis(), - limit: vi.fn().mockReturnThis(), - offset: vi.fn().mockResolvedValue([]), - } - return mock -} - -// Mock database to prevent real queries -vi.mock("@/lib/db/index", () => ({ - db: { - select: vi.fn(() => ({ - from: vi.fn(() => chainableMock()), - })), - $count: vi.fn().mockResolvedValue(0), - }, -})) - -vi.mock("@/lib/db/schema", () => ({ - games: { - id: "id", - updatedAt: "updatedAt", - capsuleImage: "capsuleImage", - syncStatus: "syncStatus", - }, - hardware: { slug: "slug", createdAt: "createdAt" }, -})) - -vi.mock("drizzle-orm", () => ({ - or: vi.fn((...args) => args[0]), - ne: vi.fn((col) => col), - isNull: vi.fn((col) => col), -})) - -describe("Sitemap Generator", () => { - it("generateSitemaps returns a single sitemap id when no data", async () => { - const mod = await import("@/app/sitemap") - const sitemaps = await mod.generateSitemaps() - expect(sitemaps).toEqual([{ id: "0" }]) - }) - - it("default export returns static pages in first chunk", async () => { - const mod = await import("@/app/sitemap") - const result = await mod.default({ id: "0" }) - expect(Array.isArray(result)).toBe(true) - // Static pages: /, /games, /dashboard, /devices, /updates, /contact - expect(result.length).toBeGreaterThanOrEqual(6) - expect(result[0].url).toContain("deckyvault.xyz") - expect(result[0].priority).toBe(1) - }) -}) diff --git a/lib/sitemap/generate-static.ts b/lib/sitemap/generate-static.ts deleted file mode 100644 index b7aa204..0000000 --- a/lib/sitemap/generate-static.ts +++ /dev/null @@ -1,189 +0,0 @@ -import fs from "fs" -import path from "path" -import { db } from "@/lib/db/index" -import { games, hardware } from "@/lib/db/schema" -import { or, ne, isNull } from "drizzle-orm" - -const PRODUCTION_URL = "https://deckyvault.xyz" - -function getBaseUrl(): string { - const envUrl = process.env.NEXT_PUBLIC_SITE_URL - // Never use localhost for sitemaps — they're for production search engines - if (envUrl && !envUrl.includes("localhost") && !envUrl.includes("127.0.0.1")) { - return envUrl.replace(/\/$/, "") - } - return PRODUCTION_URL -} - -const BASE_URL = getBaseUrl() -const MAX_URLS_PER_SITEMAP = 45000 // Leave buffer below 50k limit - -interface SitemapEntry { - loc: string - lastmod?: string - changefreq?: string - priority?: number - "image:image"?: { - "image:loc": string - } -} - -function escapeXml(str: string): string { - return str - .replace(/&/g, "&") - .replace(//g, ">") - .replace(/"/g, """) - .replace(/'/g, "'") -} - -function buildStaticEntries(): SitemapEntry[] { - return [ - { loc: BASE_URL, changefreq: "weekly", priority: 1.0 }, - { loc: `${BASE_URL}/games`, changefreq: "daily", priority: 0.8 }, - { loc: `${BASE_URL}/dashboard`, changefreq: "daily", priority: 0.7 }, - { loc: `${BASE_URL}/devices`, changefreq: "monthly", priority: 0.6 }, - { loc: `${BASE_URL}/updates`, changefreq: "weekly", priority: 0.5 }, - { loc: `${BASE_URL}/contact`, changefreq: "yearly", priority: 0.3 }, - ] -} - -function renderSitemap(entries: SitemapEntry[]): string { - const urls = entries.map((entry) => { - let xml = ` \n ${escapeXml(entry.loc)}\n` - if (entry.lastmod) xml += ` ${entry.lastmod}\n` - if (entry.changefreq) xml += ` ${entry.changefreq}\n` - if (entry.priority) xml += ` ${entry.priority}\n` - if (entry["image:image"]) { - xml += ` \n ${escapeXml(entry["image:image"]["image:loc"])}\n \n` - } - xml += ` ` - return xml - }) - - return `\n\n${urls.join("\n")}\n` -} - -function renderSitemapIndex(sitemaps: { loc: string; lastmod: string }[]): string { - const entries = sitemaps.map((s) => { - return ` \n ${escapeXml(s.loc)}\n ${s.lastmod}\n ` - }) - - return `\n\n${entries.join("\n")}\n` -} - -async function generateSitemaps() { - console.log("Generating static sitemaps...") - - // Fetch games - const gameRows = await db - .select({ - id: games.id, - updatedAt: games.updatedAt, - capsuleImage: games.capsuleImage, - }) - .from(games) - .where(or(ne(games.syncStatus, "failed"), isNull(games.syncStatus))) - - // Fetch devices - const deviceRows = await db - .select({ - slug: hardware.slug, - createdAt: hardware.createdAt, - }) - .from(hardware) - - console.log(`Found ${gameRows.length} games and ${deviceRows.length} devices`) - - // Build game entries - const gameEntries: SitemapEntry[] = gameRows.map((row) => { - const image = - row.capsuleImage && - typeof row.capsuleImage === "string" && - row.capsuleImage.trim().startsWith("https://") && - row.capsuleImage.trim().length <= 2048 - ? row.capsuleImage.trim() - : undefined - - return { - loc: `${BASE_URL}/game/${row.id}`, - lastmod: row.updatedAt ? new Date(row.updatedAt).toISOString() : undefined, - changefreq: "weekly", - priority: 0.7, - ...(image ? { "image:image": { "image:loc": image } } : {}), - } - }) - - // Build device entries - const deviceEntries: SitemapEntry[] = deviceRows.map((row) => ({ - loc: `${BASE_URL}/devices/${row.slug}`, - lastmod: row.createdAt ? new Date(row.createdAt).toISOString() : undefined, - changefreq: "monthly", - priority: 0.5, - })) - - // Static entries - const staticEntries = buildStaticEntries() - - const now = new Date().toISOString() - const publicDir = path.join(process.cwd(), "public") - - // Ensure public dir exists - if (!fs.existsSync(publicDir)) { - fs.mkdirSync(publicDir, { recursive: true }) - } - - // Decide: single file or sitemap index - const allDynamicEntries = [...gameEntries, ...deviceEntries] - const needsIndex = (staticEntries.length + allDynamicEntries.length) > MAX_URLS_PER_SITEMAP - - if (needsIndex) { - // Write static sitemap - const staticXml = renderSitemap(staticEntries) - fs.writeFileSync(path.join(publicDir, "sitemap-static.xml"), staticXml) - - // Write device sitemap - const deviceXml = renderSitemap(deviceEntries) - fs.writeFileSync(path.join(publicDir, "sitemap-devices.xml"), deviceXml) - - // Split game entries into chunks - const chunks: SitemapEntry[][] = [] - for (let i = 0; i < gameEntries.length; i += MAX_URLS_PER_SITEMAP) { - chunks.push(gameEntries.slice(i, i + MAX_URLS_PER_SITEMAP)) - } - - const sitemaps: { loc: string; lastmod: string }[] = [ - { loc: `${BASE_URL}/sitemap-static.xml`, lastmod: now }, - { loc: `${BASE_URL}/sitemap-devices.xml`, lastmod: now }, - ] - - chunks.forEach((chunk, i) => { - const filename = `sitemap-games-${i}.xml` - const xml = renderSitemap(chunk) - fs.writeFileSync(path.join(publicDir, filename), xml) - sitemaps.push({ loc: `${BASE_URL}/${filename}`, lastmod: now }) - }) - - // Write sitemap index - const indexXml = renderSitemapIndex(sitemaps) - fs.writeFileSync(path.join(publicDir, "sitemap.xml"), indexXml) - } else { - // Single sitemap - const allEntries = [...staticEntries, ...gameEntries, ...deviceEntries] - const xml = renderSitemap(allEntries) - fs.writeFileSync(path.join(publicDir, "sitemap.xml"), xml) - } - - console.log(`Sitemaps generated successfully! Total URLs: ${staticEntries.length + allDynamicEntries.length}`) -} - -// Run if called directly (CLI execution, not module import) -const isCliRun = typeof process !== "undefined" && process.argv?.[1]?.includes("generate-static") -if (isCliRun) { - generateSitemaps().catch((err) => { - console.error("Failed to generate sitemaps:", err) - process.exit(1) - }) -} - -export { generateSitemaps } \ No newline at end of file diff --git a/lib/steam/__tests__/ensure-steam-game.test.ts b/lib/steam/__tests__/ensure-steam-game.test.ts index 715fc61..922cb3b 100644 --- a/lib/steam/__tests__/ensure-steam-game.test.ts +++ b/lib/steam/__tests__/ensure-steam-game.test.ts @@ -2,19 +2,19 @@ import { describe, it, expect, vi, beforeEach } from "vitest" // ── Mock global fetch so syncSteamGame doesn't hit real APIs ── const mockFetch = vi.fn() -Object.assign(globalThis, { fetch: mockFetch as any }) +Object.assign(globalThis, { fetch: mockFetch as unknown as typeof fetch }) // ── Mock db with a queue of select results ── function createDbMock() { const state = { - selectQueue: [] as any[][], - insertResults: [] as any[], + selectQueue: [] as unknown[][], + insertResults: [] as unknown[], selectIdx: 0, } return { - setSelectQueue: (q: any[][]) => { state.selectQueue = q; state.selectIdx = 0 }, - setInsertResults: (r: any[]) => { state.insertResults = r }, + setSelectQueue: (q: unknown[][]) => { state.selectQueue = q; state.selectIdx = 0 }, + setInsertResults: (r: unknown[]) => { state.insertResults = r }, select: vi.fn().mockImplementation(() => ({ from: vi.fn().mockImplementation(() => ({ diff --git a/lib/storage/r2-client.ts b/lib/storage/r2-client.ts index 9ebe498..63cf006 100644 --- a/lib/storage/r2-client.ts +++ b/lib/storage/r2-client.ts @@ -14,7 +14,6 @@ const R2_BUCKET_NAME = process.env.R2_BUCKET_NAME ?? "deckyvault" const R2_PUBLIC_URL = process.env.R2_PUBLIC_URL ?? "" let _client: S3Client | null = null -let _configured = false function getR2ConfigStatus(): { configured: boolean; reason?: string } { if (!R2_ACCOUNT_ID || !R2_ACCESS_KEY_ID || !R2_SECRET_ACCESS_KEY) { @@ -37,7 +36,6 @@ function getClient(): S3Client { secretAccessKey: R2_SECRET_ACCESS_KEY!, }, }) - _configured = true return _client } diff --git a/package.json b/package.json index 2bfb6b1..a028376 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,6 @@ "db:studio": "drizzle-kit studio", "db:push": "drizzle-kit push", "db:seed": "bun run lib/db/seed.ts", - "build:sitemap": "tsx lib/sitemap/generate-static.ts", "test": "vitest run", "test:watch": "vitest" },