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