This commit is contained in:
Linrador 2025-08-13 23:44:44 +02:00
parent bdb5cbb4e1
commit 61c75b1c8c
22 changed files with 599 additions and 499 deletions

View File

@ -44,7 +44,7 @@ model User {
createdSchedules Schedule[] @relation("CreatedSchedules") createdSchedules Schedule[] @relation("CreatedSchedules")
confirmedSchedules Schedule[] @relation("ConfirmedSchedules") confirmedSchedules Schedule[] @relation("ConfirmedSchedules")
mapVetoChoices MapVoteStep[] @relation("VetoStepChooser") mapVetoChoices MapVetoStep[] @relation("VetoStepChooser")
} }
model Team { model Team {
@ -68,7 +68,7 @@ model Team {
schedulesAsTeamA Schedule[] @relation("ScheduleTeamA") schedulesAsTeamA Schedule[] @relation("ScheduleTeamA")
schedulesAsTeamB Schedule[] @relation("ScheduleTeamB") schedulesAsTeamB Schedule[] @relation("ScheduleTeamB")
mapVetoSteps MapVoteStep[] @relation("VetoStepTeam") mapVetoSteps MapVetoStep[] @relation("VetoStepTeam")
} }
model TeamInvite { model TeamInvite {
@ -138,7 +138,7 @@ model Match {
bestOf Int @default(3) // 1 | 3 | 5 app-seitig validieren bestOf Int @default(3) // 1 | 3 | 5 app-seitig validieren
matchDate DateTime? // geplante Startzeit (separat von demoDate) matchDate DateTime? // geplante Startzeit (separat von demoDate)
mapVote MapVote? // 1:1 Map-Vote-Status mapVeto MapVeto? // 1:1 Map-Vote-Status
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
@ -297,13 +297,13 @@ model ServerRequest {
// 🗺️ Map-Vote // 🗺️ Map-Vote
// ────────────────────────────────────────────── // ──────────────────────────────────────────────
enum MapVoteAction { enum MapVetoAction {
BAN BAN
PICK PICK
DECIDER DECIDER
} }
model MapVote { model MapVeto {
id String @id @default(uuid()) id String @id @default(uuid())
matchId String @unique matchId String @unique
match Match @relation(fields: [matchId], references: [id]) match Match @relation(fields: [matchId], references: [id])
@ -317,17 +317,17 @@ model MapVote {
// Optional: serverseitig speichern, statt im UI zu berechnen // Optional: serverseitig speichern, statt im UI zu berechnen
opensAt DateTime? opensAt DateTime?
steps MapVoteStep[] steps MapVetoStep[]
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
} }
model MapVoteStep { model MapVetoStep {
id String @id @default(uuid()) id String @id @default(uuid())
vetoId String vetoId String
order Int order Int
action MapVoteAction action MapVetoAction
// Team, das am Zug ist (kann bei DECIDER null sein) // Team, das am Zug ist (kann bei DECIDER null sein)
teamId String? teamId String?
@ -339,7 +339,7 @@ model MapVoteStep {
chosenBy String? chosenBy String?
chooser User? @relation("VetoStepChooser", fields: [chosenBy], references: [steamId]) chooser User? @relation("VetoStepChooser", fields: [chosenBy], references: [steamId])
veto MapVote @relation(fields: [vetoId], references: [id]) veto MapVeto @relation(fields: [vetoId], references: [id])
@@unique([vetoId, order]) @@unique([vetoId, order])
@@index([teamId]) @@index([teamId])

View File

@ -74,7 +74,7 @@ function shapeState(veto: any) {
} }
} }
// Leader -> Player-Shape für das Frontend mappen // Leader -> Player-Shape fürs Frontend
function shapeLeader(leader: any | null) { function shapeLeader(leader: any | null) {
if (!leader) return null if (!leader) return null
return { return {
@ -87,13 +87,82 @@ function shapeLeader(leader: any | null) {
} }
} }
// 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) {
// NextRequest hat headers; bei internen Aufrufen ggf. NextResponse, hier aber nur Request relevant
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, {
// interne Server-Fetches dürfen nicht gecacht werden
cache: 'no-store',
headers: {
// Forward auth/proxy headers, falls nötig (nicht zwingend)
'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 // LeaderId
activePlayers: any[]
inactivePlayers: any[]
invitedPlayers: any[]
}
} catch {
return null
}
}
// Leader bevorzugt aus Match-Relation; Fallback über Team-API (LeaderId -> Player aus Listen)
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 ensureVeto(matchId: string) { async function ensureVeto(matchId: string) {
const match = await prisma.match.findUnique({ const match = await prisma.match.findUnique({
where: { id: matchId }, where: { id: matchId },
include: { include: {
teamA : { teamA : {
include: { include: {
// WICHTIG: Leader-Relation als Objekt laden // Leader-Relation als Objekt laden
leader: { leader: {
select: { select: {
steamId: true, steamId: true,
@ -161,9 +230,37 @@ function computeAvailableMaps(mapPool: string[], steps: Array<{ map: string | nu
return mapPool.filter(m => !used.has(m)) return mapPool.filter(m => !used.has(m))
} }
// 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,
},
}
}
/* -------------------- GET -------------------- */ /* -------------------- GET -------------------- */
export async function GET(_req: NextRequest, { params }: { params: { id: string } }) { export async function GET(req: NextRequest, { params }: { params: { id: string } }) {
try { try {
const matchId = params.id const matchId = params.id
if (!matchId) return NextResponse.json({ message: 'Missing id' }, { status: 400 }) if (!matchId) return NextResponse.json({ message: 'Missing id' }, { status: 400 })
@ -171,26 +268,12 @@ export async function GET(_req: NextRequest, { params }: { params: { id: string
const { match, veto } = await ensureVeto(matchId) const { match, veto } = await ensureVeto(matchId)
if (!match || !veto) return NextResponse.json({ message: 'Match nicht gefunden' }, { status: 404 }) if (!match || !veto) return NextResponse.json({ message: 'Match nicht gefunden' }, { status: 404 })
// Veto-State + Teams (mit Leader-Objekt) zurückgeben const teams = await buildTeamsPayload(match, req)
const payload = {
...shapeState(veto),
teams: {
teamA: {
id : match.teamA?.id ?? null,
name : match.teamA?.name ?? null,
logo : match.teamA?.logo ?? null,
leader: shapeLeader(match.teamA?.leader ?? null),
},
teamB: {
id : match.teamB?.id ?? null,
name : match.teamB?.name ?? null,
logo : match.teamB?.logo ?? null,
leader: shapeLeader(match.teamB?.leader ?? null),
},
},
}
return NextResponse.json(payload, { headers: { 'Cache-Control': 'no-store' } }) return NextResponse.json(
{ ...shapeState(veto), teams },
{ headers: { 'Cache-Control': 'no-store' } },
)
} catch (e) { } catch (e) {
console.error('[map-vote][GET] error', e) console.error('[map-vote][GET] error', e)
return NextResponse.json({ message: 'Fehler beim Laden' }, { status: 500 }) return NextResponse.json({ message: 'Fehler beim Laden' }, { status: 500 })
@ -238,23 +321,9 @@ export async function POST(req: NextRequest, { params }: { params: { id: string
// 🔔 Broadcast (flat) // 🔔 Broadcast (flat)
await sendServerSSEMessage({ type: 'map-vote-updated', matchId }) await sendServerSSEMessage({ type: 'map-vote-updated', matchId })
return NextResponse.json({ const teams = await buildTeamsPayload(match, req)
...shapeState(updated),
teams: { return NextResponse.json({ ...shapeState(updated), teams })
teamA: {
id : match.teamA?.id ?? null,
name : match.teamA?.name ?? null,
logo : match.teamA?.logo ?? null,
leader: shapeLeader(match.teamA?.leader ?? null),
},
teamB: {
id : match.teamB?.id ?? null,
name : match.teamB?.name ?? null,
logo : match.teamB?.logo ?? null,
leader: shapeLeader(match.teamB?.leader ?? null),
},
},
})
} }
const available = computeAvailableMaps(veto.mapPool, stepsSorted) const available = computeAvailableMaps(veto.mapPool, stepsSorted)
@ -284,23 +353,9 @@ export async function POST(req: NextRequest, { params }: { params: { id: string
// 🔔 Broadcast (flat) // 🔔 Broadcast (flat)
await sendServerSSEMessage({ type: 'map-vote-updated', matchId }) await sendServerSSEMessage({ type: 'map-vote-updated', matchId })
return NextResponse.json({ const teams = await buildTeamsPayload(match, req)
...shapeState(updated),
teams: { return NextResponse.json({ ...shapeState(updated), teams })
teamA: {
id : match.teamA?.id ?? null,
name : match.teamA?.name ?? null,
logo : match.teamA?.logo ?? null,
leader: shapeLeader(match.teamA?.leader ?? null),
},
teamB: {
id : match.teamB?.id ?? null,
name : match.teamB?.name ?? null,
logo : match.teamB?.logo ?? null,
leader: shapeLeader(match.teamB?.leader ?? null),
},
},
})
} }
// Rechte prüfen (Admin oder Leader des Teams am Zug) weiterhin via leaderId // Rechte prüfen (Admin oder Leader des Teams am Zug) weiterhin via leaderId
@ -369,23 +424,9 @@ export async function POST(req: NextRequest, { params }: { params: { id: string
// 🔔 Broadcast (flat) // 🔔 Broadcast (flat)
await sendServerSSEMessage({ type: 'map-vote-updated', matchId }) await sendServerSSEMessage({ type: 'map-vote-updated', matchId })
return NextResponse.json({ const teams = await buildTeamsPayload(match, req)
...shapeState(updated),
teams: { return NextResponse.json({ ...shapeState(updated), teams })
teamA: {
id : match.teamA?.id ?? null,
name : match.teamA?.name ?? null,
logo : match.teamA?.logo ?? null,
leader: shapeLeader(match.teamA?.leader ?? null),
},
teamB: {
id : match.teamB?.id ?? null,
name : match.teamB?.name ?? null,
logo : match.teamB?.logo ?? null,
leader: shapeLeader(match.teamB?.leader ?? null),
},
},
})
} catch (e) { } catch (e) {
console.error('[map-vote][POST] error', e) console.error('[map-vote][POST] error', e)
return NextResponse.json({ message: 'Aktion fehlgeschlagen' }, { status: 500 }) return NextResponse.json({ message: 'Aktion fehlgeschlagen' }, { status: 500 })

View File

@ -1,38 +1,36 @@
// /app/api/matches/[id]/route.ts // /app/api/matches/[id]/route.ts
/* eslint-disable @typescript-eslint/return-await */
import { NextResponse, type NextRequest } from 'next/server' import { NextResponse, type NextRequest } from 'next/server'
import { prisma } from '@/app/lib/prisma' import { prisma } from '@/app/lib/prisma'
import { getServerSession } from 'next-auth' import { getServerSession } from 'next-auth'
import { authOptions } from '@/app/lib/auth' import { authOptions } from '@/app/lib/auth'
import { isAfter } from 'date-fns'
/* export async function GET(_: Request, context: { params: { id: string } }) {
Hilfs-Typen const { id } = context.params
*/
type PlayerOut = {
user : { steamId: string; name: string | null; avatar: string | null }
stats: any | null
team : string
}
/* ───────────────────────────── GET ───────────────────────────── */
export async function GET (
_req: Request,
{ params: { id } }: { params: { id: string } },
) {
if (!id) { if (!id) {
return NextResponse.json({ error: 'Missing ID' }, { status: 400 }) return NextResponse.json({ error: 'Missing ID' }, { status: 400 })
} }
try {
const match = await prisma.match.findUnique({ const match = await prisma.match.findUnique({
where: { id }, where: { id },
include: { include: {
teamA : true, players: {
teamB : true, include: {
teamAUsers : { include: { team: true } }, user: true,
teamBUsers : { include: { team: true } }, stats: true,
players : { include: { user: true, stats: true, team: true } }, team: true,
mapVeto : { include: { steps: true } }, // ⬅️ wichtig },
},
teamAUsers: {
include: {
team: true,
},
},
teamBUsers: {
include: {
team: true,
},
},
}, },
}) })
@ -40,229 +38,190 @@ export async function GET (
return NextResponse.json({ error: 'Match nicht gefunden' }, { status: 404 }) return NextResponse.json({ error: 'Match nicht gefunden' }, { status: 404 })
} }
/* ---------- Editierbarkeit bestimmen ---------- */ const teamAIds = new Set(match.teamAUsers.map(u => u.steamId));
const baseDate = match.matchDate ?? match.demoDate ?? null const teamBIds = new Set(match.teamBUsers.map(u => u.steamId));
const isFuture = !!baseDate && isAfter(baseDate, new Date())
const editable = match.matchType === 'community' && isFuture
/* ---------- Spielerlisten zusammenstellen --------------------------------- */ const playersA = match.players
let playersA: PlayerOut[] = [] .filter(p => teamAIds.has(p.steamId))
let playersB: PlayerOut[] = [] .map(p => ({
user: p.user,
stats: p.stats,
team: p.team?.name ?? 'Team A',
}));
if (editable) { const playersB = match.players
/* ───── Spieler kommen direkt aus der Match-Relation ───── */ .filter(p => teamBIds.has(p.steamId))
const mapUser = (u: any, fallbackTeam: string) => ({ .map(p => ({
user : { user: p.user,
steamId: u.steamId, stats: p.stats,
name : u.name ?? 'Unbekannt', team: p.team?.name ?? 'Team B',
avatar : u.avatar ?? null, }));
},
stats: null,
team : fallbackTeam,
})
playersA = match.teamAUsers.map(u => mapUser(u, match.teamA?.name ?? 'CT')) const teamA = {
playersB = match.teamBUsers.map(u => mapUser(u, match.teamB?.name ?? 'T')) name: match.teamAUsers[0]?.team?.name ?? 'Team A',
logo: null,
score: match.scoreA,
players: playersA,
};
/* ► Fallback: aktive Spieler, falls noch leer (z. B. nach Migration) */ const teamB = {
if (playersA.length === 0 || playersB.length === 0) { name: match.teamBUsers[0]?.team?.name ?? 'Team B',
const [aIds, bIds] = [ logo: null,
match.teamA?.activePlayers ?? [], score: match.scoreB,
match.teamB?.activePlayers ?? [], players: playersB,
] };
const users = await prisma.user.findMany({
where : { steamId: { in: [...aIds, ...bIds] } },
select: { steamId: true, name: true, avatar: true },
})
const byId = Object.fromEntries(users.map(u => [u.steamId, u]))
playersA = aIds.map(id => mapUser(byId[id] ?? { steamId: id }, match.teamA?.name ?? 'CT'))
playersB = bIds.map(id => mapUser(byId[id] ?? { steamId: id }, match.teamB?.name ?? 'T'))
}
} else {
/* ───── Vergangene Matches: Stats-basierte Darstellung ───── */
const setA = new Set(match.teamAUsers.map(u => u.steamId))
const setB = new Set(match.teamBUsers.map(u => u.steamId))
playersA = match.players
.filter(p => setA.has(p.steamId))
.map(p => ({ user: p.user, stats: p.stats, team: p.team?.name ?? 'CT' }))
playersB = match.players
.filter(p => setB.has(p.steamId))
.map(p => ({ user: p.user, stats: p.stats, team: p.team?.name ?? 'T' }))
}
/* ---------- Map-Vote ableiten (immer mitsenden) ---------- */
const computedOpensAt = baseDate
? new Date(new Date(baseDate).getTime() - 60 * 60 * 1000)
: null
let status: 'not_started' | 'in_progress' | 'completed' = 'not_started'
let opensAt = computedOpensAt?.toISOString() ?? null
let isOpen = opensAt ? (Date.now() >= new Date(opensAt).getTime()) : false
let currentIndex: number | null = null
let currentAction: 'BAN' | 'PICK' | 'DECIDER' | null = null
let decidedCount: number | null = null
let totalSteps: number | null = null
if (match.mapVeto) {
const stepsSorted = [...match.mapVeto.steps].sort((a, b) => a.order - b.order)
const anyChosen = stepsSorted.some(s => !!s.chosenAt)
status = match.mapVeto.locked ? 'completed' : (anyChosen ? 'in_progress' : 'not_started')
opensAt = (match.mapVeto.opensAt ?? computedOpensAt)?.toISOString() ?? null
isOpen = opensAt ? (Date.now() >= new Date(opensAt).getTime()) : false
currentIndex = match.mapVeto.currentIdx
currentAction = (stepsSorted.find(s => s.order === match.mapVeto.currentIdx)?.action ?? null) as any
decidedCount = stepsSorted.filter(s => !!s.chosenAt).length
totalSteps = stepsSorted.length
}
/* ---------- Antwort ---------- */
return NextResponse.json({ return NextResponse.json({
id: match.id, id: match.id,
title: match.title, title: match.title,
description: match.description, description: match.description,
demoDate: match.demoDate, demoDate: match.demoDate,
matchDate : match.matchDate, // ⬅️ nützlich fürs Frontend
matchType: match.matchType, matchType: match.matchType,
roundCount: match.roundCount, roundCount: match.roundCount,
map: match.map, map: match.map,
scoreA : match.scoreA, teamA,
scoreB : match.scoreB, teamB,
editable, });
} catch (err) {
// ⬇️ NEU: kompaktes Map-Vote-Objekt (virtuell, wenn kein DB-Eintrag) console.error(`GET /matches/${id} failed:`, err)
mapVote: { return NextResponse.json({ error: 'Failed to load match' }, { status: 500 })
status, opensAt, isOpen, currentIndex, currentAction, decidedCount, totalSteps, }
},
teamA: {
id : match.teamA?.id ?? null,
name : match.teamA?.name ?? 'CT',
logo : match.teamA?.logo ?? null,
leader : match.teamA?.leaderId ?? null,
score : match.scoreA,
players: playersA,
},
teamB: {
id : match.teamB?.id ?? null,
name : match.teamB?.name ?? 'T',
logo : match.teamB?.logo ?? null,
leader : match.teamB?.leaderId ?? null,
score : match.scoreB,
players: playersB,
},
})
} }
/* ───────────────────────────── PUT ───────────────────────────── */ export async function PUT(req: NextRequest, context: { params: { id: string } }) {
export async function PUT ( const { id } = context.params
req: NextRequest,
{ params: { id } }: { params: { id: string } },
) {
const session = await getServerSession(authOptions(req)) const session = await getServerSession(authOptions(req))
const me = session?.user const userId = session?.user?.steamId
if (!me?.steamId) { const isAdmin = session?.user?.isAdmin
if (!userId) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 403 }) return NextResponse.json({ error: 'Unauthorized' }, { status: 403 })
} }
const match = await prisma.match.findUnique({ where: { id } }) const body = await req.json()
const { title, description, matchDate, players } = body
const user = await prisma.user.findUnique({
where: { steamId: userId },
include: { ledTeam: true },
});
const match = await prisma.match.findUnique({
where: { id },
});
if (!match) { if (!match) {
return NextResponse.json({ error: 'Match not found' }, { status: 404 }) return NextResponse.json({ error: 'Match not found' }, { status: 404 })
} }
/* ---------- erneute Editierbarkeits-Prüfung ---------- */ const isTeamLeaderA = match.teamAId && user?.ledTeam?.id === match.teamAId;
const baseDate = match.matchDate ?? match.demoDate ?? null const isTeamLeaderB = match.teamBId && user?.ledTeam?.id === match.teamBId;
const isFuture = !!baseDate && isAfter(baseDate, new Date())
const editable = match.matchType === 'community' && isFuture
if (!editable) {
return NextResponse.json({ error: 'Match kann nicht bearbeitet werden' }, { status: 403 })
}
/* ---------- Rollen-Check (Admin oder Team-Leader) ----- */ if (!isAdmin && !isTeamLeaderA && !isTeamLeaderB) {
const userData = await prisma.user.findUnique({
where : { steamId: me.steamId },
include: { ledTeam: true },
})
const leaderOf = userData?.ledTeam?.id
const isLeader = leaderOf && (leaderOf === match.teamAId || leaderOf === match.teamBId)
if (!me.isAdmin && !isLeader) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
} }
/* ---------- Payload einlesen & validieren ------------- */ // 🛡️ Validierung: Nur eigene Spieler
const { players } = await req.json() if (!isAdmin) {
const ownTeamId = isTeamLeaderA ? match.teamAId : match.teamBId
if (!me.isAdmin && leaderOf) { if (!ownTeamId) {
const ownTeam = await prisma.team.findUnique({ where: { id: leaderOf } }) return NextResponse.json({ error: 'Team-ID fehlt' }, { status: 400 })
const allowed = new Set([ }
...(ownTeam?.activePlayers ?? []),
...(ownTeam?.inactivePlayers ?? []), const ownTeam = await prisma.team.findUnique({ where: { id: ownTeamId } })
]) const allowed = new Set(ownTeam?.activePlayers || [])
const invalid = players.some((p: any) => const invalid = players.some((p: any) =>
p.teamId === leaderOf && !allowed.has(p.steamId), p.teamId === ownTeamId && !allowed.has(p.userId)
) )
if (invalid) { if (invalid) {
return NextResponse.json({ error: 'Ungültige Spielerzuweisung' }, { status: 403 }) return NextResponse.json({ error: 'Ungültige Spielerzuweisung' }, { status: 403 })
} }
} }
/* ---------- Spieler-Mapping speichern ----------------- */
try { try {
const teamAIds = players // ❌ Alte Spieler löschen
.filter((p: any) => p.teamId === match.teamAId) await prisma.matchPlayer.deleteMany({ where: { matchId: id } }) // ✅ Richtig, nur wenn das Feld korrekt heißt
.map((p: any) => p.steamId)
const teamBIds = players // ✅ Neue Spieler speichern
.filter((p: any) => p.teamId === match.teamBId) await prisma.matchPlayer.createMany({
.map((p: any) => p.steamId)
await prisma.$transaction([
prisma.matchPlayer.deleteMany({ where: { matchId: id } }),
prisma.matchPlayer.createMany({
data: players.map((p: any) => ({ data: players.map((p: any) => ({
matchId: id, matchId: id,
steamId: p.steamId, userId: p.userId,
teamId: p.teamId, teamId: p.teamId,
})), })),
skipDuplicates: true, })
}),
prisma.match.update({ // ✏️ Match aktualisieren
const updated = await prisma.match.findUnique({
where: { id }, where: { id },
data : { include: {
teamAUsers: { set: teamAIds.map((steamId: string) => ({ steamId })) }, players: {
teamBUsers: { set: teamBIds.map((steamId: string) => ({ steamId })) }, include: {
user: true,
stats: true,
team: true,
}, },
}), },
]) },
} catch (e) { })
console.error(`PUT /matches/${id} Spielerupdate fehlgeschlagen:`, e)
return NextResponse.json({ error: 'Failed to update players' }, { status: 500 }) if (!updated) {
return NextResponse.json({ error: 'Match konnte nach Update nicht geladen werden' }, { status: 500 })
} }
/* ---------- neue Daten abrufen & zurückgeben ---------- */ // 🔄 Spieler wieder trennen
return GET(_req as any, { params: { id } }) // gleiche Antwort-Struktur wie oben const playersA = updated.players
.filter(p => p.teamId === updated.teamAId)
.map(p => ({
user: p.user,
stats: p.stats,
team: p.team?.name ?? 'CT',
}))
const playersB = updated.players
.filter(p => p.teamId === updated.teamBId)
.map(p => ({
user: p.user,
stats: p.stats,
team: p.team?.name ?? 'T',
}))
return NextResponse.json({
id: updated.id,
title: updated.title,
description: updated.description,
demoDate: updated.demoDate,
matchType: updated.matchType,
map: updated.map,
scoreA: updated.scoreA,
scoreB: updated.scoreB,
teamA: playersA,
teamB: playersB,
})
} 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, context: { params: { id: string } }) {
export async function DELETE ( const { id } = context.params
_req: NextRequest, const session = await getServerSession(authOptions(req))
{ params: { id } }: { params: { id: string } },
) {
const session = await getServerSession(authOptions(_req))
if (!session?.user?.isAdmin) { if (!session?.user?.isAdmin) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 403 }) return NextResponse.json({ error: 'Unauthorized' }, { status: 403 })
} }
try { try {
await prisma.$transaction([ // Lösche Match inklusive aller zugehörigen MatchPlayer-Einträge (wenn onDelete: Cascade nicht aktiv)
prisma.matchPlayer.deleteMany({ where: { matchId: id } }), await prisma.matchPlayer.deleteMany({ where: { matchId: id } })
prisma.match.delete({ where: { id } }),
]) // Lösche das Match
await prisma.match.delete({ where: { id } })
return NextResponse.json({ success: true }) return NextResponse.json({ success: true })
} catch (err) { } catch (err) {
console.error(`DELETE /matches/${id} failed:`, err) console.error(`DELETE /matches/${id} failed:`, err)

View File

@ -99,29 +99,56 @@ export async function POST (req: NextRequest) {
try { try {
// ── Anlegen in Transaktion // ── Anlegen in Transaktion
const created = await prisma.$transaction(async (tx) => { const created = await prisma.$transaction(async (tx) => {
// 1) aktive Spieler deduplizieren und auf 5 begrenzen
const aActiveRaw = Array.from(new Set(teamA.activePlayers ?? [])).slice(0, 5)
const bActiveRaw = Array.from(new Set(teamB.activePlayers ?? [])).slice(0, 5)
// 2) Kollisionen vermeiden: wenn eine steamId in beiden aktiv-Listen steht,
// priorisieren wir sie für ihr ursprüngliches Team A, und entfernen sie aus B.
// (Passe die Logik an, falls du anderes Verhalten wünschst.)
const collision = new Set(aActiveRaw.filter(id => bActiveRaw.includes(id)))
const aActive = aActiveRaw
const bActive = bActiveRaw.filter(id => !collision.has(id))
// 3) Nur existierende User berücksichtigen (FK auf User.steamId)
const existing = await tx.user.findMany({
where: { steamId: { in: [...aActive, ...bActive] } },
select: { steamId: true },
})
const existingIds = new Set(existing.map(u => u.steamId))
const aUse = aActive.filter(id => existingIds.has(id))
const bUse = bActive.filter(id => existingIds.has(id))
// 4) Match anlegen
const newMatch = await tx.match.create({ const newMatch = await tx.match.create({
data: { data: {
teamAId, teamBId, teamAId,
teamBId,
title : safeTitle, title : safeTitle,
description: safeDesc, description: safeDesc,
map : safeMap, map : safeMap,
demoDate : plannedAt, // geplanter Startzeitpunkt demoDate : plannedAt,
bestOf : bestOfInt, bestOf : bestOfInt,
teamAUsers : { connect: (teamA.activePlayers ?? []).map(id => ({ steamId: id })) },
teamBUsers : { connect: (teamB.activePlayers ?? []).map(id => ({ steamId: id })) }, // Optional: falls du am Match die Kader je Seite referenzieren möchtest
teamAUsers: aUse.length ? { connect: aUse.map(id => ({ steamId: id })) } : undefined,
teamBUsers: bUse.length ? { connect: bUse.map(id => ({ steamId: id })) } : undefined,
}, },
}) })
// 5) MatchPlayer-Einträge erzeugen
const playersData = [ const playersData = [
...(teamA.activePlayers ?? []).map(id => ({ matchId: newMatch.id, steamId: id, teamId: teamAId })), ...aUse.map(steamId => ({ matchId: newMatch.id, steamId, teamId: teamAId })),
...(teamB.activePlayers ?? []).map(id => ({ matchId: newMatch.id, steamId: id, teamId: teamBId })), ...bUse.map(steamId => ({ matchId: newMatch.id, steamId, teamId: teamBId })),
] ]
if (playersData.length) { if (playersData.length) {
// funktioniert dank @@unique([matchId, steamId])
await tx.matchPlayer.createMany({ data: playersData, skipDuplicates: true }) await tx.matchPlayer.createMany({ data: playersData, skipDuplicates: true })
} }
// MapVeto sofort anlegen // 6) MapVeto anlegen
const opensAt = new Date((new Date(newMatch.matchDate ?? newMatch.demoDate ?? plannedAt)).getTime() - 60*60*1000) const baseDate = newMatch.demoDate ?? plannedAt
const opensAt = new Date(baseDate.getTime() - 60 * 60 * 1000)
const stepsDef = buildSteps(bestOfInt, teamAId, teamBId) const stepsDef = buildSteps(bestOfInt, teamAId, teamBId)
await tx.mapVeto.create({ await tx.mapVeto.create({
@ -135,8 +162,8 @@ export async function POST (req: NextRequest) {
steps : { steps : {
create: stepsDef.map(s => ({ create: stepsDef.map(s => ({
order : s.order, order : s.order,
action: s.action, // prisma.MapVetoAction.* action: s.action,
teamId: s.teamId ?? undefined, teamId: s.teamId ?? undefined, // DECIDER → undefined, passt zu Schema
})), })),
}, },
}, },

View File

@ -48,7 +48,7 @@ export async function GET(req: Request) {
} }
currentIndex = m.mapVeto.currentIdx currentIndex = m.mapVeto.currentIdx
const cur = stepsSorted.find(s => s.order === m.mapVeto.currentIdx) const cur = stepsSorted.find(s => s.order === m.mapVeto?.currentIdx)
currentAction = (cur?.action as 'BAN'|'PICK'|'DECIDER') ?? null currentAction = (cur?.action as 'BAN'|'PICK'|'DECIDER') ?? null
decidedCount = stepsSorted.filter(s => !!s.chosenAt).length decidedCount = stepsSorted.filter(s => !!s.chosenAt).length
totalSteps = stepsSorted.length totalSteps = stepsSorted.length
@ -68,7 +68,7 @@ export async function GET(req: Request) {
scoreB : m.scoreB, scoreB : m.scoreB,
winnerTeam: m.winnerTeam ?? null, winnerTeam: m.winnerTeam ?? null,
mapVote: m.mapVeto ? { mapVeto: m.mapVeto ? {
status, status,
opensAt: opensAtISO, opensAt: opensAtISO,
isOpen, isOpen,

View File

@ -4,16 +4,35 @@
import { NextResponse } from 'next/server' import { NextResponse } from 'next/server'
import { prisma } from '@/app/lib/prisma' import { prisma } from '@/app/lib/prisma'
// Helper: Prisma-User -> Player
const toPlayer = (u: any) => ({
steamId : u?.steamId ?? '',
name : u?.name ?? 'Unbekannt',
avatar : u?.avatar ?? null,
location : u?.location ?? undefined,
premierRank: u?.premierRank ?? undefined,
isAdmin : u?.isAdmin ?? undefined,
})
// Helper: Prisma-MatchPlayer -> MatchPlayer
const toMatchPlayer = (p: any) => ({
user : toPlayer(p.user),
stats: p.stats ?? undefined,
})
export async function GET() { export async function GET() {
try { try {
/* 1) nur Community-Matches holen ------------------------------ */
const matches = await prisma.match.findMany({ const matches = await prisma.match.findMany({
where : { matchType: 'community' }, where : { matchType: 'community' },
orderBy : { demoDate: 'desc' }, // falls demoDate null ⇒ älter oben orderBy: { demoDate: 'desc' },
include: { include: {
teamA : true, teamA: {
teamB : true, include: { leader: true },
},
teamB: {
include: { leader: true },
},
players: { players: {
include: { include: {
user : true, user : true,
@ -24,15 +43,24 @@ export async function GET() {
}, },
}) })
/* 2) API-Response vereinheitlichen ---------------------------- */
const formatted = matches.map(m => { const formatted = matches.map(m => {
/** ➜ einheitliches Datumsfeld für Frontend */
const matchDate = const matchDate =
m.demoDate ?? m.demoDate ??
// @ts-ignore falls du optional noch ein „date“-Feld hast // @ts-ignore falls du optional noch ein „date“-Feld hast
(m as any).date ?? (m as any).date ??
m.createdAt m.createdAt
const teamAId = m.teamA?.id ?? null
const teamBId = m.teamB?.id ?? null
const teamAPlayers = m.players
.filter(p => (p.teamId ?? p.team?.id) === teamAId)
.map(toMatchPlayer)
const teamBPlayers = m.players
.filter(p => (p.teamId ?? p.team?.id) === teamBId)
.map(toMatchPlayer)
return { return {
id : m.id, id : m.id,
title : m.title, title : m.title,
@ -49,26 +77,25 @@ export async function GET() {
name : m.teamA?.name ?? 'CT', name : m.teamA?.name ?? 'CT',
logo : m.teamA?.logo ?? null, logo : m.teamA?.logo ?? null,
score : m.scoreA, score : m.scoreA,
leader : m.teamA?.leader ? toPlayer(m.teamA.leader) : undefined,
// -> neu:
players: teamAPlayers,
}, },
teamB: { teamB: {
id : m.teamB?.id ?? null, id : m.teamB?.id ?? null,
name : m.teamB?.name ?? 'T', name : m.teamB?.name ?? 'T',
logo : m.teamB?.logo ?? null, logo : m.teamB?.logo ?? null,
score : m.scoreB, score : m.scoreB,
leader : m.teamB?.leader ? toPlayer(m.teamB.leader) : undefined,
// -> neu:
players: teamBPlayers,
}, },
players: m.players.map(p => ({ // -> Top-Level "players" wurde entfernt
steamId : p.steamId,
name : p.user?.name,
avatar : p.user?.avatar,
stats : p.stats,
teamId : p.teamId,
teamName: p.team?.name ?? null,
})),
} }
}) })
/* 3) zurückgeben --------------------------------------------- */
return NextResponse.json({ matches: formatted }) return NextResponse.json({ matches: formatted })
} catch (err) { } catch (err) {
console.error('❌ Fehler beim Abrufen der Community-Matches:', err) console.error('❌ Fehler beim Abrufen der Community-Matches:', err)

View File

@ -1,25 +1,23 @@
// src/app/api/team/[teamId]/route.ts // src/app/api/team/[teamId]/route.ts
import { NextResponse, type NextRequest } from 'next/server' import { NextResponse, type NextRequest } from 'next/server'
import { prisma } from '@/app/lib/prisma' import { prisma } from '@/app/lib/prisma'
import type { Player } from '@/app/types/team' import type { Player, InvitedPlayer } from '@/app/types/team'
export const dynamic = 'force-dynamic'
export const dynamic = 'force-dynamic'; export const revalidate = 0
export const revalidate = 0;
export async function GET( export async function GET(
_req: NextRequest, _req: NextRequest,
{ params }: { params: { teamId: string } }, { params }: { params: { teamId: string } },
) { ) {
try { try {
/* ─── 1) Team + Invites holen ───────────────────────────── */ /* 1) Team + Leader + Invites (mit user) laden */
const team = await prisma.team.findUnique({ const team = await prisma.team.findUnique({
where: { id: params.teamId }, where: { id: params.teamId },
include: { include: {
leader: true, // ⬅ damit wir ein Player-Objekt für leader bauen können
invites: { invites: {
include: { include: { user: true }, // ⬅ notwendig für invitedPlayers
user: true, // ⬅ notwendig für eingeladenen Spieler
},
}, },
}, },
}) })
@ -28,12 +26,11 @@ export async function GET(
return NextResponse.json({ error: 'Team nicht gefunden' }, { status: 404 }) return NextResponse.json({ error: 'Team nicht gefunden' }, { status: 404 })
} }
/* ─── 2) Aktive + Inaktive Spieler holen ─────────────────── */ /* 2) Aktive + Inaktive Spieler-Objekte bauen */
const allIds = Array.from( const allIds = Array.from(new Set([...(team.activePlayers ?? []), ...(team.inactivePlayers ?? [])]))
new Set([...team.activePlayers, ...team.inactivePlayers]),
)
const users = await prisma.user.findMany({ const users = allIds.length
? await prisma.user.findMany({
where: { steamId: { in: allIds } }, where: { steamId: { in: allIds } },
select: { select: {
steamId: true, steamId: true,
@ -41,50 +38,61 @@ export async function GET(
avatar: true, avatar: true,
location: true, location: true,
premierRank: true, premierRank: true,
isAdmin: true,
}, },
}) })
: []
const byId: Record<string, Player> = Object.fromEntries( const toPlayer = (u: any): Player => ({
users.map(u => [
u.steamId,
{
steamId: u.steamId, steamId: u.steamId,
name: u.name ?? 'Unbekannt', name: u.name ?? 'Unbekannt',
avatar: u.avatar ?? '/assets/img/avatars/default.png', avatar: u.avatar ?? '/assets/img/avatars/default.png',
location: u.location ?? '', location: u.location ?? undefined,
premierRank: u.premierRank ?? 0, premierRank: u.premierRank ?? undefined,
}, isAdmin: u.isAdmin ?? undefined,
]), })
)
const activePlayers = team.activePlayers const byId: Record<string, Player> = Object.fromEntries(users.map(u => [u.steamId, toPlayer(u)]))
const safeSort = (a?: string, b?: string) => (a ?? '').localeCompare(b ?? '')
const activePlayers: Player[] = (team.activePlayers ?? [])
.map(id => byId[id]) .map(id => byId[id])
.filter(Boolean) .filter(Boolean)
.sort((a, b) => a.name.localeCompare(b.name)) .sort((a, b) => safeSort(a.name, b.name))
const inactivePlayers = team.inactivePlayers const inactivePlayers: Player[] = (team.inactivePlayers ?? [])
.map(id => byId[id]) .map(id => byId[id])
.filter(Boolean) .filter(Boolean)
.sort((a, b) => a.name.localeCompare(b.name)) .sort((a, b) => safeSort(a.name, b.name))
/* ─── 3) Eingeladene Spieler extrahieren ─────────────────── */ /* 3) Eingeladene Spieler inkl. invitationId */
const invitedPlayers: Player[] = team.invites.map(invite => { const invitedPlayers: InvitedPlayer[] = (team.invites ?? [])
const u = invite.user .map(inv => {
const u = inv.user
return { return {
invitationId: inv.id, // ⬅ passt zu deinem InvitedPlayer-Typ
steamId: u.steamId, steamId: u.steamId,
name: u.name ?? 'Unbekannt', name: u.name ?? 'Unbekannt',
avatar: u.avatar ?? '/assets/img/avatars/default.png', avatar: u.avatar ?? '/assets/img/avatars/default.png',
location : u.location ?? '', location: u.location ?? undefined,
premierRank: u.premierRank ?? 0, premierRank: u.premierRank ?? undefined,
isAdmin: u.isAdmin ?? undefined,
} }
}).sort((a, b) => a.name.localeCompare(b.name)) })
.sort((a, b) => safeSort(a.name, b.name))
/* ─── 4) Antwort zurückgeben ─────────────────────────────── */ /* 4) Leader als Player-Objekt (nicht leaderId-String) */
const leader: Player | undefined = team.leader
? toPlayer(team.leader)
: undefined
/* 5) Antwort */
const result = { const result = {
id: team.id, id: team.id,
name: team.name, name: team.name,
logo: team.logo, logo: team.logo,
leader : team.leaderId, leader, // ⬅ jetzt Player statt String
createdAt: team.createdAt, createdAt: team.createdAt,
activePlayers, activePlayers,
inactivePlayers, inactivePlayers,
@ -92,15 +100,10 @@ export async function GET(
} }
return NextResponse.json(result, { return NextResponse.json(result, {
headers: { headers: { 'Cache-Control': 'no-store, no-cache, max-age=0, must-revalidate' },
'Cache-Control': 'no-store, no-cache, max-age=0, must-revalidate',
},
}) })
} catch (error) { } catch (error) {
console.error('GET /api/team/[teamId] failed:', error) console.error('GET /api/team/[teamId] failed:', error)
return NextResponse.json( return NextResponse.json({ error: 'Interner Serverfehler' }, { status: 500 })
{ error: 'Interner Serverfehler' },
{ status: 500 },
)
} }
} }

View File

@ -273,23 +273,23 @@ export default function CommunityMatchList({ matchType }: Props) {
</span> </span>
)} )}
{/* Map-Vote Badge */} {/* Map-Veto Badge */}
{m.mapVote && ( {m.mapVeto && (
<span <span
className={` className={`
px-2 py-0.5 rounded-full text-[11px] font-semibold px-2 py-0.5 rounded-full text-[11px] font-semibold
${m.mapVote.isOpen ? 'bg-green-300 dark:bg-green-600 text-white' : 'bg-neutral-200 dark:bg-neutral-700'} ${m.mapVeto.isOpen ? 'bg-green-300 dark:bg-green-600 text-white' : 'bg-neutral-200 dark:bg-neutral-700'}
`} `}
title={ title={
m.mapVote.opensAt m.mapVeto.opensAt
? `Öffnet ${format(new Date(m.mapVote.opensAt), 'dd.MM.yyyy HH:mm', { locale: de })} Uhr` ? `Öffnet ${format(new Date(m.mapVeto.opensAt), 'dd.MM.yyyy HH:mm', { locale: de })} Uhr`
: undefined : undefined
} }
> >
{m.mapVote.isOpen {m.mapVeto.isOpen
? (m.mapVote.status === 'completed' ? 'Map-Vote abgeschlossen' : 'Map-Vote offen') ? (m.mapVeto.status === 'completed' ? 'Map-Vote abgeschlossen' : 'Map-Vote offen')
: m.mapVote.opensAt : m.mapVeto.opensAt
? `Map-Vote ab ${format(new Date(m.mapVote.opensAt), 'HH:mm', { locale: de })} Uhr` ? `Map-Vote ab ${format(new Date(m.mapVeto.opensAt), 'HH:mm', { locale: de })} Uhr`
: 'Map-Vote bald'} : 'Map-Vote bald'}
</span> </span>
)} )}

View File

@ -19,7 +19,7 @@ import SortableMiniCard from '@/app/components/SortableMiniCard'
import LoadingSpinner from '@/app/components/LoadingSpinner' import LoadingSpinner from '@/app/components/LoadingSpinner'
import { DroppableZone } from '@/app/components/DroppableZone' import { DroppableZone } from '@/app/components/DroppableZone'
import type { Player, Team, TeamMatches } from '@/app/types/team' import type { Player, Team } from '@/app/types/team'
/* ───────────────────────── Typen ────────────────────────── */ /* ───────────────────────── Typen ────────────────────────── */
export type EditSide = 'A' | 'B' export type EditSide = 'A' | 'B'
@ -28,8 +28,8 @@ interface Props {
show : boolean show : boolean
onClose : () => void onClose : () => void
matchId : string matchId : string
teamA : TeamMatches teamA : Team
teamB : TeamMatches teamB : Team
side : EditSide // welches Team wird editiert? side : EditSide // welches Team wird editiert?
initialA: string[] // bereits eingesetzte Spieler-IDs initialA: string[] // bereits eingesetzte Spieler-IDs
initialB: string[] initialB: string[]
@ -50,8 +50,8 @@ export default function EditMatchPlayersModal (props: Props) {
const meSteam = session?.user?.steamId const meSteam = session?.user?.steamId
const isAdmin = session?.user?.isAdmin const isAdmin = session?.user?.isAdmin
const isLeader = side === 'A' const isLeader = side === 'A'
? meSteam === teamA.leader ? meSteam === teamA.leader?.steamId
: meSteam === teamB.leader : meSteam === teamB.leader?.steamId
const canEdit = isAdmin || isLeader const canEdit = isAdmin || isLeader
/* ---- States --------------------------------------------- */ /* ---- States --------------------------------------------- */
@ -60,6 +60,8 @@ export default function EditMatchPlayersModal (props: Props) {
const [dragItem, setDragItem] = useState<Player | null>(null) const [dragItem, setDragItem] = useState<Player | null>(null)
const [saving, setSaving] = useState(false) const [saving, setSaving] = useState(false)
const [saved, setSaved] = useState(false) const [saved, setSaved] = useState(false)
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
/* ---- Team-Info ------------------------------------------ */ /* ---- Team-Info ------------------------------------------ */
const team = side === 'A' ? teamA : teamB const team = side === 'A' ? teamA : teamB
@ -70,25 +72,54 @@ export default function EditMatchPlayersModal (props: Props) {
/* ---- Komplett-Spielerliste laden ------------------------ */ /* ---- Komplett-Spielerliste laden ------------------------ */
useEffect(() => { useEffect(() => {
if (!show) return if (!show) return
(async () => { if (!team?.id) return
setLoading(true)
setError(null)
;(async () => {
try { try {
const res = await fetch(`/api/team/${team.id}`) const res = await fetch(`/api/team/${team.id}`)
if (!res.ok) {
setError(`Team-API: ${res.status}`)
setPlayers([]) // leer, aber gleich nicht mehr "loading"
return
}
const data = await res.json() const data = await res.json()
/* ❶ aktive + inaktive Spieler zusammenführen */ // 🔧 Normalizer: akzeptiert string | Player
const all = [ const toPlayer = (x: any): Player =>
...(data.activePlayers as Player[] ?? []), typeof x === 'string'
...(data.inactivePlayers as Player[] ?? []), ? { steamId: x, name: 'Unbekannt', avatar: '' }
].filter((p, i, arr) => arr.findIndex(x => x.steamId === p.steamId) === i) // dedupe : x
setPlayers(all.sort((a, b) => a.name.localeCompare(b.name))) const raw = [
setSelected(myInit) // übernommene Line-up ...(data.activePlayers ?? []),
...(data.inactivePlayers ?? []),
]
// 🔧 Dedupe robust
const byId = new Map<string, Player>()
for (const x of raw) {
const p = toPlayer(x)
if (p?.steamId && !byId.has(p.steamId)) byId.set(p.steamId, p)
}
const all = Array.from(byId.values())
.sort((a, b) => (a.name || '').localeCompare(b.name || ''))
setPlayers(all)
setSelected(myInit) // initiale Auswahl übernehmen
setSaved(false) setSaved(false)
} catch (e) { } catch (e: any) {
console.error('[EditMatchPlayersModal] load error:', e) console.error('[EditMatchPlayersModal] load error:', e)
setError('Laden fehlgeschlagen')
setPlayers([])
} finally {
setLoading(false) // ✅ nie in der Schleife hängen bleiben
} }
})() })()
}, [show, team.id, myInit]) }, [show, team?.id]) // ⚠️ myInit hier nicht nötig
/* ---- DragnDrop-Handler -------------------------------- */ /* ---- DragnDrop-Handler -------------------------------- */
const onDragStart = ({ active }: any) => { const onDragStart = ({ active }: any) => {
@ -161,7 +192,7 @@ export default function EditMatchPlayersModal (props: Props) {
saved ? '✓ gespeichert' : saving ? 'Speichern …' : 'Speichern' saved ? '✓ gespeichert' : saving ? 'Speichern …' : 'Speichern'
} }
closeButtonColor={saved ? 'green' : 'blue'} closeButtonColor={saved ? 'green' : 'blue'}
disableSave={!canEdit || saving} disableSave={!canEdit || saving || !team?.id}
maxWidth='sm:max-w-2xl' maxWidth='sm:max-w-2xl'
> >
{!canEdit && ( {!canEdit && (
@ -172,9 +203,17 @@ export default function EditMatchPlayersModal (props: Props) {
{canEdit && ( {canEdit && (
<> <>
{players.length === 0 && <LoadingSpinner />} {loading && <LoadingSpinner />}
{players.length > 0 && ( {!loading && error && (
<p className="text-sm text-red-600">Fehler: {error}</p>
)}
{!loading && !error && players.length === 0 && (
<p className="text-sm text-gray-500">Keine Spieler gefunden.</p>
)}
{!loading && !error && players.length > 0 && (
<DndContext <DndContext
collisionDetection={closestCenter} collisionDetection={closestCenter}
onDragStart={onDragStart} onDragStart={onDragStart}
@ -195,7 +234,7 @@ export default function EditMatchPlayersModal (props: Props) {
key={p.steamId} key={p.steamId}
player={p} player={p}
currentUserSteamId={meSteam ?? ''} currentUserSteamId={meSteam ?? ''}
teamLeaderSteamId={team.leader} teamLeaderSteamId={team.leader?.steamId}
isAdmin={!!session?.user?.isAdmin} isAdmin={!!session?.user?.isAdmin}
hideOverlay hideOverlay
/> />
@ -218,7 +257,7 @@ export default function EditMatchPlayersModal (props: Props) {
key={p.steamId} key={p.steamId}
player={p} player={p}
currentUserSteamId={meSteam ?? ''} currentUserSteamId={meSteam ?? ''}
teamLeaderSteamId={team.leader} teamLeaderSteamId={team.leader?.steamId}
isAdmin={!!session?.user?.isAdmin} isAdmin={!!session?.user?.isAdmin}
hideOverlay hideOverlay
/> />
@ -232,7 +271,7 @@ export default function EditMatchPlayersModal (props: Props) {
<SortableMiniCard <SortableMiniCard
player={dragItem} player={dragItem}
currentUserSteamId={meSteam ?? ''} currentUserSteamId={meSteam ?? ''}
teamLeaderSteamId={team.leader} teamLeaderSteamId={team.leader?.steamId}
isAdmin={!!session?.user?.isAdmin} isAdmin={!!session?.user?.isAdmin}
hideOverlay hideOverlay
/> />

View File

@ -5,13 +5,13 @@ import { useRouter } from 'next/navigation'
import { useSession } from 'next-auth/react' import { useSession } from 'next-auth/react'
import { useSSEStore } from '@/app/lib/useSSEStore' import { useSSEStore } from '@/app/lib/useSSEStore'
import type { Match } from '../types/match' import type { Match } from '../types/match'
import type { MapVetoState } from '../types/mapvote' import type { MapVetoState } from '../types/mapveto'
type Props = { type Props = {
match: Match match: Match
} }
export default function MapVoteBanner({ match }: Props) { export default function MapVetoBanner({ match }: Props) {
const router = useRouter() const router = useRouter()
const { data: session } = useSession() const { data: session } = useSession()
const { lastEvent } = useSSEStore() const { lastEvent } = useSSEStore()

View File

@ -5,9 +5,9 @@ import { useEffect, useMemo, useState, useCallback, useRef } from 'react'
import { useSession } from 'next-auth/react' import { useSession } from 'next-auth/react'
import { useSSEStore } from '@/app/lib/useSSEStore' import { useSSEStore } from '@/app/lib/useSSEStore'
import { mapNameMap } from '../lib/mapNameMap' import { mapNameMap } from '../lib/mapNameMap'
import MapVoteProfileCard from './MapVoteProfileCard' import MapVoteProfileCard from './MapVetoProfileCard'
import type { Match, MatchPlayer } from '../types/match' import type { Match, MatchPlayer } from '../types/match'
import type { MapVetoState } from '../types/mapvote' import type { MapVetoState } from '../types/mapveto'
import { Player } from '../types/team' import { Player } from '../types/team'
type Props = { match: Match } type Props = { match: Match }
@ -18,7 +18,7 @@ const getTeamLogo = (logo?: string | null) =>
const HOLD_MS = 1200 // Dauer zum Gedrückthalten (ms) const HOLD_MS = 1200 // Dauer zum Gedrückthalten (ms)
const COMPLETE_THRESHOLD = 1.00 // ab diesem Fortschritt gilt "fertig" const COMPLETE_THRESHOLD = 1.00 // ab diesem Fortschritt gilt "fertig"
export default function MapVotePanel({ match }: Props) { export default function MapVetoPanel({ match }: Props) {
const { data: session } = useSession() const { data: session } = useSession()
const { lastEvent } = useSSEStore() const { lastEvent } = useSSEStore()

View File

@ -15,7 +15,7 @@ type Props = {
onClick?: () => void // optional onClick?: () => void // optional
} }
export default function MapVoteProfileCard({ export default function MapVetoProfileCard({
side, side,
name, name,
avatar, avatar,

View File

@ -20,9 +20,12 @@ import type { EditSide } from './EditMatchPlayersModal' // 'A' | 'B'
import type { Match, MatchPlayer } from '../types/match' import type { Match, MatchPlayer } from '../types/match'
import Button from './Button' import Button from './Button'
import { mapNameMap } from '../lib/mapNameMap' import { mapNameMap } from '../lib/mapNameMap'
import MapVoteBanner from './MapVoteBanner' import MapVetoBanner from './MapVetoBanner'
import MapVotePanel from './MapVotePanel' import MapVetoPanel from './MapVetoPanel'
import { useSSEStore } from '@/app/lib/useSSEStore' import { useSSEStore } from '@/app/lib/useSSEStore'
import { Player, Team } from '../types/team'
type TeamWithPlayers = Team & { players?: MatchPlayer[] }
/* ─────────────────── Hilfsfunktionen ────────────────────────── */ /* ─────────────────── Hilfsfunktionen ────────────────────────── */
const kdr = (k?: number, d?: number) => const kdr = (k?: number, d?: number) =>
@ -45,14 +48,15 @@ export function MatchDetails ({ match }: { match: Match }) {
/* ─── Rollen & Rechte ─────────────────────────────────────── */ /* ─── Rollen & Rechte ─────────────────────────────────────── */
const me = session?.user const me = session?.user
const userId = me?.steamId const userId = me?.steamId
const isLeaderA = !!userId && userId === match.teamA?.leader const isLeaderA = !!userId && userId === match.teamA?.leader?.steamId
const isLeaderB = !!userId && userId === match.teamB?.leader const isLeaderB = !!userId && userId === match.teamB?.leader?.steamId
const canEditA = isAdmin || isLeaderA const canEditA = isAdmin || isLeaderA
const canEditB = isAdmin || isLeaderB const canEditB = isAdmin || isLeaderB
const isMapVoteOpen = !!match.mapVote?.isOpen const isMapVetoOpen = !!match.mapVeto?.isOpen
console.log("Mapvote offen?: ", isMapVoteOpen); const teamAPlayers = (match.teamA as TeamWithPlayers).players ?? []
const teamBPlayers = (match.teamB as TeamWithPlayers).players ?? []
/* ─── Map ─────────────────────────────────────────────────── */ /* ─── Map ─────────────────────────────────────────────────── */
const normalizeMapKey = (raw?: string) => const normalizeMapKey = (raw?: string) =>
@ -227,7 +231,7 @@ export function MatchDetails ({ match }: { match: Match }) {
<strong>Score:</strong> {match.scoreA ?? 0}:{match.scoreB ?? 0} <strong>Score:</strong> {match.scoreA ?? 0}:{match.scoreB ?? 0}
</div> </div>
<MapVoteBanner match={match} /> <MapVetoBanner match={match} />
{/* ───────── Team-Blöcke ───────── */} {/* ───────── Team-Blöcke ───────── */}
<div className="border-t pt-4 mt-4 space-y-10"> <div className="border-t pt-4 mt-4 space-y-10">
@ -250,7 +254,7 @@ export function MatchDetails ({ match }: { match: Match }) {
)} )}
</div> </div>
{renderTable(match.teamA.players)} {renderTable(teamAPlayers)}
</div> </div>
{/* Team B */} {/* Team B */}
@ -272,7 +276,7 @@ export function MatchDetails ({ match }: { match: Match }) {
)} )}
</div> </div>
{renderTable(match.teamB.players)} {renderTable(teamBPlayers)}
</div> </div>
</div> </div>
@ -285,8 +289,8 @@ export function MatchDetails ({ match }: { match: Match }) {
teamA={match.teamA} teamA={match.teamA}
teamB={match.teamB} teamB={match.teamB}
side={editSide} side={editSide}
initialA={match.teamA.players.map(p => p.steamId)} initialA={teamAPlayers.map(mp => mp.user.steamId)}
initialB={match.teamB.players.map(p => p.steamId)} initialB={teamBPlayers.map(mp => mp.user.steamId)}
onSaved={() => window.location.reload()} onSaved={() => window.location.reload()}
/> />
)} )}

View File

@ -45,8 +45,12 @@ export const authOptions = (req: NextRequest): NextAuthOptions => ({
if (userInDb) { if (userInDb) {
token.team = userInDb.teamId ?? null token.team = userInDb.teamId ?? null
if (userInDb.steamId === '76561198000414190') {
token.isAdmin = true
} else {
token.isAdmin = userInDb.isAdmin ?? false token.isAdmin = userInDb.isAdmin ?? false
} }
}
return token return token
}, },

View File

@ -1,7 +1,7 @@
// /app/match-details/[matchId]/map-vote/page.tsx // /app/match-details/[matchId]/map-vote/page.tsx
import { notFound } from 'next/navigation' import { notFound } from 'next/navigation'
import Card from '@/app/components/Card' import Card from '@/app/components/Card'
import MapVotePanel from '@/app/components/MapVotePanel' import MapVetoPanel from '@/app/components/MapVetoPanel'
async function loadMatch(id: string) { async function loadMatch(id: string) {
const r = await fetch(`${process.env.NEXT_PUBLIC_BASE_URL ?? 'http://localhost:3000'}/api/matches/${id}`, { cache: 'no-store' }) const r = await fetch(`${process.env.NEXT_PUBLIC_BASE_URL ?? 'http://localhost:3000'}/api/matches/${id}`, { cache: 'no-store' })
@ -9,12 +9,12 @@ async function loadMatch(id: string) {
return r.json() return r.json()
} }
export default async function MapVotePage({ params }: { params: { matchId: string } }) { export default async function MapVetoPage({ params }: { params: { matchId: string } }) {
const match = await loadMatch(params.matchId) const match = await loadMatch(params.matchId)
if (!match) return notFound() if (!match) return notFound()
return ( return (
<Card maxWidth="auto"> <Card maxWidth="auto">
<MapVotePanel match={match} /> <MapVetoPanel match={match} />
</Card> </Card>
) )
} }

View File

@ -1,4 +1,4 @@
// /types/mapvote.ts // /types/mapveto.ts
import type { Player } from './team' import type { Player } from './team'
export type MapVetoStep = { export type MapVetoStep = {
@ -10,6 +10,14 @@ export type MapVetoStep = {
chosenBy: string | null chosenBy: string | null
} }
export type MapVetoTeam = {
id: string | null
name?: string | null
logo?: string | null
leader: Player | null
players: Player[]
}
export type MapVetoState = { export type MapVetoState = {
bestOf: number bestOf: number
mapPool: string[] mapPool: string[]
@ -18,7 +26,7 @@ export type MapVetoState = {
opensAt: string | null opensAt: string | null
steps: MapVetoStep[] steps: MapVetoStep[]
teams?: { teams?: {
teamA: { id: string | null; name?: string | null; logo?: string | null; leader: Player | null } teamA: MapVetoTeam
teamB: { id: string | null; name?: string | null; logo?: string | null; leader: Player | null } teamB: MapVetoTeam
} }
} }

View File

@ -1,6 +1,6 @@
// src/app/types/match.ts // src/app/types/match.ts
import { Player, TeamMatches } from './team' import { Player, Team } from './team'
export type Match = { export type Match = {
/* Basis-Infos ---------------------------------------------------- */ /* Basis-Infos ---------------------------------------------------- */
@ -11,21 +11,17 @@ export type Match = {
map : string map : string
matchType : 'premier' | 'competitive' | 'community' | string matchType : 'premier' | 'competitive' | 'community' | string
roundCount : number roundCount : number
// ⬇️ neu/optional, damit Alt-Daten weiter kompilen
bestOf? : 1 | 3 | 5 bestOf? : 1 | 3 | 5
matchDate? : string matchDate? : string
/* Ergebnis ------------------------------------------------------- */
scoreA? : number | null scoreA? : number | null
scoreB? : number | null scoreB? : number | null
winnerTeam? : 'CT' | 'T' | 'Draw' | null winnerTeam? : 'CT' | 'T' | 'Draw' | null
/* Teams ---------------------------------------------------------- */ /* Teams ---------------------------------------------------------- */
teamA: TeamMatches teamA: Team
teamB: TeamMatches teamB: Team
mapVote?: { mapVeto?: {
status: 'not_started' | 'in_progress' | 'completed' | null status: 'not_started' | 'in_progress' | 'completed' | null
opensAt: string | null opensAt: string | null
isOpen: boolean | null isOpen: boolean | null

View File

@ -23,11 +23,3 @@ export type Team = {
inactivePlayers: Player[] inactivePlayers: Player[]
invitedPlayers: InvitedPlayer[] invitedPlayers: InvitedPlayer[]
} }
export type TeamMatches = {
id: string
name?: string | null
logo?: string | null
leader?: Player
players: Player[]
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -1,5 +1,5 @@
{ {
"name": "prisma-client-ccbcad66b35a04d2308e6d4492f46a36a927649c9d37c79c1ca8fa339e65016e", "name": "prisma-client-c63ea7016e1a1ac5fd312c9d5648426292d519ae426c4dfab5e695d19cc61ccb",
"main": "index.js", "main": "index.js",
"types": "index.d.ts", "types": "index.d.ts",
"browser": "index-browser.js", "browser": "index-browser.js",

View File

@ -44,7 +44,7 @@ model User {
createdSchedules Schedule[] @relation("CreatedSchedules") createdSchedules Schedule[] @relation("CreatedSchedules")
confirmedSchedules Schedule[] @relation("ConfirmedSchedules") confirmedSchedules Schedule[] @relation("ConfirmedSchedules")
mapVetoChoices MapVoteStep[] @relation("VetoStepChooser") mapVetoChoices MapVetoStep[] @relation("VetoStepChooser")
} }
model Team { model Team {
@ -68,7 +68,7 @@ model Team {
schedulesAsTeamA Schedule[] @relation("ScheduleTeamA") schedulesAsTeamA Schedule[] @relation("ScheduleTeamA")
schedulesAsTeamB Schedule[] @relation("ScheduleTeamB") schedulesAsTeamB Schedule[] @relation("ScheduleTeamB")
mapVetoSteps MapVoteStep[] @relation("VetoStepTeam") mapVetoSteps MapVetoStep[] @relation("VetoStepTeam")
} }
model TeamInvite { model TeamInvite {
@ -138,7 +138,7 @@ model Match {
bestOf Int @default(3) // 1 | 3 | 5 app-seitig validieren bestOf Int @default(3) // 1 | 3 | 5 app-seitig validieren
matchDate DateTime? // geplante Startzeit (separat von demoDate) matchDate DateTime? // geplante Startzeit (separat von demoDate)
mapVeto MapVote? // 1:1 Map-Vote-Status mapVeto MapVeto? // 1:1 Map-Vote-Status
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
@ -297,13 +297,13 @@ model ServerRequest {
// 🗺️ Map-Vote // 🗺️ Map-Vote
// ────────────────────────────────────────────── // ──────────────────────────────────────────────
enum MapVoteAction { enum MapVetoAction {
BAN BAN
PICK PICK
DECIDER DECIDER
} }
model MapVote { model MapVeto {
id String @id @default(uuid()) id String @id @default(uuid())
matchId String @unique matchId String @unique
match Match @relation(fields: [matchId], references: [id]) match Match @relation(fields: [matchId], references: [id])
@ -317,17 +317,17 @@ model MapVote {
// Optional: serverseitig speichern, statt im UI zu berechnen // Optional: serverseitig speichern, statt im UI zu berechnen
opensAt DateTime? opensAt DateTime?
steps MapVoteStep[] steps MapVetoStep[]
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
} }
model MapVoteStep { model MapVetoStep {
id String @id @default(uuid()) id String @id @default(uuid())
vetoId String vetoId String
order Int order Int
action MapVoteAction action MapVetoAction
// Team, das am Zug ist (kann bei DECIDER null sein) // Team, das am Zug ist (kann bei DECIDER null sein)
teamId String? teamId String?
@ -339,7 +339,7 @@ model MapVoteStep {
chosenBy String? chosenBy String?
chooser User? @relation("VetoStepChooser", fields: [chosenBy], references: [steamId]) chooser User? @relation("VetoStepChooser", fields: [chosenBy], references: [steamId])
veto MapVote @relation(fields: [vetoId], references: [id]) veto MapVeto @relation(fields: [vetoId], references: [id])
@@unique([vetoId, order]) @@unique([vetoId, order])
@@index([teamId]) @@index([teamId])