Files
quasar/plans/game-search-architecture.md
Benito Rodríguez 2667e11284
Some checks failed
CI / lint (push) Failing after 10s
CI / test-backend (push) Has been skipped
CI / test-frontend (push) Has been skipped
CI / test-e2e (push) Has been skipped
Refactor code structure for improved readability and maintainability
2026-03-22 11:34:38 +01:00

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:

  1. Ejecutar búsquedas en paralelo en los tres proveedores
  2. Normalizar todos los resultados usando la función normalize() existente
  3. Aplicar deduplicación para evitar duplicados entre proveedores
  4. Ordenar resultados por relevancia (coincidencia exacta primero)
  5. 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 externalIds de todas las fuentes para cada resultado

1.3 Modificación del Endpoint

Ubicación: backend/src/routes/metadata.ts

Cambios:

  1. Actualizar el esquema de validación para incluir year como parámetro opcional
  2. Llamar a searchGames() en lugar de enrichGame()
  3. 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:

  1. Mapear metadatos a estructura de Game
  2. Aplicar overrides si se proporcionan
  3. Guardar en base de datos usando GamesController.createGame()
  4. 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:

  • Card para cada resultado
  • Button para seleccionar

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 GameDialog por un enlace a /games/add
  • Mantener GameDialog solo 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:

  1. Debe devolver resultados de IGDB cuando hay coincidencias
  2. Debe devolver resultados de RAWG cuando IGDB falla
  3. Debe devolver resultados de TheGamesDB cuando IGDB y RAWG fallan
  4. Debe deduplicar resultados entre proveedores
  5. Debe priorizar IGDB sobre RAWG y TheGamesDB
  6. Debe filtrar por plataforma cuando se proporciona
  7. Debe filtrar por año cuando se proporciona
  8. Debe devolver array vacío cuando no hay resultados
  9. Debe manejar errores de API externas gracefully
  10. 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:

  1. Debe devolver 200 con array de resultados
  2. Debe devolver 400 si falta parámetro q
  3. Debe devolver 400 si year no es válido
  4. Debe pasar parámetros a searchGames()
  5. 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:

  1. Debe crear juego con metadatos de IGDB
  2. Debe crear juego con metadatos de RAWG
  3. Debe crear juego con metadatos de TheGamesDB
  4. Debe aplicar overrides cuando se proporcionan
  5. Debe guardar externalIds correctamente
  6. Debe devolver 400 si metadata es inválida
  7. 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:

  1. Debe mostrar campos de formulario
  2. Debe validar que título es obligatorio
  3. Debe llamar onSearch con parámetros correctos
  4. Debe deshabilitar botón cuando isLoading es true

Archivo: frontend/src/components/games/__tests__/SearchResults.test.tsx

Casos de prueba:

  1. Debe mostrar lista de resultados
  2. Debe mostrar mensaje cuando no hay resultados
  3. Debe llamar onSelect cuando se hace clic en resultado
  4. Debe mostrar estado de carga

Archivo: frontend/src/components/games/__tests__/GamePreviewDialog.test.tsx

Casos de prueba:

  1. Debe mostrar metadatos del juego
  2. Debe permitir editar campos
  3. Debe llamar onSave con metadatos y overrides
  4. Debe cerrar cuando se cancela

3.3.2 Tests E2E

Archivo: tests/e2e/game-search-flow.spec.ts

Casos de prueba:

  1. Flujo completo: búsqueda → selección → guardado
  2. Búsqueda sin resultados
  3. Cancelar selección
  4. Editar campos antes de guardar
  5. 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

  1. Sprint 1: Backend Core

    • Tests de searchGames()
    • Implementar searchGames()
    • Tests del endpoint /api/metadata/search
    • Actualizar endpoint /api/metadata/search
  2. Sprint 2: Backend Save

    • Tests del endpoint POST /api/games/from-metadata
    • Implementar endpoint POST /api/games/from-metadata
  3. Sprint 3: Frontend Components

    • Tests unitarios de SearchForm
    • Implementar SearchForm
    • Tests unitarios de SearchResults
    • Implementar SearchResults
    • Tests unitarios de GamePreviewDialog
    • Implementar GamePreviewDialog
  4. Sprint 4: Frontend Page & Integration

    • Implementar página /games/add
    • Actualizar cliente API
    • Integrar con página /games
  5. 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