// /app/api/matches/[id]/mapvote/route.ts import { NextResponse, NextRequest } from 'next/server' import { getServerSession } from 'next-auth' import { authOptions } from '@/app/lib/auth' import { prisma } from '@/app/lib/prisma' import { MapVetoAction } from '@/generated/prisma' import { sendServerSSEMessage } from '@/app/lib/sse-server-client' /* -------------------- Konstanten -------------------- */ const ACTIVE_DUTY: string[] = [ 'de_inferno','de_mirage','de_nuke','de_overpass','de_vertigo','de_ancient','de_anubis', ] const ACTION_MAP: Record = { BAN: 'ban', PICK: 'pick', DECIDER: 'decider', } /* -------------------- Helper -------------------- */ function vetoOpensAt(match: { matchDate: Date | null, demoDate: Date | null }) { const base = match.matchDate ?? match.demoDate ?? new Date() return new Date(base.getTime() - 60 * 60 * 1000) // 1h vorher } function mapActionToApi(a: MapVetoAction): 'ban'|'pick'|'decider' { return ACTION_MAP[a] } function buildSteps(bestOf: number, teamAId: string, teamBId: string) { if (bestOf === 3) { // 2x Ban, 2x Pick, 2x Ban, Decider return [ { order: 0, action: 'BAN', teamId: teamAId }, { order: 1, action: 'BAN', teamId: teamBId }, { order: 2, action: 'PICK', teamId: teamAId }, { order: 3, action: 'PICK', teamId: teamBId }, { order: 4, action: 'BAN', teamId: teamAId }, { order: 5, action: 'BAN', teamId: teamBId }, { order: 6, action: 'DECIDER', teamId: null }, ] as const } // BO5: 2x Ban, dann 5 Picks (kein Decider) return [ { order: 0, action: 'BAN', teamId: teamAId }, { order: 1, action: 'BAN', teamId: teamBId }, { order: 2, action: 'PICK', teamId: teamAId }, { order: 3, action: 'PICK', teamId: teamBId }, { order: 4, action: 'PICK', teamId: teamAId }, { order: 5, action: 'PICK', teamId: teamBId }, { order: 6, action: 'PICK', teamId: teamAId }, ] as const } function shapeState(veto: any) { const steps = [...veto.steps] .sort((a, b) => a.order - b.order) .map((s: any) => ({ order : s.order, action : mapActionToApi(s.action), teamId : s.teamId, map : s.map, chosenAt: s.chosenAt ? s.chosenAt.toISOString() : null, chosenBy: s.chosenBy ?? null, })) return { bestOf : veto.bestOf, mapPool : veto.mapPool as string[], currentIndex: veto.currentIdx, locked : veto.locked as boolean, opensAt : veto.opensAt ? new Date(veto.opensAt).toISOString() : null, steps, } } // Leader -> Player-Shape fürs Frontend function shapeLeader(leader: any | null) { if (!leader) return null return { steamId : leader.steamId, name : leader.name ?? '', avatar : leader.avatar ?? '', location : leader.location ?? undefined, premierRank: leader.premierRank ?? undefined, isAdmin : leader.isAdmin ?? undefined, } } // Player -> Player-Shape (falls wir aus Team-API übernehmen) function shapePlayer(p: any) { if (!p) return null return { steamId : p.steamId, name : p.name ?? '', avatar : p.avatar ?? '', location : p.location ?? undefined, premierRank: p.premierRank ?? undefined, isAdmin : p.isAdmin ?? undefined, } } // Base-URL aus Request ableiten (lokal/proxy-fähig) function getBaseUrl(req: NextRequest | NextResponse) { // NextRequest hat headers; bei internen Aufrufen ggf. NextResponse, hier aber nur Request relevant const proto = (req.headers.get('x-forwarded-proto') || 'http').split(',')[0].trim() const host = (req.headers.get('x-forwarded-host') || req.headers.get('host') || '').split(',')[0].trim() return `${proto}://${host}` } async function fetchTeamApi(teamId: string | null | undefined, req: NextRequest) { if (!teamId) return null const base = getBaseUrl(req) const url = `${base}/api/team/${teamId}` try { const r = await fetch(url, { // interne Server-Fetches dürfen nicht gecacht werden cache: 'no-store', headers: { // Forward auth/proxy headers, falls nötig (nicht zwingend) 'x-forwarded-proto': req.headers.get('x-forwarded-proto') || '', 'x-forwarded-host' : req.headers.get('x-forwarded-host') || '', } }) if (!r.ok) return null const json = await r.json() return json as { id: string name?: string | null logo?: string | null leader?: string | null // LeaderId activePlayers: any[] inactivePlayers: any[] invitedPlayers: any[] } } catch { return null } } // Leader bevorzugt aus Match-Relation; Fallback über Team-API (LeaderId -> Player aus Listen) function resolveLeaderPlayer(matchTeam: any | null | undefined, teamApi: any | null) { const leaderFromMatch = shapeLeader(matchTeam?.leader ?? null) if (leaderFromMatch) return leaderFromMatch const leaderId: string | null = teamApi?.leader ?? null if (!leaderId) return null const pool: any[] = [ ...(teamApi?.activePlayers ?? []), ...(teamApi?.inactivePlayers ?? []), ...(teamApi?.invitedPlayers ?? []), ] const found = pool.find(p => p?.steamId === leaderId) return shapePlayer(found) ?? { steamId: leaderId, name: '', avatar: '' } } async function ensureVeto(matchId: string) { const match = await prisma.match.findUnique({ where: { id: matchId }, include: { teamA : { include: { // Leader-Relation als Objekt laden leader: { select: { steamId: true, name: true, avatar: true, location: true, premierRank: true, isAdmin: true, } } } }, teamB : { include: { leader: { select: { steamId: true, name: true, avatar: true, location: true, premierRank: true, isAdmin: true, } } } }, mapVeto: { include: { steps: true } }, }, }) if (!match) return { match: null, veto: null } // Bereits vorhanden? if (match.mapVeto) return { match, veto: match.mapVeto } // Neu anlegen const bestOf = match.bestOf ?? 3 const mapPool = ACTIVE_DUTY const opensAt = vetoOpensAt({ matchDate: match.matchDate ?? null, demoDate: match.demoDate ?? null }) const stepsDef = buildSteps(bestOf, match.teamA!.id, match.teamB!.id) const created = await prisma.mapVeto.create({ data: { matchId : match.id, bestOf, mapPool, currentIdx: 0, locked : false, opensAt, steps : { create: stepsDef.map(s => ({ order : s.order, action: s.action as MapVetoAction, teamId: s.teamId, })), }, }, include: { steps: true }, }) return { match, veto: created } } function computeAvailableMaps(mapPool: string[], steps: Array<{ map: string | null }>) { const used = new Set(steps.map(s => s.map).filter(Boolean) as string[]) return mapPool.filter(m => !used.has(m)) } // Teams-Payload (mit Spielern) zusammenbauen async function buildTeamsPayload(match: any, req: NextRequest) { const [teamAApi, teamBApi] = await Promise.all([ fetchTeamApi(match.teamA?.id, req), fetchTeamApi(match.teamB?.id, req), ]) const teamAPlayers = (teamAApi?.activePlayers ?? []).map(shapePlayer).filter(Boolean) const teamBPlayers = (teamBApi?.activePlayers ?? []).map(shapePlayer).filter(Boolean) return { teamA: { id : match.teamA?.id ?? null, name : match.teamA?.name ?? null, logo : match.teamA?.logo ?? null, leader: resolveLeaderPlayer(match.teamA, teamAApi), players: teamAPlayers, }, teamB: { id : match.teamB?.id ?? null, name : match.teamB?.name ?? null, logo : match.teamB?.logo ?? null, leader: resolveLeaderPlayer(match.teamB, teamBApi), players: teamBPlayers, }, } } /* -------------------- GET -------------------- */ export async function GET(req: NextRequest, { params }: { params: { matchId: string } }) { try { const matchId = params.matchId if (!matchId) return NextResponse.json({ message: 'Missing id' }, { status: 400 }) const { match, veto } = await ensureVeto(matchId) if (!match || !veto) return NextResponse.json({ message: 'Match nicht gefunden' }, { status: 404 }) const teams = await buildTeamsPayload(match, req) return NextResponse.json( { ...shapeState(veto), teams }, { headers: { 'Cache-Control': 'no-store' } }, ) } catch (e) { console.error('[map-vote][GET] error', e) return NextResponse.json({ message: 'Fehler beim Laden' }, { status: 500 }) } } /* -------------------- POST ------------------- */ export async function POST(req: NextRequest, { params }: { params: { matchId: string } }) { const session = await getServerSession(authOptions(req)) const me = session?.user as { steamId: string; isAdmin?: boolean } | undefined if (!me?.steamId) return NextResponse.json({ message: 'Nicht eingeloggt' }, { status: 401 }) const matchId = params.matchId if (!matchId) return NextResponse.json({ message: 'Missing id' }, { status: 400 }) let body: { map?: string } = {} try { body = await req.json() } catch {} try { const { match, veto } = await ensureVeto(matchId) if (!match || !veto) return NextResponse.json({ message: 'Match nicht gefunden' }, { status: 404 }) // Öffnungsfenster (1h vor Match-/Demo-Beginn) const opensAt = veto.opensAt ?? vetoOpensAt({ matchDate: match.matchDate ?? null, demoDate: match.demoDate ?? null }) const isOpen = new Date() >= new Date(opensAt) if (!isOpen && !me.isAdmin) return NextResponse.json({ message: 'Veto ist noch nicht offen' }, { status: 403 }) // Schon abgeschlossen? if (veto.locked) return NextResponse.json({ message: 'Veto bereits abgeschlossen' }, { status: 409 }) // Aktuellen Schritt bestimmen const stepsSorted = [...veto.steps].sort((a: any, b: any) => a.order - b.order) const current = stepsSorted.find((s: any) => s.order === veto.currentIdx) if (!current) { // Kein Schritt mehr -> Veto abschließen await prisma.mapVeto.update({ where: { id: veto.id }, data: { locked: true } }) const updated = await prisma.mapVeto.findUnique({ where: { id: veto.id }, include: { steps: true }, }) // 🔔 Broadcast (flat) await sendServerSSEMessage({ type: 'map-vote-updated', matchId }) const teams = await buildTeamsPayload(match, req) return NextResponse.json({ ...shapeState(updated), teams }) } const available = computeAvailableMaps(veto.mapPool, stepsSorted) // DECIDER automatisch setzen, wenn nur noch 1 Map übrig if (current.action === 'DECIDER') { if (available.length !== 1) { return NextResponse.json({ message: 'DECIDER noch nicht bestimmbar' }, { status: 400 }) } const lastMap = available[0] await prisma.$transaction(async (tx) => { await tx.mapVetoStep.update({ where: { id: current.id }, data : { map: lastMap, chosenAt: new Date(), chosenBy: me.steamId }, }) await tx.mapVeto.update({ where: { id: veto.id }, data : { currentIdx: veto.currentIdx + 1, locked: true }, }) }) const updated = await prisma.mapVeto.findUnique({ where: { id: veto.id }, include: { steps: true }, }) // 🔔 Broadcast (flat) await sendServerSSEMessage({ type: 'map-vote-updated', matchId }) const teams = await buildTeamsPayload(match, req) return NextResponse.json({ ...shapeState(updated), teams }) } // Rechte prüfen (Admin oder Leader des Teams am Zug) – weiterhin via leaderId const isLeaderA = !!(match as any).teamA?.leaderId && (match as any).teamA.leaderId === me.steamId const isLeaderB = !!(match as any).teamB?.leaderId && (match as any).teamB.leaderId === me.steamId const allowed = me.isAdmin || (current.teamId && ( (current.teamId === match.teamA?.id && isLeaderA) || (current.teamId === match.teamB?.id && isLeaderB) )) if (!allowed) return NextResponse.json({ message: 'Keine Berechtigung für diesen Schritt' }, { status: 403 }) // Payload validieren const map = body.map?.trim() if (!map) return NextResponse.json({ message: 'map fehlt' }, { status: 400 }) if (!veto.mapPool.includes(map)) return NextResponse.json({ message: 'Map nicht im Pool' }, { status: 400 }) if (!available.includes(map)) return NextResponse.json({ message: 'Map bereits vergeben' }, { status: 409 }) // Schritt setzen & ggf. weiterdrehen (+ Decider evtl. auto) await prisma.$transaction(async (tx) => { // aktuellen Schritt setzen await tx.mapVetoStep.update({ where: { id: current.id }, data : { map, chosenAt: new Date(), chosenBy: me.steamId }, }) // neuen Zustand ermitteln const after = await tx.mapVeto.findUnique({ where : { id: veto.id }, include: { steps: true }, }) if (!after) return const stepsAfter = [...after.steps].sort((a: any, b: any) => a.order - b.order) let idx = after.currentIdx + 1 let locked = false // Falls nächster Schritt DECIDER und genau 1 Map übrig -> auto setzen & locken const next = stepsAfter.find(s => s.order === idx) if (next?.action === 'DECIDER') { const avail = computeAvailableMaps(after.mapPool, stepsAfter) if (avail.length === 1) { await tx.mapVetoStep.update({ where: { id: next.id }, data : { map: avail[0], chosenAt: new Date(), chosenBy: me.steamId }, }) idx += 1 locked = true } } // Ende erreicht? const maxOrder = Math.max(...stepsAfter.map(s => s.order)) if (idx > maxOrder) locked = true await tx.mapVeto.update({ where: { id: after.id }, data : { currentIdx: idx, locked }, }) }) const updated = await prisma.mapVeto.findUnique({ where : { id: veto.id }, include: { steps: true }, }) // 🔔 Broadcast (flat) await sendServerSSEMessage({ type: 'map-vote-updated', matchId }) const teams = await buildTeamsPayload(match, req) return NextResponse.json({ ...shapeState(updated), teams }) } catch (e) { console.error('[map-vote][POST] error', e) return NextResponse.json({ message: 'Aktion fehlgeschlagen' }, { status: 500 }) } }