29 KiB
Arquitectura: Nueva Pantalla de Búsqueda de Juegos
Resumen Ejecutivo
Este documento describe la arquitectura para reemplazar el popup actual (GameDialog.tsx) por una página completa de búsqueda de juegos que permite buscar en múltiples proveedores externos (IGDB, RAWG, TheGamesDB) y seleccionar un resultado para guardar en la base de datos.
1. Arquitectura del Backend
1.1 Modificaciones al Endpoint Existente
Endpoint actual: GET /api/metadata/search?q=query&platform=optional
Problema actual:
- El endpoint usa
enrichGame()que solo devuelve el primer resultado encontrado - La respuesta es un array con un solo elemento o vacío
Solución propuesta:
Crear una nueva función en metadataService.ts que busque en TODOS los proveedores y devuelva múltiples resultados.
1.2 Nueva Función: searchGames()
Ubicación: backend/src/services/metadataService.ts
export async function searchGames(opts: {
title: string;
platform?: string;
year?: number;
}): Promise<EnrichedGame[]> {
// Buscar en IGDB, RAWG y TheGamesDB simultáneamente
// Devolver todos los resultados encontrados
// Aplicar deduplicación basada en nombre + plataforma + año
}
Lógica de búsqueda:
- Ejecutar búsquedas en paralelo en los tres proveedores
- Normalizar todos los resultados usando la función
normalize()existente - Aplicar deduplicación para evitar duplicados entre proveedores
- Ordenar resultados por relevancia (coincidencia exacta primero)
- Limitar resultados (ej: 20 resultados máximos por proveedor, 50 total)
Deduplicación:
- Agrupar por nombre normalizado (lowercase, sin caracteres especiales)
- Si hay duplicados, priorizar: IGDB > RAWG > TheGamesDB
- Mantener
externalIdsde todas las fuentes para cada resultado
1.3 Modificación del Endpoint
Ubicación: backend/src/routes/metadata.ts
Cambios:
- Actualizar el esquema de validación para incluir
yearcomo parámetro opcional - Llamar a
searchGames()en lugar deenrichGame() - Devolver el array de resultados directamente
const searchMetadataSchema = z.object({
q: z.string().min(1, 'El parámetro de búsqueda es requerido'),
platform: z.string().optional(),
year: z.coerce.number().int().min(1900).max(2100).optional(),
});
app.get('/metadata/search', async (request, reply) => {
const validated = searchMetadataSchema.parse(request.query);
const results = await metadataService.searchGames({
title: validated.q,
platform: validated.platform,
year: validated.year,
});
return reply.code(200).send(results);
});
1.4 Estructura de Respuesta de la API
// GET /api/metadata/search?q=mario&year=1990&platform=NES
[
{
source: 'igdb',
externalIds: {
igdb: 1234,
rawg: 5678,
thegamesdb: 9012,
},
name: 'Super Mario Bros.',
title: 'Super Mario Bros.',
slug: 'super-mario-bros',
releaseDate: '1985-09-13T00:00:00.000Z',
genres: ['Platform'],
coverUrl: 'https://example.com/cover.jpg',
platforms: [
{
id: 18,
name: 'Nintendo Entertainment System (NES)',
abbreviation: 'NES',
slug: 'nes',
},
],
},
// ... más resultados
];
1.5 Nuevo Endpoint para Guardar Resultado
Endpoint: POST /api/games/from-metadata
Propósito: Crear un juego a partir de un resultado de búsqueda de metadatos.
Body:
{
"metadata": {
"source": "igdb",
"externalIds": { "igdb": 1234 },
"name": "Super Mario Bros.",
"slug": "super-mario-bros",
"releaseDate": "1985-09-13T00:00:00.000Z",
"genres": ["Platform"],
"coverUrl": "https://example.com/cover.jpg"
},
"overrides": {
"platform": "NES",
"year": 1985,
"description": "Descripción personalizada..."
}
}
Lógica:
- Mapear metadatos a estructura de
Game - Aplicar overrides si se proporcionan
- Guardar en base de datos usando
GamesController.createGame() - Devolver el juego creado
1.6 Diagrama de Flujo del Backend
flowchart TD
A[Cliente envía búsqueda] --> B[Validar parámetros]
B --> C[searchGames]
C --> D[Paralelo: IGDB]
C --> E[Paralelo: RAWG]
C --> F[Paralelo: TheGamesDB]
D --> G[Normalizar resultados]
E --> G
F --> G
G --> H[Deduplicar]
H --> I[Ordenar por relevancia]
I --> J[Limitar resultados]
J --> K[Devolver array]
K --> L[Cliente muestra resultados]
L --> M[Usuario selecciona resultado]
M --> N[POST /api/games/from-metadata]
N --> O[GamesController.createGame]
O --> P[Guardar en BD]
P --> Q[Devolver juego creado]
2. Arquitectura del Frontend
2.1 Nueva Ruta
Ruta: /games/add o /games/search
Archivo: frontend/src/app/games/add/page.tsx
Razón: /games/add es más semántico y sigue el patrón RESTful para crear recursos.
2.2 Componentes a Crear
2.2.1 SearchForm Component
Ubicación: frontend/src/components/games/SearchForm.tsx
Responsabilidades:
- Formulario de búsqueda con campos: título (obligatorio), año (opcional), plataforma (opcional)
- Validación de formulario
- Disparar evento de búsqueda
Componentes shadcn/ui a usar:
Props:
interface SearchFormProps {
onSearch: (params: SearchParams) => void;
isLoading: boolean;
}
2.2.2 SearchResults Component
Ubicación: frontend/src/components/games/SearchResults.tsx
Responsabilidades:
- Mostrar lista de resultados de búsqueda
- Permitir selección de un resultado
- Mostrar estado de carga
Componentes shadcn/ui a usar:
Props:
interface SearchResultsProps {
results: MetadataGame[];
onSelect: (result: MetadataGame) => void;
isLoading: boolean;
}
2.2.3 GamePreviewDialog Component
Ubicación: frontend/src/components/games/GamePreviewDialog.tsx
Responsabilidades:
- Mostrar detalles completos del resultado seleccionado
- Permitir editar campos antes de guardar
- Confirmar guardado
Componentes shadcn/ui a usar:
Props:
interface GamePreviewDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
metadata: MetadataGame | null;
onSave: (metadata: MetadataGame, overrides: GameOverrides) => void;
isSaving: boolean;
}
2.3 Flujo de Usuario
flowchart TD
A[Usuario accede a /games/add] --> B[SearchForm visible]
B --> C[Usuario ingresa título]
C --> D[Usuario hace clic en Buscar]
D --> E[LLAMADA API: GET /api/metadata/search]
E --> F[SearchResults muestra resultados]
F --> G[Usuario ve lista de juegos]
G --> H[Usuario hace clic en un resultado]
H --> I[GamePreviewDialog se abre]
I --> J[Usuario puede editar campos]
J --> K[Usuario hace clic en Guardar]
K --> L[LLAMADA API: POST /api/games/from-metadata]
L --> M{Éxito?}
M -->|Sí| N[Redirigir a /games o /games/id]
M -->|No| O[Mostrar error en dialog]
O --> J
2.4 Estructura de la Página
// frontend/src/app/games/add/page.tsx
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { SearchForm } from '@/components/games/SearchForm';
import { SearchResults } from '@/components/games/SearchResults';
import { GamePreviewDialog } from '@/components/games/GamePreviewDialog';
import { metadataApi, gamesApi } from '@/lib/api';
export default function AddGamePage() {
const router = useRouter();
const [searchParams, setSearchParams] = useState<SearchParams | null>(null);
const [results, setResults] = useState<MetadataGame[]>([]);
const [selectedResult, setSelectedResult] = useState<MetadataGame | null>(null);
const [isSearching, setIsSearching] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const handleSearch = async (params: SearchParams) => {
setIsSearching(true);
try {
const data = await metadataApi.search(params);
setResults(data);
setSearchParams(params);
} finally {
setIsSearching(false);
}
};
const handleSelect = (result: MetadataGame) => {
setSelectedResult(result);
};
const handleSave = async (metadata: MetadataGame, overrides: GameOverrides) => {
setIsSaving(true);
try {
const game = await gamesApi.createFromMetadata(metadata, overrides);
router.push(`/games/${game.id}`);
} finally {
setIsSaving(false);
}
};
return (
<div className="container mx-auto py-8">
<h1 className="text-3xl font-bold mb-8">Añadir Juego</h1>
<SearchForm onSearch={handleSearch} isLoading={isSearching} />
{results.length > 0 && (
<SearchResults
results={results}
onSelect={handleSelect}
isLoading={isSearching}
/>
)}
<GamePreviewDialog
open={!!selectedResult}
onOpenChange={(open) => !open && setSelectedResult(null)}
metadata={selectedResult}
onSave={handleSave}
isSaving={isSaving}
/>
</div>
);
}
2.5 Actualización del Cliente API
Ubicación: frontend/src/lib/api.ts
Nuevas funciones:
// Búsqueda de metadatos
export async function searchMetadata(params: {
q: string;
platform?: string;
year?: number;
}): Promise<MetadataGame[]> {
const queryParams = new URLSearchParams({ q: params.q });
if (params.platform) queryParams.append('platform', params.platform);
if (params.year) queryParams.append('year', String(params.year));
const res = await fetch(`${API_BASE}/metadata/search?${queryParams}`);
if (!res.ok) throw new Error('Error searching metadata');
return res.json();
}
// Crear juego desde metadatos
export async function createGameFromMetadata(
metadata: MetadataGame,
overrides?: GameOverrides
): Promise<Game> {
const res = await fetch(`${API_BASE}/games/from-metadata`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ metadata, overrides }),
});
if (!res.ok) throw new Error('Error creating game from metadata');
return res.json();
}
2.6 Integración con Página de Juegos
Ubicación: frontend/src/app/games/page.tsx
Cambio:
- Reemplazar el botón "Nuevo Juego" que abre
GameDialogpor un enlace a/games/add - Mantener
GameDialogsolo para edición de juegos existentes
// Antes
<Button onClick={handleCreate}>
<PlusIcon className="w-4 h-4 mr-2" />
Nuevo Juego
</Button>
// Después
<Link href="/games/add">
<Button>
<PlusIcon className="w-4 h-4 mr-2" />
Añadir Juego
</Button>
</Link>
3. Estrategia TDD (Test Driven Development)
3.1 Orden de Implementación
flowchart LR
A[Tests Backend - searchGames] --> B[Implementar searchGames]
B --> C[Tests Backend - Endpoint /metadata/search]
C --> D[Implementar Endpoint]
D --> E[Tests Backend - POST /api/games/from-metadata]
E --> F[Implementar Endpoint from-metadata]
F --> G[Tests Frontend - Componentes Unitarios]
G --> H[Implementar Componentes]
H --> I[Tests E2E - Flujo Completo]
I --> J[Validar Integración]
3.2 Tests del Backend
3.2.1 Tests de searchGames()
Archivo: backend/tests/services/metadataService.search.spec.ts
Casos de prueba:
- ✅ Debe devolver resultados de IGDB cuando hay coincidencias
- ✅ Debe devolver resultados de RAWG cuando IGDB falla
- ✅ Debe devolver resultados de TheGamesDB cuando IGDB y RAWG fallan
- ✅ Debe deduplicar resultados entre proveedores
- ✅ Debe priorizar IGDB sobre RAWG y TheGamesDB
- ✅ Debe filtrar por plataforma cuando se proporciona
- ✅ Debe filtrar por año cuando se proporciona
- ✅ Debe devolver array vacío cuando no hay resultados
- ✅ Debe manejar errores de API externas gracefully
- ✅ Debe limitar número de resultados
Ejemplo de test:
it('debe deduplicar resultados entre proveedores', async () => {
const mockIgdbResult = {
name: 'Super Mario Bros.',
source: 'igdb',
id: 1234,
// ... otros campos
};
const mockRawgResult = {
name: 'Super Mario Bros.',
source: 'rawg',
id: 5678,
// ... otros campos
};
vi.spyOn(igdb, 'searchGames').mockResolvedValue([mockIgdbResult]);
vi.spyOn(rawg, 'searchGames').mockResolvedValue([mockRawgResult]);
const results = await metadataService.searchGames({ title: 'mario' });
// Solo debería haber un resultado con ambos IDs externos
expect(results).toHaveLength(1);
expect(results[0].externalIds.igdb).toBe(1234);
expect(results[0].externalIds.rawg).toBe(5678);
});
3.2.2 Tests del Endpoint /api/metadata/search
Archivo: backend/tests/routes/metadata.search.spec.ts
Casos de prueba:
- ✅ Debe devolver 200 con array de resultados
- ✅ Debe devolver 400 si falta parámetro
q - ✅ Debe devolver 400 si
yearno es válido - ✅ Debe pasar parámetros a
searchGames() - ✅ Debe manejar errores del servicio
3.2.3 Tests del Endpoint POST /api/games/from-metadata
Archivo: backend/tests/routes/games.from-metadata.spec.ts
Casos de prueba:
- ✅ Debe crear juego con metadatos de IGDB
- ✅ Debe crear juego con metadatos de RAWG
- ✅ Debe crear juego con metadatos de TheGamesDB
- ✅ Debe aplicar overrides cuando se proporcionan
- ✅ Debe guardar
externalIdscorrectamente - ✅ Debe devolver 400 si metadata es inválida
- ✅ Debe devolver 201 con juego creado
3.3 Tests del Frontend
3.3.1 Tests Unitarios de Componentes
Archivo: frontend/src/components/games/__tests__/SearchForm.test.tsx
Casos de prueba:
- ✅ Debe mostrar campos de formulario
- ✅ Debe validar que título es obligatorio
- ✅ Debe llamar
onSearchcon parámetros correctos - ✅ Debe deshabilitar botón cuando
isLoadinges true
Archivo: frontend/src/components/games/__tests__/SearchResults.test.tsx
Casos de prueba:
- ✅ Debe mostrar lista de resultados
- ✅ Debe mostrar mensaje cuando no hay resultados
- ✅ Debe llamar
onSelectcuando se hace clic en resultado - ✅ Debe mostrar estado de carga
Archivo: frontend/src/components/games/__tests__/GamePreviewDialog.test.tsx
Casos de prueba:
- ✅ Debe mostrar metadatos del juego
- ✅ Debe permitir editar campos
- ✅ Debe llamar
onSavecon metadatos y overrides - ✅ Debe cerrar cuando se cancela
3.3.2 Tests E2E
Archivo: tests/e2e/game-search-flow.spec.ts
Casos de prueba:
- ✅ Flujo completo: búsqueda → selección → guardado
- ✅ Búsqueda sin resultados
- ✅ Cancelar selección
- ✅ Editar campos antes de guardar
- ✅ Validación de errores
Ejemplo de test E2E:
test('flujo completo de búsqueda y guardado', async ({ page }) => {
await page.goto('/games/add');
// Ingresar búsqueda
await page.fill('input[name="title"]', 'Super Mario');
await page.click('button[type="submit"]');
// Esperar resultados
await page.waitForSelector('[data-testid="search-results"]');
const results = await page.locator('[data-testid="game-result"]').count();
expect(results).toBeGreaterThan(0);
// Seleccionar primer resultado
await page.click('[data-testid="game-result"]:first-child');
// Esperar dialog de preview
await page.waitForSelector('[data-testid="preview-dialog"]');
// Guardar
await page.click('[data-testid="save-button"]');
// Verificar redirección
await page.waitForURL(/\/games\/[a-z0-9]+$/);
});
3.4 Orden de Implementación Recomendado
-
Sprint 1: Backend Core
- Tests de
searchGames() - Implementar
searchGames() - Tests del endpoint
/api/metadata/search - Actualizar endpoint
/api/metadata/search
- Tests de
-
Sprint 2: Backend Save
- Tests del endpoint
POST /api/games/from-metadata - Implementar endpoint
POST /api/games/from-metadata
- Tests del endpoint
-
Sprint 3: Frontend Components
- Tests unitarios de
SearchForm - Implementar
SearchForm - Tests unitarios de
SearchResults - Implementar
SearchResults - Tests unitarios de
GamePreviewDialog - Implementar
GamePreviewDialog
- Tests unitarios de
-
Sprint 4: Frontend Page & Integration
- Implementar página
/games/add - Actualizar cliente API
- Integrar con página
/games
- Implementar página
-
Sprint 5: E2E & Polish
- Tests E2E del flujo completo
- Corregir bugs encontrados
- Mejorar UX (loading states, error messages)
4. Integración con Código Existente
4.1 Código a Reutilizar
Backend
| Componente | Archivo | Uso |
|---|---|---|
normalize() |
metadataService.ts |
Normalizar resultados de búsqueda |
igdb.searchGames() |
igdbClient.ts |
Buscar en IGDB |
rawg.searchGames() |
rawgClient.ts |
Buscar en RAWG |
thegamesdb.searchGames() |
thegamesdbClient.ts |
Buscar en TheGamesDB |
GamesController.createGame() |
gamesController.ts |
Crear juego en BD |
Game schema |
schema.prisma |
Modelo de datos |
Frontend
| Componente | Archivo | Uso |
|---|---|---|
Button |
button.tsx |
Botones de acción |
Input |
input.tsx |
Campos de formulario |
Select |
select.tsx |
Selector de plataforma |
Card |
card.tsx |
Tarjetas de resultados |
Dialog |
dialog.tsx |
Dialog de preview |
MetadataGame type |
api.ts |
Tipo de metadatos |
Game type |
api.ts |
Tipo de juego |
4.2 Código a Modificar
Backend
| Archivo | Cambio |
|---|---|
metadataService.ts |
Agregar función searchGames() |
metadata.ts |
Actualizar endpoint /api/metadata/search |
games.ts |
Agregar endpoint POST /api/games/from-metadata |
types/index.ts |
Agregar tipos para búsqueda múltiple |
Frontend
| Archivo | Cambio |
|---|---|
api.ts |
Agregar funciones searchMetadata() y createGameFromMetadata() |
games/page.tsx |
Cambiar botón "Nuevo Juego" a enlace /games/add |
GameDialog.tsx |
Mantener solo para edición (opcional) |
4.3 Código Nuevo a Crear
Backend
| Archivo | Propósito |
|---|---|
backend/tests/services/metadataService.search.spec.ts |
Tests de searchGames() |
backend/tests/routes/games.from-metadata.spec.ts |
Tests del endpoint from-metadata |
Frontend
| Archivo | Propósito |
|---|---|
frontend/src/app/games/add/page.tsx |
Página de búsqueda de juegos |
frontend/src/components/games/SearchForm.tsx |
Formulario de búsqueda |
frontend/src/components/games/SearchResults.tsx |
Lista de resultados |
frontend/src/components/games/GamePreviewDialog.tsx |
Dialog de preview y edición |
frontend/src/components/games/__tests__/SearchForm.test.tsx |
Tests de SearchForm |
frontend/src/components/games/__tests__/SearchResults.test.tsx |
Tests de SearchResults |
frontend/src/components/games/__tests__/GamePreviewDialog.test.tsx |
Tests de GamePreviewDialog |
tests/e2e/game-search-flow.spec.ts |
Tests E2E del flujo |
5. Diagrama de Arquitectura Completa
graph TB
subgraph Frontend
A[Página /games/add] --> B[SearchForm]
A --> C[SearchResults]
A --> D[GamePreviewDialog]
B --> E[api.ts - searchMetadata]
C --> F[api.ts - createGameFromMetadata]
D --> F
end
subgraph Backend
E --> G[GET /api/metadata/search]
F --> H[POST /api/games/from-metadata]
G --> I[metadataService.searchGames]
I --> J[igdbClient.searchGames]
I --> K[rawgClient.searchGames]
I --> L[thegamesdbClient.searchGames]
H --> M[GamesController.createGame]
M --> N[(Prisma - Game)]
end
subgraph External APIs
J --> O[IGDB API]
K --> P[RAWG API]
L --> Q[TheGamesDB API]
end
style Frontend fill:#e1f5fe
style Backend fill:#f3e5f5
style External APIs fill:#fff3e0
6. Consideraciones Adicionales
6.1 Manejo de Errores
Backend:
- Capturar errores de APIs externas sin fallar completamente
- Devolver resultados parciales si algún proveedor falla
- Logging de errores para debugging
Frontend:
- Mostrar mensajes de error claros al usuario
- Permitir reintentar búsqueda
- Mostrar estado de carga durante búsquedas
6.2 Performance
Backend:
- Usar
Promise.all()para búsquedas paralelas - Implementar caching de resultados (opcional)
- Limitar número de resultados por proveedor
Frontend:
- Implementar debounce en búsqueda
- Lazy loading de imágenes de portada
- Paginación de resultados si hay muchos
6.3 Seguridad
Backend:
- Validar todos los parámetros de entrada
- Sanitizar datos antes de guardar en BD
- Rate limiting en endpoints de búsqueda
Frontend:
- Sanitizar HTML antes de mostrar
- Validar en cliente y servidor
6.4 Accesibilidad
Frontend:
- Labels en todos los campos de formulario
- Focus management en dialogs
- Keyboard navigation
- ARIA labels donde sea necesario
6.5 UX Considerations
- Mostrar loading states claros
- Feedback inmediato al usuario
- Permitir cancelar búsqueda en progreso
- Mostrar fuente de cada resultado (IGDB, RAWG, etc.)
- Permitir ver más detalles antes de seleccionar
- Confirmación antes de guardar
7. Resumen de Cambios
Backend
| Archivo | Acción |
|---|---|
metadataService.ts |
Agregar searchGames() |
metadata.ts |
Modificar endpoint /api/metadata/search |
games.ts |
Agregar endpoint POST /api/games/from-metadata |
types/index.ts |
Agregar tipos nuevos |
metadataService.search.spec.ts |
NUEVO |
games.from-metadata.spec.ts |
NUEVO |
Frontend
| Archivo | Acción |
|---|---|
api.ts |
Agregar searchMetadata() y createGameFromMetadata() |
games/page.tsx |
Cambiar botón a enlace |
games/add/page.tsx |
NUEVO |
SearchForm.tsx |
NUEVO |
SearchResults.tsx |
NUEVO |
GamePreviewDialog.tsx |
NUEVO |
| Tests unitarios | NUEVOS |
game-search-flow.spec.ts |
NUEVO |
Documento creado: 2025-03-21 Autor: Architect Mode Estado: Diseño completo listo para implementación