// /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 { MapVoteAction } from '@/generated/prisma' import { sendServerSSEMessage } from '@/app/lib/sse-server-client' import { randomInt } from 'crypto' import { MAP_OPTIONS } from '@/app/lib/mapOptions' import { createHash } from 'crypto' /* -------------------- Konstanten -------------------- */ const ACTION_MAP: Record = { BAN: 'ban', PICK: 'pick', DECIDER: 'decider', } /* -------------------- Helper -------------------- */ // Admin-Edit-Flag setzen/zurücksetzen async function setAdminEdit(voteId: string, by: string | null) { return prisma.mapVote.update({ where: { id: voteId }, data: by ? { adminEditingBy: by, adminEditingSince: new Date() } : { adminEditingBy: null, adminEditingSince: null }, include: { steps: true }, }) } function voteOpensAt(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: MapVoteAction): '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(vote: any) { const steps = [...vote.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 : vote.bestOf, mapPool : vote.mapPool as string[], currentIndex: vote.currentIdx, locked : vote.locked as boolean, opensAt : vote.opensAt ? new Date(vote.opensAt).toISOString() : null, steps, // Admin-Edit Shape adminEdit: vote.adminEditingBy ? { enabled: true, by: vote.adminEditingBy as string, since: vote.adminEditingSince ? new Date(vote.adminEditingSince).toISOString() : null, } : { enabled: false, by: null, since: null }, } } // 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) { 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, { cache: 'no-store', headers: { '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 activePlayers: any[] inactivePlayers: any[] invitedPlayers: any[] } } catch { return null } } // 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, }, } } // Leader bevorzugt aus Match-Relation; Fallback über Team-API 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 ensureVote(matchId: string) { const match = await prisma.match.findUnique({ where: { id: matchId }, include: { teamA : { include: { 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, } } } }, mapVote: { include: { steps: true } }, }, }) if (!match) return { match: null, vote: null } // Bereits vorhanden? if (match.mapVote) return { match, vote: match.mapVote } // Neu anlegen const bestOf = match.bestOf ?? 3 const mapPool = MAP_OPTIONS.map(m => m.key) const opensAt = voteOpensAt({ matchDate: match.matchDate ?? null, demoDate: match.demoDate ?? null }) const firstIsA = (typeof randomInt === 'function') ? randomInt(0, 2) === 0 : Math.random() < 0.5 const firstTeamId = firstIsA ? match.teamA!.id : match.teamB!.id const secondTeamId = firstIsA ? match.teamB!.id : match.teamA!.id const stepsDef = buildSteps(bestOf, firstTeamId, secondTeamId) const created = await prisma.mapVote.create({ data: { matchId : match.id, bestOf, mapPool, currentIdx: 0, locked : false, opensAt, steps : { create: stepsDef.map(s => ({ order : s.order, action: s.action as MapVoteAction, teamId: s.teamId, })), }, }, include: { steps: true }, }) return { match, vote: 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)) } /* ---------- Visuals: deterministisches zufälliges Bild pro Map & Match ---------- */ function buildMapVisuals(matchId: string, mapPool: string[]) { const visuals: Record = {} for (const key of mapPool) { const opt = MAP_OPTIONS.find(o => o.key === key) const label = opt?.label ?? key const imgs = opt?.images ?? [] let bg = `/assets/img/maps/${key}/1.jpg` if (imgs.length > 0) { // deterministischer Index auf Basis von matchId+key const h = createHash('sha256').update(`${matchId}:${key}`).digest('hex') const n = parseInt(h.slice(0, 8), 16) // 32-bit const idx = n % imgs.length bg = imgs[idx] } visuals[key] = { label, bg } // images optional mitgeben: { label, bg, images: imgs } } return visuals } /* -------------------- 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, vote } = await ensureVote(matchId) if (!match || !vote) return NextResponse.json({ message: 'Match nicht gefunden' }, { status: 404 }) const teams = await buildTeamsPayload(match, req) const mapVisuals = buildMapVisuals(match.id, vote.mapPool) return NextResponse.json( { ...shapeState(vote), mapVisuals, 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 }) type ToggleBody = { map?: string; adminEdit?: boolean } let body: ToggleBody = {} try { body = await req.json() } catch {} try { const { match, vote } = await ensureVote(matchId) if (!match || !vote) return NextResponse.json({ message: 'Match nicht gefunden' }, { status: 404 }) /* -------- Admin-Edit umschalten (früher Exit) -------- */ if (typeof body.adminEdit === 'boolean') { if (!me.isAdmin) { return NextResponse.json({ message: 'Nur Admins dürfen den Edit-Mode setzen' }, { status: 403 }) } const updated = await setAdminEdit(vote.id, body.adminEdit ? me.steamId : null) await sendServerSSEMessage({ type: 'map-vote-updated', matchId }) const teams = await buildTeamsPayload(match, req) const mapVisuals = buildMapVisuals(match.id, updated.mapPool) return NextResponse.json({ ...shapeState(updated), mapVisuals, teams }) } /* -------- Wenn anderer Admin editiert: Voting sperren -------- */ if (vote.adminEditingBy && vote.adminEditingBy !== me.steamId) { return NextResponse.json({ message: 'Admin-Edit aktiv – Voting vorübergehend deaktiviert' }, { status: 423 }) } /* -------- Zeitfenster prüfen (Admins dürfen trotzdem) -------- */ const opensAt = vote.opensAt ?? voteOpensAt({ matchDate: match.matchDate ?? null, demoDate: match.demoDate ?? null }) const isOpen = new Date() >= new Date(opensAt) if (!isOpen && !me.isAdmin) return NextResponse.json({ message: 'Voting ist noch nicht offen' }, { status: 403 }) // Schon abgeschlossen? if (vote.locked) return NextResponse.json({ message: 'Voting bereits abgeschlossen' }, { status: 409 }) // Aktuellen Schritt bestimmen const stepsSorted = [...vote.steps].sort((a: any, b: any) => a.order - b.order) const current = stepsSorted.find((s: any) => s.order === vote.currentIdx) if (!current) { // Kein Schritt mehr -> Vote abschließen await prisma.mapVote.update({ where: { id: vote.id }, data: { locked: true } }) const updated = await prisma.mapVote.findUnique({ where: { id: vote.id }, include: { steps: true } }) await sendServerSSEMessage({ type: 'map-vote-updated', matchId }) const teams = await buildTeamsPayload(match, req) const mapVisuals = buildMapVisuals(match.id, updated!.mapPool) return NextResponse.json({ ...shapeState(updated), mapVisuals, teams }) } const available = computeAvailableMaps(vote.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.mapVoteStep.update({ where: { id: current.id }, data : { map: lastMap, chosenAt: new Date(), chosenBy: me.steamId }, }) await tx.mapVote.update({ where: { id: vote.id }, data : { currentIdx: vote.currentIdx + 1, locked: true }, }) }) const updated = await prisma.mapVote.findUnique({ where: { id: vote.id }, include: { steps: true }, }) await sendServerSSEMessage({ type: 'map-vote-updated', matchId }) const teams = await buildTeamsPayload(match, req) const mapVisuals = buildMapVisuals(match.id, updated!.mapPool) return NextResponse.json({ ...shapeState(updated), mapVisuals, teams }) } // Rechte prüfen (Admin oder Leader des Teams am Zug) 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 (!vote.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.mapVoteStep.update({ where: { id: current.id }, data : { map, chosenAt: new Date(), chosenBy: me.steamId }, }) // neuen Zustand ermitteln const after = await tx.mapVote.findUnique({ where : { id: vote.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.mapVoteStep.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.mapVote.update({ where: { id: after.id }, data : { currentIdx: idx, locked }, }) }) const updated = await prisma.mapVote.findUnique({ where : { id: vote.id }, include: { steps: true }, }) await sendServerSSEMessage({ type: 'map-vote-updated', matchId }) const teams = await buildTeamsPayload(match, req) const mapVisuals = buildMapVisuals(match.id, updated!.mapPool) return NextResponse.json({ ...shapeState(updated), mapVisuals, teams }) } catch (e) { console.error('[map-vote][POST] error', e) return NextResponse.json({ message: 'Aktion fehlgeschlagen' }, { status: 500 }) } }