From 6dfab5b6447e85403c0a48d0c6e9eef1eb3c6d49 Mon Sep 17 00:00:00 2001 From: gabriel Date: Thu, 14 May 2026 14:45:11 +0200 Subject: [PATCH 01/16] =?UTF-8?q?fix:=20sprint=20testing=20issue=20?= =?UTF-8?q?=E2=80=94=20add=20synchronize=20trigger=20+=20dedup=20check?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/sprint-testing.yml | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/.github/workflows/sprint-testing.yml b/.github/workflows/sprint-testing.yml index 4d32959..3938331 100644 --- a/.github/workflows/sprint-testing.yml +++ b/.github/workflows/sprint-testing.yml @@ -3,7 +3,7 @@ name: Sprint Testing Issue on: pull_request: branches: [main] - types: [opened, reopened] + types: [opened, reopened, synchronize] jobs: create-testing-issue: @@ -33,6 +33,21 @@ jobs: with: script: | const pr = context.payload.pull_request; + + // Skip if a testing issue already exists for this PR + const existing = await github.rest.issues.listForRepo({ + owner: context.repo.owner, + repo: context.repo.repo, + labels: 'testing,sprint', + state: 'open', + }); + const alreadyExists = existing.data.some(i => + i.title.includes(`Sprint PR #${pr.number}`) + ); + if (alreadyExists) { + console.log('Testing issue already exists for this PR, skipping.'); + return; + } const commits = `${{ steps.commits.outputs.list }}`; const body = [ From 66a5d7e5fd08c6f137746ecdd0a50d767187fd1c Mon Sep 17 00:00:00 2001 From: gabriel Date: Thu, 14 May 2026 14:51:49 +0200 Subject: [PATCH 02/16] feat: reopen testing issue if closed with unchecked boxes Co-Authored-By: Claude Sonnet 4.6 --- .../workflows/enforce-testing-checklist.yml | 55 +++++++++++++++++++ 1 file changed, 55 insertions(+) create mode 100644 .github/workflows/enforce-testing-checklist.yml diff --git a/.github/workflows/enforce-testing-checklist.yml b/.github/workflows/enforce-testing-checklist.yml new file mode 100644 index 0000000..044426c --- /dev/null +++ b/.github/workflows/enforce-testing-checklist.yml @@ -0,0 +1,55 @@ +name: Enforce Testing Checklist + +on: + issues: + types: [closed] + +jobs: + check-all-boxes: + name: Verify all checkboxes are checked + runs-on: ubuntu-latest + # Only for testing issues + if: contains(github.event.issue.labels.*.name, 'testing') + permissions: + issues: write + steps: + - name: Check all boxes and reopen if incomplete + uses: actions/github-script@v7 + with: + script: | + const issue = context.payload.issue; + const body = issue.body || ''; + + // Count total and checked boxes + const total = (body.match(/- \[[ x]\]/g) || []).length; + const checked = (body.match(/- \[x\]/g) || []).length; + const pending = total - checked; + + if (pending > 0) { + // Reopen the issue + await github.rest.issues.update({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issue.number, + state: 'open', + }); + + // Comment explaining why + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issue.number, + body: [ + `## ❌ No se puede cerrar — checklist incompleto`, + ``, + `Hay **${pending} de ${total}** items sin marcar.`, + ``, + `Completa todas las pruebas antes de cerrar este issue.`, + `El deploy a producción no debe aprobarse hasta que todo esté validado.`, + ].join('\n'), + }); + + console.log(`Reopened issue #${issue.number} — ${pending}/${total} checks pending`); + } else { + console.log(`All ${total} checks completed — issue can be closed`); + } From 2b19f947266af99627f78e8aa3e13fa5bb5b5ac2 Mon Sep 17 00:00:00 2001 From: gabriel Date: Thu, 14 May 2026 15:04:52 +0200 Subject: [PATCH 03/16] =?UTF-8?q?chore:=20remove=20future=5Ffeatures=5Froa?= =?UTF-8?q?dmap.md=20=E2=80=94=20replaced=20by=20GitHub=20Issues?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All roadmap items migrated to GitHub Issues #160-#172 with labels, milestones (Sprint 2, Sprint 3, Backlog) and GitHub Project board. GitHub is now the single source of truth for roadmap management. Co-Authored-By: Claude Sonnet 4.6 --- docs/future_features_roadmap.md | 328 -------------------------------- 1 file changed, 328 deletions(-) delete mode 100644 docs/future_features_roadmap.md diff --git a/docs/future_features_roadmap.md b/docs/future_features_roadmap.md deleted file mode 100644 index eeecd84..0000000 --- a/docs/future_features_roadmap.md +++ /dev/null @@ -1,328 +0,0 @@ -# Roadmap de features futuras — RiftLine - -Backlog priorizado de funcionalidades pendientes para la plataforma RiftLine (Competitive Intel para Predecessor). - -Complementa: -- `docs/planning.md` → tareas activas con desglose operativo -- `docs/react_crash_patterns.md` → diagnóstico de crashes React -- GitHub Issues → unidad de trabajo accionable - -> **Principio rector:** reglas deterministas primero. LLM solo para resumir evidencias ya calculadas. Nunca inventar causalidad ni predicciones no trazables. - ---- - -## Escala de prioridad - -| Nivel | Criterio | -|-------|----------| -| **P0 — Crítico** | Bloquea estabilidad, datos o el flujo principal en producción | -| **P1 — Alto valor** | Mejora fuerte y directa para coaches/staff/jugadores | -| **P2 — Medio plazo** | Útil, no bloquea el core. Construir cuando P1 esté validado | -| **P3 — Largo plazo** | Experimental o dependiente de datos/uso real todavía insuficiente | - ---- - -## Estado a Mayo 2026 - -### Completado ✅ -| Feature | Tarea | -|---------|-------| -| Player Scouting, Team Analysis, Match Detail (Timeline, Analysis, Stats) | T3, T6 | -| Analyst Rules Engine (9 reglas deterministas) | T8 | -| Review Queue + Team/Player Goals | T10 | -| Battle Plan prescriptivo (ScrimReport mejorado) | T12 | -| Zonas tácticas del mapa (11 zonas calibradas) | T13 | -| VOD & Replay Index | T16 | -| RBAC completo + invitaciones + perfiles de usuario | T17 | -| View As Role (admin previsualiza como cualquier rol) | T17 | -| Player self-linking (vinculación de perfil de jugador) | T17 | -| Backend Analytics: Phase/Vision/Objective/Draft Analysis | T20 | -| Rival Scouting (`/analysis/rival`) | T20 | -| Platform Admin panel (Staff, Data Controls, Audit Logs) | T20 | -| Rebrand RiftLine + Landing page + Login fullscreen | T21 | -| Dashboard diferenciado por rol | T21 | -| Sistema de retención de datos (3 meses configurable + cron mensual) | T22 | -| Despliegue en Railway (single service: API + frontend) | T22 | - -### En producción / desplegando 🚀 -- Railway deployment — PR #152 - ---- - -# P0 — Crítico - -## 1. Estabilización del despliegue en Railway - -**Estado:** En curso -**Plazo estimado:** inmediato - -- [ ] Verificar health check en producción (`/health`) -- [ ] Confirmar que cookies httpOnly funcionan con HTTPS en Railway -- [ ] Actualizar `PRED_GG_CALLBACK_URL` y `FRONTEND_URL` con el dominio de Railway -- [ ] Verificar que el sync worker funciona desde el admin panel en producción -- [ ] Confirmar que el cron de limpieza mensual arranca correctamente -- [ ] Dominio personalizado (opcional, post-estabilización) - ---- - -## 2. Migración a TimescaleDB - -**Estado:** Planificado -**Plazo estimado:** tras estabilizar producción -**Impacto:** reducción 5-10x en espacio de event stream. `drop_chunks()` en lugar de `DELETE` masivo. - -- [ ] Añadir TimescaleDB como servicio en Railway (template disponible) -- [ ] Convertir tablas de event stream a hypertables por `startTime`/`gameTime`: - - `HeroKill`, `ObjectiveKill`, `WardEvent`, `Transaction`, `StructureDestruction` -- [ ] Activar política de compresión columnar automática para chunks > 1 mes -- [ ] Migrar `cleanupOldData()` a `drop_chunks(INTERVAL '3 months')` -- [ ] Validar compatibilidad con Prisma (transparente — sigue siendo PostgreSQL) -- [ ] Objetivo de espacio: de ~3 GB → ~400 MB con retención de 3 meses - -**Referencia técnica:** TimescaleDB comprime datos de series temporales en bloques columnares. Queries por rango temporal son 10-100x más rápidas porque el planificador salta directamente al chunk correcto en lugar de escanear toda la tabla. - ---- - -# P1 — Alto valor - -## 3. Analyst: LLM AI Summaries (Claude API) - -**Estado:** Planificado -**Prerequisito:** T8 validada en producción -**Coste estimado:** <$0.01 por análisis (claude-sonnet-4-6 con prompt caching) - -- [ ] "Focus of the Day" en Dashboard — resumen diario de 3-5 insights clave del equipo -- [ ] Streaming SSE al frontend (respuesta progresiva) -- [ ] Prompt caching del contexto fijo de Predecessor (reglas del juego, mecánicas) -- [ ] El LLM **resume** evidencias ya calculadas, no inventa causalidad -- [ ] Opción "Explicar este insight" en InsightCard — LLM explica el porqué en lenguaje natural - -**Límites claros:** -- Nunca generar recomendaciones sin evidencia trazable de la DB -- No predecir resultados de partidas -- Todo lo que muestre el LLM debe poder respaldarse con datos reales - ---- - -## 4. Coach Session Mode - -**Estado:** Planificado -**Uso:** proyectar en Discord/stream durante sesiones de review - -- [ ] Vista limpia activable desde header (MANAGER/COACH) -- [ ] Muestra: Battle Plan + 3 insights clave + objetivos de sesión activos -- [ ] Sin sidebar ni distracciones de navegación -- [ ] Fullscreen con font grande legible desde lejos - ---- - -## 5. B2C Player: Weekly Performance Summary - -**Estado:** Planificado -**Rol:** PLAYER standalone (sin equipo) - -- [ ] `GET /reports/player-weekly/:playerId` — KDA semanal vs histórico, héroe más jugado, WR 7d vs 30d, mejora/bajada de métricas clave, partidas jugadas en la semana -- [ ] Página `/reports/weekly` con condicional por rol -- [ ] Trend up/down por métrica con comparativa semana anterior -- [ ] Player Development autogenerado desde reglas (slump, hero pool, consistencia) - ---- - -## 6. Tactical Board - -**Estado:** Planificado -**Librería recomendada:** Konva.js (canvas con React) - -- [ ] Modelos `TacticalBoard` + `BoardObject` en schema.prisma -- [ ] Crear tablero vacío sobre el mapa de Predecessor -- [ ] Tipos de objeto: `ally_player`, `enemy_player`, `ward`, `danger_zone`, `engage_point`, `rotation_arrow`, `objective_setup`, `reset_point`, `do_not_fight`, `priority_area`, `text_note` -- [ ] Guardar/cargar tablero, asociar a match/equipo/rival -- [ ] Duplicar tablero para variantes -- [ ] Exportar como imagen -- [ ] Coordenadas normalizadas (MAP_BOUNDS ya calibrados) - -**Casos de uso prioritarios:** Fangtooth setup · Shaper setup · Prime defense · Corrección de error con anotación - ---- - -## 7. Discord Companion Bot (MVP) - -**Estado:** Planificado -**Prerequisito:** T8 + T10 en producción y validadas -**Arquitectura:** bot consume datos procesados por RiftLine — no calcula nada propio - -- [ ] Modelos `DiscordIntegration`, `DiscordChannelConfig`, `NotificationRule` en schema.prisma -- [ ] Vincular servidor Discord ↔ equipo de RiftLine -- [ ] Configurar canales: alerts, match-reports, review-queue, team-goals, scouting -- [ ] Enviar resumen de partida al importar (resultado, duración, alertas críticas, botones a RiftLine) -- [ ] Enviar Review Alert cuando `severity: critical` -- [ ] Slash commands: `/riftline match `, `/riftline review pending`, `/riftline report last-match` -- [ ] Permisos mínimos: Send Messages, Embed Links, Use Slash Commands — **sin Administrator** - -**Canales recomendados:** `#riftline-alerts` · `#match-reports` · `#review-queue` · `#team-goals` · `#scouting-reports` - ---- - -# P2 — Medio plazo - -## 8. Tactical Timeline con anotaciones - -**Estado:** Planificado -**Diferencia con el Timeline tab actual:** orientado a sesión de review de equipo, con anotaciones y creación de review items - -- [ ] Cargar partida por Match ID -- [ ] Eventos sobre mapa con slider temporal -- [ ] Event Feed lateral: gameTime, eventType, player, hero, context, priority -- [ ] Filtros: equipo, evento, rol, jugador, fase, objetivo cercano, ventana (30/60/90/120s) -- [ ] Crear Review Item desde evento -- [ ] Añadir anotación de coach a evento -- [ ] Guardar sesión asociada a match - -**Limitaciones a comunicar en UI:** no hay posición continua de jugadores, no infiere rutas, no sustituye el replay del juego. - ---- - -## 9. Scrim Planner + Playbook + Review Sessions - -**Estado:** Futuro -**Prerequisito:** Review Queue + Tactical Board validados en uso real - -- [ ] **Scrim Planner** — planificador de scrims con focus area vinculada a métrica, notas post-scrim -- [ ] **Playbook** — biblioteca de estrategias, setups y reglas tácticas del equipo -- [ ] **Review Sessions** — sesiones organizadas con agenda, review items, boards y action items - ---- - -## 10. Rival alerting system - -**Estado:** Planificado - -Alertas automáticas cuando un rival trackado cambia: -- [ ] Hero pool — nuevo héroe en rotación o abandona rotación -- [ ] Role swap — cambio de rol de un jugador clave -- [ ] Performance drop/spike — cambio significativo de KDA/WR -- [ ] Nuevo jugador en roster -- [ ] Cambio de tendencia de bans/picks - -Canales: in-app (badge en `/analysis/rival`) + Discord (cuando el bot esté activo) - ---- - -## 11. Mobile responsive: mejoras pendientes - -**Estado:** Planificado -**Prioridad en vistas:** Player Search → Profile · Dashboard PLAYER · Scrim Report (lectura) - -- [ ] Breakpoints 640px/1024px en App.css (actualmente solo 920px para sidebar) -- [ ] Sidebar colapsable en tablet -- [ ] Tablas con scroll horizontal en mobile -- [ ] Match Detail — adaptar swim lanes del Timeline para pantalla pequeña - ---- - -## 12. Draft Board interactivo - -**Estado:** Planificado -**Nota:** Draft Analysis (estadísticas de picks/bans) ya está implementado. Esto es la herramienta de planificación activa. - -- [ ] Hero pool por jugador con comfort score -- [ ] Recomendaciones de bans basadas en rival scouting -- [ ] Guardar composiciones y draft plans -- [ ] Patch-aware filtering -- [ ] **No incluye en MVP:** live draft automation, inferir orden real de picks/bans cuando la API no lo expone - ---- - -# P3 — Largo plazo / experimental - -## 13. Matchup evaluator explicable - -**Prerequisito:** datos suficientes de hero pool y Build/Stat Calculator -**Regla:** produce ventaja/riesgo, no "ganador garantizado" - -Dimensiones a evaluar: -- burst window por nivel/item spike -- sustained DPS, durability, mobility, crowd control pressure -- weak/strong phases - ---- - -## 14. Build/Stat Calculator - -**Estado:** Pospuesto -**Motivo:** alto mantenimiento por parche. Validar primero demanda real de usuarios. - -- Selección: versión, héroe, nivel, rol, crest/items, skill order -- Salida: base stats + bonus + final + ability values -- **Regla crítica:** no fingir pasivas no soportadas por la API. Etiquetar como unsupported/partial. - ---- - -## 15. Opponent strategy fingerprinting - -Clasificar estilo de rival de forma automática: -- early pressure · objective control · scaling · dive/pick · vision denial · weak-side - -Basado en features del event stream. Validación manual del coach antes de mostrar etiqueta. - ---- - -## 16. Live Draft Mode / overlays - -**Estado:** Experimental — alto riesgo -**Antes de implementar:** -- revisar compliance con el juego y torneos -- validar utilidad real con usuarios -- evitar overlays que puedan considerarse ventaja indebida en competición oficial - ---- - -## 17. Discord OAuth (login social) - -**Estado:** Pospuesto -**Motivo:** login interno en producción funciona bien. OAuth añade complejidad sin urgencia. -**Cuando implementar:** cuando el volumen de usuarios justifique el coste de mantenimiento. - ---- - -# Funcionalidades explícitamente fuera de scope - -| Funcionalidad | Motivo | -|---------------|-------| -| Predicción exacta de ganador | No fiable ni explicable con datos disponibles | -| Pathing continuo de jugadores | La API expone eventos puntuales, no trayectoria | -| POV automático desde replay | No hay soporte oficial — usar VOD Index | -| IA generativa como motor principal | Reglas primero; LLM solo resume evidencias | -| Simulación completa de teamfight | Complejidad muy alta, no necesaria para MVP | -| Migración automática de builds entre parches | Puede romper semántica si cambian items/stats | -| Predicción de roster de rival | Insuficientes datos y riesgo de falsos positivos | - ---- - -# Orden recomendado de implementación - -``` -1. Estabilizar Railway (P0) ← AHORA -2. TimescaleDB (P0) ← después de estabilizar -3. LLM AI Summaries (P1) ← alto ROI con bajo esfuerzo -4. Coach Session Mode (P1) ← rápido de implementar -5. B2C Player Weekly Reports (P1) ← nuevo segmento de usuarios -6. Tactical Board (P1) ← alta demanda de coaches -7. Discord Bot MVP (P1) ← multiplica el alcance de la plataforma -8. Tactical Timeline (P2) -9. Rival alerting (P2) -10. Mobile responsive (P2) -11. Scrim Planner + Playbook (P2) -12. Draft Board interactivo (P2) -13. Matchup evaluator (P3) -14. Build/Stat Calculator (P3) -15. Opponent fingerprinting (P3) -``` - ---- - -## Nota de mantenimiento - -Cuando una feature pase a desarrollo activo → moverla a `docs/planning.md` con desglose de subtareas. -Este documento es el backlog estratégico, no el tablero operativo. -Revisar prioridades cada 2-3 meses o tras validación con usuarios reales. From 3453b4b976e4233af3983168cb502780071a4e01 Mon Sep 17 00:00:00 2001 From: gabriel Date: Thu, 14 May 2026 15:17:08 +0200 Subject: [PATCH 04/16] =?UTF-8?q?feat:=20admin=20password=20reset=20?= =?UTF-8?q?=E2=80=94=20button=20+=20modal=20+=20endpoint?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - POST /admin/users/:id/reset-password with bcrypt hash - KeyRound button in user list (disabled for own account) - Modal with password field (min 8 chars) and confirmation - resetPassword() added to apiClient.admin Closes #173 Co-Authored-By: Claude Sonnet 4.6 --- apps/api/src/routes/admin.ts | 22 ++++++++++++ apps/web/src/api/client.ts | 2 ++ apps/web/src/pages/UsersPage.tsx | 61 +++++++++++++++++++++++++++++++- 3 files changed, 84 insertions(+), 1 deletion(-) diff --git a/apps/api/src/routes/admin.ts b/apps/api/src/routes/admin.ts index ce7bd7e..92c15c9 100644 --- a/apps/api/src/routes/admin.ts +++ b/apps/api/src/routes/admin.ts @@ -1,4 +1,5 @@ import { Router, Request, Response, NextFunction } from 'express'; +import bcrypt from 'bcryptjs'; import { z } from 'zod'; import { db } from '../db.js'; import { logger } from '../logger.js'; @@ -757,6 +758,27 @@ adminRouter.patch('/teams/:id/tier', async (req, res, next) => { } catch (err) { next(err); } }); +/** + * POST /admin/users/:id/reset-password + * Admin sets a new password for any user. + */ +adminRouter.post('/users/:id/reset-password', async (req, res, next) => { + try { + const { newPassword } = z.object({ + newPassword: z.string().min(8, 'Minimum 8 characters'), + }).parse(req.body); + + const hash = await bcrypt.hash(newPassword, 12); + await db.user.update({ + where: { id: req.params.id }, + data: { passwordHash: hash }, + }); + + logger.info({ targetUserId: req.params.id }, 'admin: password reset'); + res.json({ ok: true }); + } catch (err) { next(err); } +}); + /** * POST /admin/cleanup-old-data * Deletes data older than DATA_RETENTION_MONTHS (default 3). diff --git a/apps/web/src/api/client.ts b/apps/web/src/api/client.ts index 3e92ca5..6c57ebb 100644 --- a/apps/web/src/api/client.ts +++ b/apps/web/src/api/client.ts @@ -1020,6 +1020,8 @@ export const apiClient = { users: () => fetchApi<{ users: unknown[] }>('/admin/users'), updateUser: (id: string, data: { isActive?: boolean; globalRole?: string; name?: string; email?: string; playerTier?: string; playerTierExpiresAt?: string | null }) => fetchApi<{ user: unknown }>(`/admin/users/${id}`, { method: 'PATCH', body: JSON.stringify(data) }), + resetPassword: (id: string, newPassword: string) => + fetchApi<{ ok: boolean }>(`/admin/users/${id}/reset-password`, { method: 'POST', body: JSON.stringify({ newPassword }) }), updateTeamTier: (teamId: string, teamTier: string, teamTierExpiresAt?: string | null) => fetchApi<{ team: unknown }>(`/admin/teams/${teamId}/tier`, { method: 'PATCH', body: JSON.stringify({ teamTier, teamTierExpiresAt }) }), apiStatus: () => fetchApi('/admin/api-status'), diff --git a/apps/web/src/pages/UsersPage.tsx b/apps/web/src/pages/UsersPage.tsx index 7864061..0110f7d 100644 --- a/apps/web/src/pages/UsersPage.tsx +++ b/apps/web/src/pages/UsersPage.tsx @@ -1,5 +1,5 @@ import { useEffect, useState } from 'react'; -import { Edit2, Shield, CheckCircle, XCircle, UserPlus, X, Save, Star } from 'lucide-react'; +import { Edit2, Shield, CheckCircle, XCircle, UserPlus, X, Save, Star, KeyRound } from 'lucide-react'; import { toast } from 'sonner'; import { apiClient, ApiErrorResponse } from '../api/client'; import { useAuth } from '../hooks/useAuth'; @@ -45,6 +45,9 @@ export default function UsersPage() { const [editActive, setEditActive] = useState(true); const [editTierExpiry, setEditTierExpiry] = useState(''); const [saving, setSaving] = useState(false); + const [resetPasswordUser, setResetPasswordUser] = useState(null); + const [newPassword, setNewPassword] = useState(''); + const [resetting, setResetting] = useState(false); const isAdmin = !internalLoading && !!me && me.globalRole === 'PLATFORM_ADMIN'; @@ -168,6 +171,14 @@ export default function UsersPage() { + + + + + + )} ); } From f0a47c6ec1367696a75aa995ea665142ad2ccd32 Mon Sep 17 00:00:00 2001 From: gabriel Date: Thu, 14 May 2026 15:41:54 +0200 Subject: [PATCH 05/16] =?UTF-8?q?feat:=20staging=20environment=20=E2=80=94?= =?UTF-8?q?=20nginx=20:8080=20+=20production=20build=20in=20WSL?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - serve.sh staging: build frontend + start Express :3002 + nginx :8080 - serve.sh staging-stop / staging-status - .env.staging template (gitignored) - Mirrors production config: NODE_ENV=production, nginx proxy, PM2-like Co-Authored-By: Claude Sonnet 4.6 --- .gitignore | 1 + serve.sh | 112 +++++++++++++++++++++++++++++++++++++++++++++++------ 2 files changed, 102 insertions(+), 11 deletions(-) diff --git a/.gitignore b/.gitignore index 3150ad2..0510251 100644 --- a/.gitignore +++ b/.gitignore @@ -67,3 +67,4 @@ logs/ # API sample fixtures (large JSON files, regenerate from live API) omeda-samples/ scripts/api-samples/ +.env.staging diff --git a/serve.sh b/serve.sh index 81575bc..f1e5d7d 100755 --- a/serve.sh +++ b/serve.sh @@ -202,8 +202,13 @@ CMD="${1:-help}" ARG="${2:---dev}" MODE="${ARG#--}" # strip leading "--" → dev | prod +STAGING_API_PID="$PIDS_DIR/staging-api.pid" +STAGING_API_PORT=3002 +STAGING_NGINX_PORT=8080 +STAGING_LOG="$LOGS_DIR/staging.log" + # Load .env for all commands that interact with services -if [[ -f "$ROOT/.env" && "$CMD" != "help" ]]; then +if [[ -f "$ROOT/.env" && "$CMD" != "help" && "$CMD" != "staging" ]]; then set -a # shellcheck disable=SC1090 source "$ROOT/.env" @@ -268,27 +273,112 @@ case "$CMD" in esac ;; + staging) + if [[ ! -f "$ROOT/.env.staging" ]]; then + fail ".env.staging not found — copy .env.example to .env.staging and configure it" + exit 1 + fi + + echo "" + echo -e "${BOLD}RiftLine — Staging${NC} ${CYAN}(production-like, nginx :$STAGING_NGINX_PORT → API :$STAGING_API_PORT)${NC}" + echo "──────────────────────────────────────────────────────────" + + # Stop previous staging if running + if is_running "$STAGING_API_PID"; then + stop_service "Staging API" "$STAGING_API_PID" + fi + + # Build frontend for staging + log "Building frontend (production build)..." + (cd "$ROOT/apps/web" && NODE_ENV=production npm run build >> "$LOGS_DIR/staging-build.log" 2>&1) \ + || { fail "Frontend build failed — check logs/staging-build.log"; exit 1; } + ok "Frontend built" + + # Generate Prisma client + log "Generating Prisma client..." + (cd "$ROOT" && npx prisma generate --schema=workers/data-sync/prisma/schema.prisma >> "$LOGS_DIR/staging-build.log" 2>&1) + ok "Prisma client ready" + + # Load staging env and start API + log "Starting Staging API on port $STAGING_API_PORT..." + mkdir -p "$PIDS_DIR" "$LOGS_DIR" + set -a + source "$ROOT/.env.staging" + set +a + + "$ROOT/node_modules/.bin/tsx" "$ROOT/apps/api/src/index.ts" \ + >> "$STAGING_LOG" 2>&1 & + staging_pid=$! + echo "$staging_pid" > "$STAGING_API_PID" + + if wait_for_port "$STAGING_API_PORT"; then + ok "Staging API running on port $STAGING_API_PORT (PID $staging_pid)" + else + fail "Staging API did not start — check logs/staging.log" + exit 1 + fi + + # Start nginx + log "Starting nginx..." + sudo nginx -t > /dev/null 2>&1 && sudo nginx 2>/dev/null || sudo nginx -s reload 2>/dev/null || true + + if wait_for_port "$STAGING_NGINX_PORT"; then + ok "nginx proxying http://localhost:$STAGING_NGINX_PORT → :$STAGING_API_PORT" + else + warn "nginx may not be running — try: sudo nginx" + fi + + echo "" + echo -e "${GREEN}✓ Staging ready${NC} — ${BOLD}http://localhost:$STAGING_NGINX_PORT${NC}" + echo -e "${CYAN}Logs:${NC} tail -f logs/staging.log" + echo -e "${CYAN}Stop:${NC} ./serve.sh staging-stop" + echo "" + ;; + + staging-stop) + echo "" + echo -e "${BOLD}RiftLine — Stopping Staging${NC}" + echo "──────────────────────────────────────────" + stop_service "Staging API" "$STAGING_API_PID" + sudo nginx -s stop 2>/dev/null || true + ok "nginx stopped" + echo "" + ;; + + staging-status) + echo "" + echo -e "${BOLD}RiftLine — Staging Status${NC}" + echo "──────────────────────────────────────────" + status_service "Staging API" "$STAGING_API_PID" "$STAGING_API_PORT" + if curl -s "http://localhost:$STAGING_NGINX_PORT/health" | grep -q "ok" 2>/dev/null; then + echo -e " ${GREEN}●${NC} ${BOLD}nginx${NC} running port $STAGING_NGINX_PORT" + else + echo -e " ${RED}○${NC} ${BOLD}nginx${NC} stopped" + fi + echo "" + ;; + help|*) echo "" echo -e "${BOLD}Usage:${NC} ./serve.sh [--dev|--prod]" echo "" - echo -e "${BOLD}Commands:${NC}" + echo -e "${BOLD}Development commands:${NC}" echo " start [--dev|--prod] Start API and Frontend (default: --dev)" - echo " stop Stop all services" + echo " stop Stop all dev services" echo " restart [--dev|--prod] Stop and start again" echo " status Show PID and port for each service" echo " logs [api|web|build] Tail log files (default: both)" echo "" - echo -e "${BOLD}Modes:${NC}" - echo " --dev Dev servers (API requires restart; frontend uses Vite)" - echo " --prod Build frontend, then run production servers" + echo -e "${BOLD}Staging commands (production-like, nginx + built static):${NC}" + echo " staging Build frontend + start nginx :8080 → API :3002" + echo " staging-stop Stop staging services" + echo " staging-status Show staging status" echo "" - echo -e "${BOLD}Prerequisites:${NC}" - echo " 1. Copy .env.example → .env and fill in DATABASE_URL + PRED_GG_* vars" - echo " 2. npm install" - echo " 3. Start PostgreSQL and run: npm run db:migrate --workspace=@predecessor/data-sync" + echo -e "${BOLD}Environments:${NC}" + echo " .env → development (Vite HMR, port 5173)" + echo " .env.staging → staging (nginx :8080, NODE_ENV=production)" echo "" - echo -e "${BOLD}Logs:${NC} logs/api.log logs/web.log logs/build.log" + echo -e "${BOLD}Logs:${NC} logs/api.log logs/web.log logs/staging.log logs/staging-build.log" echo "" ;; esac From 0e85860dd874f9975d8527ba5df096dc9205ec15 Mon Sep 17 00:00:00 2001 From: gabriel Date: Thu, 14 May 2026 15:52:17 +0200 Subject: [PATCH 06/16] feat: staging uses git worktree from develop branch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ./serve.sh staging now fetches origin/develop and runs from /tmp/riftline-staging — always reflects the real develop state, not local uncommitted changes. Worktree cleaned up on staging-stop. Co-Authored-By: Claude Sonnet 4.6 --- serve.sh | 40 ++++++++++++++++++++++++++++++++-------- 1 file changed, 32 insertions(+), 8 deletions(-) diff --git a/serve.sh b/serve.sh index f1e5d7d..c42fd4c 100755 --- a/serve.sh +++ b/serve.sh @@ -279,8 +279,10 @@ case "$CMD" in exit 1 fi + STAGING_DIR="/tmp/riftline-staging" + echo "" - echo -e "${BOLD}RiftLine — Staging${NC} ${CYAN}(production-like, nginx :$STAGING_NGINX_PORT → API :$STAGING_API_PORT)${NC}" + echo -e "${BOLD}RiftLine — Staging${NC} ${CYAN}(develop branch → nginx :$STAGING_NGINX_PORT → API :$STAGING_API_PORT)${NC}" echo "──────────────────────────────────────────────────────────" # Stop previous staging if running @@ -288,25 +290,41 @@ case "$CMD" in stop_service "Staging API" "$STAGING_API_PID" fi - # Build frontend for staging + # Sync develop branch via git worktree (doesn't touch your working branch) + log "Syncing develop branch from GitHub..." + git -C "$ROOT" fetch origin develop --quiet + if [[ -d "$STAGING_DIR" ]]; then + git -C "$STAGING_DIR" reset --hard origin/develop --quiet + else + git -C "$ROOT" worktree add "$STAGING_DIR" origin/develop --quiet 2>/dev/null \ + || git -C "$ROOT" worktree add "$STAGING_DIR" develop --quiet + fi + ok "develop branch ready at $STAGING_DIR" + + # Install deps in worktree + log "Installing dependencies..." + (cd "$STAGING_DIR" && npm install --prefer-offline --silent) + ok "Dependencies installed" + + # Build frontend log "Building frontend (production build)..." - (cd "$ROOT/apps/web" && NODE_ENV=production npm run build >> "$LOGS_DIR/staging-build.log" 2>&1) \ + (cd "$STAGING_DIR/apps/web" && npm run build >> "$LOGS_DIR/staging-build.log" 2>&1) \ || { fail "Frontend build failed — check logs/staging-build.log"; exit 1; } ok "Frontend built" # Generate Prisma client log "Generating Prisma client..." - (cd "$ROOT" && npx prisma generate --schema=workers/data-sync/prisma/schema.prisma >> "$LOGS_DIR/staging-build.log" 2>&1) + (cd "$STAGING_DIR" && npx prisma generate --schema=workers/data-sync/prisma/schema.prisma 2>&1 | grep -v "^$" | tail -3) ok "Prisma client ready" - # Load staging env and start API + # Load staging env and start API from worktree log "Starting Staging API on port $STAGING_API_PORT..." mkdir -p "$PIDS_DIR" "$LOGS_DIR" set -a source "$ROOT/.env.staging" set +a - "$ROOT/node_modules/.bin/tsx" "$ROOT/apps/api/src/index.ts" \ + "$STAGING_DIR/node_modules/.bin/tsx" "$STAGING_DIR/apps/api/src/index.ts" \ >> "$STAGING_LOG" 2>&1 & staging_pid=$! echo "$staging_pid" > "$STAGING_API_PID" @@ -328,20 +346,26 @@ case "$CMD" in warn "nginx may not be running — try: sudo nginx" fi + # Show which commit is deployed + STAGING_COMMIT=$(git -C "$STAGING_DIR" log --oneline -1 2>/dev/null || echo "unknown") echo "" echo -e "${GREEN}✓ Staging ready${NC} — ${BOLD}http://localhost:$STAGING_NGINX_PORT${NC}" - echo -e "${CYAN}Logs:${NC} tail -f logs/staging.log" - echo -e "${CYAN}Stop:${NC} ./serve.sh staging-stop" + echo -e "${CYAN}Branch:${NC} develop @ $STAGING_COMMIT" + echo -e "${CYAN}Logs:${NC} tail -f logs/staging.log" + echo -e "${CYAN}Stop:${NC} ./serve.sh staging-stop" echo "" ;; staging-stop) + STAGING_DIR="/tmp/riftline-staging" echo "" echo -e "${BOLD}RiftLine — Stopping Staging${NC}" echo "──────────────────────────────────────────" stop_service "Staging API" "$STAGING_API_PID" sudo nginx -s stop 2>/dev/null || true ok "nginx stopped" + # Remove worktree + git -C "$ROOT" worktree remove "$STAGING_DIR" --force 2>/dev/null && ok "Worktree removed" || true echo "" ;; From c7376ced01a966d7463641f277735146cd2b1469 Mon Sep 17 00:00:00 2001 From: gabriel Date: Thu, 14 May 2026 16:03:19 +0200 Subject: [PATCH 07/16] fix: Refresh button visible for all users + player sync uses stored credentials - POST /players/sync falls back to stored predgg_refresh_token when no user OAuth token (allows MANAGER/COACH/PLAYER to refresh player profiles) - Refresh button shown for internalAuthenticated (any logged-in user) instead of authenticated (pred.gg OAuth only) Co-Authored-By: Claude Sonnet 4.6 --- apps/api/src/routes/players.ts | 14 ++++++++++++-- apps/web/src/pages/PlayerScouting.tsx | 4 ++-- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/apps/api/src/routes/players.ts b/apps/api/src/routes/players.ts index a16c2ef..ed52043 100644 --- a/apps/api/src/routes/players.ts +++ b/apps/api/src/routes/players.ts @@ -5,7 +5,7 @@ import { syncPlayerByName } from '../services/sync-service.js'; import { AppError } from '../middleware/error-handler.js'; import { requireAuth } from '../middleware/require-auth.js'; import { db } from '../db.js'; -import { getValidToken } from './auth.js'; +import { getValidToken, exchangeToken } from './auth.js'; export const playersRouter = Router(); @@ -79,7 +79,17 @@ playersRouter.post('/sync', async (req, res, next) => { name: z.string().min(1).max(100).trim(), }).parse(req.body); - const userToken = await getValidToken(req, res); + // Try user OAuth token first, fall back to stored platform credentials + let userToken = await getValidToken(req, res); + if (!userToken) { + try { + const cred = await db.platformCredential.findUnique({ where: { key: 'predgg_refresh_token' } }); + if (cred) { + const result = await exchangeToken({ grant_type: 'refresh_token', refresh_token: cred.value }); + if (result.ok && result.data.access_token) userToken = result.data.access_token; + } + } catch { /* no stored token, continue without */ } + } const synced = await syncPlayerByName(db, name, userToken); if (!synced) { diff --git a/apps/web/src/pages/PlayerScouting.tsx b/apps/web/src/pages/PlayerScouting.tsx index 0420c91..6adbc1e 100644 --- a/apps/web/src/pages/PlayerScouting.tsx +++ b/apps/web/src/pages/PlayerScouting.tsx @@ -78,7 +78,7 @@ class ProfileErrorBoundary extends React.Component< } export default function PlayerScouting() { - const { authenticated } = useAuth(); + const { authenticated, internalAuthenticated } = useAuth(); const location = useLocation(); const [query, setQuery] = useState(''); const [phase, setPhase] = useState({ tag: 'idle' }); @@ -254,7 +254,7 @@ export default function PlayerScouting() { setProfilePhase({ tag: 'idle' })} - onRefresh={authenticated ? () => void handleRefreshProfile(profilePhase.profile.displayName, profilePhase.profile.id) : undefined} + onRefresh={internalAuthenticated ? () => void handleRefreshProfile(profilePhase.profile.displayName, profilePhase.profile.id) : undefined} /> )} From 7d3bcbb09283512051aabbb29b6c562fd7a2eedd Mon Sep 17 00:00:00 2001 From: gabriel Date: Thu, 14 May 2026 16:40:27 +0200 Subject: [PATCH 08/16] fix: auto-sync player profile on load when generalStats is empty When a player profile is opened and has no snapshot data (generalStats empty), automatically triggers the same sync as the Refresh button. User sees data appear without having to click anything. Co-Authored-By: Claude Sonnet 4.6 --- apps/web/src/pages/PlayerScouting.tsx | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/apps/web/src/pages/PlayerScouting.tsx b/apps/web/src/pages/PlayerScouting.tsx index 6adbc1e..065f1fc 100644 --- a/apps/web/src/pages/PlayerScouting.tsx +++ b/apps/web/src/pages/PlayerScouting.tsx @@ -153,6 +153,12 @@ export default function PlayerScouting() { try { const profile = await apiClient.players.getProfile(playerId); setProfilePhase({ tag: 'loaded', profile }); + + // Auto-sync if generalStats is missing (no snapshot yet) + const hasStats = Object.keys(profile.generalStats ?? {}).length > 0; + if (!hasStats && internalAuthenticated) { + void handleRefreshProfile(profile.displayName, profile.id); + } } catch (err) { const msg = err instanceof ApiErrorResponse ? err.error.message : 'Could not load player profile.'; setProfilePhase({ tag: 'error', message: msg }); From 5a19e57df039535824680e7edbb5601a609b06d3 Mon Sep 17 00:00:00 2001 From: gabriel Date: Thu, 14 May 2026 16:43:56 +0200 Subject: [PATCH 09/16] feat: Refresh syncs player profile + recent matches + event stream POST /players/sync now: 1. Syncs player profile and creates snapshot (immediate, blocking) 2. Syncs recent matches + event stream in background (non-blocking) After clicking Refresh on a player profile, all new matches and their timeline/analysis data appear automatically within seconds. Co-Authored-By: Claude Sonnet 4.6 --- apps/api/src/routes/players.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/apps/api/src/routes/players.ts b/apps/api/src/routes/players.ts index ed52043..e6b8c34 100644 --- a/apps/api/src/routes/players.ts +++ b/apps/api/src/routes/players.ts @@ -1,7 +1,7 @@ import { Router } from 'express'; import { z } from 'zod'; import { getPlayerProfile, comparePlayers, searchPlayers, getPlayerAdvancedMetrics } from '../services/player-service.js'; -import { syncPlayerByName } from '../services/sync-service.js'; +import { syncPlayerByName, syncRecentMatchesForPlayer } from '../services/sync-service.js'; import { AppError } from '../middleware/error-handler.js'; import { requireAuth } from '../middleware/require-auth.js'; import { db } from '../db.js'; @@ -100,6 +100,11 @@ playersRouter.post('/sync', async (req, res, next) => { ); } + // Also sync recent matches + event stream if we have a token + if (userToken && synced.predggId) { + syncRecentMatchesForPlayer(db, synced.predggId, userToken, 10).catch(() => null); + } + res.json({ synced: true, player: synced }); } catch (err) { next(err); From bedf26216c6a2d7ff2e278acba32cced9f737a3a Mon Sep 17 00:00:00 2001 From: gabriel Date: Thu, 14 May 2026 16:49:28 +0200 Subject: [PATCH 10/16] fix: sprint-testing workflow needs pull-requests: write to comment on PR Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/sprint-testing.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/sprint-testing.yml b/.github/workflows/sprint-testing.yml index 3938331..42d2047 100644 --- a/.github/workflows/sprint-testing.yml +++ b/.github/workflows/sprint-testing.yml @@ -13,7 +13,7 @@ jobs: if: github.head_ref == 'develop' permissions: issues: write - pull-requests: read + pull-requests: write steps: - name: Checkout uses: actions/checkout@v4 From 62a4417122106b7fa6074c665cae94167c2ab6ba Mon Sep 17 00:00:00 2001 From: gabriel Date: Thu, 14 May 2026 16:52:53 +0200 Subject: [PATCH 11/16] fix: testing issue specifies staging (localhost:8080) not production Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/sprint-testing.yml | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/.github/workflows/sprint-testing.yml b/.github/workflows/sprint-testing.yml index 42d2047..dfbd750 100644 --- a/.github/workflows/sprint-testing.yml +++ b/.github/workflows/sprint-testing.yml @@ -68,11 +68,19 @@ jobs: `- [ ] Funcionalidades nuevas de este sprint verificadas`, `- [ ] No hay regresiones visibles en navegación y carga`, ``, + `### Dónde probar`, + `Prueba en **staging local** antes del merge — no en producción:`, + `\`\`\`bash`, + `./serve.sh staging # arranca nginx :8080 con el código de develop`, + `\`\`\``, + `Accede a **http://localhost:8080** y valida cada punto.`, + ``, `### Instrucciones`, - `1. Prueba todo lo anterior en \`https://riftline.app\` (pre-merge) o en local`, - `2. Marca cada checkbox conforme lo valides`, - `3. **Cierra este issue** cuando todo esté OK`, - `4. Ve a GitHub → Actions → aprueba el deploy en Environment \`production\``, + `1. Ejecuta \`./serve.sh staging\` en tu terminal WSL`, + `2. Abre \`http://localhost:8080\` y prueba cada checkbox`, + `3. Marca cada checkbox conforme lo valides`, + `4. **Cierra este issue** cuando todo esté OK`, + `5. Ve a GitHub → Actions → aprueba el deploy en Environment \`production\``, ``, `> ⚠️ No aprobar el deploy sin haber cerrado este issue primero.`, ].join('\n'); From f3030ac8b9822a85f9afc450212850e53b062450 Mon Sep 17 00:00:00 2001 From: gabriel Date: Thu, 14 May 2026 17:27:23 +0200 Subject: [PATCH 12/16] =?UTF-8?q?fix:=20auto-sync=20on=20profile=20load=20?= =?UTF-8?q?=E2=80=94=20remove=20internalAuthenticated=20gate=20+=20precise?= =?UTF-8?q?=20check?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove internalAuthenticated check (race condition with async auth state) - Check typeof matches === 'number' instead of Object.keys empty check (handles partial snapshots with favRole/favHero but missing numeric stats) Co-Authored-By: Claude Sonnet 4.6 --- apps/web/src/pages/PlayerScouting.tsx | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/apps/web/src/pages/PlayerScouting.tsx b/apps/web/src/pages/PlayerScouting.tsx index 065f1fc..275e535 100644 --- a/apps/web/src/pages/PlayerScouting.tsx +++ b/apps/web/src/pages/PlayerScouting.tsx @@ -154,9 +154,11 @@ export default function PlayerScouting() { const profile = await apiClient.players.getProfile(playerId); setProfilePhase({ tag: 'loaded', profile }); - // Auto-sync if generalStats is missing (no snapshot yet) - const hasStats = Object.keys(profile.generalStats ?? {}).length > 0; - if (!hasStats && internalAuthenticated) { + // Auto-sync if generalStats is missing (no snapshot yet). + // Don't gate on internalAuthenticated — auth state may not be ready yet + // when this runs. The sync endpoint handles auth internally. + const hasStats = typeof (profile.generalStats as Record)?.matches === 'number'; + if (!hasStats) { void handleRefreshProfile(profile.displayName, profile.id); } } catch (err) { From f51704fcf57755664126f2a0d6c258ddd18bb36e Mon Sep 17 00:00:00 2001 From: gabriel Date: Thu, 14 May 2026 17:34:15 +0200 Subject: [PATCH 13/16] fix: calculate generalStats from MatchPlayer records when snapshot is missing When no snapshot exists or snapshot has no match data, calculate MATCHES, WIN RATE, KDA and HERO DAMAGE directly from the MatchPlayer records already loaded for the profile. This works without pred.gg API call and shows data immediately on first load. Co-Authored-By: Claude Sonnet 4.6 --- apps/api/src/services/player-service.ts | 24 +++++++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/apps/api/src/services/player-service.ts b/apps/api/src/services/player-service.ts index a7d6bc9..a3b8e32 100644 --- a/apps/api/src/services/player-service.ts +++ b/apps/api/src/services/player-service.ts @@ -140,7 +140,29 @@ export async function getPlayerProfile(playerId: string): Promise ratingPoints: latestSnapshot.ratingPoints, } : null, - generalStats: isRecord(latestSnapshot?.generalStats) ? latestSnapshot.generalStats : {}, + generalStats: (() => { + // Use snapshot generalStats if it has numeric match data + const sg = isRecord(latestSnapshot?.generalStats) ? latestSnapshot.generalStats : {}; + if (typeof (sg as Record).matches === 'number') return sg; + + // Fallback: calculate from MatchPlayer records already loaded + if (player.matchPlayers.length === 0) return sg; + const mps = player.matchPlayers; + const wins = mps.filter(mp => mp.match.winningTeam !== null && mp.team === mp.match.winningTeam).length; + const totalKills = mps.reduce((s, mp) => s + mp.kills, 0); + const totalDeaths = mps.reduce((s, mp) => s + mp.deaths, 0); + const totalAssists = mps.reduce((s, mp) => s + mp.assists, 0); + const totalDmg = mps.reduce((s, mp) => s + (mp.heroDamage ?? 0), 0); + return { + ...sg, + matches: mps.length, + wins, + losses: mps.length - wins, + winRate: mps.length > 0 ? Math.round((wins / mps.length) * 1000) / 10 : 0, + kda: totalDeaths > 0 ? Math.round(((totalKills + totalAssists) / totalDeaths) * 100) / 100 : totalKills + totalAssists, + heroDamage: totalDmg, + }; + })(), heroStats: Array.isArray(latestSnapshot?.heroStats) ? latestSnapshot.heroStats : [], roleStats: Array.isArray(latestSnapshot?.roleStats) ? latestSnapshot.roleStats : [], recentMatches, From d10e575f9810294819cc83320f36f3d275584dd7 Mon Sep 17 00:00:00 2001 From: gabriel Date: Thu, 14 May 2026 17:49:04 +0200 Subject: [PATCH 14/16] =?UTF-8?q?fix:=20compute=20generalStats=20before=20?= =?UTF-8?q?return=20=E2=80=94=20avoid=20IIFE=20closure=20issue?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace IIFE with explicit variable computed before the return statement. The IIFE was silently returning {} despite matchPlayers being available. Co-Authored-By: Claude Sonnet 4.6 --- apps/api/src/services/player-service.ts | 45 ++++++++++++------------- 1 file changed, 22 insertions(+), 23 deletions(-) diff --git a/apps/api/src/services/player-service.ts b/apps/api/src/services/player-service.ts index a3b8e32..a148a4f 100644 --- a/apps/api/src/services/player-service.ts +++ b/apps/api/src/services/player-service.ts @@ -94,6 +94,27 @@ export async function getPlayerProfile(playerId: string): Promise const latestSnapshot = player.snapshots[0] ?? null; const heroMeta = buildHeroMetaMap(latestSnapshot?.heroStats); + // Build generalStats: prefer snapshot, fall back to MatchPlayer records + const snapshotStats = isRecord(latestSnapshot?.generalStats) ? latestSnapshot!.generalStats : {}; + let computedGeneralStats: Record = snapshotStats; + if (typeof (snapshotStats as Record).matches !== 'number' && player.matchPlayers.length > 0) { + const mps = player.matchPlayers; + const wins = mps.filter((mp) => mp.match.winningTeam !== null && mp.team === mp.match.winningTeam).length; + const totalKills = mps.reduce((s, mp) => s + mp.kills, 0); + const totalDeaths = mps.reduce((s, mp) => s + mp.deaths, 0); + const totalAssists = mps.reduce((s, mp) => s + mp.assists, 0); + const totalDmg = mps.reduce((s, mp) => s + (mp.heroDamage ?? 0), 0); + computedGeneralStats = { + ...snapshotStats, + matches: mps.length, + wins, + losses: mps.length - wins, + winRate: mps.length > 0 ? Math.round((wins / mps.length) * 1000) / 10 : 0, + kda: totalDeaths > 0 ? Math.round(((totalKills + totalAssists) / totalDeaths) * 100) / 100 : totalKills + totalAssists, + heroDamage: totalDmg, + }; + } + const recentMatches = player.matchPlayers.map((mp) => { const isWin = mp.match.winningTeam !== null && mp.team === mp.match.winningTeam; const isLoss = mp.match.winningTeam !== null && mp.team !== mp.match.winningTeam; @@ -140,29 +161,7 @@ export async function getPlayerProfile(playerId: string): Promise ratingPoints: latestSnapshot.ratingPoints, } : null, - generalStats: (() => { - // Use snapshot generalStats if it has numeric match data - const sg = isRecord(latestSnapshot?.generalStats) ? latestSnapshot.generalStats : {}; - if (typeof (sg as Record).matches === 'number') return sg; - - // Fallback: calculate from MatchPlayer records already loaded - if (player.matchPlayers.length === 0) return sg; - const mps = player.matchPlayers; - const wins = mps.filter(mp => mp.match.winningTeam !== null && mp.team === mp.match.winningTeam).length; - const totalKills = mps.reduce((s, mp) => s + mp.kills, 0); - const totalDeaths = mps.reduce((s, mp) => s + mp.deaths, 0); - const totalAssists = mps.reduce((s, mp) => s + mp.assists, 0); - const totalDmg = mps.reduce((s, mp) => s + (mp.heroDamage ?? 0), 0); - return { - ...sg, - matches: mps.length, - wins, - losses: mps.length - wins, - winRate: mps.length > 0 ? Math.round((wins / mps.length) * 1000) / 10 : 0, - kda: totalDeaths > 0 ? Math.round(((totalKills + totalAssists) / totalDeaths) * 100) / 100 : totalKills + totalAssists, - heroDamage: totalDmg, - }; - })(), + generalStats: computedGeneralStats, heroStats: Array.isArray(latestSnapshot?.heroStats) ? latestSnapshot.heroStats : [], roleStats: Array.isArray(latestSnapshot?.roleStats) ? latestSnapshot.roleStats : [], recentMatches, From 97a7782ab129faa75edde559f7043010b4df633d Mon Sep 17 00:00:00 2001 From: gabriel Date: Thu, 14 May 2026 18:03:47 +0200 Subject: [PATCH 15/16] fix: generalStats fallback from MatchPlayer + clear tsx cache on staging start - Compute generalStats as explicit variable before return (IIFE had tsx cache issue) - Clear ~/.cache/tsx on each staging start to ensure latest code runs Co-Authored-By: Claude Sonnet 4.6 --- serve.sh | 3 +++ 1 file changed, 3 insertions(+) diff --git a/serve.sh b/serve.sh index c42fd4c..dbd2843 100755 --- a/serve.sh +++ b/serve.sh @@ -317,6 +317,9 @@ case "$CMD" in (cd "$STAGING_DIR" && npx prisma generate --schema=workers/data-sync/prisma/schema.prisma 2>&1 | grep -v "^$" | tail -3) ok "Prisma client ready" + # Clear tsx compile cache to ensure latest code is used + rm -rf ~/.cache/tsx 2>/dev/null || true + # Load staging env and start API from worktree log "Starting Staging API on port $STAGING_API_PORT..." mkdir -p "$PIDS_DIR" "$LOGS_DIR" From 6719498dd2929c7a8cbb50e4d4a5eff5927a88fd Mon Sep 17 00:00:00 2001 From: gabriel Date: Thu, 14 May 2026 18:07:33 +0200 Subject: [PATCH 16/16] =?UTF-8?q?docs:=20add=20debugging=5Flessons.md=20?= =?UTF-8?q?=E2=80=94=20bugs=20that=20took=203+=20iterations=20to=20solve?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BUG-001: generalStats empty (tsx cache + IIFE issue) BUG-002: staging serving old code (tsx cache) BUG-003: upgrade-insecure-requests blocks fetch on HTTP BUG-004: secure cookies rejected over HTTP Co-Authored-By: Claude Sonnet 4.6 --- docs/debugging_lessons.md | 161 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 161 insertions(+) create mode 100644 docs/debugging_lessons.md diff --git a/docs/debugging_lessons.md b/docs/debugging_lessons.md new file mode 100644 index 0000000..7ee16dd --- /dev/null +++ b/docs/debugging_lessons.md @@ -0,0 +1,161 @@ +# Debugging Lessons — Bugs que costaron más de 3 iteraciones + +Cada entrada documenta un bug real que requirió muchas iteraciones para resolver. +Objetivo: que en el futuro se identifique y resuelva en la primera o segunda iteración. + +--- + +## BUG-001 — generalStats vacío en Player Scouting pese a tener MatchPlayer records + +**Síntoma** +El perfil de un jugador mostraba `-` en MATCHES, WIN RATE, KDA, HERO DAMAGE. +AVG GPM, AVG DPM, AVG CS y Advanced Metrics sí aparecían. +Hacer Refresh solucionaba el problema. + +**Causa raíz (multicapa)** + +1. `generalStats` viene del `PlayerSnapshot`. Si el player no tiene snapshot, devuelve `{}`. +2. Se añadió un fallback para calcular `generalStats` desde los `MatchPlayer` ya cargados en la query. +3. El fallback se implementó como IIFE (función autoinvocada) dentro del objeto `return`. Funcionaba en tests directos pero **tsx tenía cacheado el código anterior** en `~/.cache/tsx`. +4. Aunque el source file estaba actualizado, tsx servía la versión compilada cacheada → el fallback nunca se ejecutaba. + +**Diagnóstico** +- Verificar que el player tiene MatchPlayers en DB: `SELECT COUNT(*) FROM "MatchPlayer" WHERE "playerId" = '...'` +- Verificar que el snapshot existe: `SELECT id, "generalStats" FROM "PlayerSnapshot" WHERE "playerId" = '...'` +- Añadir log temporal para confirmar que el código nuevo se ejecuta realmente (no la versión cacheada) +- Confirmar caché tsx: `ls ~/.cache/tsx` + +**Solución** +1. Calcular `generalStats` como variable explícita ANTES del return (no como IIFE): + ```ts + let computedGeneralStats = snapshotStats; + if (typeof snapshotStats.matches !== 'number' && player.matchPlayers.length > 0) { + // calcular desde matchPlayers... + computedGeneralStats = { matches: mps.length, wins, ... }; + } + return { ..., generalStats: computedGeneralStats, ... }; + ``` +2. Limpiar caché de tsx: `rm -rf ~/.cache/tsx` +3. Añadir limpieza de caché en `serve.sh staging` antes de arrancar el API. + +**Archivos afectados** +- `apps/api/src/services/player-service.ts` — función `getPlayerProfile` +- `serve.sh` — comando `staging`: añadir `rm -rf ~/.cache/tsx` antes de iniciar tsx + +**Prevención futura** +- Cuando un fix no surte efecto en staging aunque el source esté correcto → sospechar caché de tsx. +- Nunca usar IIFEs en objetos de retorno para lógica importante — usar variables explícitas. +- El `serve.sh staging` ya limpia `~/.cache/tsx` automáticamente desde el fix. + +--- + +## BUG-002 — Staging sirviendo código antiguo tras modificar source + +**Síntoma** +Se modifica un archivo TypeScript en `/tmp/riftline-staging/apps/api/src/`. +Se verifica que el source tiene el cambio. El staging API sigue comportándose como antes. + +**Causa raíz** +tsx compila TypeScript on-the-fly y cachea el resultado en `~/.cache/tsx`. +El caché es por hash del contenido del archivo. Si tsx tiene una entrada cacheada +para un archivo que ha cambiado externamente (worktree reset), puede servir +la versión antigua si el hash de archivo coincide con alguna entrada previa. + +**Diagnóstico** +```bash +ls ~/.cache/tsx # ver si hay caché +grep "DEBUG" logs/staging.log # añadir console.log temporal y verificar que se ejecuta +``` + +**Solución** +```bash +rm -rf ~/.cache/tsx +./serve.sh staging-stop && ./serve.sh staging +``` + +**Prevención futura** +`serve.sh staging` ya ejecuta `rm -rf ~/.cache/tsx` antes de cada arranque. + +--- + +## BUG-003 — upgrade-insecure-requests en CSP bloquea todas las llamadas fetch en HTTP + +**Síntoma** +La app carga (index.html llega al browser) pero se queda en blanco. +`/health` devuelve 200 pero el frontend no renderiza nada. +Las llamadas a la API desde el frontend no llegan al servidor. + +**Causa raíz** +Helmet.js incluye `upgrade-insecure-requests` en el Content-Security-Policy por defecto. +Esta directiva ordena al browser convertir todas las peticiones HTTP a HTTPS. +Sin HTTPS configurado, todas las llamadas `fetch()` del frontend fallan silenciosamente. + +**Diagnóstico** +```bash +curl -I http://localhost:3001/ | grep -i content-security +# Si aparece upgrade-insecure-requests → ese es el problema +``` + +**Solución** +```ts +app.use(helmet({ + contentSecurityPolicy: process.env.HTTPS_ENABLED === 'true' ? undefined : false, +})); +``` +Activar con `HTTPS_ENABLED=true` en el `.env` de producción cuando haya HTTPS configurado. + +**Archivos afectados** +- `apps/api/src/index.ts` + +**Prevención futura** +Al desplegar en un nuevo servidor sin HTTPS, establecer `HTTPS_ENABLED=false` (o no establecer). +Activar `HTTPS_ENABLED=true` solo después de configurar SSL/nginx. + +--- + +## BUG-004 — Cookies de sesión rechazadas en HTTP (login redirige a landing) + +**Síntoma** +El usuario introduce credenciales correctas, hace click en "Acceder" y vuelve a la landing page. +No aparece ningún error. El login parece completarse pero la sesión no se guarda. + +**Causa raíz** +Las cookies de sesión se configuran con `secure: true` en `NODE_ENV=production`. +Los browsers rechazan cookies `Secure` enviadas sobre HTTP (no HTTPS). +La cookie se envía en la respuesta pero el browser la ignora → sesión perdida. + +**Diagnóstico** +```bash +# Hacer login y ver headers de respuesta +curl -v -X POST http://servidor/api/internal-auth/login \ + -H "Content-Type: application/json" \ + -d '{"email":"...","password":"..."}' 2>&1 | grep -i "set-cookie" +# Si el cookie tiene "Secure" pero el servidor está en HTTP → ese es el problema +``` + +**Solución** +```ts +const secureCookies = process.env.HTTPS_ENABLED === 'true'; +res.cookie(SESSION_COOKIE, token, { + httpOnly: true, + sameSite: 'lax', + secure: secureCookies, // no NODE_ENV, sino HTTPS_ENABLED + maxAge: SESSION_MAX_AGE_MS, +}); +``` + +**Archivos afectados** +- `apps/api/src/routes/internal-auth.ts` — función `setSessionCookie` + +**Prevención futura** +Igual que BUG-003: la variable `HTTPS_ENABLED=true` controla tanto CSP como cookies seguras. +Nunca basar `secure` en `NODE_ENV === 'production'` — usar la variable explícita. + +--- + +## Regla general de documentación + +Cuando un bug requiere **más de 3 iteraciones** para resolverse: +1. Añadir una entrada en este documento con la estructura anterior. +2. Commit en `develop` con mensaje `docs: document BUG-XXX resolution`. +3. La entrada debe incluir: síntoma, causa raíz, diagnóstico, solución, archivos afectados y prevención.