diff --git a/.github/workflows/corba-cd.prod.yml b/.github/workflows/corba-cd.prod.yml index aa0afee3..85ba0e38 100644 --- a/.github/workflows/corba-cd.prod.yml +++ b/.github/workflows/corba-cd.prod.yml @@ -45,11 +45,11 @@ jobs: run: | # -f apunta al Dockerfile un nivel arriba # El '.' final se cambia por '..' para que el contexto sea ./corba-services - docker build -f Dockerfile.prod -t us-east1-docker.pkg.dev/cnsa-2026/draftkings/corba-news-manager:0.0.1 -f ../Dockerfile .. + docker build -f Dockerfile.prod -t us-east1-docker.pkg.dev/cnsa-2026/draftkings/corba-news-manager:1.0.0 -f ../Dockerfile .. - name: Push Docker image to Artifact Registry run: | - docker push us-east1-docker.pkg.dev/cnsa-2026/draftkings/corba-news-manager:0.0.1 + docker push us-east1-docker.pkg.dev/cnsa-2026/draftkings/corba-news-manager:1.0.0 # 1. Instalar la llave ANTES de comprobar la conexión - name: Install SSH Key diff --git a/.github/workflows/ionic-ci-cd.prod.yml b/.github/workflows/ionic-ci-cd.prod.yml index dfa9fe7d..fe9c642d 100644 --- a/.github/workflows/ionic-ci-cd.prod.yml +++ b/.github/workflows/ionic-ci-cd.prod.yml @@ -357,18 +357,18 @@ jobs: # Que la imagen se suba con el tag actual cuando se etnga el actions del tag - name: Build Docker image run: | - docker build -t us-east1-docker.pkg.dev/cnsa-2026/draftkings/client-ionic:0.0.1 . + docker build -t us-east1-docker.pkg.dev/cnsa-2026/draftkings/client-ionic:1.0.0 . - name: Push Docker image to Artifact Registry run: | - docker push us-east1-docker.pkg.dev/cnsa-2026/draftkings/client-ionic:0.0.1 + docker push us-east1-docker.pkg.dev/cnsa-2026/draftkings/client-ionic:1.0.0 - name: Deploy to Cloud Run uses: google-github-actions/deploy-cloudrun@v3 with: service: "draftkings" region: "us-east1" - image: "us-east1-docker.pkg.dev/cnsa-2026/draftkings/client-ionic:0.0.1" + image: "us-east1-docker.pkg.dev/cnsa-2026/draftkings/client-ionic:1.0.0" flags: "--service-account=draftkings@cnsa-2026.iam.gserviceaccount.com --allow-unauthenticated --ingress=all" secrets: | FIREBASE_CONFIG_PROD=FIREBASE_CONFIG_PROD:latest diff --git a/.github/workflows/node-cd.prod.yml b/.github/workflows/node-cd.prod.yml index a3fbcfa5..9a95681d 100644 --- a/.github/workflows/node-cd.prod.yml +++ b/.github/workflows/node-cd.prod.yml @@ -41,16 +41,16 @@ jobs: - name: Build Docker image run: | - docker build -f Dockerfile.prod -t us-east1-docker.pkg.dev/cnsa-2026/draftkings/api-node:0.0.1 . + docker build -f Dockerfile.prod -t us-east1-docker.pkg.dev/cnsa-2026/draftkings/api-node:1.0.0 . - name: Push Docker image to Artifact Registry run: | - docker push us-east1-docker.pkg.dev/cnsa-2026/draftkings/api-node:0.0.1 + docker push us-east1-docker.pkg.dev/cnsa-2026/draftkings/api-node:1.0.0 - name: Deploy to Cloud Run uses: google-github-actions/deploy-cloudrun@v3 with: service: "dk-node" region: "us-east1" - image: "us-east1-docker.pkg.dev/cnsa-2026/draftkings/api-node:0.0.1" + image: "us-east1-docker.pkg.dev/cnsa-2026/draftkings/api-node:1.0.0" flags: "--service-account=draftkings@cnsa-2026.iam.gserviceaccount.com --allow-unauthenticated --ingress=all --set-secrets=/app/enviroments/env=NODE_ENV_PROD:latest" diff --git a/.github/workflows/spring-cd.prod.yml b/.github/workflows/spring-cd.prod.yml index 4d0208ab..9acc97eb 100644 --- a/.github/workflows/spring-cd.prod.yml +++ b/.github/workflows/spring-cd.prod.yml @@ -68,11 +68,11 @@ jobs: - name: Build Docker image run: | - docker build -f Dockerfile.prod -t us-east1-docker.pkg.dev/cnsa-2026/draftkings/${{ steps.slug.outputs.service_slug }}:0.0.1 . + docker build -f Dockerfile.prod -t us-east1-docker.pkg.dev/cnsa-2026/draftkings/${{ steps.slug.outputs.service_slug }}:1.0.0 . - name: Push Docker image to Artifact Registry run: | - docker push us-east1-docker.pkg.dev/cnsa-2026/draftkings/${{ steps.slug.outputs.service_slug }}:0.0.1 + docker push us-east1-docker.pkg.dev/cnsa-2026/draftkings/${{ steps.slug.outputs.service_slug }}:1.0.0 - name: Set Secret Placeholder id: secret @@ -103,7 +103,7 @@ jobs: with: service: "${{ steps.slug.outputs.service_slug }}" region: "us-east1" - image: "us-east1-docker.pkg.dev/cnsa-2026/draftkings/${{ steps.slug.outputs.service_slug }}:0.0.1" + image: "us-east1-docker.pkg.dev/cnsa-2026/draftkings/${{ steps.slug.outputs.service_slug }}:1.0.0" flags: | --allow-unauthenticated --ingress=all diff --git a/README.md b/README.md index 34f3b1cf..86d86fe0 100644 --- a/README.md +++ b/README.md @@ -298,7 +298,7 @@ La arquitectura de integración y despliegue continuo está diseñada para garan **Deployment a Prod** (`node-cd.prod.yml`): - Trigger: Se ejecuta si CI es exitoso en rama `main` - Steps: Similares a Dev, con diferencias: - - Build Docker con tag versionado: `:0.0.1` + - Build Docker con tag versionado: `:1.0.0` - Usa `Dockerfile.prod` optimizado - Service: `dk-node` (sin sufijo -dev) - Secret: `NODE_ENV_PROD` @@ -385,7 +385,7 @@ on: **Diferencias Dev/Prod:** - **Dev:** Tag `:latest`, secret `_DEV` -- **Prod:** Tag `:0.0.1`, secret `_PROD`, Dockerfile.prod +- **Prod:** Tag `:1.0.0`, secret `_PROD`, Dockerfile.prod --- @@ -473,7 +473,7 @@ El deployment de CORBA es **más complejo** que otros servicios debido a infraes **Deployment a Prod** (`corba-cd.prod.yml`): - VM: `dk-corba` (sin sufijo -dev) - DNS: `dk-corba.cnsa-2026-dsa069.tech` -- Imagen: Tag `:0.0.1` +- Imagen: Tag `:1.0.0` - Usa `Dockerfile.prod` --- diff --git a/api-node/draftKings_api/services/apiFootballService.ts b/api-node/draftKings_api/services/apiFootballService.ts index 753ad792..502dbc9d 100644 --- a/api-node/draftKings_api/services/apiFootballService.ts +++ b/api-node/draftKings_api/services/apiFootballService.ts @@ -1,32 +1,39 @@ -import axios from "axios"; // Asegúrate de tener axios instalado (npm i axios) +import axios from "axios"; import Player from "../models/player"; +interface TeamLeagueInfo { + teamName: string | null; + leagueName: string | null; +} + +const API_BASE = "https://v3.football.api-sports.io"; + export class ApiFootballService { private apiKey = process.env.API_FOOTBALL_KEY; - // 1) Obtener y transformar datos de la API externa + private get headers() { + return { + "x-apisports-key": this.apiKey, + "x-rapidapi-host": "v3.football.api-sports.io", + }; + } + async searchPlayers(search?: string): Promise { const params: any = {}; if (search) params.search = search; try { - const response = await axios.get( - "https://v3.football.api-sports.io/players/profiles", - { - headers: { - "x-apisports-key": this.apiKey, - "x-rapidapi-host": "v3.football.api-sports.io", - }, - params, - }, - ); + const response = await axios.get(`${API_BASE}/players/profiles`, { + headers: this.headers, + params, + }); const data = response.data; if (!data || !data.response || !Array.isArray(data.response)) return []; - // Mapeo idéntico al que tenías en Angular return data.response.map((item: any) => ({ + externalId: item.player.id, name: item.player.name, firstName: item.player.firstname || "", lastName: item.player.lastname || "", @@ -51,24 +58,160 @@ export class ApiFootballService { } } - // 2) Importar los jugadores pasados desde el front a MongoDB + async resolveTeamAndLeague( + playerId: number, + nationality?: string, + ): Promise { + const currentYear = new Date().getFullYear(); + + let teamsJson: any; + try { + teamsJson = await this.getPlayerTeams(playerId); + } catch { + return { teamName: null, leagueName: null }; + } + + const teamsResponse = teamsJson?.response; + if (!teamsResponse || !Array.isArray(teamsResponse)) { + return { teamName: null, leagueName: null }; + } + + let teamName: string | null = null; + let teamId: number | null = null; + const yearsToTry = [currentYear, currentYear - 1]; + + for (const year of yearsToTry) { + let foundInYear = false; + for (const t of teamsResponse) { + const team = t.team; + const seasons = t.seasons; + if (!team || !seasons) continue; + + const hasYear = seasons.includes(year); + + if ( + hasYear && + nationality && + !team.name?.includes(nationality) + ) { + teamName = team.name; + teamId = team.id; + foundInYear = true; + break; + } + } + if (foundInYear) break; + } + + if (teamId === null) { + return { teamName, leagueName: null }; + } + + let leaguesJson: any; + try { + leaguesJson = await this.getLeaguesByTeam(teamId); + } catch { + return { teamName, leagueName: null }; + } + + const leaguesResponse = leaguesJson?.response; + if (!leaguesResponse || !Array.isArray(leaguesResponse)) { + return { teamName, leagueName: null }; + } + + let leagueName: string | null = null; + let maxDuration = -1; + + for (const l of leaguesResponse) { + const league = l.league; + if (!league || league.type !== "League") continue; + + const country = l.country; + if (country?.name === "World") continue; + + for (const s of l.seasons) { + const seasonYear = s.year; + if (seasonYear === currentYear || seasonYear === currentYear - 1) { + const start = this.parseDate(s.start); + const end = this.parseDate(s.end); + if (start && end) { + const duration = + (end.getTime() - start.getTime()) / (1000 * 60 * 60 * 24); + if (duration > maxDuration) { + maxDuration = duration; + leagueName = league.name; + } + } + } + } + } + + return { teamName, leagueName }; + } + + private async getPlayerTeams(playerId: number): Promise { + const response = await axios.get(`${API_BASE}/players/teams`, { + headers: this.headers, + params: { player: playerId }, + }); + return response.data; + } + + private async getLeaguesByTeam(teamId: number): Promise { + const response = await axios.get(`${API_BASE}/leagues`, { + headers: this.headers, + params: { team: teamId }, + }); + return response.data; + } + + private parseDate(dateStr: string): Date | null { + if (!dateStr) return null; + try { + const d = new Date(dateStr); + return isNaN(d.getTime()) ? null : d; + } catch { + return null; + } + } + async importPlayers(players: any[]): Promise { if (!players || players.length === 0) return; - // Transformamos los objetos planos al formato con GeoJSON para Mongoose - const docsToInsert = players.map((player) => ({ - ...player, - birthdate: player.birthdate ? new Date(player.birthdate) : null, - coords: { - type: "Point", - coordinates: [ - Number(player.longitude || 0), - Number(player.latitude || 0), - ], - }, - })); - - // Usamos insertMany para mayor eficiencia en base de datos - await Player.insertMany(docsToInsert); + const enrichedPlayers = await Promise.all( + players.map(async (player) => { + let teamName = player.team || null; + let leagueName = player.league || null; + + if ( + player.externalId && + teamName === "API Football" && + leagueName === "External" + ) { + const info = await this.resolveTeamAndLeague( + player.externalId, + player.nationality, + ); + teamName = info.teamName || teamName; + leagueName = info.leagueName || leagueName; + } + + return { + ...player, + team: teamName, + league: leagueName, + birthdate: player.birthdate ? new Date(player.birthdate) : null, + coords: { + type: "Point", + coordinates: [ + Number(player.longitude || 0), + Number(player.latitude || 0), + ], + }, + }; + }), + ); + + await Player.insertMany(enrichedPlayers); } } diff --git a/api-node/draftKings_api/test/unit/apiFootballService.unit.test.ts b/api-node/draftKings_api/test/unit/apiFootballService.unit.test.ts index e69fa7ff..30069f30 100644 --- a/api-node/draftKings_api/test/unit/apiFootballService.unit.test.ts +++ b/api-node/draftKings_api/test/unit/apiFootballService.unit.test.ts @@ -5,7 +5,9 @@ import { emptyApiFootballResponse, importPlayersApiPayload, searchApiFootballResponse, - transformedExternalPlayers, + teamsByPlayerResponse, + leaguesByTeamResponse, + transformedExternalPlayersWithExternalId, } from "../utils/data/apiFootball.test.data"; jest.mock("axios"); @@ -44,7 +46,7 @@ describe("ApiFootballService (Pruebas Unitarias)", () => { params: { search: "Lamine" }, }), ); - expect(result).toEqual(transformedExternalPlayers); + expect(result).toEqual(transformedExternalPlayersWithExternalId); }); it("Debería devolver un array vacío si la respuesta no tiene formato válido", async () => { @@ -62,6 +64,54 @@ describe("ApiFootballService (Pruebas Unitarias)", () => { }); }); + describe("resolveTeamAndLeague()", () => { + it("Debería devolver team y league cuando se encuentran", async () => { + (axios.get as jest.Mock) + .mockResolvedValueOnce(teamsByPlayerResponse) + .mockResolvedValueOnce(leaguesByTeamResponse); + + const result = await apiFootballService.resolveTeamAndLeague( + 123, + "Spain", + ); + + expect(result.teamName).toBe("Real Madrid"); + expect(result.leagueName).toBe("La Liga"); + expect(axios.get).toHaveBeenCalledWith( + "https://v3.football.api-sports.io/players/teams", + expect.objectContaining({ params: { player: 123 } }), + ); + expect(axios.get).toHaveBeenCalledWith( + "https://v3.football.api-sports.io/leagues", + expect.objectContaining({ params: { team: 541 } }), + ); + }); + + it("Debería devolver nulls si no hay equipos", async () => { + (axios.get as jest.Mock).mockResolvedValue({ data: { response: [] } }); + + const result = await apiFootballService.resolveTeamAndLeague( + 999, + "Spain", + ); + + expect(result.teamName).toBeNull(); + expect(result.leagueName).toBeNull(); + }); + + it("Debería devolver nulls si falla la llamada a la API", async () => { + (axios.get as jest.Mock).mockRejectedValue(new Error("timeout")); + + const result = await apiFootballService.resolveTeamAndLeague( + 123, + "Spain", + ); + + expect(result.teamName).toBeNull(); + expect(result.leagueName).toBeNull(); + }); + }); + describe("importPlayers()", () => { it("Debería no hacer nada si recibe un array vacío", async () => { await apiFootballService.importPlayers([]); @@ -96,8 +146,41 @@ describe("ApiFootballService (Pruebas Unitarias)", () => { type: "Point", coordinates: [2.2, 41.1], }, + team: null, + league: null, }, ]); }); + + it("Debería enriquecer con team/league si el jugador tiene externalId", async () => { + (Player.insertMany as jest.Mock).mockResolvedValue(true); + (axios.get as jest.Mock) + .mockResolvedValueOnce(teamsByPlayerResponse) + .mockResolvedValueOnce(leaguesByTeamResponse); + + const players = [ + { + externalId: 123, + name: "Lamine Yamal", + nationality: "Spain", + team: "API Football", + league: "External", + latitude: 41.1, + longitude: 2.2, + }, + ]; + + await apiFootballService.importPlayers(players); + + expect(Player.insertMany).toHaveBeenCalledWith( + expect.arrayContaining([ + expect.objectContaining({ + name: "Lamine Yamal", + team: "Real Madrid", + league: "La Liga", + }), + ]), + ); + }); }); }); diff --git a/api-node/draftKings_api/test/unit/player.unit.test.ts b/api-node/draftKings_api/test/unit/player.unit.test.ts index 1768a042..69655b13 100644 --- a/api-node/draftKings_api/test/unit/player.unit.test.ts +++ b/api-node/draftKings_api/test/unit/player.unit.test.ts @@ -9,6 +9,7 @@ import { playersImport, } from "../../controllers/playerController"; import Player from "../../models/player"; +import Review from "../../models/review"; import { PlayerService } from "../../services/playerService"; import { ApiFootballService } from "../../services/apiFootballService"; import { @@ -27,6 +28,7 @@ import { // 1. Mockeamos las dependencias del controlador jest.mock("../../models/player"); +jest.mock("../../models/review"); jest.mock("../../services/playerService"); jest.mock("../../services/apiFootballService"); @@ -255,6 +257,7 @@ describe("PlayerController (Pruebas Unitarias)", () => { it("Debería retornar 404 si no encuentra el jugador", async () => { mockRequest.params = { id: "507f1f77bcf86cd799439011" }; + (Review.deleteMany as jest.Mock).mockResolvedValue(undefined); (Player.findByIdAndDelete as jest.Mock).mockReturnValue( mockExecResolved(null), ); @@ -268,6 +271,7 @@ describe("PlayerController (Pruebas Unitarias)", () => { it("Debería borrar el jugador y retornar 204", async () => { mockRequest.params = { id: "507f1f77bcf86cd799439011" }; + (Review.deleteMany as jest.Mock).mockResolvedValue(undefined); (Player.findByIdAndDelete as jest.Mock).mockReturnValue( mockExecResolved({ _id: "507f1f77bcf86cd799439011" }), ); diff --git a/api-node/draftKings_api/test/utils/data/apiFootball.test.data.ts b/api-node/draftKings_api/test/utils/data/apiFootball.test.data.ts index f3ef4b02..0e6e3d99 100644 --- a/api-node/draftKings_api/test/utils/data/apiFootball.test.data.ts +++ b/api-node/draftKings_api/test/utils/data/apiFootball.test.data.ts @@ -12,6 +12,7 @@ export const searchApiFootballResponse = { response: [ { player: { + id: 123, name: "Lamine Yamal", firstname: "Lamine", lastname: "Yamal", @@ -29,8 +30,58 @@ export const searchApiFootballResponse = { }, } satisfies ExternalApiPlayerResponse; +export const teamsByPlayerResponse = { + data: { + response: [ + { + team: { id: 541, name: "Real Madrid" }, + seasons: [2025, 2024], + }, + ], + }, +}; + +const currentYear = new Date().getFullYear(); + +export const leaguesByTeamResponse = { + data: { + response: [ + { + league: { id: 140, name: "La Liga", type: "League" }, + country: { name: "Spain" }, + seasons: [ + { year: currentYear, start: `${currentYear}-08-15`, end: `${currentYear + 1}-05-30` }, + { year: currentYear - 1, start: `${currentYear - 1}-08-15`, end: `${currentYear}-05-30` }, + ], + }, + ], + }, +}; + +export const transformedExternalPlayersWithExternalId = [ + { + externalId: 123, + name: "Lamine Yamal", + firstName: "Lamine", + lastName: "Yamal", + age: 17, + birthdate: "2007-07-13", + nationality: "Spain", + position: "Attacker", + photoUrl: "https://example.com/photo.jpg", + team: "API Football", + league: "External", + latitude: 0, + longitude: 0, + height: "180 cm", + weight: "72 kg", + number: 19, + }, +]; + export const transformedExternalPlayers = [ { + externalId: 123, name: "Lamine Yamal", firstName: "Lamine", lastName: "Yamal", diff --git a/api-node/draftKings_api/test/utils/data/player.test.data.ts b/api-node/draftKings_api/test/utils/data/player.test.data.ts index c115a36d..75462f77 100644 --- a/api-node/draftKings_api/test/utils/data/player.test.data.ts +++ b/api-node/draftKings_api/test/utils/data/player.test.data.ts @@ -35,6 +35,7 @@ export interface PlayerUpdateBody { } export interface PlayerImportBody { + externalId?: number; name: string; birthdate?: string; latitude: number; @@ -56,6 +57,7 @@ export interface ExternalApiPlayerResponse { data: { response: Array<{ player: { + id?: number; name: string; firstname?: string; lastname?: string; @@ -307,6 +309,7 @@ export const externalPlayerApiResponse = { response: [ { player: { + id: 123, name: "Lamine Yamal", firstname: "Lamine", lastname: "Yamal", @@ -334,6 +337,7 @@ export const invalidExternalPlayerApiResponse = { export const transformedExternalPlayers = [ { + externalId: 123, name: "Lamine Yamal", firstName: "Lamine", lastName: "Yamal", diff --git a/api-spring/config.server/pom.xml b/api-spring/config.server/pom.xml index ccff9395..75435129 100644 --- a/api-spring/config.server/pom.xml +++ b/api-spring/config.server/pom.xml @@ -10,7 +10,7 @@ draftkings config.server - 0.0.1-SNAPSHOT + 1.0.0-SNAPSHOT diff --git a/api-spring/eureka.client.player/pom.xml b/api-spring/eureka.client.player/pom.xml index 10866404..c0837577 100644 --- a/api-spring/eureka.client.player/pom.xml +++ b/api-spring/eureka.client.player/pom.xml @@ -10,7 +10,7 @@ draftkings eureka.client.player - 0.0.1-SNAPSHOT + 1.0.0-SNAPSHOT diff --git a/api-spring/eureka.client.player/src/main/java/draftkings/eureka/client/player/controller/PlayerController.java b/api-spring/eureka.client.player/src/main/java/draftkings/eureka/client/player/controller/PlayerController.java index bedd7c2a..2f097d50 100644 --- a/api-spring/eureka.client.player/src/main/java/draftkings/eureka/client/player/controller/PlayerController.java +++ b/api-spring/eureka.client.player/src/main/java/draftkings/eureka/client/player/controller/PlayerController.java @@ -31,6 +31,7 @@ import org.springframework.web.bind.annotation.*; import java.time.LocalDate; +import java.time.ZoneId; import java.util.Date; import java.util.List; @@ -173,25 +174,59 @@ public ResponseEntity> getExternalPlayers( // 7) Importar jugadores desde la API externa -> DIRECTO A REPOSITORY (El // servicio ya hace la orquestación y transformación) @PostMapping("/import") - @Operation(summary = "Importar jugadores desde la API externa", description = "Recibe una lista de jugadores y los persiste en la BD") + @Operation(summary = "Importar jugadores desde la API externa", description = "Recibe una lista de jugadores externos y los persiste en la BD con team/league enriquecido") @ApiResponses({ @ApiResponse(responseCode = "201", description = "Jugadores importados correctamente"), @ApiResponse(responseCode = "400", description = "El body es inválido o está vacío", content = @Content(schema = @Schema(implementation = CustomResponse.class), examples = @ExampleObject(value = "{\"timestamp\":\"2026-04-10T12:00:00Z\",\"status\":400,\"error\":\"El body no contiene jugadores para importar\",\"path\":\"/api/players/import\"}"))), @ApiResponse(responseCode = "500", description = "Error al insertar en base de datos", content = @Content(schema = @Schema(implementation = CustomResponse.class), examples = @ExampleObject(value = "{\"timestamp\":\"2026-04-10T12:00:00Z\",\"status\":500,\"error\":\"Error importing players\",\"path\":\"/api/players/import\"}"))) }) - public ResponseEntity importPlayers(@RequestBody List playersToImport) { + public ResponseEntity importPlayers(@RequestBody List playersToImport) { if (playersToImport == null || playersToImport.isEmpty()) { throw new BadRequestException("El body no contiene jugadores para importar"); } - // Aseguramos que la fecha de creación se asigne si es necesario - for (Player p : playersToImport) { - if (p.getCreatedAt() == null) { - p.setCreatedAt(new Date()); + List players = new java.util.ArrayList<>(); + + for (PlayerExternalDTO dto : playersToImport) { + String teamName = null; + String leagueName = null; + + if (dto.getExternalId() != null) { + ApiFootballService.TeamLeagueInfo info = apiFootballService.resolveTeamAndLeague( + dto.getExternalId(), dto.getNationality()); + teamName = info.teamName(); + leagueName = info.leagueName(); + } + + Player player = new Player(); + player.setName(dto.getName()); + player.setFirstName(dto.getFirstName()); + player.setLastName(dto.getLastName()); + player.setAge(dto.getAge()); + + if (dto.getBirthdate() != null && !dto.getBirthdate().isEmpty()) { + try { + LocalDate ld = LocalDate.parse(dto.getBirthdate()); + player.setBirthdate(Date.from(ld.atStartOfDay(ZoneId.systemDefault()).toInstant())); + } catch (Exception ignored) { + } } + + player.setNationality(dto.getNationality()); + player.setHeight(dto.getHeight()); + player.setWeight(dto.getWeight()); + player.setNumber(dto.getNumber()); + player.setPosition(dto.getPosition()); + player.setPhotoUrl(dto.getPhotoUrl()); + player.setTeam(teamName); + player.setLeague(leagueName); + player.setLatitude(dto.getLatitude() != null ? dto.getLatitude() : java.math.BigDecimal.ZERO); + player.setLongitude(dto.getLongitude() != null ? dto.getLongitude() : java.math.BigDecimal.ZERO); + player.setCreatedAt(new Date()); + + players.add(player); } - // El repositorio guarda toda la lista en una sola transacción eficiente - playerRepository.saveAll(playersToImport); + playerRepository.saveAll(players); return ResponseEntity.status(HttpStatus.CREATED).build(); } diff --git a/api-spring/eureka.client.player/src/main/java/draftkings/eureka/client/player/domain/Player.java b/api-spring/eureka.client.player/src/main/java/draftkings/eureka/client/player/domain/Player.java index b0a9fba2..05686cf8 100644 --- a/api-spring/eureka.client.player/src/main/java/draftkings/eureka/client/player/domain/Player.java +++ b/api-spring/eureka.client.player/src/main/java/draftkings/eureka/client/player/domain/Player.java @@ -51,8 +51,8 @@ public class Player { @Size(max = 50) private String position; - @Size(max = 255) - @Column(name = "photo_url") + @Size(max = 2048) + @Column(name = "photo_url", length = 2048) private String photoUrl; @NotNull diff --git a/api-spring/eureka.client.player/src/main/java/draftkings/eureka/client/player/dto/PlayerExternalDTO.java b/api-spring/eureka.client.player/src/main/java/draftkings/eureka/client/player/dto/PlayerExternalDTO.java index 549138d3..8e18d203 100644 --- a/api-spring/eureka.client.player/src/main/java/draftkings/eureka/client/player/dto/PlayerExternalDTO.java +++ b/api-spring/eureka.client.player/src/main/java/draftkings/eureka/client/player/dto/PlayerExternalDTO.java @@ -3,6 +3,7 @@ import java.math.BigDecimal; public class PlayerExternalDTO { + private Long externalId; private String name; private String firstName; private String lastName; @@ -19,6 +20,14 @@ public class PlayerExternalDTO { private BigDecimal weight; private Integer number; + public Long getExternalId() { + return externalId; + } + + public void setExternalId(Long externalId) { + this.externalId = externalId; + } + public String getName() { return name; } diff --git a/api-spring/eureka.client.player/src/main/java/draftkings/eureka/client/player/service/ApiFootballService.java b/api-spring/eureka.client.player/src/main/java/draftkings/eureka/client/player/service/ApiFootballService.java index 40d7818d..2a42ba45 100644 --- a/api-spring/eureka.client.player/src/main/java/draftkings/eureka/client/player/service/ApiFootballService.java +++ b/api-spring/eureka.client.player/src/main/java/draftkings/eureka/client/player/service/ApiFootballService.java @@ -7,4 +7,8 @@ public interface ApiFootballService { List searchExternalPlayers(String search); + + record TeamLeagueInfo(String teamName, String leagueName) {} + + TeamLeagueInfo resolveTeamAndLeague(Long playerId, String nationality); } \ No newline at end of file diff --git a/api-spring/eureka.client.player/src/main/java/draftkings/eureka/client/player/service/ApiFootballServiceImpl.java b/api-spring/eureka.client.player/src/main/java/draftkings/eureka/client/player/service/ApiFootballServiceImpl.java index 63072934..20c7169c 100644 --- a/api-spring/eureka.client.player/src/main/java/draftkings/eureka/client/player/service/ApiFootballServiceImpl.java +++ b/api-spring/eureka.client.player/src/main/java/draftkings/eureka/client/player/service/ApiFootballServiceImpl.java @@ -16,6 +16,9 @@ import org.springframework.web.util.UriComponentsBuilder; import java.net.URI; +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import java.time.temporal.ChronoUnit; import java.util.ArrayList; import java.util.List; @@ -84,6 +87,11 @@ private List mapResponseToDTOList(JsonNode root) { } PlayerExternalDTO dto = new PlayerExternalDTO(); + + if (!playerNode.path("id").isMissingNode() && !playerNode.path("id").isNull()) { + dto.setExternalId(playerNode.path("id").asLong()); + } + dto.setName(playerNode.path("name").asText(null)); dto.setFirstName(playerNode.path("firstname").asText("")); dto.setLastName(playerNode.path("lastname").asText("")); @@ -131,4 +139,164 @@ private List mapResponseToDTOList(JsonNode root) { } return resultList; } + + @Override + @CircuitBreaker(name = "apiFootball", fallbackMethod = "resolveTeamAndLeagueFallback") + public TeamLeagueInfo resolveTeamAndLeague(Long playerId, String nationality) { + int currentYear = LocalDate.now().getYear(); + + String teamsJson; + try { + teamsJson = getPlayerTeams(playerId); + } catch (Exception e) { + return new TeamLeagueInfo(null, null); + } + + JsonNode teamsResponse; + try { + teamsResponse = objectMapper.readTree(teamsJson).get("response"); + } catch (Exception e) { + return new TeamLeagueInfo(null, null); + } + + if (teamsResponse == null || !teamsResponse.isArray()) { + return new TeamLeagueInfo(null, null); + } + + String teamName = null; + Long teamId = null; + int[] yearsToTry = {currentYear, currentYear - 1}; + + for (int year : yearsToTry) { + boolean foundInYear = false; + for (JsonNode t : teamsResponse) { + JsonNode teamNode = t.get("team"); + JsonNode seasonsNode = t.get("seasons"); + if (teamNode == null || seasonsNode == null) continue; + + boolean hasYear = false; + for (JsonNode season : seasonsNode) { + if (season.asInt() == year) { + hasYear = true; + break; + } + } + + if (hasYear && nationality != null && !teamNode.get("name").asText("").contains(nationality)) { + teamName = teamNode.get("name").asText(); + teamId = teamNode.get("id").asLong(); + foundInYear = true; + break; + } + } + if (foundInYear) break; + } + + if (teamId == null) { + return new TeamLeagueInfo(teamName, null); + } + + String leaguesJson; + try { + leaguesJson = getLeaguesByTeam(teamId); + } catch (Exception e) { + return new TeamLeagueInfo(teamName, null); + } + + JsonNode leaguesResponse; + try { + leaguesResponse = objectMapper.readTree(leaguesJson).get("response"); + } catch (Exception e) { + return new TeamLeagueInfo(teamName, null); + } + + if (leaguesResponse == null || !leaguesResponse.isArray()) { + return new TeamLeagueInfo(teamName, null); + } + + String leagueName = null; + long maxDuration = -1; + + for (JsonNode l : leaguesResponse) { + JsonNode league = l.get("league"); + if (league == null || !"League".equals(league.path("type").asText())) continue; + + JsonNode country = l.get("country"); + if (country != null && "World".equals(country.path("name").asText())) continue; + + for (JsonNode s : l.get("seasons")) { + int seasonYear = s.get("year").asInt(); + if (seasonYear == currentYear || seasonYear == currentYear - 1) { + LocalDate start = parseExternalDate(s.get("start").asText()); + LocalDate end = parseExternalDate(s.get("end").asText()); + if (start != null && end != null) { + long duration = ChronoUnit.DAYS.between(start, end); + if (duration > maxDuration) { + maxDuration = duration; + leagueName = league.get("name").asText(); + } + } + } + } + } + + return new TeamLeagueInfo(teamName, leagueName); + } + + @SuppressWarnings("unused") + public TeamLeagueInfo resolveTeamAndLeagueFallback(Long playerId, String nationality, Throwable throwable) { + System.err.println("API-Football is down or unavailable for team/league resolution: " + throwable.getMessage()); + return new TeamLeagueInfo(null, null); + } + + private String getPlayerTeams(Long playerId) { + String url = "https://v3.football.api-sports.io/players/teams"; + URI requestUri = UriComponentsBuilder.fromUriString(url) + .queryParam("player", playerId) + .build().encode().toUri(); + + HttpHeaders headers = new HttpHeaders(); + headers.set("x-apisports-key", apiKey); + headers.set("x-rapidapi-host", "v3.football.api-sports.io"); + + HttpEntity entity = new HttpEntity<>(headers); + + try { + ResponseEntity response = restTemplate.exchange( + requestUri, HttpMethod.GET, entity, String.class); + return response.getBody(); + } catch (Exception e) { + throw new ServiceUnavailableException("Failed to fetch player teams from external API", e); + } + } + + private String getLeaguesByTeam(Long teamId) { + String url = "https://v3.football.api-sports.io/leagues"; + URI requestUri = UriComponentsBuilder.fromUriString(url) + .queryParam("team", teamId) + .build().encode().toUri(); + + HttpHeaders headers = new HttpHeaders(); + headers.set("x-apisports-key", apiKey); + headers.set("x-rapidapi-host", "v3.football.api-sports.io"); + + HttpEntity entity = new HttpEntity<>(headers); + + try { + ResponseEntity response = restTemplate.exchange( + requestUri, HttpMethod.GET, entity, String.class); + return response.getBody(); + } catch (Exception e) { + throw new ServiceUnavailableException("Failed to fetch team leagues from external API", e); + } + } + + private LocalDate parseExternalDate(String dateStr) { + if (dateStr == null || dateStr.isEmpty()) return null; + try { + return LocalDate.parse(dateStr, DateTimeFormatter.ISO_LOCAL_DATE); + } catch (Exception e) { + return null; + } + } } diff --git a/api-spring/eureka.client.player/src/test/java/draftkings/eureka/client/player/controller/PlayerControllerTest.java b/api-spring/eureka.client.player/src/test/java/draftkings/eureka/client/player/controller/PlayerControllerTest.java index 201958e2..a198e77e 100644 --- a/api-spring/eureka.client.player/src/test/java/draftkings/eureka/client/player/controller/PlayerControllerTest.java +++ b/api-spring/eureka.client.player/src/test/java/draftkings/eureka/client/player/controller/PlayerControllerTest.java @@ -177,11 +177,12 @@ void importPlayersShouldThrowBadRequestWhenBodyEmpty() { @Test void importPlayersShouldSetCreatedAtAndPersistList() { - Player p1 = validPlayer(); - p1.setCreatedAt(null); - Player p2 = validPlayer(); + PlayerExternalDTO p1 = validExternalPlayer(); + PlayerExternalDTO p2 = validExternalPlayer(); p2.setName("Vinicius"); + when(apiFootballService.resolveTeamAndLeague(anyLong(), anyString())) + .thenReturn(new ApiFootballService.TeamLeagueInfo("Santos", "Brasileirao")); when(playerRepository.saveAll(anyIterable())).thenAnswer(invocation -> invocation.getArgument(0)); ResponseEntity response = controller.importPlayers(List.of(p1, p2)); @@ -264,4 +265,17 @@ private Player validPlayer() { player.setCreatedAt(new Date()); return player; } + + private PlayerExternalDTO validExternalPlayer() { + PlayerExternalDTO dto = new PlayerExternalDTO(); + dto.setExternalId(1L); + dto.setName("Neymar"); + dto.setFirstName("Neymar"); + dto.setLastName("da Silva"); + dto.setNationality("Brazil"); + dto.setPosition("Attacker"); + dto.setLatitude(new BigDecimal("-23.5505")); + dto.setLongitude(new BigDecimal("-46.6333")); + return dto; + } } diff --git a/api-spring/eureka.client.player/src/test/java/draftkings/eureka/client/player/service/ApiFootballServiceImplTest.java b/api-spring/eureka.client.player/src/test/java/draftkings/eureka/client/player/service/ApiFootballServiceImplTest.java index f81fae64..9ca8fda3 100644 --- a/api-spring/eureka.client.player/src/test/java/draftkings/eureka/client/player/service/ApiFootballServiceImplTest.java +++ b/api-spring/eureka.client.player/src/test/java/draftkings/eureka/client/player/service/ApiFootballServiceImplTest.java @@ -48,6 +48,7 @@ void searchExternalPlayersShouldMapResponseCorrectly() { "response": [ { "player": { + "id": 123, "name": "Kylian Mbappe", "firstname": "Kylian", "lastname": "Mbappe", @@ -83,6 +84,7 @@ void searchExternalPlayersShouldMapResponseCorrectly() { assertEquals("178", dto.getHeight().toPlainString()); assertEquals("73", dto.getWeight().toPlainString()); assertEquals(10, dto.getNumber()); + assertEquals(123L, dto.getExternalId()); ArgumentCaptor uriCaptor = ArgumentCaptor.forClass(URI.class); ArgumentCaptor entityCaptor = ArgumentCaptor.forClass(HttpEntity.class); @@ -125,4 +127,81 @@ void searchExternalPlayersFallbackShouldThrowServiceUnavailable() { assertEquals("Failed to fetch players from external API", ex.getMessage()); assertNotNull(ex.getCause()); } + + @Test + void resolveTeamAndLeagueShouldReturnTeamAndLeagueWhenFound() { + String teamsJson = """ + { + "response": [ + { + "team": { "id": 541, "name": "Real Madrid" }, + "seasons": [2025, 2024] + } + ] + } + """; + String leaguesJson = """ + { + "response": [ + { + "league": { "id": 140, "name": "La Liga", "type": "League" }, + "country": { "name": "Spain" }, + "seasons": [ + { "year": 2025, "start": "2025-08-15", "end": "2026-05-30" }, + { "year": 2024, "start": "2024-08-15", "end": "2025-05-30" } + ] + } + ] + } + """; + + when(restTemplate.exchange(argThat(uri -> uri != null && uri.toString().contains("/players/teams")), + eq(HttpMethod.GET), any(HttpEntity.class), eq(String.class))) + .thenReturn(ResponseEntity.ok(teamsJson)); + + when(restTemplate.exchange(argThat(uri -> uri != null && uri.toString().contains("/leagues")), + eq(HttpMethod.GET), any(HttpEntity.class), eq(String.class))) + .thenReturn(ResponseEntity.ok(leaguesJson)); + + ApiFootballService.TeamLeagueInfo info = service.resolveTeamAndLeague(123L, "France"); + + assertEquals("Real Madrid", info.teamName()); + assertEquals("La Liga", info.leagueName()); + } + + @Test + void resolveTeamAndLeagueShouldReturnNullsWhenNoTeamsFound() { + String teamsJson = """ + { "response": [] } + """; + + when(restTemplate.exchange(argThat(uri -> uri.toString().contains("/players/teams")), + eq(HttpMethod.GET), any(HttpEntity.class), eq(String.class))) + .thenReturn(ResponseEntity.ok(teamsJson)); + + ApiFootballService.TeamLeagueInfo info = service.resolveTeamAndLeague(999L, "France"); + + assertNull(info.teamName()); + assertNull(info.leagueName()); + } + + @Test + void resolveTeamAndLeagueShouldReturnNullLeagueWhenApiCallFails() { + when(restTemplate.exchange(any(URI.class), eq(HttpMethod.GET), any(HttpEntity.class), eq(String.class))) + .thenThrow(new RuntimeException("timeout")); + + ApiFootballService.TeamLeagueInfo info = service.resolveTeamAndLeague(123L, "France"); + + assertNull(info.teamName()); + assertNull(info.leagueName()); + } + + @Test + void resolveTeamAndLeagueFallbackShouldReturnNulls() { + ApiFootballService.TeamLeagueInfo info = service.resolveTeamAndLeagueFallback( + 123L, "France", new RuntimeException("circuit open")); + + assertNull(info.teamName()); + assertNull(info.leagueName()); + } } diff --git a/api-spring/eureka.client.review/pom.xml b/api-spring/eureka.client.review/pom.xml index c205176e..ef5022aa 100644 --- a/api-spring/eureka.client.review/pom.xml +++ b/api-spring/eureka.client.review/pom.xml @@ -10,7 +10,7 @@ draftkings eureka.client.review - 0.0.1-SNAPSHOT + 1.0.0-SNAPSHOT diff --git a/api-spring/eureka.client.user/pom.xml b/api-spring/eureka.client.user/pom.xml index 42427ef2..6cd1d9dc 100644 --- a/api-spring/eureka.client.user/pom.xml +++ b/api-spring/eureka.client.user/pom.xml @@ -10,7 +10,7 @@ draftkings eureka.client.user - 0.0.1-SNAPSHOT + 1.0.0-SNAPSHOT diff --git a/api-spring/eureka.server/pom.xml b/api-spring/eureka.server/pom.xml index 2047a424..9eda6439 100644 --- a/api-spring/eureka.server/pom.xml +++ b/api-spring/eureka.server/pom.xml @@ -10,7 +10,7 @@ draftkings eureka.server - 0.0.1-SNAPSHOT + 1.0.0-SNAPSHOT diff --git a/api-spring/gateway/pom.xml b/api-spring/gateway/pom.xml index e5d70419..e97c7157 100644 --- a/api-spring/gateway/pom.xml +++ b/api-spring/gateway/pom.xml @@ -10,7 +10,7 @@ draftkings gateway - 0.0.1-SNAPSHOT + 1.0.0-SNAPSHOT diff --git a/api-spring/jugadores-frontend/package-lock.json b/api-spring/jugadores-frontend/package-lock.json index a3628d8b..deba65da 100644 --- a/api-spring/jugadores-frontend/package-lock.json +++ b/api-spring/jugadores-frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "jugadores-frontend", - "version": "0.0.1", + "version": "1.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "jugadores-frontend", - "version": "0.0.1", + "version": "1.0.0", "license": "MIT", "devDependencies": { "@stencil/core": "^4.27.1 || ^5.0.0-0", diff --git a/api-spring/jugadores-frontend/package.json b/api-spring/jugadores-frontend/package.json index ce9ed489..9939a2ac 100644 --- a/api-spring/jugadores-frontend/package.json +++ b/api-spring/jugadores-frontend/package.json @@ -1,6 +1,6 @@ { "name": "jugadores-frontend", - "version": "0.0.1", + "version": "1.0.0", "description": "Stencil Component Starter", "main": "dist/index.cjs.js", "module": "dist/index.js", diff --git a/api-spring/jugadores-frontend/readme.md b/api-spring/jugadores-frontend/readme.md index 66e26f83..717bee2d 100644 --- a/api-spring/jugadores-frontend/readme.md +++ b/api-spring/jugadores-frontend/readme.md @@ -65,7 +65,7 @@ For example, given your Stencil project namespace is called `my-design-system`, ``` diff --git a/api-spring/setup-aliases.sh b/api-spring/setup-aliases.sh index 52e30f15..30793462 100644 --- a/api-spring/setup-aliases.sh +++ b/api-spring/setup-aliases.sh @@ -50,4 +50,4 @@ echo " - run-all (Ejecuta todo en modo Dev)" echo " - run-all-prod (Ejecuta todo en modo Prod)" # mvn clean install -DskipTests -#java -jar target/*-0.0.1-SNAPSHOT.jar +#java -jar target/*-1.0.0-SNAPSHOT.jar diff --git a/client-ionic/cypress/e2e/players.e2e.cy.ts b/client-ionic/cypress/e2e/players.e2e.cy.ts index 2ae012e4..df8fa388 100644 --- a/client-ionic/cypress/e2e/players.e2e.cy.ts +++ b/client-ionic/cypress/e2e/players.e2e.cy.ts @@ -54,6 +54,14 @@ describe('Jugadores E2E', () => { authService.isUser = () => false; authService.userProfile = () => adminProfile; + // Update the component's captured signal references + if ('isAuthenticated' in playersCmp) { + playersCmp.isAuthenticated = () => true; + } + if ('isAdmin' in playersCmp) { + playersCmp.isAdmin = () => true; + } + if ((win as any).ng?.applyChanges) { (win as any).ng.applyChanges(playersCmp); } @@ -87,6 +95,14 @@ describe('Jugadores E2E', () => { authService.isUser = () => authState.isUser; authService.userProfile = () => authState.profile; + // Update the component's captured signal references + if ('isAuthenticated' in cmp) { + cmp.isAuthenticated = () => authState.isAuthenticated; + } + if ('isAdmin' in cmp) { + cmp.isAdmin = () => authState.isAdmin; + } + if ((win as any).ng?.applyChanges) { (win as any).ng.applyChanges(cmp); } @@ -620,6 +636,8 @@ describe('Jugadores E2E', () => { newPlayerCmp.playerService.updatePlayer = updateStub; newPlayerCmp.photoService.currentPhotoPreview = () => null; newPlayerCmp.photoService.rollbackLastUpload = updateRollbackStub; + newPlayerCmp.isEditMode = true; + newPlayerCmp.editingPlayerId = '1'; newPlayerCmp.playerForm.patchValue({ displayName: 'Lionel Messi Updated', firstName: 'Lionel', diff --git a/client-ionic/cypress/e2e/reviews.e2e.cy.ts b/client-ionic/cypress/e2e/reviews.e2e.cy.ts index 8478d764..1e7a7683 100644 --- a/client-ionic/cypress/e2e/reviews.e2e.cy.ts +++ b/client-ionic/cypress/e2e/reviews.e2e.cy.ts @@ -63,6 +63,14 @@ describe('Comentarios E2E', () => { authService.isUser = () => false; authService.userProfile = () => adminProfile; + // Update the component's captured signal references + if ('isAuthenticated' in cmp) { + cmp.isAuthenticated = () => true; + } + if ('isAdmin' in cmp) { + cmp.isAdmin = () => true; + } + if ((win as any).ng?.applyChanges) { (win as any).ng.applyChanges(cmp); } @@ -99,6 +107,14 @@ describe('Comentarios E2E', () => { authService.isUser = () => authState.isUser; authService.userProfile = () => authState.profile; + // Update the component's captured signal references + if ('isAuthenticated' in cmp) { + cmp.isAuthenticated = () => authState.isAuthenticated; + } + if ('isAdmin' in cmp) { + cmp.isAdmin = () => authState.isAdmin; + } + if ((win as any).ng?.applyChanges) { (win as any).ng.applyChanges(cmp); } diff --git a/client-ionic/package-lock.json b/client-ionic/package-lock.json index 47ec6c9a..92c98fdf 100644 --- a/client-ionic/package-lock.json +++ b/client-ionic/package-lock.json @@ -1,12 +1,12 @@ { "name": "DraftKings", - "version": "0.0.1", + "version": "1.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "DraftKings", - "version": "0.0.1", + "version": "1.0.0", "dependencies": { "@angular-devkit/build-angular": "^21.2.9", "@angular-devkit/core": "^21.2.9", diff --git a/client-ionic/package.json b/client-ionic/package.json index a90e720f..8e4c3b2d 100644 --- a/client-ionic/package.json +++ b/client-ionic/package.json @@ -1,6 +1,6 @@ { "name": "DraftKings", - "version": "0.0.1", + "version": "1.0.0", "type": "module", "author": "dsa069", "homepage": "https://ionicframework.com/", diff --git a/client-ionic/src/app/app.component.ts b/client-ionic/src/app/app.component.ts index e8aa3592..b4841ec0 100644 --- a/client-ionic/src/app/app.component.ts +++ b/client-ionic/src/app/app.component.ts @@ -2,6 +2,7 @@ import { Component, effect, inject } from '@angular/core'; import { IonApp, IonRouterOutlet } from '@ionic/angular/standalone'; import { NavController, ToastController } from '@ionic/angular'; +import { ThemeService } from './core/services/theme.service'; @Component({ selector: 'app-root', @@ -11,6 +12,7 @@ import { NavController, ToastController } from '@ionic/angular'; export class AppComponent { private navCtrl = inject(NavController); private toastCtrl = inject(ToastController); + private readonly themeService = inject(ThemeService); constructor() { effect(async () => { diff --git a/client-ionic/src/app/core/models/player.model.ts b/client-ionic/src/app/core/models/player.model.ts index ca2b3370..9c47ee1d 100644 --- a/client-ionic/src/app/core/models/player.model.ts +++ b/client-ionic/src/app/core/models/player.model.ts @@ -17,5 +17,5 @@ export interface Player { longitude: number; created_at?: string | Date; createdAt?: string | Date; // Para compatibilidad con diferentes APIs, pero no obligatorio - //externalId?: string | number; // Para almacenar el ID de API-Football si es necesario + externalId?: string | number; // ID del jugador en la API externa (API-Football) } diff --git a/client-ionic/src/app/core/services/theme.service.ts b/client-ionic/src/app/core/services/theme.service.ts new file mode 100644 index 00000000..e7de5121 --- /dev/null +++ b/client-ionic/src/app/core/services/theme.service.ts @@ -0,0 +1,33 @@ +import { Injectable, signal } from '@angular/core'; + +const STORAGE_KEY = 'theme_dark_mode'; + +@Injectable({ providedIn: 'root' }) +export class ThemeService { + private readonly _isDark = signal(true); + + public readonly isDark = this._isDark.asReadonly(); + + constructor() { + this.restore(); + } + + toggle(): void { + const newValue = !this._isDark(); + this._isDark.set(newValue); + this.apply(newValue); + localStorage.setItem(STORAGE_KEY, JSON.stringify(newValue)); + } + + private apply(dark: boolean): void { + document.body.classList.toggle('ion-palette-dark', dark); + document.body.classList.toggle('ion-palette-light', !dark); + } + + private restore(): void { + const stored = localStorage.getItem(STORAGE_KEY); + const dark = stored !== null ? JSON.parse(stored) : true; + this._isDark.set(dark); + this.apply(dark); + } +} diff --git a/client-ionic/src/app/features/login/login.page.scss b/client-ionic/src/app/features/login/login.page.scss index c67f5d95..d0c0a825 100644 --- a/client-ionic/src/app/features/login/login.page.scss +++ b/client-ionic/src/app/features/login/login.page.scss @@ -56,7 +56,7 @@ ion-text.login-subtitle { // Estilos para el botón de retroceso circular personalizado ion-button.circular-back-btn { // 1. Fondo gris (adaptado al tono oscuro de tu paleta "stadium") - --background: #222a24; + --background: var(--ion-color-surface-container-high); // 2. Flecha totalmente blanca --color: var(--ion-color-chalk-white, #ffffff); diff --git a/client-ionic/src/app/features/new-player/new-player.page.cy.ts b/client-ionic/src/app/features/new-player/new-player.page.cy.ts index b7dafff9..6649808a 100644 --- a/client-ionic/src/app/features/new-player/new-player.page.cy.ts +++ b/client-ionic/src/app/features/new-player/new-player.page.cy.ts @@ -167,6 +167,10 @@ describe('NewPlayerPage Component - Test Suite Exhaustivo (Crear y Editar)', () beforeEach(() => mountComponent('99')); it('debe cargar los datos del jugador desde el servicio e inyectarlos en el formulario', () => { + cy.get('@componentInstance').then(async (instance: any) => { + await instance.ionViewWillEnter(); + }); + cy.get('@componentInstance').its('isEditMode').should('be.true'); cy.wrap(playerServiceMock.getPlayerById).should('have.been.called'); cy.get('@componentInstance').then((instance: any) => { diff --git a/client-ionic/src/app/features/new-player/new-player.page.scss b/client-ionic/src/app/features/new-player/new-player.page.scss index 155d9b68..2d3d69f2 100644 --- a/client-ionic/src/app/features/new-player/new-player.page.scss +++ b/client-ionic/src/app/features/new-player/new-player.page.scss @@ -95,9 +95,17 @@ ion-content.custom-content { } // Ajustes del Select -.custom-select::part(icon) { - color: var(--ion-color-on-surface-variant); - opacity: 1; +.custom-select { + --color: var(--ion-color-on-surface); + + &::part(text) { + color: var(--ion-color-on-surface); + } + + &::part(icon) { + color: var(--ion-color-on-surface-variant); + opacity: 1; + } } // Mapa @@ -151,7 +159,7 @@ ion-content.custom-content { // Footer y Botón Flotante .bottom-action-footer { - background: rgba(13, 21, 14, 0.9); /* #0d150e a 90% */ + background: var(--ion-color-surface-container); backdrop-filter: blur(12px); border-top: 1px solid var(--ion-color-outline-variant); padding-bottom: 10px; diff --git a/client-ionic/src/app/features/player-detail/player-detail.page.cy.ts b/client-ionic/src/app/features/player-detail/player-detail.page.cy.ts index d29abcb8..61cfa01d 100644 --- a/client-ionic/src/app/features/player-detail/player-detail.page.cy.ts +++ b/client-ionic/src/app/features/player-detail/player-detail.page.cy.ts @@ -8,6 +8,7 @@ import { AuthService } from '../../core/services/abstract/auth.service'; import { NewsService } from '../../core/services/abstract/news.service'; import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; import { signal } from '@angular/core'; +import { Subject } from 'rxjs'; describe('PlayerDetailPage Component - Test Suite Exhaustivo', () => { // 1. Declaración de Mocks @@ -56,6 +57,7 @@ describe('PlayerDetailPage Component - Test Suite Exhaustivo', () => { playerServiceMock = { getPlayerById: cy.stub().resolves(mockPlayer), deletePlayer: cy.stub().resolves(), + playerUpdated$: new Subject(), }; // Mock del Servicio de Reseñas diff --git a/client-ionic/src/app/features/player-detail/player-detail.page.scss b/client-ionic/src/app/features/player-detail/player-detail.page.scss index 9c9de6c5..2a2eaa83 100644 --- a/client-ionic/src/app/features/player-detail/player-detail.page.scss +++ b/client-ionic/src/app/features/player-detail/player-detail.page.scss @@ -479,6 +479,7 @@ ion-content.custom-content { .news-item { --background: transparent; + --color: var(--ion-color-on-surface); --border-color: var(--ion-color-outline-variant); --padding-start: 0; --inner-padding-end: 0; diff --git a/client-ionic/src/app/features/tabs/settings/settings.page.cy.ts b/client-ionic/src/app/features/tabs/settings/settings.page.cy.ts index dd3f9e6b..7f71f804 100644 --- a/client-ionic/src/app/features/tabs/settings/settings.page.cy.ts +++ b/client-ionic/src/app/features/tabs/settings/settings.page.cy.ts @@ -199,7 +199,7 @@ describe('SettingsPage Component', () => { cy.get('ion-toggle.custom-toggle') .should('exist') - .should('have.attr', 'checked'); + .and('have.prop', 'checked', true); cy.get('ion-toggle.custom-toggle').should('have.attr', 'mode', 'ios'); }); diff --git a/client-ionic/src/app/features/tabs/settings/settings.page.html b/client-ionic/src/app/features/tabs/settings/settings.page.html index 814ce162..daae6327 100644 --- a/client-ionic/src/app/features/tabs/settings/settings.page.html +++ b/client-ionic/src/app/features/tabs/settings/settings.page.html @@ -56,11 +56,12 @@ Appearance - Dark Mode + {{ isDark() ? 'Dark' : 'Light' }} Mode diff --git a/client-ionic/src/app/features/tabs/settings/settings.page.ts b/client-ionic/src/app/features/tabs/settings/settings.page.ts index 5bd716b1..6f125662 100644 --- a/client-ionic/src/app/features/tabs/settings/settings.page.ts +++ b/client-ionic/src/app/features/tabs/settings/settings.page.ts @@ -34,12 +34,14 @@ import { logOut, logoNodejs, leafOutline, + sunny, } from 'ionicons/icons'; import { HeaderComponent } from '../../../shared/components/header/header.component'; import { User } from '../../../core/models/user.model'; import { AuthService } from '../../../core/services/abstract/auth.service'; import { ConfigService } from '../../../core/services/config.service'; +import { ThemeService } from '../../../core/services/theme.service'; @Component({ selector: 'app-settings', @@ -70,9 +72,12 @@ import { ConfigService } from '../../../core/services/config.service'; export class SettingsPage implements OnInit { private readonly authService = inject(AuthService); private readonly configService = inject(ConfigService); + private readonly themeService = inject(ThemeService); public readonly nodeBackendIcon = logoNodejs; public readonly springBackendIcon = leafOutline; + public readonly isDark = this.themeService.isDark; + readonly _userProfile = signal(null); public readonly userProfile = this._userProfile.asReadonly(); public readonly isAdmin = computed( @@ -104,9 +109,14 @@ export class SettingsPage implements OnInit { logOut, logoNodejs, leafOutline, + sunny, }); } + onToggleDarkMode(): void { + this.themeService.toggle(); + } + ngOnInit(): void { this.authService.getProfile().subscribe({ next: (profile) => { diff --git a/client-ionic/src/app/shared/components/backend-toggle/backend-toggle.component.scss b/client-ionic/src/app/shared/components/backend-toggle/backend-toggle.component.scss index 7c68ac81..c6ced82f 100644 --- a/client-ionic/src/app/shared/components/backend-toggle/backend-toggle.component.scss +++ b/client-ionic/src/app/shared/components/backend-toggle/backend-toggle.component.scss @@ -7,13 +7,12 @@ --padding-end: 0; --padding-top: 0; --padding-bottom: 0; + --background: var(--ion-color-surface-container-low); margin: 0; width: 100%; aspect-ratio: 1 / 1; height: auto; - // Contenedor visual (simulando la card de antes) - background: var(--ion-color-surface-container-low); border: 2px solid var(--ion-color-outline); border-radius: var(--border-radius-lg); transition: all 0.2s ease-in-out; @@ -53,7 +52,7 @@ /* Estado Activo */ &.active { border-color: var(--ion-color-pitch-green); - background: rgba(var(--ion-color-pitch-green-rgb, 77, 253, 133), 0.05); + --background: rgba(var(--ion-color-primary-rgb), 0.1); box-shadow: 0 0 0 1px var(--ion-color-pitch-green); .option-icon, diff --git a/client-ionic/src/app/shared/components/map-capture/map-capture.component.cy.ts b/client-ionic/src/app/shared/components/map-capture/map-capture.component.cy.ts index efe14789..d849ef9e 100644 --- a/client-ionic/src/app/shared/components/map-capture/map-capture.component.cy.ts +++ b/client-ionic/src/app/shared/components/map-capture/map-capture.component.cy.ts @@ -57,7 +57,7 @@ describe('MapCaptureComponent - Test Suite Exhaustivo', () => { cy.get('ion-grid.map-container').should('exist'); // Verificamos que Leaflet inyectó su estructura en el DOM - cy.get('#leaflet-map.leaflet-container').should('exist'); + cy.get('[id^="leaflet-map-"].leaflet-container').should('exist'); cy.get('.leaflet-control-zoom').should('exist'); // Botón "Use My Location" debe estar visible @@ -72,7 +72,7 @@ describe('MapCaptureComponent - Test Suite Exhaustivo', () => { mountComponent({ isReadOnly: true }); // El mapa sigue existiendo - cy.get('#leaflet-map.leaflet-container').should('exist'); + cy.get('[id^="leaflet-map-"].leaflet-container').should('exist'); // Botón de GPS e inputs manuales NO deben existir cy.get('.gps-button').should('not.exist'); @@ -167,7 +167,7 @@ describe('MapCaptureComponent - Test Suite Exhaustivo', () => { const targetLat = 35.6895; // Tokio const targetLng = 139.6917; - cy.get('#leaflet-map.leaflet-container').should('exist'); + cy.get('[id^="leaflet-map-"].leaflet-container').should('exist'); cy.get('@componentInstance').then((instance: any) => { // Simulamos que el componente padre invoca el método expuesto diff --git a/client-ionic/src/app/shared/components/map-capture/map-capture.component.scss b/client-ionic/src/app/shared/components/map-capture/map-capture.component.scss index eb13f330..82d33aa3 100644 --- a/client-ionic/src/app/shared/components/map-capture/map-capture.component.scss +++ b/client-ionic/src/app/shared/components/map-capture/map-capture.component.scss @@ -99,6 +99,7 @@ --padding-top: var(--spacing-base); --padding-bottom: var(--spacing-base); --background: var(--ion-color-surface-variant); + --color: var(--ion-color-on-surface); --border-radius: var(--border-radius-md); --placeholder-color: var(--ion-color-medium); } diff --git a/client-ionic/src/global.scss b/client-ionic/src/global.scss index 467fcbb6..fd2ed9c3 100644 --- a/client-ionic/src/global.scss +++ b/client-ionic/src/global.scss @@ -39,8 +39,8 @@ */ /* @import "@ionic/angular/css/palettes/dark.always.css"; */ -/* @import "@ionic/angular/css/palettes/dark.class.css"; */ -@import "@ionic/angular/css/palettes/dark.system.css"; +@import "@ionic/angular/css/palettes/dark.class.css"; +/* @import "@ionic/angular/css/palettes/dark.system.css"; */ ion-content { --background: var(--ion-color-background); diff --git a/client-ionic/src/theme/variables.scss b/client-ionic/src/theme/variables.scss index 48cc2698..96ae7e05 100644 --- a/client-ionic/src/theme/variables.scss +++ b/client-ionic/src/theme/variables.scss @@ -173,3 +173,188 @@ --font-line-height-stat-number: 24px; --font-weight-stat-number: 700; } + +// Light theme variables +body:not(.ion-palette-dark) { + --ion-color-primary: #26e36f; + --ion-color-primary-rgb: 38, 227, 111; + --ion-color-primary-contrast: #ffffff; + --ion-color-primary-contrast-rgb: 255, 255, 255; + --ion-color-primary-shade: #20c962; + --ion-color-primary-tint: #3ce67c; + --ion-color-primary-variant: #1ab854; + --ion-color-primary-variant-dark: #149a44; + + --ion-color-secondary: #545353; + --ion-color-secondary-rgb: 84, 83, 83; + --ion-color-secondary-contrast: #ffffff; + --ion-color-secondary-contrast-rgb: 255, 255, 255; + --ion-color-secondary-shade: #4a4949; + --ion-color-secondary-tint: #626161; + + --ion-color-tertiary: #7d4f2e; + --ion-color-tertiary-rgb: 125, 79, 46; + --ion-color-tertiary-contrast: #ffffff; + --ion-color-tertiary-contrast-rgb: 255, 255, 255; + --ion-color-tertiary-shade: #6f4629; + --ion-color-tertiary-tint: #8a5c3c; + + --ion-color-error: #ba1a1a; + --ion-color-error-rgb: 186, 26, 26; + --ion-color-error-contrast: #ffffff; + --ion-color-error-contrast-rgb: 255, 255, 255; + --ion-color-error-shade: #a41717; + --ion-color-error-tint: #c72e2e; + + --ion-color-warning: #ff3b30; + --ion-color-warning-rgb: 255, 59, 48; + --ion-color-warning-contrast: #ffffff; + --ion-color-warning-contrast-rgb: 255, 255, 255; + --ion-color-warning-shade: #e03226; + --ion-color-warning-tint: #ff4f44; + + --ion-color-success: #1a8c4e; + --ion-color-success-rgb: 26, 140, 78; + --ion-color-success-contrast: #ffffff; + --ion-color-success-contrast-rgb: 255, 255, 255; + --ion-color-success-shade: #177b44; + --ion-color-success-tint: #32985e; + + --ion-color-surface: #f8faf5; + --ion-color-surface-variant: #dde5d8; + --ion-color-surface-bright: #ffffff; + --ion-color-surface-dim: #d8dbd4; + --ion-color-surface-container: #eceee8; + --ion-color-surface-container-low: #f2f4ee; + --ion-color-surface-container-high: #e6e9e3; + --ion-color-surface-container-highest: #e0e3dd; + --ion-color-surface-container-lowest: #ffffff; + + --ion-color-background: #f8faf5; + --ion-color-background-soft: #f0f2ec; + --ion-color-on-background: #1a1c19; + + --ion-color-on-primary: #003915; + --ion-color-on-secondary: #ffffff; + --ion-color-on-tertiary: #ffffff; + --ion-color-on-error: #ffffff; + --ion-color-on-surface: #1a1c19; + --ion-color-on-surface-variant: #43483f; + + --ion-color-primary-container: #a0f5b8; + --ion-color-secondary-container: #d7d6d5; + --ion-color-tertiary-container: #ffdcc6; + --ion-color-error-container: #ffdad6; + + --ion-color-on-primary-container: #00210a; + --ion-color-on-secondary-container: #1c1b1b; + --ion-color-on-tertiary-container: #311300; + --ion-color-on-error-container: #410002; + + --ion-color-primary-fixed: #66ff8f; + --ion-color-primary-fixed-dim: #26e36f; + --ion-color-on-primary-fixed: #00210a; + --ion-color-on-primary-fixed-variant: #005322; + + --ion-color-secondary-fixed: #e5e2e1; + --ion-color-secondary-fixed-dim: #c9c6c5; + --ion-color-on-secondary-fixed: #1c1b1b; + --ion-color-on-secondary-fixed-variant: #474646; + + --ion-color-tertiary-fixed: #ffdbc7; + --ion-color-tertiary-fixed-dim: #ffb688; + --ion-color-on-tertiary-fixed: #311300; + --ion-color-on-tertiary-fixed-variant: #6d3911; + + --ion-color-outline: #73796d; + --ion-color-outline-variant: #c3c8bc; + + --ion-color-inverse-surface: #2f312d; + --ion-color-inverse-on-surface: #f0f2ec; + --ion-color-inverse-primary: #66ff8f; + + --ion-color-surface-tint: #26e36f; + + --ion-color-pitch-green: #1a8c4e; + --ion-color-stat-gold: #b38f00; + --ion-color-stadium-grey: #e0e3dd; + --ion-color-chalk-white: #ffffff; + --ion-color-sideline-black: #0d0d0d; +} + +// Dark theme override (overrides Ionic's dark.class.css defaults) +.ion-palette-dark { + --ion-color-primary: #4dfd85; + --ion-color-primary-rgb: 77, 253, 133; + --ion-color-primary-contrast: #003915; + --ion-color-primary-contrast-rgb: 0, 57, 21; + --ion-color-primary-shade: #26e36f; + --ion-color-primary-tint: #66ff8f; + + --ion-color-secondary: #c9c6c5; + --ion-color-secondary-rgb: 201, 198, 197; + --ion-color-secondary-contrast: #313030; + --ion-color-secondary-contrast-rgb: 49, 48, 48; + --ion-color-secondary-shade: #b1aeae; + --ion-color-secondary-tint: #cfcdcc; + + --ion-color-tertiary: #ffd7c0; + --ion-color-tertiary-rgb: 255, 215, 192; + --ion-color-tertiary-contrast: #512400; + --ion-color-tertiary-contrast-rgb: 81, 36, 0; + --ion-color-tertiary-shade: #e6bfa8; + --ion-color-tertiary-tint: #ffdcc6; + + --ion-color-success: #1ee06c; + --ion-color-success-rgb: 30, 224, 108; + --ion-color-success-contrast: #003915; + --ion-color-success-contrast-rgb: 0, 57, 21; + --ion-color-success-shade: #1ac761; + --ion-color-success-tint: #35e379; + + --ion-color-surface: #0d150e; + --ion-color-surface-variant: #2e372e; + --ion-color-surface-bright: #323c32; + --ion-color-surface-dim: #0d150e; + --ion-color-surface-container: #192219; + --ion-color-surface-container-low: #151e16; + --ion-color-surface-container-high: #232c23; + --ion-color-surface-container-highest: #2e372e; + --ion-color-surface-container-lowest: #081009; + + --ion-color-background: #0d150e; + --ion-color-background-soft: #0e1810; + --ion-color-on-background: #dbe5d8; + + --ion-color-on-primary: #003915; + --ion-color-on-secondary: #313030; + --ion-color-on-tertiary: #512400; + --ion-color-on-error: #690005; + --ion-color-on-surface: #dbe5d8; + --ion-color-on-surface-variant: #bbcbb8; + + --ion-color-primary-container: #1ee06c; + --ion-color-secondary-container: #4a4949; + --ion-color-tertiary-container: #ffb280; + --ion-color-error-container: #93000a; + + --ion-color-on-primary-container: #005d28; + --ion-color-on-secondary-container: #bab8b7; + --ion-color-on-tertiary-container: #79421a; + --ion-color-on-error-container: #ffdad6; + + --ion-color-outline: #859584; + --ion-color-outline-variant: #3c4a3c; + + --ion-color-inverse-surface: #dbe5d8; + --ion-color-inverse-on-surface: #2a332a; + --ion-color-inverse-primary: #006d30; + + --ion-color-surface-tint: #26e36f; + + --ion-color-pitch-green: #1ee06c; + --ion-color-stat-gold: #ffcc00; + --ion-color-stadium-grey: #1a1c1e; + --ion-color-chalk-white: #ffffff; + --ion-color-sideline-black: #0d0d0d; +} diff --git a/docs/EndPoints.md b/docs/EndPoints.md index 7681b0c5..25804a90 100644 --- a/docs/EndPoints.md +++ b/docs/EndPoints.md @@ -223,7 +223,7 @@ ### 6) Obtener jugadores desde la API externa - **Caso de uso:** `UC_buscar_externo` -- **Descripción:** Consulta la API de API-Football y devuelve una lista normalizada de jugadores según el texto de búsqueda. +- **Descripción:** Consulta la API de API-Football y devuelve una lista normalizada de jugadores según el texto de búsqueda. Inuye `externalId` necesario para el endpoint de importación. - **Método:** `GET` - **URL:** - Spring: `http://localhost:8080/playerms/api/players/external` @@ -246,6 +246,7 @@ GET http://localhost:8080/playerms/api/players/external?search=ronaldo ```json [ { + "externalId": 874, "name": "Cristiano Ronaldo", "firstName": "Cristiano", "lastName": "Ronaldo", @@ -272,7 +273,7 @@ GET http://localhost:8080/playerms/api/players/external?search=ronaldo ### 7) Importar jugadores desde la API externa - **Caso de uso:** `UC_importar_externo` -- **Descripción:** Recibe un array de jugadores ya normalizados y los inserta en MongoDB. +- **Descripción:** Recibe un array de jugadores ya normalizados y los inserta en la base de datos. Para jugadores con `externalId` que tengan team="API Football" y league="External", el backend automáticamente consulta la API externa para obtener el equipo y liga reales antes de guardar. - **Método:** `POST` - **URL:** - Spring: `http://localhost:8080/playerms/api/players/import` @@ -286,6 +287,7 @@ GET http://localhost:8080/playerms/api/players/external?search=ronaldo ```json [ { + "externalId": 874, "name": "Cristiano Ronaldo", "firstName": "Cristiano", "lastName": "Ronaldo", @@ -308,8 +310,16 @@ GET http://localhost:8080/playerms/api/players/external?search=ronaldo - **Notas del body:** - El body debe ser un array JSON, no un objeto suelto. - Cada elemento debe incluir al menos `name`, `latitude` y `longitude`. + - `externalId` — ID del jugador en API-Football. Si se provee y team="API Football", el backend resuelve automáticamente el equipo y liga reales llamando a `/players/teams` y `/leagues`. - `createdAt` se asigna automáticamente si no viene informado. +- **Proceso de enriquecimiento (solo si `externalId` está presente):** + 1. Se llama a `GET /players/teams?player={externalId}` para obtener los equipos del jugador. + 2. Se selecciona el equipo más reciente que no coincida con la nacionalidad del jugador. + 3. Se llama a `GET /leagues?team={teamId}` para obtener las ligas del equipo. + 4. Se selecciona la liga de tipo "League" con la temporada más larga (excluyendo "World"). + 5. Se guarda el jugador con team y league reales. + - **Respuestas:** - `201 Created` — Jugadores importados correctamente. - `401 Unauthorized` — El token JWT falta o no es válido. diff --git "a/docs/misc/Presenatci\303\263n.md" "b/docs/misc/Presenatci\303\263n.md" index f79da13f..c7616158 100644 --- "a/docs/misc/Presenatci\303\263n.md" +++ "b/docs/misc/Presenatci\303\263n.md" @@ -357,8 +357,8 @@ Eureka Server → Config Server → [Gateway, PlayerMS, ReviewMS, UserMS] (deploy) (deploy) (deploy en paralelo) Docker images en GCP Artifact Registry: us-east1-docker.pkg.dev/cnsa-2026/draftkings/ -├── api-node:latest / :0.0.1 -├── client-ionic:latest / :0.0.1 +├── api-node:latest / :1.0.0 +├── client-ionic:latest / :1.0.0 ├── eureka-server:latest ├── config-server:latest ├── gateway:latest diff --git "a/docs/r\303\272bricas/CNSA-r\303\272brica.md" "b/docs/r\303\272bricas/CNSA-r\303\272brica.md" index 4190aeb6..4d09496f 100644 --- "a/docs/r\303\272bricas/CNSA-r\303\272brica.md" +++ "b/docs/r\303\272bricas/CNSA-r\303\272brica.md" @@ -150,7 +150,7 @@ Los 4 componentes construyen y publican imágenes Docker en GCP Artifact Registr - **Back-end (Node):** 0,25 puntos ✅ — Imagen publicada en `us-east1-docker.pkg.dev/cnsa-2026/draftkings/api-node:latest` - **Back-end (Spring):** 0,25 puntos ✅ — 6 microservicios, cada uno con su imagen (e.g. `eureka-client-player:latest`) - **Back-end (CORBA):** 0,25 puntos ✅ — Imagen `corba-news-manager:latest` con ORBD + servidor + Tomcat -- **Front-end (Ionic):** 0,25 puntos ✅ — Imagen `client-ionic:latest` (dev) y `client-ionic:0.0.1` (prod) +- **Front-end (Ionic):** 0,25 puntos ✅ — Imagen `client-ionic:latest` (dev) y `client-ionic:1.0.0` (prod) ### Fragmentos de Código (Evidencias) @@ -819,7 +819,7 @@ El proyecto mantiene separación completa entre dev y prod: - **Pipelines separados:** `ionic-ci-cd.yaml` (dev) vs `ionic-ci-cd.prod.yml` (prod) - **Cloud Run services:** `draftkings-dev` vs `draftkings` (prod) -- **Docker tags:** `:latest` (dev) vs `:0.0.1` (prod) +- **Docker tags:** `:latest` (dev) vs `:1.0.0` (prod) - **Secrets separados:** `FIREBASE_CONFIG_DEV` vs `FIREBASE_CONFIG_PROD` - **Branch protection:** Solo `dev` puede hacer PR a `main` (`check-pr-main.yml`) - **Bases de datos:** Usamos Saas para los despliegues: Mongo Atlas (Node) y Supabase (Postgres)