Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
79ffe7f
feat: add predgg coach data foundation
Aug 26, 2026
916e171
fix: bound catalog backfill to recent patches
Aug 26, 2026
a0e5173
fix: resolve build catalogs for internal game versions
Aug 26, 2026
630ec74
fix: backfill new telemetry during player enrichment
Aug 26, 2026
499c4ff
fix: align rating distribution with live schema
Aug 26, 2026
277a57e
feat: expose build coach from personal match detail
Aug 26, 2026
20e37ae
fix: distinguish final items from ancestors
Aug 26, 2026
685218e
feat: surface builds and ancestors in player coach
Aug 26, 2026
3510e93
fix: label Pred.gg ancestor slots
Aug 26, 2026
aaf5b35
fix: open synced matches without Pred.gg live access
Aug 26, 2026
b7b7afd
feat: explain contextual build coaching
Aug 26, 2026
de3e3ea
fix: keep build advice role-aware and non-duplicative
Aug 26, 2026
e2539ea
test: cover role-aware build advice
Aug 26, 2026
18224a8
feat: teach build patterns in weekly coach report
Aug 26, 2026
2789a9a
feat: make match build coaching educational
Aug 26, 2026
2dfb162
fix: rank build evidence by match impact
Aug 26, 2026
744c4a6
fix: compare loadouts against match perk catalog
Aug 26, 2026
fcfee90
feat: analyze full build playstyle and tradeoffs
Aug 26, 2026
f5c1e05
fix: remove noisy global build classifications
Aug 26, 2026
a0d06d3
fix: explain unavailable gold timeline
Aug 26, 2026
1eea0aa
fix: preserve gold timeline on match resync
Aug 26, 2026
e678ff0
fix: retry public event stream after api key rejection
Aug 26, 2026
0f8e5b7
fix: sync public gold timeline separately
Aug 26, 2026
4d3d7fe
fix: backfill missing gold timelines in player sync
Aug 26, 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
8 changes: 8 additions & 0 deletions apps/api/src/routes/admin.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,12 @@ vi.mock('../db.js', () => ({

vi.mock('../services/predgg-token-service.js', () => ({
getPlatformAccessToken: vi.fn().mockResolvedValue(null),
readPlatformOAuthStatus: vi.fn().mockResolvedValue({
requestedScopes: ['offline_access', 'profile'], grantedScopes: [], missingScopes: ['offline_access', 'profile'],
capabilities: { profile: false, offlineRefresh: false, playerIntervals: false, heroLeaderboard: false, matchupStatistics: false },
checkedAt: null, error: 'Granted scopes have not been inspected yet',
}),
inspectPlatformOAuthStatus: vi.fn(),
platformTokenState: { status: 'missing', lastCheckedAt: null, lastError: null },
}));

Expand All @@ -22,6 +28,8 @@ vi.mock('../services/sync-service.js', () => ({
syncVersionsFromPredgg: vi.fn().mockResolvedValue(5),
syncStalePlayers: vi.fn().mockResolvedValue({ synced: 2, skipped: 0, errors: 0 }),
syncIncompleteMatches: vi.fn().mockResolvedValue({ synced: 1, errors: 0 }),
syncGameCatalog: vi.fn().mockResolvedValue({ version: '1.16.1', items: 270, perks: 276, eternalCategories: 6 }),
syncTrackedGameCatalogs: vi.fn().mockResolvedValue({ versions: 1, catalogs: [{ version: '1.16.1', items: 270, perks: 276, eternalCategories: 6 }] }),
repairEventStreamPlayerIds: vi.fn().mockResolvedValue({
heroKillsUpdated: 1,
objectiveKillsUpdated: 1,
Expand Down
24 changes: 22 additions & 2 deletions apps/api/src/routes/admin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,15 @@ import {
syncMatchEventStream,
resyncMatch,
cleanupOldData,
syncGameCatalog,
syncTrackedGameCatalogs,
} from '../services/sync-service.js';
import { syncHeroMeta } from '../services/hero-meta-service.js';
import { invalidateHeroMetaCache } from './hero-meta.js';
import { getAllConfig, updateConfigValue, updateConfigText, resetConfigValue } from '../services/config-service.js';
import { getPermissions, savePermissions, DEFAULT_PERMISSIONS, CONFIGURABLE_ROLES } from '../services/permissions-service.js';
import { getValidToken } from './auth.js';
import { getPlatformAccessToken, platformTokenState } from '../services/predgg-token-service.js';
import { getPlatformAccessToken, inspectPlatformOAuthStatus, platformTokenState, readPlatformOAuthStatus } from '../services/predgg-token-service.js';
import { requireAuth } from '../middleware/require-auth.js';
import { requirePlatformAdmin } from '../middleware/require-platform-admin.js';

Expand Down Expand Up @@ -676,6 +678,11 @@ adminRouter.get('/api-status', async (_req, res, next) => {
}

const dayAgo = new Date(Date.now() - 24 * 60 * 60 * 1000);
const platformToken = await getPlatformAccessToken();
const oauth = platformToken
? await inspectPlatformOAuthStatus(platformToken.accessToken)
: await readPlatformOAuthStatus();

const [totalErrors, recentErrors, lastSuccess, recentBySource] = await Promise.all([
db.syncLog.count({ where: { status: 'error' } }),
db.syncLog.count({ where: { status: 'error', syncedAt: { gte: dayAgo } } }),
Expand All @@ -684,7 +691,7 @@ adminRouter.get('/api-status', async (_req, res, next) => {
]);

res.json({
predgg: { status: predggStatus, responseMs: predggMs, error: predggError, endpoint: GQL_URL },
predgg: { status: predggStatus, responseMs: predggMs, error: predggError, endpoint: GQL_URL, oauth },
syncErrors: { total: totalErrors, last24h: recentErrors, bySource: recentBySource },
lastSuccessfulSync: lastSuccess,
});
Expand All @@ -705,6 +712,19 @@ adminRouter.post('/sync-heroes', requireAuth, requirePlatformAdmin, async (_req,
}
});

/** POST /admin/sync-game-catalog — current or selected patch catalog. */
adminRouter.post('/sync-game-catalog', requireAuth, requirePlatformAdmin, async (req, res, next) => {
try {
const { versionId } = z.object({ versionId: z.string().optional() }).parse(req.body ?? {});
const result = versionId
? await syncGameCatalog(db, versionId)
: await syncTrackedGameCatalogs(db);
res.json({ ok: true, ...result });
} catch (err) {
next(err);
}
});

/**
* GET /admin/config
* Returns all platform config entries.
Expand Down
23 changes: 21 additions & 2 deletions apps/api/src/routes/auth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@ import { describe, it, expect, vi } from 'vitest';
import request from 'supertest';
import express from 'express';
import cookieParser from 'cookie-parser';
import { authRouter } from './auth.js';
import { savePlatformOAuthTokens } from '../services/predgg-token-service.js';
import { authRouter, getValidToken } from './auth.js';
import { getPlatformAccessToken, savePlatformOAuthTokens } from '../services/predgg-token-service.js';

vi.hoisted(() => {
process.env.PRED_GG_CLIENT_ID = 'test-client-id';
Expand All @@ -22,9 +22,28 @@ vi.mock('../services/predgg-token-service.js', () => ({

const app = express();
app.use(cookieParser());
app.get('/token-probe', async (req, res) => {
const token = await getValidToken(req, res);
res.json({ token: token ?? null });
});
app.use('/auth', authRouter);

describe('GET /auth/predgg', () => {
it('clears an expired browser session when silent refresh is unavailable', async () => {
vi.mocked(getPlatformAccessToken).mockResolvedValueOnce(null);

const res = await request(app)
.get('/token-probe')
.set('Cookie', ['predgg_expires_at=1']);

expect(res.status).toBe(200);
expect(res.body).toEqual({ token: null });
expect(res.headers['set-cookie']).toEqual(expect.arrayContaining([
expect.stringContaining('predgg_token='),
expect.stringContaining('predgg_expires_at='),
]));
});

it('redirects to the pred.gg OAuth SPA authorize route with PKCE', async () => {
const res = await request(app).get('/auth/predgg');

Expand Down
2 changes: 1 addition & 1 deletion apps/api/src/routes/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ export async function getValidToken(req: Request, res: Response): Promise<string
return platformToken.accessToken;
}

logger.warn({ error: result.data.error }, 'silent refresh failed — user must re-login');
logger.warn('silent refresh failed — user must re-login');
// Clear stale cookies so /auth/me returns unauthenticated
res.clearCookie(COOKIE_TOKEN);
res.clearCookie(COOKIE_EXPIRES_AT);
Expand Down
60 changes: 58 additions & 2 deletions apps/api/src/routes/matches.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,39 @@
import { describe, expect, it } from 'vitest';
import { buildLiveDetail } from './matches.js';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import cookieParser from 'cookie-parser';
import express from 'express';
import request from 'supertest';
import { authCookie } from '../test/auth-cookie.js';
import { errorHandler } from '../middleware/error-handler.js';

const mocks = vi.hoisted(() => ({
matchFindUnique: vi.fn(),
getMatchDetail: vi.fn(),
getMatchEvents: vi.fn(),
getValidToken: vi.fn(),
}));

vi.mock('../db.js', () => ({
db: {
match: { findUnique: mocks.matchFindUnique },
user: { findUnique: vi.fn() },
matchPlayer: { findFirst: vi.fn() },
},
}));
vi.mock('../services/match-service.js', () => ({
getMatchDetail: mocks.getMatchDetail,
getMatchEvents: mocks.getMatchEvents,
}));
vi.mock('./auth.js', () => ({ getValidToken: mocks.getValidToken }));
vi.mock('../services/sync-service.js', () => ({ resyncMatch: vi.fn(), syncMatchEventStream: vi.fn() }));
vi.mock('../services/build-coach-service.js', () => ({ getMatchBuildAnalysis: vi.fn() }));

import { buildLiveDetail, matchesRouter } from './matches.js';

const app = express();
app.use(express.json());
app.use(cookieParser());
app.use('/matches', matchesRouter);
app.use(errorHandler);

describe('buildLiveDetail', () => {
it('ignores null and empty inventory slots returned by pred.gg', () => {
Expand All @@ -26,3 +60,25 @@ describe('buildLiveDetail', () => {
expect(result.detail.dusk[0].inventoryItems).toEqual(['lightning-hawk']);
});
});

describe('GET /matches/live/:predggUuid', () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.matchFindUnique.mockResolvedValue({ id: 'stored-match-1' });
mocks.getMatchDetail.mockResolvedValue({ id: 'stored-match-1', predggUuid: 'uuid-1', dusk: [], dawn: [] });
mocks.getMatchEvents.mockResolvedValue({ heroKills: [], objectiveKills: [], structureDestructions: [], wardEvents: [], transactions: [] });
});

it('serves a synced local match without depending on the browser Pred.gg token', async () => {
const cookie = await authCookie({ globalRole: 'PLAYER', memberships: [] });

const response = await request(app).get('/matches/live/uuid-1').set('Cookie', cookie);

expect(response.status).toBe(200);
expect(response.headers['cache-control']).toBe('no-store');
expect(response.body.detail.id).toBe('stored-match-1');
expect(mocks.getMatchDetail).toHaveBeenCalledWith('stored-match-1');
expect(mocks.getMatchEvents).toHaveBeenCalledWith('stored-match-1');
expect(mocks.getValidToken).not.toHaveBeenCalled();
});
});
Loading