843 lines
29 KiB
TypeScript
843 lines
29 KiB
TypeScript
// /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<MapVoteAction, 'ban'|'pick'|'decider'> = {
|
||
BAN: 'ban', PICK: 'pick', DECIDER: 'decider',
|
||
}
|
||
|
||
/* -------------------- Helper -------------------- */
|
||
|
||
const sleep = (ms: number) => new Promise<void>(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<string, { label: string; bg: string; logo?: 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}/${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<T>(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<string>([
|
||
...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<string, string> = {}
|
||
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<string, string> },
|
||
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 })
|
||
}
|
||
}
|