283 lines
11 KiB
TypeScript
283 lines
11 KiB
TypeScript
// /src/app/api/matches/[id]/route.ts
|
||
import { NextResponse, type NextRequest } from 'next/server'
|
||
import { prisma } from '@/app/lib/prisma'
|
||
import { getServerSession } from 'next-auth'
|
||
import { authOptions } from '@/app/lib/auth'
|
||
import {
|
||
isFuture,
|
||
buildCommunityFuturePayload,
|
||
buildDefaultPayload,
|
||
} from './_builders'
|
||
import { sendServerSSEMessage } from '@/app/lib/sse-server-client'
|
||
|
||
/* ───────────────────────── GET ───────────────────────── */
|
||
export async function GET(_: Request, { params }: { params: { matchId: string } }) {
|
||
const id = params.matchId
|
||
if (!id) return NextResponse.json({ error: 'Missing ID' }, { status: 400 })
|
||
|
||
try {
|
||
const m = await prisma.match.findUnique({
|
||
where: { id },
|
||
include: {
|
||
teamA: { include: { leader: true } },
|
||
teamB: { include: { leader: true } },
|
||
players: { include: { user: true, stats: true, team: true } },
|
||
teamAUsers: { include: { team: true } },
|
||
teamBUsers: { include: { team: true } },
|
||
mapVote: {
|
||
include: {
|
||
steps: {
|
||
orderBy: { order: 'asc' },
|
||
select: { order: true, action: true, map: true, teamId: true, chosenAt: true, chosenBy: true },
|
||
},
|
||
},
|
||
},
|
||
},
|
||
})
|
||
if (!m) return NextResponse.json({ error: 'Match nicht gefunden' }, { status: 404 })
|
||
|
||
const payload =
|
||
m.matchType === 'community' && isFuture(m)
|
||
? await buildCommunityFuturePayload(m)
|
||
: buildDefaultPayload(m);
|
||
|
||
// ⬇️ Zusatz: opensAt (und leadMinutes) an die Antwort hängen
|
||
const baseTs = (m.matchDate ?? m.demoDate)?.getTime?.() ?? null;
|
||
const opensAt = m.mapVote?.opensAt ?? null;
|
||
const leadMinutes =
|
||
opensAt && baseTs != null
|
||
? Math.max(0, Math.round((baseTs - opensAt.getTime()) / 60000))
|
||
: null;
|
||
|
||
return NextResponse.json({
|
||
...payload,
|
||
mapVote: {
|
||
...(payload as any).mapVote,
|
||
opensAt, // <- wichtig
|
||
leadMinutes, // optional, aber nett zu haben
|
||
},
|
||
}, { headers: { 'Cache-Control': 'no-store' } });
|
||
} catch (err) {
|
||
console.error(`GET /matches/${params.matchId} failed:`, err)
|
||
return NextResponse.json({ error: 'Failed to load match' }, { status: 500 })
|
||
}
|
||
}
|
||
|
||
/* ───────────────────────── PUT ───────────────────────── */
|
||
export async function PUT(req: NextRequest, { params }: { params: { matchId: string } }) {
|
||
const id = params.matchId
|
||
const session = await getServerSession(authOptions(req))
|
||
const me = session?.user
|
||
if (!me?.steamId) return NextResponse.json({ error: 'Unauthorized' }, { status: 403 })
|
||
|
||
// Erwartet: players: Array<{ steamId: string; teamId: string | null }>
|
||
const body = await req.json()
|
||
const input = (body?.players ?? []) as Array<{ steamId?: string; teamId?: string | null }>
|
||
|
||
const match = await prisma.match.findUnique({
|
||
where: { id },
|
||
include: {
|
||
teamA: { include: { leader: true } },
|
||
teamB: { include: { leader: true } },
|
||
teamAUsers: true,
|
||
teamBUsers: true,
|
||
},
|
||
})
|
||
if (!match) return NextResponse.json({ error: 'Match not found' }, { status: 404 })
|
||
|
||
// Eingaben säubern + deduplizieren
|
||
const bySteam = new Map<string, { steamId: string; teamId: string | null }>()
|
||
for (const p of input) {
|
||
if (typeof p?.steamId === 'string' && p.steamId.trim()) {
|
||
bySteam.set(p.steamId.trim(), { steamId: p.steamId.trim(), teamId: p.teamId ?? null })
|
||
}
|
||
}
|
||
const unique = Array.from(bySteam.values())
|
||
|
||
const isAdmin = !!me.isAdmin
|
||
const isLeaderA = !!match.teamAId && match.teamA?.leader?.steamId === me.steamId
|
||
const isLeaderB = !!match.teamBId && match.teamB?.leader?.steamId === me.steamId
|
||
|
||
// Community-Roster-Mitgliedschaft
|
||
const rosterA = new Set(match.teamAUsers.map(u => u.steamId))
|
||
const rosterB = new Set(match.teamBUsers.map(u => u.steamId))
|
||
const isInA = rosterA.has(me.steamId)
|
||
const isInB = rosterB.has(me.steamId)
|
||
|
||
// Welche Seite darf der User ändern?
|
||
let editorSide: 'A' | 'B' | 'both' | null = null
|
||
if (isAdmin) editorSide = 'both'
|
||
else if (isLeaderA || isInA) editorSide = 'A'
|
||
else if (isLeaderB || isInB) editorSide = 'B'
|
||
else editorSide = null
|
||
|
||
if (!editorSide) return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||
|
||
const hasTeamIds = Boolean(match.teamAId && match.teamBId)
|
||
|
||
// Seite zuordnen (mit/ohne echte Team-IDs)
|
||
const incomingA = unique.filter(p => hasTeamIds ? p.teamId === match.teamAId : rosterA.has(p.steamId))
|
||
const incomingB = unique.filter(p => hasTeamIds ? p.teamId === match.teamBId : rosterB.has(p.steamId))
|
||
|
||
// Validierung: nur eigene Seite prüfen
|
||
if (!isAdmin) {
|
||
if (editorSide === 'A') {
|
||
const invalid = incomingA.some(p => hasTeamIds ? p.teamId !== match.teamAId : !rosterA.has(p.steamId))
|
||
if (invalid) return NextResponse.json({ error: 'Ungültige Spielerzuweisung (A)' }, { status: 403 })
|
||
}
|
||
if (editorSide === 'B') {
|
||
const invalid = incomingB.some(p => hasTeamIds ? p.teamId !== match.teamBId : !rosterB.has(p.steamId))
|
||
if (invalid) return NextResponse.json({ error: 'Ungültige Spielerzuweisung (B)' }, { status: 403 })
|
||
}
|
||
}
|
||
|
||
// SteamId-Listen für die Relation
|
||
const aSteamIds = Array.from(new Set(incomingA.map(p => p.steamId)))
|
||
const bSteamIds = Array.from(new Set(incomingB.map(p => p.steamId)))
|
||
|
||
try {
|
||
await prisma.$transaction(async (tx) => {
|
||
// 1) teamAUsers / teamBUsers SETZEN (nur die Seite(n), die editiert werden)
|
||
const data: any = {}
|
||
if (editorSide === 'both' || editorSide === 'A') {
|
||
data.teamAUsers = {
|
||
set: [], // erst leeren
|
||
connect: aSteamIds.map(steamId => ({ steamId })),
|
||
}
|
||
}
|
||
if (editorSide === 'both' || editorSide === 'B') {
|
||
data.teamBUsers = {
|
||
set: [],
|
||
connect: bSteamIds.map(steamId => ({ steamId })),
|
||
}
|
||
}
|
||
if (Object.keys(data).length) {
|
||
await tx.match.update({ where: { id }, data })
|
||
}
|
||
|
||
// 2) (optional) matchPlayer für die geänderten Seiten synchron halten
|
||
// – so bleiben Teilnehmer + spätere Stats konsistent.
|
||
if (editorSide === 'both' || editorSide === 'A') {
|
||
if (hasTeamIds) {
|
||
await tx.matchPlayer.deleteMany({ where: { matchId: id, teamId: match.teamAId! } })
|
||
if (aSteamIds.length) {
|
||
await tx.matchPlayer.createMany({
|
||
data: aSteamIds.map(steamId => ({ matchId: id, steamId, teamId: match.teamAId! })),
|
||
skipDuplicates: true,
|
||
})
|
||
}
|
||
} else {
|
||
// Community ohne teamId → anhand Roster löschen
|
||
await tx.matchPlayer.deleteMany({ where: { matchId: id, steamId: { in: Array.from(rosterA) } } })
|
||
if (aSteamIds.length) {
|
||
await tx.matchPlayer.createMany({
|
||
data: aSteamIds.map(steamId => ({ matchId: id, steamId, teamId: null })),
|
||
skipDuplicates: true,
|
||
})
|
||
}
|
||
}
|
||
}
|
||
if (editorSide === 'both' || editorSide === 'B') {
|
||
if (hasTeamIds) {
|
||
await tx.matchPlayer.deleteMany({ where: { matchId: id, teamId: match.teamBId! } })
|
||
if (bSteamIds.length) {
|
||
await tx.matchPlayer.createMany({
|
||
data: bSteamIds.map(steamId => ({ matchId: id, steamId, teamId: match.teamBId! })),
|
||
skipDuplicates: true,
|
||
})
|
||
}
|
||
} else {
|
||
await tx.matchPlayer.deleteMany({ where: { matchId: id, steamId: { in: Array.from(rosterB) } } })
|
||
if (bSteamIds.length) {
|
||
await tx.matchPlayer.createMany({
|
||
data: bSteamIds.map(steamId => ({ matchId: id, steamId, teamId: null })),
|
||
skipDuplicates: true,
|
||
})
|
||
}
|
||
}
|
||
}
|
||
})
|
||
|
||
// Antwort wie GET
|
||
const updated = await prisma.match.findUnique({
|
||
where: { id },
|
||
include: {
|
||
teamA: { include: { leader: true } },
|
||
teamB: { include: { leader: true } },
|
||
players: { include: { user: true, stats: true, team: true } },
|
||
teamAUsers: { include: { team: true } },
|
||
teamBUsers: { include: { team: true } },
|
||
mapVote: {
|
||
include: {
|
||
steps: {
|
||
orderBy: { order: 'asc' },
|
||
select: { order: true, action: true, map: true, teamId: true, chosenAt: true, chosenBy: true },
|
||
},
|
||
},
|
||
},
|
||
},
|
||
})
|
||
if (!updated) return NextResponse.json({ error: 'Match konnte nach Update nicht geladen werden' }, { status: 500 })
|
||
|
||
await sendServerSSEMessage({
|
||
type: 'match-lineup-updated',
|
||
payload: {
|
||
matchId: updated.id,
|
||
teamA: updated.teamAUsers.map(u => u.steamId),
|
||
teamB: updated.teamBUsers.map(u => u.steamId),
|
||
updatedAt: new Date().toISOString(),
|
||
},
|
||
})
|
||
|
||
// Trennung für Response (gleich wie bisher)
|
||
const setA = new Set(updated.teamAUsers.map(u => u.steamId))
|
||
const setB = new Set(updated.teamBUsers.map(u => u.steamId))
|
||
const playersA = updated.players.filter(p => setA.has(p.steamId)).map(p => ({ user: p.user, stats: p.stats, team: p.team?.name ?? 'Team A' }))
|
||
const playersB = updated.players.filter(p => setB.has(p.steamId)).map(p => ({ user: p.user, stats: p.stats, team: p.team?.name ?? 'Team B' }))
|
||
|
||
return NextResponse.json({
|
||
id: updated.id,
|
||
title: updated.title,
|
||
description: updated.description,
|
||
demoDate: updated.demoDate,
|
||
matchType: updated.matchType,
|
||
roundCount: updated.roundCount,
|
||
map: updated.map,
|
||
teamA: {
|
||
id: updated.teamAId ?? null,
|
||
name: updated.teamAUsers[0]?.team?.name ?? updated.teamA?.name ?? 'Team A',
|
||
logo: updated.teamAUsers[0]?.team?.logo ?? updated.teamA?.logo ?? null,
|
||
score: updated.scoreA,
|
||
players: playersA,
|
||
},
|
||
teamB: {
|
||
id: updated.teamBId ?? null,
|
||
name: updated.teamBUsers[0]?.team?.name ?? updated.teamB?.name ?? 'Team B',
|
||
logo: updated.teamBUsers[0]?.team?.logo ?? updated.teamB?.logo ?? null,
|
||
score: updated.scoreB,
|
||
players: playersB,
|
||
},
|
||
}, { headers: { 'Cache-Control': 'no-store' } })
|
||
} catch (err) {
|
||
console.error(`PUT /matches/${id} failed:`, err)
|
||
return NextResponse.json({ error: 'Failed to update match' }, { status: 500 })
|
||
}
|
||
}
|
||
|
||
|
||
|
||
/* ───────────────────────── DELETE ───────────────────────── */
|
||
export async function DELETE(req: NextRequest, { params }: { params: { matchId: string } }) {
|
||
const id = params.matchId
|
||
const session = await getServerSession(authOptions(req))
|
||
if (!session?.user?.isAdmin) return NextResponse.json({ error: 'Unauthorized' }, { status: 403 })
|
||
try {
|
||
await prisma.matchPlayer.deleteMany({ where: { matchId: id } })
|
||
await prisma.match.delete({ where: { id } })
|
||
return NextResponse.json({ success: true })
|
||
} catch (err) {
|
||
console.error(`DELETE /matches/${id} failed:`, err)
|
||
return NextResponse.json({ error: 'Failed to delete match' }, { status: 500 })
|
||
}
|
||
}
|