495 lines
17 KiB
TypeScript
495 lines
17 KiB
TypeScript
// /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 })
|
||
}
|
||
}
|