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`); + } diff --git a/.github/workflows/sprint-testing.yml b/.github/workflows/sprint-testing.yml index 4d32959..dfbd750 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: @@ -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 @@ -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 = [ @@ -53,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'); 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/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/api/src/routes/players.ts b/apps/api/src/routes/players.ts index a16c2ef..e6b8c34 100644 --- a/apps/api/src/routes/players.ts +++ b/apps/api/src/routes/players.ts @@ -1,11 +1,11 @@ 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'; -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) { @@ -90,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); diff --git a/apps/api/src/services/player-service.ts b/apps/api/src/services/player-service.ts index a7d6bc9..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,7 +161,7 @@ export async function getPlayerProfile(playerId: string): Promise ratingPoints: latestSnapshot.ratingPoints, } : null, - generalStats: isRecord(latestSnapshot?.generalStats) ? latestSnapshot.generalStats : {}, + generalStats: computedGeneralStats, heroStats: Array.isArray(latestSnapshot?.heroStats) ? latestSnapshot.heroStats : [], roleStats: Array.isArray(latestSnapshot?.roleStats) ? latestSnapshot.roleStats : [], recentMatches, 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/PlayerScouting.tsx b/apps/web/src/pages/PlayerScouting.tsx index 0420c91..275e535 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' }); @@ -153,6 +153,14 @@ 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). + // 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) { const msg = err instanceof ApiErrorResponse ? err.error.message : 'Could not load player profile.'; setProfilePhase({ tag: 'error', message: msg }); @@ -254,7 +262,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} /> )} 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() { + + + + + + )} ); } 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. 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. diff --git a/serve.sh b/serve.sh index 81575bc..dbd2843 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,139 @@ 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 + + STAGING_DIR="/tmp/riftline-staging" + + echo "" + echo -e "${BOLD}RiftLine — Staging${NC} ${CYAN}(develop branch → 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 + + # 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 "$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 "$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" + set -a + source "$ROOT/.env.staging" + set +a + + "$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" + + 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 + + # 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}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 "" + ;; + + 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