diff --git a/.env.example b/.env.example
index cb419ac..4489d97 100644
--- a/.env.example
+++ b/.env.example
@@ -50,13 +50,13 @@ DISCORD_CLIENT_ID=""
DISCORD_CLIENT_SECRET=""
# -----------------------------------------------------------------------------
-# STORAGE (S3 COMPATIBLE)
+# STORAGE (R2)
# -----------------------------------------------------------------------------
-# S3-compatible credentials for file storage
-S3_ACCESS_KEY_ID=""
-S3_SECRET_ACCESS_KEY=""
-S3_BUCKET_NAME=""
-S3_PUBLIC_URL=""
+R2_ACCOUNT_ID="account_id_here"
+R2_ACCESS_KEY_ID="access_key_id_here"
+R2_SECRET_ACCESS_KEY="secret_access_key_here"
+R2_BUCKET_NAME="deckyvault"
+R2_PUBLIC_URL="https://cdn.deckyvault.xyz"
# -----------------------------------------------------------------------------
# PASSKEY (WebAuthn)
diff --git a/docs/2026-05-09-v2026.0.98-architectural-spec.md b/docs/2026-05-09-v2026.0.98-architectural-spec.md
new file mode 100644
index 0000000..8dca8fe
--- /dev/null
+++ b/docs/2026-05-09-v2026.0.98-architectural-spec.md
@@ -0,0 +1,825 @@
+# Architectural Spec: Static Sitemaps, Visualizations, Profiles, Game Details UX, S3 Storage & Changelog
+
+**Date:** 2026-05-09
+**Status:** Final — Awaiting Tactical Planning
+**Author:** Autonomous Architect
+**Target Version:** 2026.0.98
+**Depends On:** `docs/2026-05-05-sitemap-pwa-refinements-architectural-spec.md` (sitemap and PWA constraints — some decisions are superseded)
+
+---
+
+## 1. Problem Statement
+
+Seven objectives converge on three architectural concerns: **performance at scale** (sitemap load time, S3 lifecycle), **user expression** (profile photos, data dashboards), and **UX density** (game details layout, search filter polish, changelog hygiene). One objective (advanced search/filtering) is an audit revealing the feature was already fully shipped in v2026.0.95.
+
+---
+
+## 2. Objective 1 — Static Sitemaps
+
+### 2.1 Observed Symptom
+
+> "Sitemap takes too long to load"
+
+The current sitemap (`app/sitemap.ts`) uses `force-dynamic` to generate a fresh sitemap from PostgreSQL on every request. With ~50K games, the DB round-trip + serialization adds latency. While the spec at `docs/2026-05-05-sitemap-pwa-refinements-architectural-spec.md` correctly removed ISR caching (which was poisoning Googlebot with empty responses), the pendulum swung too far toward liveness at the cost of response time.
+
+### 2.2 Root Cause
+
+`force-dynamic` forces a live DB query per request. For a resource that Googlebot fetches daily and that changes gradually (a few hundred games added per sync cycle), this is over-engineered for freshness and under-engineered for speed.
+
+### 2.3 Architecture: Static Sitemap Generation Pipeline
+
+**Strategy**: Generate sitemap XML files **at build time** as a `postbuild` script, output them to the `public/` directory, and serve them as static files via Next.js. Use a **sitemap index** to split large catalogs into paginated child sitemaps (Google limit: 50,000 URLs or 50MB per file).
+
+#### 2.3.1 Generation Pipeline
+
+```
+Build Step Output
+─────────────────────────────────────────────────────────
+postbuild script (bun run build:sitemap)
+ ├── Query: SELECT id, updated_at FROM games
+ │ WHERE sync_status != 'failed' OR sync_status IS NULL
+ ├── Query: SELECT slug, created_at FROM hardware
+ ├── Build static page entries (/, /games, /devices, etc.)
+ ├── Split game entries into pages of 45,000 URLs
+ ├── Generate sitemap-index.xml → public/sitemap.xml
+ ├── Generate sitemap-1.xml → public/sitemap-1.xml
+ ├── Generate sitemap-2.xml → public/sitemap-2.xml
+ └── ... → public/sitemap-N.xml
+```
+
+#### 2.3.2 Sitemap Index Structure
+
+```xml
+
+
+
+ https://deckyvault.xyz/sitemap-static.xml
+ 2026-05-09
+
+
+ https://deckyvault.xyz/sitemap-games-1.xml
+ 2026-05-09
+
+
+ https://deckyvault.xyz/sitemap-games-2.xml
+ 2026-05-09
+
+
+```
+
+Where `sitemap-static.xml` contains the static pages (/, /games, /devices, /updates, /contact) and each `sitemap-games-N.xml` contains a page of game detail URLs.
+
+#### 2.3.3 Build Script Location
+
+**New file**: `scripts/build-sitemap.ts`
+- Runs as `bun run scripts/build-sitemap.ts` in the `postbuild` npm script
+- Uses the same Drizzle DB connection as the app
+- Must NOT import Next.js — pure Node/bun script with direct DB access
+- Reads `NEXT_PUBLIC_SITE_URL` from environment
+
+**package.json change**:
+```json
+"scripts": {
+ "build": "next build --webpack",
+ "postbuild": "bun run scripts/build-sitemap.ts"
+}
+```
+
+#### 2.3.4 Route Handling
+
+**`app/sitemap.ts` changes**:
+- Remove `force-dynamic` — this becomes a static route
+- Redirect `/sitemap.xml` to the static file: return an empty sitemap that Next.js ignores, OR
+- **Better approach**: Delete `app/sitemap.ts` entirely and rely on `public/sitemap.xml` being served as a static asset. Next.js serves `public/` files at the root path. `/sitemap.xml` → `public/sitemap.xml`.
+
+**Verification**: `public/sitemap.xml` takes precedence over `app/sitemap.ts` in Next.js. Delete `app/sitemap.ts` to avoid conflicts.
+
+#### 2.3.5 Staleness Mitigation
+
+**Constraint**: The sitemap is generated at build time. If games are added between builds, they won't appear in the sitemap until the next deployment.
+
+**Mitigation options** (choose one during implementation planning):
+
+| Option | Pros | Cons |
+|---|---|---|
+| **A. Rebuild on every deployment** (default) | Simple, uses existing deploy pipeline | Sitemap lags behind live data |
+| **B. Scheduled regeneration via cron** | Keeps sitemap fresh without full deploy | Requires Vercel Cron or cron-job service; adds moving part |
+| **C. Hybrid: build-time static pages + runtime dynamic game entries** | Static pages are fast; game entries stay fresh | Most complex; two generation mechanisms |
+
+**Recommendation**: Option A for now. Deployment frequency is high enough (multiple per week per changelog history) that staleness is acceptable. If Google Search Console reports indexing gaps, graduate to Option B.
+
+#### 2.3.6 Security & Rate Limiting
+
+- Sitemap files are public static assets — no auth, no rate limiting
+- No sensitive data in sitemaps (only URLs and modification dates)
+- DB credentials in the build script: use the same env vars as the app (`DATABASE_URL`), already available at build time
+
+### 2.4 Validation Checklist
+
+- [ ] `app/sitemap.ts` is deleted
+- [ ] `scripts/build-sitemap.ts` exists and runs without Next.js imports
+- [ ] `public/sitemap.xml` is a valid sitemap index
+- [ ] `public/sitemap-games-*.xml` files contain valid game URLs
+- [ ] `postbuild` script runs sitemap generation
+- [ ] Total XML output < 50MB per file, < 50,000 URLs per file
+- [ ] `https://deckyvault.xyz/sitemap.xml` serves instantly (no DB latency)
+- [ ] Existing `lib/sitemap/` directory is fully removed (deprecated in v2026.0.97)
+
+---
+
+## 3. Objective 2 — Data Visualization Dashboard
+
+### 3.1 Feature Scope
+
+A new **public-facing dashboard page** (`/dashboard`) with three visualization panels:
+
+| Panel | Data Source | Visualization |
+|---|---|---|
+| **Trending / Hot Games This Week** | Games with most benchmark submissions in last 7 days | Ranked list with sparkline charts |
+| **Best Performing New Releases** | Games released in last 30 days, ranked by avg FPS | Sortable table with FPS bars |
+| **Most Tested / Most Reported Games** | Top games by benchmark count + top games by report count | Dual-column leaderboard |
+
+### 3.2 Architecture
+
+#### 3.2.1 New Page Route
+
+**File**: `app/dashboard/page.tsx`
+- Public route — no auth required
+- SSR with `force-dynamic` (data is computed fresh per request)
+- Parallel data fetching for three panels
+
+#### 3.2.2 New API Endpoints
+
+**File**: `lib/api/dashboard-public.ts` (separate from existing `dashboard.ts` which is admin-only)
+
+```
+GET /api/dashboard-public/trending
+ Query: limit? (default 10)
+ Returns: { games: [{ id, title, capsuleImage, playabilityStatus, submissionCount, submissionsLast7Days }] }
+
+GET /api/dashboard-public/new-releases
+ Query: limit? (default 10), sort? (performance|popularity|recent)
+ Returns: { games: [{ id, title, capsuleImage, fpsAvg, releaseDate, benchmarkCount }] }
+
+GET /api/dashboard-public/most-tested
+ Query: limit? (default 20)
+ Returns: {
+ mostBenchmarked: [{ id, title, count }],
+ mostReported: [{ id, title, count }]
+ }
+```
+
+#### 3.2.3 Data Queries
+
+**Trending / Hot Games**:
+```sql
+SELECT g.id, g.title, g.capsule_image, g.playability_status,
+ COUNT(pe.id) FILTER (WHERE pe.created_at >= NOW() - INTERVAL '7 days') AS submissions_7d,
+ COUNT(pe.id) AS total_submissions
+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
+ORDER BY submissions_7d DESC
+LIMIT 10
+```
+
+**Best Performing New Releases**:
+```sql
+SELECT g.id, g.title, g.capsule_image, g.release_date,
+ AVG(pe.fps_avg) AS avg_fps,
+ COUNT(DISTINCT 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.release_date IS NOT NULL
+ AND g.release_date::date >= CURRENT_DATE - INTERVAL '30 days'
+GROUP BY g.id
+HAVING COUNT(DISTINCT pe.id) >= 3 -- minimum benchmark threshold
+ORDER BY avg_fps DESC
+LIMIT 10
+```
+
+**Most Tested / Most Reported**:
+```sql
+-- Most benchmarked
+SELECT g.id, g.title, COUNT(DISTINCT pe.id) AS 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
+GROUP BY g.id
+ORDER BY count DESC
+LIMIT 10
+
+-- Most reported
+SELECT g.id, g.title, COUNT(r.id) AS 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
+GROUP BY g.id
+ORDER BY count DESC
+LIMIT 10
+```
+
+#### 3.2.4 Visualization Components
+
+Reuse the existing ECharts infrastructure (`echarts-for-react` + custom chart components in `components/charts/`). New components:
+
+| Component | Chart Type | Data |
+|---|---|---|
+| `TrendingGamesList` | Ranked cards with mini area sparkline | Trending data |
+| `NewReleasesTable` | Sortable table with horizontal bars | New releases data |
+| `TestedReportedLeaderboard` | Dual-panel bar chart | Most tested + most reported |
+
+#### 3.2.5 Navigation Integration
+
+- Add `/dashboard` to the navbar (`components/navbar.tsx`)
+- Add to static sitemap entries
+- Add to JSON-LD (if applicable)
+
+### 3.3 Security
+
+- All data is public — no auth gates
+- Queries are read-only
+- Rate limiting applies via existing `rateLimit(60, 100)` middleware on `/api/*`
+
+---
+
+## 4. Objective 3 — Profile Photos
+
+### 4.1 Current State
+
+| Item | Status |
+|---|---|
+| `user.image` column | ✅ Exists (text, nullable) |
+| Profile settings page | ✅ Exists at `/profile` → Settings tab |
+| Profile header component | ✅ Exists (`components/profile/profile-header.tsx`) |
+| S3 upload infrastructure | ❌ Missing (`@aws-sdk/client-s3` is in dependencies but zero usage in `lib/`) |
+| Image upload UI | ❌ Missing |
+
+### 4.2 Architecture
+
+#### 4.2.1 S3 Upload Pipeline
+
+```
+Client Server S3
+────── ────── ──
+1. Select file
+2. POST /api/user/me/avatar →
+ (multipart/form-data) 3. Validate file type/size
+ 4. Generate unique filename
+ 5. Resize to 256×256 (sharp)
+ 6. Upload to S3 →
+ 7. Update user.image in DB
+ 8. Return new image URL ←
+9. Update UI with new URL
+```
+
+#### 4.2.2 Upload Endpoint
+
+**File**: New route in `lib/api/user.ts`
+
+```
+POST /api/user/me/avatar
+ Auth: Required (session)
+ Body: multipart/form-data { file: File }
+ Validates:
+ - Content-Type: image/jpeg, image/png, image/webp
+ - Max size: 5 MB
+ - File extension matches magic bytes (not just extension-based)
+ Process:
+ - Resize to 256×256 with sharp (maintains aspect ratio, center crop)
+ - Convert to WebP (80% quality) for consistent sizing
+ - Upload to S3: `avatars/{userId}.webp`
+ - Set Cache-Control: public, max-age=31536000, immutable
+ Response: { imageUrl: "https://..." }
+```
+
+#### 4.2.3 S3 Bucket Configuration
+
+**Bucket**: `deckyvault-uploads` (or existing bucket, to be confirmed)
+**Prefix**: `avatars/`
+**Lifecycle policy**:
+- No auto-deletion of avatars (they're tied to active users)
+- Enable versioning for rollback safety
+- CORS: Allow GET from `https://deckyvault.xyz` (for direct S3 reads if needed)
+- Public-read ACL on avatar objects OR presigned CloudFront URL
+
+**Environment variables** (already in `.env.example`, now put into active use):
+```
+AWS_ACCESS_KEY_ID
+AWS_SECRET_ACCESS_KEY
+AWS_S3_BUCKET
+AWS_REGION (new — needed for S3 client initialization)
+```
+
+#### 4.2.4 UI Integration
+
+**Files to modify**:
+- `components/profile/settings-profile-tab.tsx` — Add avatar upload section
+- `components/profile/profile-header.tsx` — Show current avatar (already fetches `image` field)
+
+**New component**: `components/profile/avatar-upload.tsx`
+- Drag-and-drop zone + file input
+- Preview before upload
+- Loading state during upload
+- Error display (file too large, wrong type)
+- Success toast
+
+#### 4.2.5 Security Constraints
+
+- **Auth required**: Only the authenticated user can upload their own avatar
+- **File type validation by magic bytes**: Don't trust `Content-Type` header or file extension
+- **Size limit enforcement**: Both client-side (UX) and server-side (security)
+- **No arbitrary path injection**: Filename is always `{userId}.webp` — not derived from user input
+- **S3 bucket policy**: Avatars are publicly readable but only the server IAM role can write
+- **No executable content**: Image processing strips EXIF/metadata and re-encodes
+
+### 4.3 Dependency Additions
+
+| Package | Purpose |
+|---|---|
+| `sharp` | Server-side image resizing (already in `trustedDependencies`) |
+
+Sharp is already listed in `ignoreScripts`/`trustedDependencies` but may need to be added to `dependencies` explicitly if not already.
+
+---
+
+## 5. Objective 4 — Game Details Layout
+
+### 5.1 Current Layout (game-page-client.tsx)
+
+```
+Section 1: Hero Header (cover image + title + badges + stats + external links)
+Section 2: Overview (Description | Details grid)
+Section 3: Device Selector + Filters
+Section 4: Presets (Pinned + Community)
+Section 5: Statistics Dashboard (charts)
+Section 6: Comments
+```
+
+### 5.2 Required Changes
+
+#### Change 1: System Requirements Expanded by Default
+
+**Current**: `const [showSystemReq, setShowSystemReq] = useState(false)` — collapsed.
+**Change**: `useState(true)` — expanded by default.
+**Rationale**: System requirements are critical for Steam Deck users deciding if a game can run. Hiding them behind a click reduces information density on a page where users are actively researching performance.
+
+#### Change 2: Move Details Inline with Game Info
+
+**Current layout (Section 2)**:
+```
+┌────────────────────────────┬──────────────┐
+│ About (description) │ Details │
+│ │ (meta grid) │
+│ System Reqs (collapsed) │ Platforms │
+│ │ Platform Sup │
+└────────────────────────────┴──────────────┘
+```
+
+**Target layout (Section 2 revised)**:
+```
+┌───────────────────────────────────────────────┐
+│ Game Info (inline) │
+│ ┌──────────┬──────────┬──────────┬──────────┐ │
+│ │ Developer│ Publisher│ Source │ Release │ │
+│ │ Platform │ Multiplay│ Price │ Metacrit │ │
+│ └──────────┴──────────┴──────────┴──────────┘ │
+│ │
+│ Platform Support (inline badges, not dropdown)│
+│ │
+├───────────────────────────────────────────────┤
+│ System Requirements (EXPANDED BY DEFAULT) │
+│ ┌──────────────────┬──────────────────────┐ │
+│ │ Minimum │ Recommended │ │
+│ └──────────────────┴──────────────────────┘ │
+├───────────────────────────────────────────────┤
+│ Description │
+└───────────────────────────────────────────────┘
+```
+
+**Key changes**:
+- Move metadata grid ABOVE description (currently to the right)
+- Make platform support an inline badge row (not a stacked list)
+- System requirements always visible, at full width, with both min/recommended side by side
+- Description pushed down as secondary content
+
+#### Change 3: Controller and Mobile Optimization
+
+**Controller (gamepad)**:
+- The `useGamepadNavigation` hook is already imported and used on the games listing page. Extend to game detail page.
+- Gamepad focus ring on preset cards, filter selects, comment section
+- L1/R1: tab between "Presets" and "Comments" sections (scroll to anchor)
+- Y button: toggle system requirements (even when expanded by default)
+- X button: "Add Benchmark" quick action
+
+**Mobile viewing**:
+- Stack the two-column layout (description + details) into single column on mobile
+- Preset cards: horizontal scroll with snap points (already implemented via flex overflow)
+- Ensure 44×44px touch targets on all interactive elements (filter selects, expand buttons, external links)
+- Viewport optimization already applied (viewport-fit=cover from v2026.0.97)
+
+### 5.3 Files Affected
+
+| File | Change |
+|---|---|
+| `app/game/[id]/game-page-client.tsx` | Layout restructure, system reqs default, gamepad hook import |
+| `app/game/[id]/page.tsx` | No significant changes (data fetching stays the same) |
+| `lib/hooks/use-gamepad-navigation.ts` | May need to add `onL1`, `onR1`, `onYButton` callbacks if not already present |
+
+---
+
+## 6. Objective 5 — Advanced Search & Filtering (Audit)
+
+### 6.1 Audit Result: Feature Already Shipped
+
+The advanced search and filtering system described in the objective was **fully implemented in v2026.0.95** and refined in v2026.0.97. A line-by-line audit confirms every requested capability exists:
+
+| Requested Feature | Implementation | Status | File |
+|---|---|---|---|
+| FPS range filter | Subquery + device scoping | ✅ Shipped | `lib/api/games-listing.ts:95-108` |
+| Device filter | Platform support + benchmark join | ✅ Shipped | `lib/api/games-listing.ts:68-91` |
+| FSR support filter | Upscaler type subquery | ✅ Shipped | `lib/api/games-listing.ts:111-123` |
+| Proton/Native filter | Proton status subquery | ✅ Shipped | `lib/api/games-listing.ts:126-142` |
+| Anti-cheat filter | Anti-cheat status subquery | ✅ Shipped | `lib/api/games-listing.ts:145-159` |
+| Playability status filter | Direct WHERE clause | ✅ Shipped | `lib/api/games-listing.ts:163-164` |
+| Steam review min % | Direct WHERE clause | ✅ Shipped | `lib/api/games-listing.ts:168` |
+| Free-to-play filter | Direct WHERE clause | ✅ Shipped | `lib/api/games-listing.ts:173` |
+| Multiplayer filter | Direct WHERE clause | ✅ Shipped | `lib/api/games-listing.ts:178-182` |
+| Sorting: Performance | Subquery ORDER BY AVG(fps_avg) | ✅ Shipped | `lib/api/games-listing.ts:202-222` |
+| Sorting: Popularity | ORDER BY recommendations_total | ✅ Shipped | `lib/api/games-listing.ts:199` |
+| Sorting: Release Date | ORDER BY release_date | ✅ Shipped | `lib/api/games-listing.ts:200` |
+| Sorting: Recency | ORDER BY created_at (default) | ✅ Shipped | `lib/api/games-listing.ts:224` |
+| Saved/bookmarked filters | CRUD API + component | ✅ Shipped | `lib/api/saved-filters.ts` + `components/saved-filters.tsx` |
+| URL-synced filter state | URLSearchParams + router.replace | ✅ Shipped | `app/games/games-page-client.tsx` |
+| Multi-genre (OR logic) | Comma-separated with @> jsonb OR | ✅ Shipped | `lib/api/games-listing.ts:60-66` |
+| Infinite scroll | IntersectionObserver | ✅ Shipped | `app/games/games-page-client.tsx` |
+
+### 6.2 Gap: Genre Multi-Select Visual Polish
+
+**Minor UX gap**: When loading a saved filter with multiple genres, the chips display correctly but there's no visual indication of which genres were auto-selected from the saved filter vs manually clicked. This is below the threshold for a dedicated objective — address as part of saved filters loading interaction if desired.
+
+### 6.3 Recommendation
+
+**No implementation work required for this objective.** Mark as verified/complete. If the user observes specific filter behavior they find incorrect, that's a bug report, not a feature request.
+
+---
+
+## 7. Objective 6 — S3 Storage Tracking & Optimization
+
+### 7.1 Current State
+
+| Item | Status |
+|---|---|
+| `@aws-sdk/client-s3` dependency | ✅ Installed (v3.1036.0) |
+| S3 env vars in `.env.example` | ✅ Present but unused |
+| S3 client usage in codebase | ❌ Zero imports anywhere in `lib/` |
+| Cron job mechanism | ❌ None exists |
+| S3 lifecycle management | ❌ None |
+
+### 7.2 Architecture
+
+#### 7.2.1 S3 Service Module
+
+**New file**: `lib/storage/s3.ts`
+- Singleton S3 client factory
+- Operations:
+ - `uploadAvatar(userId: string, buffer: Buffer): Promise` — used by Objective 3
+ - `deleteAvatar(userId: string): Promise`
+ - `listOrphanedObjects(prefix: string, referencedKeys: Set): Promise`
+ - `deleteObjects(keys: string[]): Promise`
+ - `getBucketStats(): Promise<{ objectCount: number, totalSizeBytes: number }>`
+
+**Design principle**: All S3 interactions go through this module. No direct `S3Client` usage in API routes or scripts.
+
+#### 7.2.2 Daily Cron Job (Unified)
+
+**Design**: A single daily cron endpoint (`/api/cron/daily`) that handles all recurring daily maintenance tasks. This avoids endpoint fragmentation — new daily jobs are added to this same handler in the future.
+
+**File**: `app/api/cron/daily/route.ts`
+```typescript
+export const dynamic = "force-dynamic"
+export async function GET(request: Request) {
+ // Verify cron secret
+ const authHeader = request.headers.get("authorization")
+ if (authHeader !== `Bearer ${process.env.CRON_SECRET}`) {
+ return new Response("Unauthorized", { status: 401 })
+ }
+
+ const results: Record = {}
+
+ // ── Job 1: S3 Avatar Orphan Cleanup ──────────────────────────
+ // 1. Query all referenced avatar keys from user.image column
+ // 2. List all objects in avatars/ prefix
+ // 3. Delete any S3 object whose key is NOT referenced by a user
+ // 4. Log results (structured JSON)
+ results.s3Cleanup = { deleted: count }
+
+ // Future daily jobs go here ──────────────────────────────────
+ // results.someOtherJob = { ... }
+
+ console.info(JSON.stringify({
+ event: "cron_daily_completed",
+ ...results,
+ timestamp: new Date().toISOString(),
+ }))
+
+ return Response.json(results)
+}
+```
+
+**vercel.json** (or Vercel dashboard):
+```json
+{
+ "crons": [
+ {
+ "path": "/api/cron/daily",
+ "schedule": "0 3 * * *"
+ }
+ ]
+}
+```
+
+Schedule: 3:00 AM UTC daily (low-traffic window).
+**CRON_SECRET**: New env var — a random 64-char string used to authenticate cron requests.
+
+#### 7.2.3 S3 Lifecycle Policy (AWS-Side)
+
+**Configure on the S3 bucket itself** (not in application code):
+- `avatars/`: No auto-deletion (avatars are persistent)
+- `temp/`: Expire after 24 hours (for temporary uploads if needed later)
+- Enable Intelligent-Tiering for cost optimization on `avatars/`
+
+#### 7.2.4 Bucket Inventory & Monitoring
+
+**New API endpoint** (admin-only, for the manage dashboard):
+
+```
+GET /api/admin/storage/stats
+ Auth: Admin required
+ Returns:
+ {
+ bucket: "deckyvault-uploads",
+ objectCount: 1234,
+ totalSizeBytes: 52428800,
+ avatarCount: 567,
+ orphanedCount: 3,
+ lastCleanupAt: "2026-05-09T03:00:00Z"
+ }
+```
+
+Add a storage card to the manage dashboard (`components/manage/` or the dashboard page).
+
+### 7.3 Security
+
+- **Cron endpoint** (`/api/cron/daily`): Protected by `CRON_SECRET` bearer token. Rejects all requests without it. Single endpoint for all daily maintenance jobs.
+- **Admin storage endpoint**: Protected by `requireAdmin` auth guard.
+- **S3 bucket**: Server-side IAM role with limited permissions (PutObject, DeleteObject, ListObjects on specific prefixes only). No wildcard permissions.
+- **Public access**: Only `avatars/` prefix is publicly readable. All other prefixes are private.
+- **No user-accessible S3 endpoints**: Users never interact with S3 directly. All uploads go through the server.
+
+### 7.4 No Existing Cron — Explicit Decision
+
+A search of the codebase confirms there is **no existing cron job mechanism** — no `vercel.json` crons config, no cron routes, no scheduled tasks. The cron infrastructure is created fresh in this objective.
+
+---
+
+## 8. Objective 7 — Changelog & Version Bump
+
+### 8.1 Procedure
+
+Follow the established pattern from CHANGELOG.md (Keep a Changelog format) and content/updates/ (frontmatter + markdown).
+
+#### 8.1.1 Version Bump
+
+**`package.json`**: `"version": "2026.0.971"` → `"2026.0.98"`
+
+#### 8.1.2 CHANGELOG.md Entry
+
+Append before the existing `## [2026.0.971]` entry:
+
+```markdown
+## [2026.0.98] - 2026-05-XX
+
+### Added
+- Static sitemap generation pipeline: sitemaps are now pre-built at deploy time via postbuild script, eliminating DB latency on every crawl request
+- Public data visualization dashboard at `/dashboard` with trending games, best performing new releases, and most tested/reported leaderboards
+- Profile photo upload with drag-and-drop, image resizing, and S3 storage (avatars)
+- S3 storage infrastructure: upload module, daily orphan cleanup cron job, admin storage monitoring
+
+### Changed
+- Game details page: system requirements are now expanded by default; metadata grid moved inline above description
+- Platform support badges now display inline on game detail pages instead of stacked list
+- Gamepad navigation extended to game detail pages (L1/R1 for section switching, Y for system reqs toggle)
+- Mobile layout for game detail pages: metadata stacks to single column below 768px
+
+### Fixed
+- Advanced search & filtering audit confirmed all v2026.0.95 filter dimensions are operational (no code changes needed)
+
+### Technical
+- New `scripts/build-sitemap.ts` for static sitemap generation
+- New `app/api/cron/daily/` unified Vercel cron route for all daily maintenance (S3 orphan cleanup + future jobs)
+- New `lib/storage/s3.ts` service module for all S3 interactions
+- New `lib/api/dashboard-public.ts` for public dashboard data queries
+- `app/sitemap.ts` removed — replaced by static `public/sitemap.xml`
+- `lib/sitemap/` directory fully removed
+- Added `sharp` to dependencies for avatar image processing
+```
+
+#### 8.1.3 New Update News Entry
+
+**File**: `content/updates/2026-05-XX-v2026.0.98.md`
+
+```markdown
+---
+title: "Data Dashboard, Profile Photos & Performance Improvements"
+date: "2026-05-XX"
+version: "2026.0.98"
+summary: "New public data dashboard, profile photo uploads, faster sitemaps, game details UX improvements, and S3 storage infrastructure."
+---
+
+# Hello everyone!
+
+A feature-packed update with several quality-of-life improvements and new tools for the community.
+
+## Data Dashboard
+
+Head over to the new [Dashboard](https://deckyvault.xyz/dashboard) to see what's trending this week, which new releases are performing best on Steam Deck, and which games the community is testing most actively.
+
+## Profile Photos
+
+You can now upload a profile photo! Visit your [Profile Settings](https://deckyvault.xyz/profile) and drag-and-drop an image to personalize your account.
+
+## Game Details Improvements
+
+System requirements are now shown upfront on game detail pages — no more clicking to expand. The page layout has been streamlined with metadata inline with game info, and gamepad navigation works across the entire page.
+
+## Behind the Scenes
+
+- Sitemaps are now pre-generated at build time for faster crawling
+- S3 storage infrastructure for uploads with daily automated cleanup
+- Mobile-optimized game detail pages
+
+As always, if you run into any issues, feel free to visit the [contact page](https://deckyvault.xyz/contact) to submit a report!
+
+> Adrian Bonpin
+```
+
+**Date replacement**: Replace `2026-05-XX` with the actual ship date in both the CHANGELOG and the update `.md` file.
+
+### 8.2 Version Bump Constraint
+
+**Not a major version change**: The version scheme uses `YYYY.MINOR.PATCH` format (e.g., `2026.0.98`). The user explicitly stated "not major yet" — bump the patch from `.971` → `.98`.
+
+---
+
+## 9. Architecture Boundaries — Cross-Cutting
+
+### 9.1 Performance Budget
+
+| Metric | Budget | Rationale |
+|---|---|---|
+| Static sitemap generation | < 30s | Build step; must not block deployment |
+| `/dashboard` TTFB (p95) | < 2s | Multiple subqueries; cache what's reasonable |
+| Avatar upload response time | < 3s | Includes resize + S3 upload |
+| S3 cleanup cron duration | < 60s | Single-digit orphaned objects expected |
+| Game details page LCP | < 2.5s | Presets + charts load after initial render |
+
+### 9.2 Dependency Additions
+
+| Package | Purpose | Version Constraint |
+|---|---|---|
+| `sharp` | Server-side image resizing for avatars | Already in trustedDependencies; ensure in `dependencies` |
+
+No other new dependencies. Charts use existing `echarts-for-react`. S3 uses existing `@aws-sdk/client-s3`. File uploads use native `Request.formData()`.
+
+### 9.3 Environment Variables (New)
+
+| Variable | Purpose | Where Used |
+|---|---|---|
+| `CRON_SECRET` | Bearer token for cron job auth | `app/api/cron/daily/route.ts` |
+| `AWS_REGION` | S3 bucket region | `lib/storage/s3.ts` |
+
+### 9.4 Files Affected (Summary)
+
+| File | Change Type | Objective |
+|---|---|---|
+| `app/sitemap.ts` | **Delete** — replaced by static files | Sitemap |
+| `lib/sitemap/` | **Delete** — deprecated, fully removed | Sitemap |
+| `scripts/build-sitemap.ts` | **Create** — postbuild sitemap generator | Sitemap |
+| `public/sitemap.xml` | **Create** (at build time) — sitemap index | Sitemap |
+| `public/sitemap-games-*.xml` | **Create** (at build time) — paginated game entries | Sitemap |
+| `app/dashboard/page.tsx` | **Create** — new public dashboard page | Dashboard |
+| `lib/api/dashboard-public.ts` | **Create** — dashboard data aggregations | Dashboard |
+| `components/charts/TrendingGamesList.tsx` | **Create** — trending games ranking | Dashboard |
+| `components/charts/NewReleasesTable.tsx` | **Create** — new releases leaderboard | Dashboard |
+| `components/charts/TestedReportedLeaderboard.tsx` | **Create** — dual leaderboard chart | Dashboard |
+| `lib/storage/s3.ts` | **Create** — S3 service module | Storage + Photos |
+| `lib/api/user.ts` | **Modify** — add `POST /api/user/me/avatar` | Photos |
+| `components/profile/avatar-upload.tsx` | **Create** — drag-and-drop upload UI | Photos |
+| `components/profile/settings-profile-tab.tsx` | **Modify** — integrate avatar upload section | Photos |
+| `components/profile/profile-header.tsx` | **Modify** — show uploaded avatar | Photos |
+| `app/game/[id]/game-page-client.tsx` | **Modify** — layout restructure, system reqs default, gamepad hook | Game Details |
+| `app/api/cron/daily/route.ts` | **Create** — unified Vercel daily cron job (S3 cleanup + future jobs) | Storage |
+| `vercel.json` | **Create/Modify** — add cron schedule | Storage |
+| `CHANGELOG.md` | **Modify** — append v2026.0.98 | Changelog |
+| `content/updates/2026-05-XX-v2026.0.98.md` | **Create** — update news entry | Changelog |
+| `package.json` | **Modify** — version bump, postbuild script | Changelog + Sitemap |
+| `components/navbar.tsx` | **Modify** — add `/dashboard` link | Dashboard |
+| `.env.example` | **Modify** — add `CRON_SECRET`, `AWS_REGION` | Storage + Photos |
+
+### 9.5 Files NOT Affected
+
+- `lib/db/schema/*` — No schema changes required (all data exists in current tables)
+- `lib/steam/sync.ts` — No sync pipeline changes
+- `lib/api/games-listing.ts` — Filters already complete (Objective 5 audit)
+- `app/games/page.tsx` and `games-page-client.tsx` — Already feature-complete
+- `app/search/page.tsx` — Search already complete
+- `app/api/auth/*` — No auth changes
+- `app/(auth)/*` — No auth page changes
+- `app/(manage)/*` — Manage dashboard stays as-is
+- `lib/api/dashboard.ts` — Admin dashboard unchanged
+
+---
+
+## 10. Risk Register
+
+| # | Risk | Probability | Impact | Mitigation |
+|---|---|---|---|---|
+| R1 | Static sitemap grows stale between deployments | Medium | Low | Current deploy cadence is 2-4x per week. Acceptable. Add scheduled rebuild only if needed. |
+| R2 | Sitemap with >50K games exceeds single-file limit | Low | Medium | Pagination built into the generator from day one (45K URLs per file). |
+| R3 | Sharp fails to install on Vercel (native dependency) | Medium | Medium | Sharp is already in `trustedDependencies`. Test in preview deploy. Fallback: skip resize, upload original (with size validation). |
+| R4 | S3 cron accidentally deletes active avatars | Low | High | Cron compares against live `user.image` column values. Only deletes objects with zero references. Thoroughly tested with dry-run mode first. |
+| R5 | Dashboard queries cause DB load at peak times | Low | Low | Queries target small subsets (7-day window, 30-day window with HAVING >= 3). Use existing DB indexes. |
+| R6 | Game details layout change breaks existing user muscle memory | Medium | Low | Keep all existing data visible. Only reorder, not remove. System reqs expanded = more information, not less. |
+| R7 | Vercel Cron is not available on the current plan | Low | Medium | Vercel Hobby has 1 cron job with daily frequency. If on Pro, 2 crons. Fallback: GitHub Actions scheduled workflow calling the cron endpoint. |
+
+---
+
+## 11. Out of Scope (Explicitly)
+
+- **Sitemap real-time updates**: Static generation only. No hybrid or event-driven sitemap updates.
+- **Dashboard personalization**: Public dashboard is global, not per-user. No "my dashboard" yet.
+- **Multiple avatar sizes**: Single 256×256 size. No responsive srcSet.
+- **Avatar crop UI**: Center-crop only. No user-customizable crop region.
+- **Game detail page full redesign**: Layout changes only. No new data, no removed features.
+- **New advanced filter dimensions**: The 12 existing filter dimensions are complete. No new filters.
+- **S3 bucket migration**: Using existing or new bucket — no data migration from other storage.
+- **User-uploaded game screenshots**: Avatars only. Game media stays on Steam CDN/SteamGridDB.
+- **Major version bump**: Explicitly "not major yet."
+
+---
+
+## 12. Validation Checklist (Post-Implementation)
+
+### Sitemap
+- [ ] `app/sitemap.ts` no longer exists
+- [ ] `https://deckyvault.xyz/sitemap.xml` returns valid sitemap index XML
+- [ ] Each child sitemap has ≤ 45,000 URLs and < 50MB
+- [ ] Response time is < 50ms (static file serve, no DB query)
+- [ ] Google Search Console accepts and processes the sitemap
+- [ ] Sitemap regenerates on every `bun run build`
+
+### Dashboard
+- [ ] `/dashboard` page loads with all three panels
+- [ ] Trending panel shows games with recent submissions
+- [ ] New releases panel shows games from last 30 days with ≥ 3 benchmarks
+- [ ] Most tested/most reported panels show correct counts
+- [ ] Dashboard accessible from navbar
+- [ ] Dashboard included in static sitemap entries
+
+### Profile Photos
+- [ ] Avatar upload accepts JPG, PNG, WebP files
+- [ ] Files > 5MB are rejected
+- [ ] Non-image files are rejected (magic byte check)
+- [ ] Uploaded avatar appears in profile header
+- [ ] Uploaded avatar appears in settings page preview
+- [ ] S3 bucket contains the uploaded avatar webp
+
+### Game Details
+- [ ] System requirements are visible without clicking
+- [ ] Metadata grid appears above description
+- [ ] Platform support is inline badges, not stacked list
+- [ ] Gamepad D-pad navigates preset cards
+- [ ] Gamepad L1/R1 switches between sections
+- [ ] Mobile layout (≤ 768px) stacks to single column
+
+### S3 Storage
+- [ ] Cron endpoint (`/api/cron/daily`) is protected by `CRON_SECRET`
+- [ ] Cron runs daily at 3:00 AM UTC
+- [ ] Orphan cleanup correctly identifies unreferenced objects
+- [ ] Admin storage stats endpoint returns bucket metrics
+- [ ] S3 lifecycle policies configured on the bucket
+- [ ] Route uses extensible structure for adding future daily jobs
+
+### Changelog
+- [ ] `package.json` version is `2026.0.98`
+- [ ] `CHANGELOG.md` has v2026.0.98 entry
+- [ ] `content/updates/2026-05-XX-v2026.0.98.md` exists
+- [ ] Update appears on `/updates` page listing
+- [ ] Dates are the actual ship date (not `2026-05-XX`)
diff --git a/drizzle/0022_married_zeigeist.sql b/drizzle/0022_married_zeigeist.sql
new file mode 100644
index 0000000..b6071f4
--- /dev/null
+++ b/drizzle/0022_married_zeigeist.sql
@@ -0,0 +1,17 @@
+CREATE TABLE "storage_objects" (
+ "id" text PRIMARY KEY NOT NULL,
+ "key" text NOT NULL,
+ "bucket" text NOT NULL,
+ "size" integer NOT NULL,
+ "mime_type" text NOT NULL,
+ "entity_type" text NOT NULL,
+ "entity_id" text,
+ "uploaded_by" text NOT NULL,
+ "created_at" timestamp DEFAULT now() NOT NULL,
+ "last_accessed_at" timestamp,
+ "is_orphaned" boolean DEFAULT false NOT NULL
+);
+--> statement-breakpoint
+ALTER TABLE "storage_objects" ADD CONSTRAINT "storage_objects_uploaded_by_user_id_fk" FOREIGN KEY ("uploaded_by") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
+CREATE INDEX "storage_entity_idx" ON "storage_objects" USING btree ("entity_type","entity_id");--> statement-breakpoint
+CREATE INDEX "storage_key_idx" ON "storage_objects" USING btree ("key");
\ No newline at end of file
diff --git a/drizzle/meta/0022_snapshot.json b/drizzle/meta/0022_snapshot.json
new file mode 100644
index 0000000..fc37700
--- /dev/null
+++ b/drizzle/meta/0022_snapshot.json
@@ -0,0 +1,2210 @@
+{
+ "id": "89a9eb34-2d12-4c65-b41a-b905085c87ab",
+ "prevId": "4f1f05fb-97c0-4941-be17-5b9b916768ba",
+ "version": "7",
+ "dialect": "postgresql",
+ "tables": {
+ "public.account": {
+ "name": "account",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "account_id": {
+ "name": "account_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "provider_id": {
+ "name": "provider_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "access_token": {
+ "name": "access_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "refresh_token": {
+ "name": "refresh_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "id_token": {
+ "name": "id_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "access_token_expires_at": {
+ "name": "access_token_expires_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "refresh_token_expires_at": {
+ "name": "refresh_token_expires_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "scope": {
+ "name": "scope",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "password": {
+ "name": "password",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {
+ "account_userId_idx": {
+ "name": "account_userId_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "account_user_id_user_id_fk": {
+ "name": "account_user_id_user_id_fk",
+ "tableFrom": "account",
+ "tableTo": "user",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.passkey": {
+ "name": "passkey",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "public_key": {
+ "name": "public_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "credential_id": {
+ "name": "credential_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "counter": {
+ "name": "counter",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "device_type": {
+ "name": "device_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "backed_up": {
+ "name": "backed_up",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "transports": {
+ "name": "transports",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "aaguid": {
+ "name": "aaguid",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {
+ "passkey_userId_idx": {
+ "name": "passkey_userId_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "passkey_credentialID_idx": {
+ "name": "passkey_credentialID_idx",
+ "columns": [
+ {
+ "expression": "credential_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "passkey_user_id_user_id_fk": {
+ "name": "passkey_user_id_user_id_fk",
+ "tableFrom": "passkey",
+ "tableTo": "user",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.session": {
+ "name": "session",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "token": {
+ "name": "token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "ip_address": {
+ "name": "ip_address",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "user_agent": {
+ "name": "user_agent",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "impersonated_by": {
+ "name": "impersonated_by",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {
+ "session_userId_idx": {
+ "name": "session_userId_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "session_user_id_user_id_fk": {
+ "name": "session_user_id_user_id_fk",
+ "tableFrom": "session",
+ "tableTo": "user",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "session_token_unique": {
+ "name": "session_token_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "token"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.user": {
+ "name": "user",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "email": {
+ "name": "email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "email_verified": {
+ "name": "email_verified",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "image": {
+ "name": "image",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "role": {
+ "name": "role",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "banned": {
+ "name": "banned",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": false,
+ "default": false
+ },
+ "ban_reason": {
+ "name": "ban_reason",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "ban_expires": {
+ "name": "ban_expires",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "last_login_method": {
+ "name": "last_login_method",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "user_email_unique": {
+ "name": "user_email_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "email"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.verification": {
+ "name": "verification",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "identifier": {
+ "name": "identifier",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "value": {
+ "name": "value",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "verification_identifier_idx": {
+ "name": "verification_identifier_idx",
+ "columns": [
+ {
+ "expression": "identifier",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.community_suggestions": {
+ "name": "community_suggestions",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "game_id": {
+ "name": "game_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "field_name": {
+ "name": "field_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "current_value": {
+ "name": "current_value",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "proposed_value": {
+ "name": "proposed_value",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "reason": {
+ "name": "reason",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "status": {
+ "name": "status",
+ "type": "suggestion_status",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'pending'"
+ },
+ "reviewed_by": {
+ "name": "reviewed_by",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "reviewed_at": {
+ "name": "reviewed_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "review_note": {
+ "name": "review_note",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "community_suggestions_game_field_user": {
+ "name": "community_suggestions_game_field_user",
+ "columns": [
+ {
+ "expression": "game_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "field_name",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "community_suggestions_game_id_games_id_fk": {
+ "name": "community_suggestions_game_id_games_id_fk",
+ "tableFrom": "community_suggestions",
+ "tableTo": "games",
+ "columnsFrom": [
+ "game_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "community_suggestions_user_id_user_id_fk": {
+ "name": "community_suggestions_user_id_user_id_fk",
+ "tableFrom": "community_suggestions",
+ "tableTo": "user",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "community_suggestions_reviewed_by_user_id_fk": {
+ "name": "community_suggestions_reviewed_by_user_id_fk",
+ "tableFrom": "community_suggestions",
+ "tableTo": "user",
+ "columnsFrom": [
+ "reviewed_by"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.game_comments": {
+ "name": "game_comments",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "game_id": {
+ "name": "game_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "parent_id": {
+ "name": "parent_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "content": {
+ "name": "content",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "upvotes": {
+ "name": "upvotes",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "is_removed": {
+ "name": "is_removed",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "comments_game_created_idx": {
+ "name": "comments_game_created_idx",
+ "columns": [
+ {
+ "expression": "game_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "created_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "comments_parent_idx": {
+ "name": "comments_parent_idx",
+ "columns": [
+ {
+ "expression": "parent_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "game_comments_game_id_games_id_fk": {
+ "name": "game_comments_game_id_games_id_fk",
+ "tableFrom": "game_comments",
+ "tableTo": "games",
+ "columnsFrom": [
+ "game_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "game_comments_user_id_user_id_fk": {
+ "name": "game_comments_user_id_user_id_fk",
+ "tableFrom": "game_comments",
+ "tableTo": "user",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "game_comments_parent_id_game_comments_id_fk": {
+ "name": "game_comments_parent_id_game_comments_id_fk",
+ "tableFrom": "game_comments",
+ "tableTo": "game_comments",
+ "columnsFrom": [
+ "parent_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.game_platform_support": {
+ "name": "game_platform_support",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "game_id": {
+ "name": "game_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "hardware_slug": {
+ "name": "hardware_slug",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "is_supported": {
+ "name": "is_supported",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "proton_status": {
+ "name": "proton_status",
+ "type": "proton_status",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'unknown'"
+ },
+ "anti_cheat_relevant": {
+ "name": "anti_cheat_relevant",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "anti_cheat_name": {
+ "name": "anti_cheat_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "anti_cheat_version": {
+ "name": "anti_cheat_version",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "anti_cheat_status": {
+ "name": "anti_cheat_status",
+ "type": "anti_cheat_status",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'unknown'"
+ },
+ "playability_status": {
+ "name": "playability_status",
+ "type": "playability_status",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'unknown'"
+ },
+ "playability_override": {
+ "name": "playability_override",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "playability_calculated_at": {
+ "name": "playability_calculated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "game_platform_support_game_id_games_id_fk": {
+ "name": "game_platform_support_game_id_games_id_fk",
+ "tableFrom": "game_platform_support",
+ "tableTo": "games",
+ "columnsFrom": [
+ "game_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "game_platform_support_hardware_slug_hardware_slug_fk": {
+ "name": "game_platform_support_hardware_slug_hardware_slug_fk",
+ "tableFrom": "game_platform_support",
+ "tableTo": "hardware",
+ "columnsFrom": [
+ "hardware_slug"
+ ],
+ "columnsTo": [
+ "slug"
+ ],
+ "onDelete": "restrict",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "game_hardware_unique": {
+ "name": "game_hardware_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "game_id",
+ "hardware_slug"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.game_versions": {
+ "name": "game_versions",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "game_id": {
+ "name": "game_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "build_id": {
+ "name": "build_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "version_string": {
+ "name": "version_string",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "is_latest": {
+ "name": "is_latest",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "game_versions_game_id_games_id_fk": {
+ "name": "game_versions_game_id_games_id_fk",
+ "tableFrom": "game_versions",
+ "tableTo": "games",
+ "columnsFrom": [
+ "game_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "game_build_unique": {
+ "name": "game_build_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "game_id",
+ "build_id"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.games": {
+ "name": "games",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "steam_app_id": {
+ "name": "steam_app_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "source": {
+ "name": "source",
+ "type": "game_source",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'steam'"
+ },
+ "title": {
+ "name": "title",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "publisher": {
+ "name": "publisher",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "developer": {
+ "name": "developer",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "genres": {
+ "name": "genres",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "header_image": {
+ "name": "header_image",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "capsule_image": {
+ "name": "capsule_image",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "store_url": {
+ "name": "store_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "online_multiplayer_status": {
+ "name": "online_multiplayer_status",
+ "type": "online_multiplayer_status",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'unknown'"
+ },
+ "system_requirements": {
+ "name": "system_requirements",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "metacritic_score": {
+ "name": "metacritic_score",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "metacritic_url": {
+ "name": "metacritic_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "recommendations_total": {
+ "name": "recommendations_total",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "steam_review_score": {
+ "name": "steam_review_score",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "steam_review_sentiment": {
+ "name": "steam_review_sentiment",
+ "type": "steam_review_sentiment",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "steam_review_count": {
+ "name": "steam_review_count",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "playability_status": {
+ "name": "playability_status",
+ "type": "playability_status",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'unknown'"
+ },
+ "playability_override": {
+ "name": "playability_override",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "playability_calculated_at": {
+ "name": "playability_calculated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "price_current": {
+ "name": "price_current",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "price_initial": {
+ "name": "price_initial",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "price_currency": {
+ "name": "price_currency",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "is_free": {
+ "name": "is_free",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "release_date": {
+ "name": "release_date",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "categories": {
+ "name": "categories",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "platforms": {
+ "name": "platforms",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "last_sync": {
+ "name": "last_sync",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "sync_status": {
+ "name": "sync_status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "default": "'pending'"
+ },
+ "sync_error": {
+ "name": "sync_error",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "sync_retry_count": {
+ "name": "sync_retry_count",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "default": 0
+ },
+ "sync_next_retry": {
+ "name": "sync_next_retry",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_by": {
+ "name": "created_by",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "games_source_idx": {
+ "name": "games_source_idx",
+ "columns": [
+ {
+ "expression": "source",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "games_steam_app_id_unique": {
+ "name": "games_steam_app_id_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "steam_app_id"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.hardware": {
+ "name": "hardware",
+ "schema": "",
+ "columns": {
+ "slug": {
+ "name": "slug",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "device_type": {
+ "name": "device_type",
+ "type": "device_type",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "image": {
+ "name": "image",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "sort_order": {
+ "name": "sort_order",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.performance_entries": {
+ "name": "performance_entries",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "version_id": {
+ "name": "version_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "hardware_slug": {
+ "name": "hardware_slug",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "fps_avg": {
+ "name": "fps_avg",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "fps_low": {
+ "name": "fps_low",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "fps_one_percent_low": {
+ "name": "fps_one_percent_low",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "fps_high": {
+ "name": "fps_high",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "proton_version": {
+ "name": "proton_version",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "os_version": {
+ "name": "os_version",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "upscaler_type": {
+ "name": "upscaler_type",
+ "type": "upscaler_type",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'none'"
+ },
+ "upscaler_version": {
+ "name": "upscaler_version",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "frame_gen_method": {
+ "name": "frame_gen_method",
+ "type": "frame_gen_method",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'none'"
+ },
+ "load_time_ssd": {
+ "name": "load_time_ssd",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "load_time_sd": {
+ "name": "load_time_sd",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "launch_options": {
+ "name": "launch_options",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "settings_json": {
+ "name": "settings_json",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "user_notes": {
+ "name": "user_notes",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "estimated_battery_min": {
+ "name": "estimated_battery_min",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "custom_system": {
+ "name": "custom_system",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "is_removed": {
+ "name": "is_removed",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "is_pinned": {
+ "name": "is_pinned",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "pinned_at": {
+ "name": "pinned_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "removed_reason": {
+ "name": "removed_reason",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "upvotes": {
+ "name": "upvotes",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "downvotes": {
+ "name": "downvotes",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "verified_at": {
+ "name": "verified_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "verified_by": {
+ "name": "verified_by",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "perf_hardware_upscaler_idx": {
+ "name": "perf_hardware_upscaler_idx",
+ "columns": [
+ {
+ "expression": "hardware_slug",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "upscaler_type",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "perf_version_idx": {
+ "name": "perf_version_idx",
+ "columns": [
+ {
+ "expression": "version_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "perf_user_idx": {
+ "name": "perf_user_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "performance_entries_version_id_game_versions_id_fk": {
+ "name": "performance_entries_version_id_game_versions_id_fk",
+ "tableFrom": "performance_entries",
+ "tableTo": "game_versions",
+ "columnsFrom": [
+ "version_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "performance_entries_hardware_slug_hardware_slug_fk": {
+ "name": "performance_entries_hardware_slug_hardware_slug_fk",
+ "tableFrom": "performance_entries",
+ "tableTo": "hardware",
+ "columnsFrom": [
+ "hardware_slug"
+ ],
+ "columnsTo": [
+ "slug"
+ ],
+ "onDelete": "restrict",
+ "onUpdate": "no action"
+ },
+ "performance_entries_user_id_user_id_fk": {
+ "name": "performance_entries_user_id_user_id_fk",
+ "tableFrom": "performance_entries",
+ "tableTo": "user",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "performance_entries_verified_by_user_id_fk": {
+ "name": "performance_entries_verified_by_user_id_fk",
+ "tableFrom": "performance_entries",
+ "tableTo": "user",
+ "columnsFrom": [
+ "verified_by"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.saved_games": {
+ "name": "saved_games",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "game_id": {
+ "name": "game_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "saved_games_user_id_user_id_fk": {
+ "name": "saved_games_user_id_user_id_fk",
+ "tableFrom": "saved_games",
+ "tableTo": "user",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "saved_games_game_id_games_id_fk": {
+ "name": "saved_games_game_id_games_id_fk",
+ "tableFrom": "saved_games",
+ "tableTo": "games",
+ "columnsFrom": [
+ "game_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "saved_games_user_game_unique": {
+ "name": "saved_games_user_game_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "user_id",
+ "game_id"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.reports": {
+ "name": "reports",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "entry_id": {
+ "name": "entry_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "reporter_id": {
+ "name": "reporter_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "reason": {
+ "name": "reason",
+ "type": "report_reason",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "details": {
+ "name": "details",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "status": {
+ "name": "status",
+ "type": "report_status",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'open'"
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "reports_entry_reporter_unique": {
+ "name": "reports_entry_reporter_unique",
+ "columns": [
+ {
+ "expression": "entry_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "reporter_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "reports_entry_id_performance_entries_id_fk": {
+ "name": "reports_entry_id_performance_entries_id_fk",
+ "tableFrom": "reports",
+ "tableTo": "performance_entries",
+ "columnsFrom": [
+ "entry_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "reports_reporter_id_user_id_fk": {
+ "name": "reports_reporter_id_user_id_fk",
+ "tableFrom": "reports",
+ "tableTo": "user",
+ "columnsFrom": [
+ "reporter_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.saved_filters": {
+ "name": "saved_filters",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "filters": {
+ "name": "filters",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "saved_filters_user_id_user_id_fk": {
+ "name": "saved_filters_user_id_user_id_fk",
+ "tableFrom": "saved_filters",
+ "tableTo": "user",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "saved_filters_user_name": {
+ "name": "saved_filters_user_name",
+ "nullsNotDistinct": false,
+ "columns": [
+ "user_id",
+ "name"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.storage_objects": {
+ "name": "storage_objects",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "key": {
+ "name": "key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "bucket": {
+ "name": "bucket",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "size": {
+ "name": "size",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "mime_type": {
+ "name": "mime_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "entity_type": {
+ "name": "entity_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "entity_id": {
+ "name": "entity_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "uploaded_by": {
+ "name": "uploaded_by",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "last_accessed_at": {
+ "name": "last_accessed_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "is_orphaned": {
+ "name": "is_orphaned",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ }
+ },
+ "indexes": {
+ "storage_entity_idx": {
+ "name": "storage_entity_idx",
+ "columns": [
+ {
+ "expression": "entity_type",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "entity_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "storage_key_idx": {
+ "name": "storage_key_idx",
+ "columns": [
+ {
+ "expression": "key",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "storage_objects_uploaded_by_user_id_fk": {
+ "name": "storage_objects_uploaded_by_user_id_fk",
+ "tableFrom": "storage_objects",
+ "tableTo": "user",
+ "columnsFrom": [
+ "uploaded_by"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ }
+ },
+ "enums": {
+ "public.suggestion_status": {
+ "name": "suggestion_status",
+ "schema": "public",
+ "values": [
+ "pending",
+ "approved",
+ "rejected"
+ ]
+ },
+ "public.anti_cheat_status": {
+ "name": "anti_cheat_status",
+ "schema": "public",
+ "values": [
+ "none",
+ "supported",
+ "unsupported",
+ "unknown"
+ ]
+ },
+ "public.proton_status": {
+ "name": "proton_status",
+ "schema": "public",
+ "values": [
+ "native",
+ "proton",
+ "unsupported",
+ "unknown"
+ ]
+ },
+ "public.game_source": {
+ "name": "game_source",
+ "schema": "public",
+ "values": [
+ "steam",
+ "manual",
+ "gog",
+ "epic"
+ ]
+ },
+ "public.online_multiplayer_status": {
+ "name": "online_multiplayer_status",
+ "schema": "public",
+ "values": [
+ "none",
+ "supported",
+ "unknown"
+ ]
+ },
+ "public.playability_status": {
+ "name": "playability_status",
+ "schema": "public",
+ "values": [
+ "great",
+ "playable",
+ "needs_tweaks",
+ "unplayable",
+ "unknown"
+ ]
+ },
+ "public.steam_review_sentiment": {
+ "name": "steam_review_sentiment",
+ "schema": "public",
+ "values": [
+ "overwhelmingly_positive",
+ "very_positive",
+ "positive",
+ "mostly_positive",
+ "mixed",
+ "mostly_negative",
+ "negative",
+ "very_negative",
+ "overwhelmingly_negative"
+ ]
+ },
+ "public.device_type": {
+ "name": "device_type",
+ "schema": "public",
+ "values": [
+ "handheld",
+ "console"
+ ]
+ },
+ "public.frame_gen_method": {
+ "name": "frame_gen_method",
+ "schema": "public",
+ "values": [
+ "none",
+ "fsr_fg",
+ "dlss_fg",
+ "lsfg",
+ "other"
+ ]
+ },
+ "public.upscaler_type": {
+ "name": "upscaler_type",
+ "schema": "public",
+ "values": [
+ "none",
+ "fsr",
+ "dlss",
+ "xess",
+ "lsfg",
+ "other"
+ ]
+ },
+ "public.report_reason": {
+ "name": "report_reason",
+ "schema": "public",
+ "values": [
+ "inaccurate",
+ "spam",
+ "inappropriate",
+ "other"
+ ]
+ },
+ "public.report_status": {
+ "name": "report_status",
+ "schema": "public",
+ "values": [
+ "open",
+ "reviewed",
+ "dismissed"
+ ]
+ }
+ },
+ "schemas": {},
+ "sequences": {},
+ "roles": {},
+ "policies": {},
+ "views": {},
+ "_meta": {
+ "columns": {},
+ "schemas": {},
+ "tables": {}
+ }
+}
\ No newline at end of file
diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json
index cdca8d1..60408ec 100644
--- a/drizzle/meta/_journal.json
+++ b/drizzle/meta/_journal.json
@@ -155,6 +155,13 @@
"when": 1777547793864,
"tag": "0021_demonic_texas_twister",
"breakpoints": true
+ },
+ {
+ "idx": 22,
+ "version": "7",
+ "when": 1778301841773,
+ "tag": "0022_married_zeigeist",
+ "breakpoints": true
}
]
}
\ No newline at end of file
diff --git a/lib/db/schema/index.ts b/lib/db/schema/index.ts
index 2246a3e..ce6b802 100644
--- a/lib/db/schema/index.ts
+++ b/lib/db/schema/index.ts
@@ -10,3 +10,4 @@ export * from "./savedGames"
export * from "./reports"
export * from "./community-suggestions"
export * from "./saved-filters"
+export * from "./storage"
diff --git a/lib/db/schema/storage.ts b/lib/db/schema/storage.ts
new file mode 100644
index 0000000..d553fa9
--- /dev/null
+++ b/lib/db/schema/storage.ts
@@ -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),
+ ],
+)
\ No newline at end of file