2025-08-15 13:34:23 +02:00

495 lines
17 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 { 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<MapVoteAction, 'ban'|'pick'|'decider'> = {
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<string, { label: string; bg: string; images?: string[] }> = {}
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 })
}
}