Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
6dfab5b
fix: sprint testing issue — add synchronize trigger + dedup check
saggacce May 14, 2026
66a5d7e
feat: reopen testing issue if closed with unchecked boxes
saggacce May 14, 2026
5ba18c9
Merge branch 'main' into develop
saggacce May 14, 2026
2b19f94
chore: remove future_features_roadmap.md — replaced by GitHub Issues
saggacce May 14, 2026
3453b4b
feat: admin password reset — button + modal + endpoint
saggacce May 14, 2026
f0a47c6
feat: staging environment — nginx :8080 + production build in WSL
saggacce May 14, 2026
0e85860
feat: staging uses git worktree from develop branch
saggacce May 14, 2026
c7376ce
fix: Refresh button visible for all users + player sync uses stored c…
saggacce May 14, 2026
7d3bcbb
fix: auto-sync player profile on load when generalStats is empty
saggacce May 14, 2026
5a19e57
feat: Refresh syncs player profile + recent matches + event stream
saggacce May 14, 2026
bedf262
fix: sprint-testing workflow needs pull-requests: write to comment on PR
saggacce May 14, 2026
62a4417
fix: testing issue specifies staging (localhost:8080) not production
saggacce May 14, 2026
f3030ac
fix: auto-sync on profile load — remove internalAuthenticated gate + …
saggacce May 14, 2026
f51704f
fix: calculate generalStats from MatchPlayer records when snapshot is…
saggacce May 14, 2026
d10e575
fix: compute generalStats before return — avoid IIFE closure issue
saggacce May 14, 2026
97a7782
fix: generalStats fallback from MatchPlayer + clear tsx cache on stag…
saggacce May 14, 2026
6719498
docs: add debugging_lessons.md — bugs that took 3+ iterations to solve
saggacce May 14, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 55 additions & 0 deletions .github/workflows/enforce-testing-checklist.yml
Original file line number Diff line number Diff line change
@@ -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`);
}
35 changes: 29 additions & 6 deletions .github/workflows/sprint-testing.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ name: Sprint Testing Issue
on:
pull_request:
branches: [main]
types: [opened, reopened]
types: [opened, reopened, synchronize]

jobs:
create-testing-issue:
Expand All @@ -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
Expand All @@ -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 = [
Expand All @@ -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');
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -67,3 +67,4 @@ logs/
# API sample fixtures (large JSON files, regenerate from live API)
omeda-samples/
scripts/api-samples/
.env.staging
22 changes: 22 additions & 0 deletions apps/api/src/routes/admin.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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).
Expand Down
21 changes: 18 additions & 3 deletions apps/api/src/routes/players.ts
Original file line number Diff line number Diff line change
@@ -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();

Expand Down Expand Up @@ -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;
Comment on lines +84 to +89
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Require auth before using platform sync token

Because /players/sync is still registered without requireAuth, this fallback lets any unauthenticated caller exchange the stored predgg_refresh_token and run player + recent-match syncs against the database. The rest of the app mounts playersRouter directly (apps/api/src/index.ts) with no global auth, so a public POST can now consume the platform credential/quota and write arbitrary synced players; gate this fallback/route behind internal auth or admin authorization before reading the stored credential.

Useful? React with 👍 / 👎.

}
} catch { /* no stored token, continue without */ }
}
const synced = await syncPlayerByName(db, name, userToken);

if (!synced) {
Expand All @@ -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);
Expand Down
23 changes: 22 additions & 1 deletion apps/api/src/services/player-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,27 @@ export async function getPlayerProfile(playerId: string): Promise<PlayerProfile>
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<string, unknown> = snapshotStats;
if (typeof (snapshotStats as Record<string, unknown>).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;
Expand Down Expand Up @@ -140,7 +161,7 @@ export async function getPlayerProfile(playerId: string): Promise<PlayerProfile>
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,
Expand Down
2 changes: 2 additions & 0 deletions apps/web/src/api/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<unknown>('/admin/api-status'),
Expand Down
12 changes: 10 additions & 2 deletions apps/web/src/pages/PlayerScouting.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<Phase>({ tag: 'idle' });
Expand Down Expand Up @@ -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<string, unknown>)?.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 });
Expand Down Expand Up @@ -254,7 +262,7 @@ export default function PlayerScouting() {
<PlayerProfilePanel
profile={profilePhase.profile}
onClose={() => 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}
/>
</ProfileErrorBoundary>
)}
Expand Down
61 changes: 60 additions & 1 deletion apps/web/src/pages/UsersPage.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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<PlatformUser | null>(null);
const [newPassword, setNewPassword] = useState('');
const [resetting, setResetting] = useState(false);

const isAdmin = !internalLoading && !!me && me.globalRole === 'PLATFORM_ADMIN';

Expand Down Expand Up @@ -168,6 +171,14 @@ export default function UsersPage() {
<button onClick={() => openEdit(u)} className="btn-secondary" style={{ padding: '0.35rem' }} title="Editar usuario">
<Edit2 size={13} style={{ color: 'var(--accent-blue)' }} />
</button>
<button
onClick={() => { setResetPasswordUser(u); setNewPassword(''); }}
disabled={u.id === me.id}
className="btn-secondary"
style={{ padding: '0.35rem', opacity: u.id === me.id ? 0.3 : 1 }}
title="Resetear contraseña">
<KeyRound size={13} style={{ color: 'var(--accent-prime)' }} />
</button>
<button
onClick={async () => {
try {
Expand Down Expand Up @@ -250,6 +261,54 @@ export default function UsersPage() {
</div>
</div>
)}

{/* Reset password modal */}
{resetPasswordUser && (
<div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.7)', zIndex: 1000, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: '1rem' }}>
<div className="glass-card" style={{ width: '100%', maxWidth: 400, padding: '1.5rem' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.6rem', marginBottom: '0.5rem' }}>
<KeyRound size={16} style={{ color: 'var(--accent-prime)' }} />
<h3 style={{ margin: 0, fontSize: '0.95rem', fontWeight: 800 }}>Resetear contraseña</h3>
</div>
<p style={{ fontSize: '0.78rem', color: 'var(--text-muted)', marginBottom: '1.25rem' }}>
Establece una nueva contraseña para <strong style={{ color: 'var(--text-primary)' }}>{resetPasswordUser.name || resetPasswordUser.email}</strong>. Compártela con el usuario de forma segura.
</p>
<div style={{ marginBottom: '1rem' }}>
<label style={{ display: 'block', fontSize: '0.7rem', color: 'var(--text-muted)', fontWeight: 700, marginBottom: '0.3rem', textTransform: 'uppercase', letterSpacing: '0.06em' }}>Nueva contraseña</label>
<input
className="input"
type="password"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
placeholder="Mínimo 8 caracteres"
style={{ width: '100%' }}
autoFocus
/>
</div>
<div style={{ display: 'flex', gap: '0.5rem', justifyContent: 'flex-end' }}>
<button onClick={() => setResetPasswordUser(null)} className="btn-secondary" style={{ fontSize: '0.82rem' }}>Cancelar</button>
<button
disabled={newPassword.length < 8 || resetting}
className="btn-primary"
style={{ display: 'flex', alignItems: 'center', gap: '0.4rem', fontSize: '0.82rem' }}
onClick={async () => {
setResetting(true);
try {
await (apiClient as any).admin.resetPassword(resetPasswordUser.id, newPassword);
toast.success('Contraseña restablecida');
setResetPasswordUser(null);
setNewPassword('');
} catch (err) {
toast.error(err instanceof ApiErrorResponse ? err.error.message : 'Error al resetear');
} finally { setResetting(false); }
}}
>
<KeyRound size={13} /> {resetting ? 'Guardando…' : 'Restablecer'}
</button>
</div>
</div>
</div>
)}
</div>
);
}
Loading
Loading