// /src/app/api/matches/[matchId]/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, createHash, randomBytes } from 'crypto' import { MAP_OPTIONS } from '@/app/lib/mapOptions' export const runtime = 'nodejs' export const dynamic = 'force-dynamic' /* -------------------- Konstanten -------------------- */ const ACTION_MAP: Record = { BAN: 'ban', PICK: 'pick', DECIDER: 'decider', } /* -------------------- Helper -------------------- */ const sleep = (ms: number) => new Promise(res => setTimeout(res, ms)); async function unloadCurrentMatch() { // einige MatchZy Builds nutzen "matchzy_unloadmatch", // andere trennen zwischen cancel/end. Der Unload reicht meist. await sendServerCommand('matchzy_unloadmatch') // optional „end/cancel“ hinterher, falls dein Build es erfordert: // await sendServerCommand('matchzy_cancelmatch') await sleep(500) // Server eine halbe Sekunde Luft lassen } function makeRandomMatchId() { try { // 9–10-stellige ID (>= 100_000_000) – Obergrenze exklusiv return typeof randomInt === 'function' ? randomInt(100_000_000, 2_147_483_647) // bis INT32_MAX : (Math.floor(Math.random() * (2_147_483_647 - 100_000_000)) + 100_000_000) } catch { return Math.floor(Math.random() * 1_000_000_000) + 100_000_000 } } function buildPteroClientUrl(base: string, serverId: string) { const u = new URL(base.includes('://') ? base : `https://${base}`) const cleaned = (u.pathname || '').replace(/\/+$/, '') u.pathname = `${cleaned}/api/client/servers/${serverId}/command` return u.toString() } async function sendServerCommand(command: string) { try { const panelBase = process.env.PTERODACTYL_PANEL_URL || process.env.NEXT_PUBLIC_PTERODACTYL_PANEL_URL || '' if (!panelBase) { console.warn('[mapvote] PTERODACTYL_PANEL_URL fehlt – Command wird nicht gesendet.') return } // ⬇️ Client-API-Key NUR aus .env ziehen const clientApiKey = process.env.PTERODACTYL_CLIENT_API || '' if (!clientApiKey) { console.warn('[mapvote] PTERODACTYL_CLIENT_API fehlt – Command wird nicht gesendet.') return } // Server-ID weiterhin aus DB (ServerConfig) const cfg = await prisma.serverConfig.findUnique({ where: { id: 'default' }, select: { pterodactylServerId: true }, }) if (!cfg?.pterodactylServerId) { console.warn('[mapvote] pterodactylServerId fehlt in ServerConfig – Command wird nicht gesendet.') return } const url = buildPteroClientUrl(panelBase, cfg.pterodactylServerId) const res = await fetch(url, { method: 'POST', headers: { Authorization: `Bearer ${clientApiKey}`, // ✅ Client-API-Key Accept: 'Application/vnd.pterodactyl.v1+json', 'Content-Type': 'application/json', }, body: JSON.stringify({ command }), cache: 'no-store', }) if (res.status === 204) { console.log('[mapvote] Command OK (204):', command) return } if (!res.ok) { const t = await res.text().catch(() => '') console.error('[mapvote] Command fehlgeschlagen:', res.status, t) return } console.log('[mapvote] Command OK:', command) } catch (e) { console.error('[mapvote] Command-Fehler:', e) } } // 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: 'DECIDER', teamId: null }, ] as const } function shapeState(vote: any, match?: 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, })) const opensAtDate = vote.opensAt ? new Date(vote.opensAt) : null const baseDate = match?.matchDate ?? match?.demoDate ?? null const leadMinutes = opensAtDate && baseDate ? Math.max(0, Math.round((new Date(baseDate).getTime() - opensAtDate.getTime()) / 60000)) : null return { bestOf : vote.bestOf, mapPool : vote.mapPool as string[], currentIndex: vote.currentIdx, locked : vote.locked as boolean, opensAt : opensAtDate ? opensAtDate.toISOString() : null, leadMinutes, steps, 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, } } // Teams-Payload (mit Spielern) zusammenbauen function shapeUser(u: any) { if (!u) return null return { steamId : u.steamId, name : u.name ?? '', avatar : u.avatar ?? '', location : u.location ?? undefined, premierRank: u.premierRank ?? undefined, isAdmin : u.isAdmin ?? undefined, } } function buildTeamsPayloadFromMatch(match: any) { const teamAPlayers = (match.teamAUsers ?? []).map(shapeUser).filter(Boolean) const teamBPlayers = (match.teamBUsers ?? []).map(shapeUser).filter(Boolean) return { teamA: { id : match.teamA?.id ?? null, name : match.teamA?.name ?? null, logo : match.teamA?.logo ?? null, leader : shapeLeader(match.teamA?.leader ?? null), players: teamAPlayers, }, teamB: { id : match.teamB?.id ?? null, name : match.teamB?.name ?? null, logo : match.teamB?.logo ?? null, leader : shapeLeader(match.teamB?.leader ?? null), players: teamBPlayers, }, } } 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, } } } }, // 👉 Spieler direkt am Match laden: teamAUsers: true, teamBUsers: true, // optional zusätzlich: players: { include: { user: true } }, // falls du MatchPlayer brauchst 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.filter(m => m.active).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}/${key}_1_png.webp` if (imgs.length > 0) { const h = createHash('sha256').update(`${matchId}:${key}`).digest('hex') const n = parseInt(h.slice(0, 8), 16) const idx = n % imgs.length bg = imgs[idx] } const logo = opt?.icon ?? `/assets/img/mapicons/map_icon_${key}.svg` // ⬅️ neu visuals[key] = { label, bg, logo } } return visuals } function uniq(arr: T[]) { return Array.from(new Set(arr)) } function collectParticipants(match: any): string[] { const fromMatchPlayers = (match.players ?? []) .map((mp: any) => mp?.user?.steamId) .filter(Boolean) const fromTeamUsersA = (match.teamAUsers ?? []).map((u: any) => u?.steamId).filter(Boolean) const fromTeamUsersB = (match.teamBUsers ?? []).map((u: any) => u?.steamId).filter(Boolean) const fromActiveA = Array.isArray(match.teamA?.activePlayers) ? match.teamA.activePlayers : [] const fromActiveB = Array.isArray(match.teamB?.activePlayers) ? match.teamB.activePlayers : [] const leaderA = match.teamA?.leader?.steamId ? [match.teamA.leader.steamId] : [] const leaderB = match.teamB?.leader?.steamId ? [match.teamB.leader.steamId] : [] return uniq([ ...fromMatchPlayers, ...fromTeamUsersA, ...fromTeamUsersB, ...fromActiveA, ...fromActiveB, ...leaderA, ...leaderB, ]) } async function persistMatchPlayers(match: any) { // Teilnehmer ermitteln (du hast schon collectParticipants) const participants = collectParticipants(match); // string[] der steamIds // teamId pro Spieler bestimmen (A oder B), sonst null const aIds = new Set((match.teamAUsers ?? []).map((u: any) => String(u?.steamId)).filter(Boolean)); const bIds = new Set((match.teamBUsers ?? []).map((u: any) => String(u?.steamId)).filter(Boolean)); const ops = participants.map((steamId) => { const onTeamA = aIds.has(String(steamId)); const onTeamB = bIds.has(String(steamId)); const teamId = onTeamA ? match.teamA?.id : onTeamB ? match.teamB?.id : null; // Upsert je Spieler fürs Match return prisma.matchPlayer.upsert({ where: { matchId_steamId: { matchId: match.id, steamId } }, update: { teamId }, // falls sich die Team-Zuordnung ändert create: { matchId: match.id, steamId, teamId }, }); }); await prisma.$transaction(ops); } /* ---------- Export-Helfer ---------- */ type PlayerLike = { user?: { steamId: string, name?: string | null }, steamId?: string, name?: string | null } type MatchLike = { id: string | number bestOf?: number teamA?: { name?: string | null, players?: PlayerLike[] | any[] } teamB?: { name?: string | null, players?: PlayerLike[] | any[] } } type MapVoteStep = { action: 'ban' | 'pick' | 'decider', map?: string | null, teamId?: string | null } type MapVoteStateForExport = { bestOf?: number, steps: MapVoteStep[], locked?: boolean } function sanitizeFilePart(s?: string | null) { return (s ?? 'team').toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '') } function playersMapFromList(list: PlayerLike[] | any[] | undefined) { const out: Record = {} for (const p of list ?? []) { const sid = (p?.user?.steamId ?? (p as any)?.steamId) as string | undefined if (!sid) continue const name = (p?.user?.name ?? p?.name ?? 'Player') as string out[sid] = name } return out } function toDeMapName(key: string) { if (key.startsWith('de_')) return key return `de_${key}` } // ⬇️ buildMatchJson anpassen: matchid statt "" → zufälliger Integer function buildMatchJson(match: MatchLike, state: MapVoteStateForExport) { const bestOf = match.bestOf ?? state.bestOf ?? 3 const chosen = (state.steps ?? []).filter(s => (s.action === 'pick' || s.action === 'decider') && s.map) const maplist = chosen.slice(0, bestOf).map(s => toDeMapName(s.map!)) const map_sides = maplist.map((_, i) => { if (i === maplist.length - 1) return 'knife' return i % 2 === 0 ? 'team1_ct' : 'team2_ct' }) const team1Name = match.teamA?.name ?? 'Team_1' const team2Name = match.teamB?.name ?? 'Team_2' const team1Players = playersMapFromList(match.teamA?.players) const team2Players = playersMapFromList(match.teamB?.players) // 👇 hier neu: zufällige Integer-ID const rndId = makeRandomMatchId() return { matchid: rndId, // vorher: "" team1: { name: team1Name, players: team1Players }, team2: { name: team2Name, players: team2Players }, num_maps: bestOf, maplist, map_sides, spectators: { players: {} as Record }, clinch_series: true, players_per_team: 5, cvars: { hostname: `Iron:e Open 4 | ${team1Name} vs ${team2Name}`, mp_friendlyfire: '1', }, } } async function exportMatchToSftpDirect(match: any, vote: any) { try { const SFTPClient = (await import('ssh2-sftp-client')).default const mLike: MatchLike = { id: match.id, bestOf: match.bestOf ?? vote.bestOf ?? 3, teamA: { name: match.teamA?.name ?? 'Team_1', players: match.teamAUsers ?? [] }, teamB: { name: match.teamB?.name ?? 'Team_2', players: match.teamBUsers ?? [] }, } const sLike: MapVoteStateForExport = { bestOf: vote.bestOf, steps: [...vote.steps] .sort((a: any, b: any) => a.order - b.order) .map((s: any) => ({ action: mapActionToApi(s.action), map: s.map, teamId: s.teamId })), locked: vote.locked, } if (!sLike.locked) return const bestOf = mLike.bestOf ?? sLike.bestOf ?? 3 const chosen = (sLike.steps ?? []).filter(s => (s.action === 'pick' || s.action === 'decider') && s.map) if (chosen.length < bestOf) return // ⬇️ JSON bauen (enthält cs2MatchId/rndId) const json = buildMatchJson(mLike, sLike) const jsonStr = JSON.stringify(json, null, 2) const filename = `${match.id}.json` // --- SFTP Upload wie gehabt --- const url = process.env.PTERO_SERVER_SFTP_URL || '' const user = process.env.PTERO_SERVER_SFTP_USER const pass = process.env.PTERO_SERVER_SFTP_PASSWORD if (!url || !user || !pass) { throw new Error('SFTP-Umgebungsvariablen fehlen (PTERO_SERVER_SFTP_URL, _USER, _PASSWORD).') } let host = url let port = 22 try { const u = new URL(url.includes('://') ? url : `sftp://${url}`) host = u.hostname port = Number(u.port) || 22 } catch { const [h, p] = url.split(':') host = h ?? url port = p ? Number(p) : 22 } const sftp = new SFTPClient() await sftp.connect({ host, port, username: user, password: pass }) const remotePath = `/game/csgo/${filename}` await sftp.put(Buffer.from(jsonStr, 'utf8'), remotePath) await sftp.end() console.log(`[mapvote] Export OK → ${remotePath}`) // erst aktuelles Match beenden/entladen … await unloadCurrentMatch() // … dann das neue laden await sendServerCommand(`matchzy_loadmatch ${filename}`) // Spieler persistieren + cs2MatchId speichern wie gehabt await persistMatchPlayers(match) if (typeof json.matchid === 'number') { await prisma.match.update({ where: { id: match.id }, data: { cs2MatchId: json.matchid, exportedAt: new Date() }, }) } else { await prisma.match.update({ where: { id: match.id }, data: { exportedAt: new Date() }, }) } // ⬇️ OPTIONAL: cs2MatchId + exportedAt im Match speichern if (typeof json.matchid === 'number') { await prisma.match.update({ where: { id: match.id }, data: { cs2MatchId: json.matchid, exportedAt: new Date() }, }) } else { await prisma.match.update({ where: { id: match.id }, data: { exportedAt: new Date() }, }) } } catch (err) { console.error('[mapvote] Export fehlgeschlagen:', err) } } /* ---------- kleine Helfer für match-ready Payload ---------- */ function deriveChosenSteps(vote: any) { const steps = [...vote.steps].sort((a: any, b: any) => a.order - b.order) return steps.filter((s: any) => (s.action === 'PICK' || s.action === 'DECIDER') && s.map) } /* -------------------- GET -------------------- */ export async function GET(req: NextRequest, { params }: { params: { matchId: string } }) { try { const matchId = params.matchId if (!matchId) return NextResponse.json({ message: 'Missing matchId' }, { status: 400 }) const { match, vote } = await ensureVote(matchId) if (!match || !vote) return NextResponse.json({ message: 'Match nicht gefunden' }, { status: 404 }) const teams = buildTeamsPayloadFromMatch(match) const mapVisuals = buildMapVisuals(match.id, vote.mapPool) return NextResponse.json( { ...shapeState(vote, match), mapVisuals, teams }, // 👈 match mitgeben { 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', payload: { matchId, opensAt: updated.opensAt ?? null }, }) const teams = buildTeamsPayloadFromMatch(match) const mapVisuals = buildMapVisuals(match.id, updated.mapPool) return NextResponse.json({ ...shapeState(updated, match), 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', payload: { matchId, opensAt: updated?.opensAt ?? null }, }) const teams = buildTeamsPayloadFromMatch(match) const mapVisuals = buildMapVisuals(match.id, updated!.mapPool) // ➕ match-ready senden (erste Map + Teilnehmer) if (updated?.locked) { await sleep(3000); const chosen = deriveChosenSteps(updated) const first = chosen[0] const key = first?.map ?? null const label = key ? (mapVisuals?.[key]?.label ?? key) : '?' const bg = key ? (mapVisuals?.[key]?.bg ?? `/assets/img/maps/${key}/1.jpg`) : '/assets/img/maps/cs2.webp' const participants = collectParticipants(match) await sendServerSSEMessage({ type: 'match-ready', matchId, firstMap: { key, label, bg }, participants, }); // Export serverseitig await exportMatchToSftpDirect(match, updated) } return NextResponse.json({ ...shapeState(updated, match), 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', payload: { matchId, opensAt: updated?.opensAt ?? null }, }) const teams = buildTeamsPayloadFromMatch(match) const mapVisuals = buildMapVisuals(match.id, updated!.mapPool) // ➕ match-ready senden if (updated?.locked) { await sleep(3000); const chosen = deriveChosenSteps(updated) const first = chosen[0] const key = first?.map ?? null const label = key ? (mapVisuals?.[key]?.label ?? key) : '?' const bg = key ? (mapVisuals?.[key]?.bg ?? `/assets/img/maps/${key}/1.jpg`) : '/assets/img/maps/cs2.webp' const participants = collectParticipants(match) await sendServerSSEMessage({ type: 'match-ready', matchId, firstMap: { key, label, bg }, participants, }); // Export serverseitig await exportMatchToSftpDirect(match, updated) } return NextResponse.json({ ...shapeState(updated, match), 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', payload: { matchId, opensAt: updated?.opensAt ?? null }, }) const teams = buildTeamsPayloadFromMatch(match) const mapVisuals = buildMapVisuals(match.id, updated!.mapPool) // Falls durch diesen Schritt locked wurde → Export & match-ready if (updated?.locked) { await sleep(3000); const chosen = deriveChosenSteps(updated) const first = chosen[0] const key = first?.map ?? null const label = key ? (mapVisuals?.[key]?.label ?? key) : '?' const bg = key ? (mapVisuals?.[key]?.bg ?? `/assets/img/maps/${key}/1.jpg`) : '/assets/img/maps/cs2.webp' const participants = collectParticipants(match) await sendServerSSEMessage({ type: 'match-ready', matchId, firstMap: { key, label, bg }, participants, }); await exportMatchToSftpDirect(match, updated) } return NextResponse.json({ ...shapeState(updated, match), mapVisuals, teams }) } catch (e) { console.error('[map-vote][POST] error', e) return NextResponse.json({ message: 'Aktion fehlgeschlagen' }, { status: 500 }) } }