2025-08-14 15:06:48 +02:00

435 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// /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<MapVetoAction, 'ban'|'pick'|'decider'> = {
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 })
}
}