This commit is contained in:
Linrador 2025-08-13 07:13:56 +02:00
parent 534860a12f
commit 5d0150d903
18 changed files with 5656 additions and 210 deletions

View File

@ -43,6 +43,8 @@ model User {
createdSchedules Schedule[] @relation("CreatedSchedules")
confirmedSchedules Schedule[] @relation("ConfirmedSchedules")
mapVetoChoices MapVoteStep[] @relation("VetoStepChooser")
}
model Team {
@ -65,6 +67,8 @@ model Team {
schedulesAsTeamA Schedule[] @relation("ScheduleTeamA")
schedulesAsTeamB Schedule[] @relation("ScheduleTeamB")
mapVetoSteps MapVoteStep[] @relation("VetoStepTeam")
}
model TeamInvite {
@ -98,6 +102,10 @@ model Notification {
// ──────────────────────────────────────────────
//
// ──────────────────────────────────────────────
// 🎮 Matches
// ──────────────────────────────────────────────
model Match {
id String @id @default(uuid())
title String
@ -128,6 +136,10 @@ model Match {
roundHistory Json?
winnerTeam String?
bestOf Int @default(3) // 1 | 3 | 5 app-seitig validieren
matchDate DateTime? // geplante Startzeit (separat von demoDate)
mapVote MapVote? // 1:1 Map-Vote-Status
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@ -175,19 +187,19 @@ model PlayerStats {
headshots Int @default(0)
noScopes Int @default(0)
blindKills Int @default(0)
aim Int @default(0)
oneK Int @default(0)
twoK Int @default(0)
threeK Int @default(0)
fourK Int @default(0)
fiveK Int @default(0)
aim Int @default(0)
rankOld Int?
rankNew Int?
rankChange Int?
winCount Int?
oneK Int @default(0)
twoK Int @default(0)
threeK Int @default(0)
fourK Int @default(0)
fiveK Int @default(0)
rankOld Int?
rankNew Int?
rankChange Int?
winCount Int?
matchPlayer MatchPlayer @relation(fields: [matchId, steamId], references: [matchId, steamId])
@ -280,3 +292,56 @@ model ServerRequest {
@@unique([steamId, matchId])
}
// ──────────────────────────────────────────────
// 🗺️ Map-Vote
// ──────────────────────────────────────────────
enum MapVoteAction {
BAN
PICK
DECIDER
}
model MapVote {
id String @id @default(uuid())
matchId String @unique
match Match @relation(fields: [matchId], references: [id])
// Basiszustand
bestOf Int @default(3)
mapPool String[] // z.B. ["de_inferno","de_mirage",...]
currentIdx Int @default(0)
locked Boolean @default(false)
// Optional: serverseitig speichern, statt im UI zu berechnen
opensAt DateTime?
steps MapVoteStep[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model MapVoteStep {
id String @id @default(uuid())
vetoId String
order Int
action MapVoteAction
// Team, das am Zug ist (kann bei DECIDER null sein)
teamId String?
team Team? @relation("VetoStepTeam", fields: [teamId], references: [id])
// Ergebnis & wer gewählt hat
map String?
chosenAt DateTime?
chosenBy String?
chooser User? @relation("VetoStepChooser", fields: [chosenBy], references: [steamId])
veto MapVote @relation(fields: [vetoId], references: [id])
@@unique([vetoId, order])
@@index([teamId])
@@index([chosenBy])
}

View File

@ -0,0 +1,47 @@
// /app/api/matches/[id]/delete/route.ts
import { NextRequest, NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/app/lib/auth'
import { prisma } from '@/app/lib/prisma'
export async function POST(req: NextRequest, { params }: { params: { id: string } }) {
try {
const session = await getServerSession(authOptions(req))
const me = session?.user as { steamId: string; isAdmin?: boolean } | undefined
if (!me?.isAdmin) {
return NextResponse.json({ message: 'Nur Admins dürfen löschen.' }, { status: 403 })
}
const matchId = params?.id
if (!matchId) {
return NextResponse.json({ message: 'Match-ID fehlt.' }, { status: 400 })
}
const match = await prisma.match.findUnique({
where: { id: matchId },
select: { id: true },
})
if (!match) {
return NextResponse.json({ message: 'Match nicht gefunden.' }, { status: 404 })
}
await prisma.$transaction(async (tx) => {
await tx.mapVetoStep.deleteMany({ where: { veto: { matchId } } })
await tx.mapVeto.deleteMany({ where: { matchId } })
await tx.playerStats.deleteMany({ where: { matchId } })
await tx.matchPlayer.deleteMany({ where: { matchId } })
await tx.rankHistory.deleteMany({ where: { matchId } })
await tx.demoFile.deleteMany({ where: { matchId } })
await tx.serverRequest.deleteMany({ where: { matchId } })
await tx.schedule.deleteMany({
where: { linkedMatchId: matchId },
})
await tx.match.delete({ where: { id: matchId } })
})
return NextResponse.json({ ok: true })
} catch (e) {
console.error('[DELETE MATCH] error', e)
return NextResponse.json({ message: 'Löschen fehlgeschlagen.' }, { status: 500 })
}
}

View File

@ -0,0 +1,273 @@
// /app/api/matches/[id]/map-vote/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' // gemeinsame Prisma-Instanz nutzen
import { MapVetoAction } from '@/generated/prisma' // dein Prisma-Types-Output
import { sendServerSSEMessage } from '@/app/lib/sse-server-client'
/* -------------------- Konstanten -------------------- */
const ACTIVE_DUTY: string[] = [
'de_inferno','de_mirage','de_nuke','de_overpass','de_vertigo','de_ancient','de_anubis',
]
const ACTION_MAP: Record<MapVetoAction, 'ban'|'pick'|'decider'> = {
BAN: 'ban', PICK: 'pick', DECIDER: 'decider',
}
/* -------------------- Helper -------------------- */
function vetoOpensAt(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: MapVetoAction): 'ban'|'pick'|'decider' {
return ACTION_MAP[a]
}
function buildSteps(bestOf: number, teamAId: string, teamBId: string) {
if (bestOf === 3) {
// 2x Ban, 2x Pick, 2x Ban, Decider
return [
{ order: 0, action: 'BAN', teamId: teamAId },
{ order: 1, action: 'BAN', teamId: teamBId },
{ order: 2, action: 'PICK', teamId: teamAId },
{ order: 3, action: 'PICK', teamId: teamBId },
{ order: 4, action: 'BAN', teamId: teamAId },
{ order: 5, action: 'BAN', teamId: teamBId },
{ order: 6, action: 'DECIDER', teamId: null },
] as const
}
// BO5: 2x Ban, dann 5 Picks (kein Decider)
return [
{ order: 0, action: 'BAN', teamId: teamAId },
{ order: 1, action: 'BAN', teamId: teamBId },
{ order: 2, action: 'PICK', teamId: teamAId },
{ order: 3, action: 'PICK', teamId: teamBId },
{ order: 4, action: 'PICK', teamId: teamAId },
{ order: 5, action: 'PICK', teamId: teamBId },
{ order: 6, action: 'PICK', teamId: teamAId },
] as const
}
function shapeState(veto: any) {
const steps = [...veto.steps]
.sort((a, b) => a.order - b.order)
.map((s: any) => ({
order : s.order,
action : mapActionToApi(s.action),
teamId : s.teamId,
map : s.map,
chosenAt: s.chosenAt ? s.chosenAt.toISOString() : null,
chosenBy: s.chosenBy ?? null,
}))
return {
bestOf : veto.bestOf,
mapPool : veto.mapPool as string[],
currentIndex: veto.currentIdx,
locked : veto.locked as boolean,
opensAt : veto.opensAt ? new Date(veto.opensAt).toISOString() : null,
steps,
}
}
async function ensureVeto(matchId: string) {
const match = await prisma.match.findUnique({
where: { id: matchId },
include: {
teamA : true,
teamB : true,
mapVeto: { include: { steps: true } },
},
})
if (!match) return { match: null, veto: null }
// Bereits vorhanden?
if (match.mapVeto) return { match, veto: match.mapVeto }
// Neu anlegen
const bestOf = match.bestOf ?? 3
const mapPool = ACTIVE_DUTY
const opensAt = vetoOpensAt({ matchDate: match.matchDate ?? null, demoDate: match.demoDate ?? null })
const stepsDef = buildSteps(bestOf, match.teamA!.id, match.teamB!.id)
const created = await prisma.mapVeto.create({
data: {
matchId : match.id,
bestOf,
mapPool,
currentIdx: 0,
locked : false,
opensAt,
steps : {
create: stepsDef.map(s => ({
order : s.order,
action: s.action as MapVetoAction,
teamId: s.teamId,
})),
},
},
include: { steps: true },
})
return { match, veto: 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))
}
/* -------------------- GET -------------------- */
export async function GET(_req: NextRequest, { params }: { params: { id: string } }) {
try {
const matchId = params.id
if (!matchId) return NextResponse.json({ message: 'Missing id' }, { status: 400 })
const { veto } = await ensureVeto(matchId)
if (!veto) return NextResponse.json({ message: 'Match nicht gefunden' }, { status: 404 })
return NextResponse.json(
shapeState(veto),
{ 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: { id: 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.id
if (!matchId) return NextResponse.json({ message: 'Missing id' }, { status: 400 })
let body: { map?: string } = {}
try { body = await req.json() } catch {}
try {
const { match, veto } = await ensureVeto(matchId)
if (!match || !veto) return NextResponse.json({ message: 'Match nicht gefunden' }, { status: 404 })
// Öffnungsfenster (1h vor Match-/Demo-Beginn)
const opensAt = veto.opensAt ?? vetoOpensAt({ matchDate: match.matchDate ?? null, demoDate: match.demoDate ?? null })
const isOpen = new Date() >= new Date(opensAt)
if (!isOpen && !me.isAdmin) return NextResponse.json({ message: 'Veto ist noch nicht offen' }, { status: 403 })
// Schon abgeschlossen?
if (veto.locked) return NextResponse.json({ message: 'Veto bereits abgeschlossen' }, { status: 409 })
// Aktuellen Schritt bestimmen
const stepsSorted = [...veto.steps].sort((a: any, b: any) => a.order - b.order)
const current = stepsSorted.find((s: any) => s.order === veto.currentIdx)
if (!current) {
// Kein Schritt mehr -> Veto abschließen
await prisma.mapVeto.update({ where: { id: veto.id }, data: { locked: true } })
const updated = await prisma.mapVeto.findUnique({ where: { id: veto.id }, include: { steps: true } })
await sendServerSSEMessage({ type: 'map-vote-updated', payload: { matchId } })
return NextResponse.json(shapeState(updated))
}
const available = computeAvailableMaps(veto.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.mapVetoStep.update({
where: { id: current.id },
data : { map: lastMap, chosenAt: new Date(), chosenBy: me.steamId },
})
await tx.mapVeto.update({
where: { id: veto.id },
data : { currentIdx: veto.currentIdx + 1, locked: true },
})
})
const updated = await prisma.mapVeto.findUnique({ where: { id: veto.id }, include: { steps: true } })
await sendServerSSEMessage({ type: 'map-vote-updated', payload: { matchId } })
return NextResponse.json(shapeState(updated))
}
// Rechte prüfen (Admin oder Leader des Teams am Zug)
const isLeaderA = !!match.teamA?.leaderId && match.teamA.leaderId === me.steamId
const isLeaderB = !!match.teamB?.leaderId && match.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 (!veto.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.mapVetoStep.update({
where: { id: current.id },
data : { map, chosenAt: new Date(), chosenBy: me.steamId },
})
// neuen Zustand ermitteln
const after = await tx.mapVeto.findUnique({
where : { id: veto.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.mapVetoStep.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.mapVeto.update({
where: { id: after.id },
data : { currentIdx: idx, locked },
})
})
const updated = await prisma.mapVeto.findUnique({
where : { id: veto.id },
include: { steps: true },
})
await sendServerSSEMessage({ type: 'map-vote-updated', payload: { matchId } })
return NextResponse.json(shapeState(updated))
} catch (e) {
console.error('[map-vote][POST] error', e)
return NextResponse.json({ message: 'Aktion fehlgeschlagen' }, { status: 500 })
}
}

View File

@ -20,8 +20,9 @@ export async function GET (
_req: Request,
{ params: { id } }: { params: { id: string } },
) {
if (!id)
if (!id) {
return NextResponse.json({ error: 'Missing ID' }, { status: 400 })
}
const match = await prisma.match.findUnique({
where : { id },
@ -31,14 +32,17 @@ export async function GET (
teamAUsers : { include: { team: true } },
teamBUsers : { include: { team: true } },
players : { include: { user: true, stats: true, team: true } },
mapVeto : { include: { steps: true } }, // ⬅️ wichtig
},
})
if (!match)
if (!match) {
return NextResponse.json({ error: 'Match nicht gefunden' }, { status: 404 })
}
/* ---------- Editierbarkeit bestimmen ---------- */
const isFuture = !!match.demoDate && isAfter(match.demoDate, new Date())
const baseDate = match.matchDate ?? match.demoDate ?? null
const isFuture = !!baseDate && isAfter(baseDate, new Date())
const editable = match.matchType === 'community' && isFuture
/* ---------- Spielerlisten zusammenstellen --------------------------------- */
@ -47,22 +51,20 @@ export async function GET (
if (editable) {
/* ───── Spieler kommen direkt aus der Match-Relation ───── */
/* ▸ teamAUsers / teamBUsers enthalten bereits User-Objekte */
const mapUser = (u: any, fallbackTeam: string) => ({
user : { // nur die Felder, die das Frontend braucht
user : {
steamId: u.steamId,
name : u.name ?? 'Unbekannt',
avatar : u.avatar ?? null,
},
stats: null, // noch keine Stats vorhanden
stats: null,
team : fallbackTeam,
})
playersA = match.teamAUsers.map(u => mapUser(u, match.teamA?.name ?? 'CT'))
playersB = match.teamBUsers.map(u => mapUser(u, match.teamB?.name ?? 'T'))
/* Falls beim Anlegen noch keine Spieler zugewiesen wurden,
(z. B. nach Migration) greifen wir auf activePlayers zurück */
/* ► Fallback: aktive Spieler, falls noch leer (z. B. nach Migration) */
if (playersA.length === 0 || playersB.length === 0) {
const [aIds, bIds] = [
match.teamA?.activePlayers ?? [],
@ -92,18 +94,50 @@ export async function GET (
.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({
id : match.id,
title : match.title,
description: match.description,
demoDate : match.demoDate,
matchDate : match.matchDate, // ⬅️ nützlich fürs Frontend
matchType : match.matchType,
roundCount : match.roundCount,
map : match.map,
scoreA : match.scoreA,
scoreB : match.scoreB,
editable, // <-- Frontend-Flag
editable,
// ⬇️ NEU: kompaktes Map-Vote-Objekt (virtuell, wenn kein DB-Eintrag)
mapVote: {
status, opensAt, isOpen, currentIndex, currentAction, decidedCount, totalSteps,
},
teamA: {
id : match.teamA?.id ?? null,
name : match.teamA?.name ?? 'CT',
@ -130,18 +164,22 @@ export async function PUT (
) {
const session = await getServerSession(authOptions(req))
const me = session?.user
if (!me?.steamId)
if (!me?.steamId) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 403 })
}
const match = await prisma.match.findUnique({ where: { id } })
if (!match)
if (!match) {
return NextResponse.json({ error: 'Match not found' }, { status: 404 })
}
/* ---------- erneute Editierbarkeits-Prüfung ---------- */
const isFuture = !!match.demoDate && isAfter(match.demoDate, new Date())
const editable = match.matchType === 'community' && isFuture
if (!editable)
const baseDate = match.matchDate ?? match.demoDate ?? null
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) ----- */
const userData = await prisma.user.findUnique({
@ -151,13 +189,13 @@ export async function PUT (
const leaderOf = userData?.ledTeam?.id
const isLeader = leaderOf && (leaderOf === match.teamAId || leaderOf === match.teamBId)
if (!me.isAdmin && !isLeader)
if (!me.isAdmin && !isLeader) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}
/* ---------- Payload einlesen & validieren ------------- */
const { players } = await req.json() // title / description etc. bei Bedarf ergänzen
const { players } = await req.json()
// wenn kein Admin: sicherstellen, dass nur Spieler des eigenen Teams gesetzt werden
if (!me.isAdmin && leaderOf) {
const ownTeam = await prisma.team.findUnique({ where: { id: leaderOf } })
const allowed = new Set([
@ -165,16 +203,16 @@ export async function PUT (
...(ownTeam?.inactivePlayers ?? []),
])
const invalid = players.some((p: any) =>
const invalid = players.some((p: any) =>
p.teamId === leaderOf && !allowed.has(p.steamId),
)
if (invalid)
if (invalid) {
return NextResponse.json({ error: 'Ungültige Spielerzuweisung' }, { status: 403 })
}
}
/* ---------- Spieler-Mapping speichern ----------------- */
try {
/* ► Listen pro Team aus dem Payload aufteilen */
const teamAIds = players
.filter((p: any) => p.teamId === match.teamAId)
.map((p: any) => p.steamId)
@ -184,10 +222,7 @@ export async function PUT (
.map((p: any) => p.steamId)
await prisma.$transaction([
/* 1) alle alten Zuordnungen löschen … */
prisma.matchPlayer.deleteMany({ where: { matchId: id } }),
/* 2) … neue anlegen */
prisma.matchPlayer.createMany({
data: players.map((p: any) => ({
matchId: id,
@ -196,8 +231,6 @@ export async function PUT (
})),
skipDuplicates: true,
}),
/* 3) M-N-Relationen an Match-Eintrag aktualisieren */
prisma.match.update({
where: { id },
data : {
@ -212,7 +245,7 @@ export async function PUT (
}
/* ---------- neue Daten abrufen & zurückgeben ---------- */
return GET(req, { params: { id } }) // gleiche Antwort-Struktur wie oben
return GET(_req as any, { params: { id } }) // gleiche Antwort-Struktur wie oben
}
/* ─────────────────────────── DELETE ─────────────────────────── */
@ -221,8 +254,9 @@ export async function DELETE (
{ 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 })
}
try {
await prisma.$transaction([

View File

@ -3,10 +3,40 @@ import { NextRequest, NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/app/lib/auth'
import { prisma } from '@/app/lib/prisma'
import { MapVetoAction } from '@/generated/prisma'
import { sendServerSSEMessage } from '@/app/lib/sse-server-client'
export const dynamic = 'force-dynamic'
// Hilfsfunktion in der Datei (oder shared):
function buildSteps(bestOf: number, teamAId: string, teamBId: string) {
if (bestOf === 5) {
return [
{ order: 0, action: MapVetoAction.BAN, teamId: teamAId },
{ order: 1, action: MapVetoAction.BAN, teamId: teamBId },
{ order: 2, action: MapVetoAction.PICK, teamId: teamAId },
{ order: 3, action: MapVetoAction.PICK, teamId: teamBId },
{ order: 4, action: MapVetoAction.PICK, teamId: teamAId },
{ order: 5, action: MapVetoAction.PICK, teamId: teamBId },
{ order: 6, action: MapVetoAction.PICK, teamId: teamAId },
] as const
}
// default BO3
return [
{ order: 0, action: MapVetoAction.BAN, teamId: teamAId },
{ order: 1, action: MapVetoAction.BAN, teamId: teamBId },
{ order: 2, action: MapVetoAction.PICK, teamId: teamAId },
{ order: 3, action: MapVetoAction.PICK, teamId: teamBId },
{ order: 4, action: MapVetoAction.BAN, teamId: teamAId },
{ order: 5, action: MapVetoAction.BAN, teamId: teamBId },
{ order: 6, action: MapVetoAction.DECIDER, teamId: null },
] as const
}
const ACTIVE_DUTY = [
'de_inferno','de_mirage','de_nuke','de_overpass','de_vertigo','de_ancient','de_anubis',
]
export async function POST (req: NextRequest) {
// ── Auth: nur Admins
const session = await getServerSession(authOptions(req))
@ -71,15 +101,14 @@ export async function POST (req: NextRequest) {
const created = await prisma.$transaction(async (tx) => {
const newMatch = await tx.match.create({
data: {
teamAId,
teamBId,
title : safeTitle,
description : safeDesc,
map : safeMap,
demoDate : plannedAt,
// ⚠ hier KEIN "type" setzen existiert nicht im Schema
teamAUsers : { connect: (teamA.activePlayers ?? []).map(id => ({ steamId: id })) },
teamBUsers : { connect: (teamB.activePlayers ?? []).map(id => ({ steamId: id })) },
teamAId, teamBId,
title: safeTitle,
description: safeDesc,
map: safeMap,
demoDate: plannedAt, // du nutzt demoDate als geplante Zeit
bestOf: bestOfInt,
teamAUsers: { connect: (teamA.activePlayers ?? []).map(id => ({ steamId: id })) },
teamBUsers: { connect: (teamB.activePlayers ?? []).map(id => ({ steamId: id })) },
},
})
@ -91,6 +120,28 @@ export async function POST (req: NextRequest) {
await tx.matchPlayer.createMany({ data: playersData, skipDuplicates: true })
}
// ⬇️ MapVeto sofort anlegen
const opensAt = new Date((new Date(newMatch.matchDate ?? newMatch.demoDate ?? plannedAt)).getTime() - 60*60*1000)
const stepsDef = buildSteps(bestOfInt, teamAId, teamBId)
await tx.mapVeto.create({
data: {
matchId: newMatch.id,
bestOf : bestOfInt,
mapPool: ACTIVE_DUTY,
currentIdx: 0,
locked: false,
opensAt,
steps: {
create: stepsDef.map(s => ({
order: s.order,
action: s.action, // prisma.MapVetoAction.*
teamId: s.teamId ?? undefined,
})),
},
},
})
return newMatch
})

View File

@ -1,113 +1,110 @@
// /app/api/user/[steamId]/matches/route.ts
import { NextResponse, type NextRequest } from 'next/server'
import { NextResponse } from 'next/server'
import { prisma } from '@/app/lib/prisma'
export async function GET(
req: NextRequest,
{ params }: { params: { steamId: string } },
) {
const steamId = params.steamId
if (!steamId) {
return NextResponse.json({ error: 'Steam-ID fehlt' }, { status: 400 })
}
const { searchParams } = new URL(req.url)
// Query-Parameter
const typesParam = searchParams.get('types') // z. B. "premier,competitive"
const types = typesParam
? typesParam.split(',').map(t => t.trim()).filter(Boolean)
: []
const limit = Math.min(
Math.max(parseInt(searchParams.get('limit') || '10', 10), 1),
50,
) // 1..50 (Default 10)
const cursor = searchParams.get('cursor') // letzte match.id der vorherigen Page
export async function GET(req: Request) {
try {
// Matches, in denen der Spieler vorkommt
const matches = await prisma.match.findMany({
where: {
players: { some: { steamId } },
...(types.length ? { matchType: { in: types } } : {}),
},
orderBy: [{ demoDate: 'desc' }, { id: 'desc' }],
take: limit + 1, // eine extra zum Prüfen, ob es weiter geht
...(cursor ? { cursor: { id: cursor }, skip: 1 } : {}),
const { searchParams } = new URL(req.url)
const matchType = searchParams.get('type') // z. B. "community"
select: {
id: true,
demoDate: true,
map: true,
roundCount: true,
scoreA: true,
scoreB: true,
matchType: true,
teamAId: true,
teamBId: true,
teamAUsers: { select: { steamId: true } },
teamBUsers: { select: { steamId: true } },
winnerTeam: true,
// nur die MatchPlayer-Zeile des aktuellen Users (für Stats)
players: {
where: { steamId },
select: { stats: true },
},
const matches = await prisma.match.findMany({
where : matchType ? { matchType } : undefined,
orderBy: { demoDate: 'desc' },
include: {
teamA : true,
teamB : true,
players: { include: { user: true, stats: true, team: true } },
mapVeto: { include: { steps: true } },
},
})
const hasMore = matches.length > limit
const page = hasMore ? matches.slice(0, limit) : matches
const formatted = matches.map(m => {
let status: 'not_started' | 'in_progress' | 'completed' | null = null
let opensAtISO: string | null = null
let isOpen = false // <-- immer boolean
let currentIndex: number | null = null
let currentAction: 'BAN'|'PICK'|'DECIDER' | null = null
let decidedCount: number | null = null
let totalSteps: number | null = null
let opensInMinutes: number | null = null // <-- optional
const items = page.map(m => {
const stats = m.players[0]?.stats ?? null
const kills = stats?.kills ?? 0
const deaths = stats?.deaths ?? 0
const kdr = deaths ? (kills / deaths).toFixed(2) : '∞'
const rankOld = stats?.rankOld ?? null
const rankNew = stats?.rankNew ?? null
const aim = stats?.aim ?? null
const rankChange =
rankNew != null && rankOld != null ? rankNew - rankOld : null
if (m.mapVeto) {
const stepsSorted = [...m.mapVeto.steps].sort((a, b) => a.order - b.order)
const anyChosen = stepsSorted.some(s => !!s.chosenAt)
status = m.mapVeto.locked ? 'completed' : (anyChosen ? 'in_progress' : 'not_started')
// Teamzugehörigkeit aus Sicht des Spielers (CT/T)
const playerTeam = m.teamAUsers.some(u => u.steamId === steamId) ? 'CT' : 'T'
const score = `${m.scoreA ?? 0} : ${m.scoreB ?? 0}`
const computedOpensAt =
m.mapVeto.opensAt ??
(() => {
const base = m.matchDate ?? m.demoDate ?? new Date()
return new Date(base.getTime() - 60 * 60 * 1000) // 1h vorher
})()
opensAtISO = computedOpensAt?.toISOString() ?? null
if (opensAtISO) {
const now = Date.now()
const oa = new Date(opensAtISO).getTime()
isOpen = now >= oa
opensInMinutes = Math.max(0, Math.ceil((oa - now) / 60000))
}
currentIndex = m.mapVeto.currentIdx
const cur = stepsSorted.find(s => s.order === m.mapVeto.currentIdx)
currentAction = (cur?.action as 'BAN'|'PICK'|'DECIDER') ?? null
decidedCount = stepsSorted.filter(s => !!s.chosenAt).length
totalSteps = stepsSorted.length
}
// Fallback für Anzeige-Datum im Frontend
const displayDate = m.demoDate ?? m.matchDate
return {
id: m.id,
map: m.map ?? 'Unknown',
date: m.demoDate?.toISOString() ?? '',
matchType: m.matchType ?? 'community',
score,
roundCount: m.roundCount,
rankOld,
rankNew,
rankChange,
kills,
deaths,
kdr,
aim,
id : m.id,
map : m.map,
demoDate : m.demoDate, // unverändert
matchDate : m.matchDate, // falls dus im UI brauchst
displayDate, // <-- neu (für sicheres Rendern)
matchType : m.matchType,
scoreA : m.scoreA,
scoreB : m.scoreB,
winnerTeam: m.winnerTeam ?? null,
team: playerTeam, // 'CT' | 'T'
mapVote: m.mapVeto ? {
status,
opensAt: opensAtISO,
isOpen,
opensInMinutes, // <-- optional
currentIndex,
currentAction,
decidedCount,
totalSteps,
} : null,
teamA: {
id : m.teamA?.id ?? null,
name : m.teamA?.name ?? 'CT',
logo : m.teamA?.logo ?? null,
score: m.scoreA,
},
teamB: {
id : m.teamB?.id ?? null,
name : m.teamB?.name ?? 'T',
logo : m.teamB?.logo ?? null,
score: m.scoreB,
},
players: m.players.map(p => ({
steamId : p.steamId,
name : p.user?.name,
avatar : p.user?.avatar,
stats : p.stats,
teamId : p.teamId,
teamName: p.team?.name ?? null,
})),
}
})
const nextCursor = hasMore ? page[page.length - 1].id : null
return NextResponse.json({
items,
nextCursor,
hasMore,
})
return NextResponse.json(formatted)
} catch (err) {
console.error('[API] /user/[steamId]/matches Fehler:', err)
return NextResponse.json({ error: 'Serverfehler' }, { status: 500 })
console.error('GET /matches failed:', err)
return NextResponse.json({ error: 'Failed to load matches' }, { status: 500 })
}
}

View File

@ -69,6 +69,15 @@ export default function CommunityMatchList({ matchType }: Props) {
[teams]
)
useEffect(() => {
const id = setInterval(() => {
// force re-render, damit isOpen (Vergleich mit Date.now) neu bewertet wird
setMatches(m => [...m])
}, 30_000)
return () => clearInterval(id)
}, [])
// Auto-Titel
useEffect(() => {
if (!autoTitle) return
@ -240,6 +249,27 @@ export default function CommunityMatchList({ matchType }: Props) {
</span>
)}
{/* Map-Vote Badge */}
{m.mapVote && (
<span
className={`
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'}
`}
title={
m.mapVote.opensAt
? `Öffnet ${format(new Date(m.mapVote.opensAt), 'dd.MM.yyyy HH:mm', { locale: de })} Uhr`
: undefined
}
>
{m.mapVote.isOpen
? (m.mapVote.status === 'completed' ? 'Map-Vote abgeschlossen' : 'Map-Vote offen')
: m.mapVote.opensAt
? `Map-Vote ab ${format(new Date(m.mapVote.opensAt), 'HH:mm', { locale: de })} Uhr`
: 'Map-Vote bald'}
</span>
)}
<div className="flex w-full justify-around items-center">
<div className="flex flex-col items-center w-1/3">
<Image src={getTeamLogo(m.teamA.logo)} alt={m.teamA.name} width={48} height={48} className="rounded-full border bg-white" />
@ -256,6 +286,7 @@ export default function CommunityMatchList({ matchType }: Props) {
<span className={`px-3 py-0.5 rounded-full text-sm font-semibold ${isLive ? 'bg-red-300 dark:bg-red-500' : 'bg-yellow-300 dark:bg-yellow-500'}`}>
{format(new Date(m.demoDate), 'dd.MM.yyyy', { locale: de })}
</span>
<span className="flex items-center gap-1 text-xs opacity-80">
<svg xmlns="http://www.w3.org/2000/svg" className="w-3.5 h-3.5" fill="currentColor" viewBox="0 0 512 512">
<path d="M256 48a208 208 0 1 0 208 208A208.24 208.24 0 0 0 256 48Zm0 384a176 176 0 1 1 176-176 176.2 176.2 0 0 1-176 176Zm80-176h-64V144a16 16 0 0 0-32 0v120a16 16 0 0 0 16 16h80a16 16 0 0 0 0-32Z" />

View File

@ -0,0 +1,249 @@
// /app/components/MapVotePanel.tsx
'use client'
import { useEffect, useMemo, useState, useCallback } from 'react'
import { useSession } from 'next-auth/react'
import { mapNameMap } from '../lib/mapNameMap'
import Button from './Button'
import { useSSEStore } from '@/app/lib/useSSEStore'
import type { Match } from '../types/match'
import type { MapVetoState } from '../types/mapvote'
type Props = { match: Match }
export default function MapVotePanel({ match }: Props) {
const { data: session } = useSession()
const [state, setState] = useState<MapVetoState | null>(null)
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
// --- Zeitpunkt: 1h vor Matchbeginn ---
const opensAtTs = useMemo(() => {
const base = new Date(match.matchDate ?? match.demoDate ?? Date.now())
return base.getTime() - 60 * 60 * 1000
}, [match.matchDate, match.demoDate])
// Sauberer Countdown/„Jetzt offen“-Trigger
const [nowTs, setNowTs] = useState(() => Date.now())
const isOpen = nowTs >= opensAtTs
const msToOpen = Math.max(opensAtTs - nowTs, 0)
useEffect(() => {
if (isOpen) return
const t = setInterval(() => setNowTs(Date.now()), 1000)
return () => clearInterval(t)
}, [isOpen])
// --- Berechtigungen: nur Leader des Teams, Admins immer ---
const me = session?.user
const isAdmin = !!me?.isAdmin
const isLeaderA = !!me?.steamId && match.teamA?.leader === me.steamId
const isLeaderB = !!me?.steamId && match.teamB?.leader === me.steamId
const canActForTeamId = useCallback((teamId?: string | null) => {
if (!teamId) return false
if (isAdmin) return true
return (teamId === match.teamA?.id && isLeaderA) ||
(teamId === match.teamB?.id && isLeaderB)
}, [isAdmin, isLeaderA, isLeaderB, match.teamA?.id, match.teamB?.id])
// --- Laden / Reload ---
const load = useCallback(async () => {
setIsLoading(true)
setError(null)
try {
const r = await fetch(`/api/matches/${match.id}/map-vote`, { cache: 'no-store' })
if (!r.ok) {
// Server-Fehlertext übernehmen, falls vorhanden
const j = await r.json().catch(() => ({}))
throw new Error(j?.message || 'Laden fehlgeschlagen')
}
const json = await r.json()
// ➜ harte Validierung der erwarteten Struktur
if (!json || !Array.isArray(json.steps)) {
// optional: console.debug('Unerwartete Antwort:', json)
throw new Error('Ungültige Serverantwort (steps fehlt)')
}
setState(json)
} catch (e: any) {
setState(null) // wichtig: kein halbgares Objekt rendern
setError(e?.message ?? 'Unbekannter Fehler')
} finally {
setIsLoading(false)
}
}, [match.id])
useEffect(() => { load() }, [load])
// --- SSE: bei Änderungen nachladen ---
const { lastEvent } = useSSEStore()
useEffect(() => {
if (!lastEvent) return
if (lastEvent.type !== 'map-vote-updated') return
const matchId = lastEvent.payload?.matchId
if (matchId !== match.id) return
load()
}, [lastEvent, match.id, load])
// --- Abgeleitete Zustände ---
const currentStep = state?.steps?.[state.currentIndex]
const isMyTurn = Boolean(
isOpen && !state?.locked && currentStep?.teamId && canActForTeamId(currentStep.teamId)
)
const mapPool = state?.mapPool ?? []
const pickedOrBanned = useMemo(
() => new Set((state?.steps ?? []).map(s => s.map).filter(Boolean) as string[]),
[state?.steps]
)
const fmt = (k: string) => mapNameMap[k]?.name ?? k
// --- Aktionen ---
const handlePickOrBan = async (map: string) => {
if (!isMyTurn || !currentStep) return
try {
const r = await fetch(`/api/matches/${match.id}/map-vote`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ map }),
})
if (!r.ok) {
const j = await r.json().catch(() => ({}))
alert(j.message ?? 'Aktion fehlgeschlagen')
return
}
// Erfolg: Server triggert SSE → load() via SSE
} catch {
alert('Netzwerkfehler')
}
}
if (isLoading && !state) return <div className="p-4">Lade Map-Voting</div>
if (error && !state) return <div className="p-4 text-red-600">{error}</div>
return (
<div className="mt-8 border rounded-lg p-4 dark:border-neutral-700">
<div className="flex items-center justify-between mb-3">
<h3 className="text-lg font-semibold">Map-Vote</h3>
<div className="text-sm opacity-80">Modus: BO{match.bestOf ?? state?.bestOf ?? 3}</div>
</div>
{!isOpen && (
<div className="mb-4 text-sm">
<span className="inline-block px-2 py-1 rounded bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-100">
Öffnet in {formatCountdown(msToOpen)}
</span>
</div>
)}
{state && Array.isArray(state.steps) && (
<>
<ol className="mb-4 grid gap-2 sm:grid-cols-2 lg:grid-cols-3">
{state.steps.map((s, i) => {
const done = !!s.map
const isCurrent = i === state.currentIndex && !state.locked
const actionLabel = s.action === 'ban' ? 'Ban' : s.action === 'pick' ? 'Pick' : 'Decider'
const teamLabel = s.teamId
? (s.teamId === match.teamA.id ? match.teamA.name : match.teamB.name)
: '—'
return (
<li key={i} className={`p-2 rounded border text-sm ${
done
? 'bg-green-50 border-green-200 dark:bg-green-900/20 dark:border-green-900/40'
: isCurrent
? 'bg-blue-50 border-blue-200 dark:bg-blue-900/20 dark:border-blue-900/40'
: 'border-gray-200 dark:border-neutral-700'
}`}>
<div className="font-medium">
{actionLabel} {s.teamId ? `${teamLabel}` : ''}
</div>
<div className="opacity-80">
{s.map ? fmt(s.map) : (isCurrent ? 'am Zug…' : '—')}
</div>
</li>
)
})}
</ol>
{/* Karten-Grid */}
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-3">
{mapPool.map((map) => {
const taken = pickedOrBanned.has(map)
const isAvailable = !taken && isMyTurn && isOpen && !state.locked
return (
<button
key={map}
type="button"
onClick={() => isAvailable && handlePickOrBan(map)}
className={[
'relative rounded-lg border px-3 py-2 text-left transition',
taken
? 'bg-neutral-100 dark:bg-neutral-800 border-neutral-300 dark:border-neutral-700 opacity-60 cursor-not-allowed'
: isAvailable
? 'bg-white dark:bg-neutral-900 border-blue-400 ring-1 ring-blue-300 hover:bg-blue-50 dark:hover:bg-blue-950 cursor-pointer'
: 'bg-white dark:bg-neutral-900 border-neutral-300 dark:border-neutral-700'
].join(' ')}
disabled={!isAvailable}
title={
taken
? 'Bereits gewählt/gestrichen'
: isAvailable
? 'Jetzt wählen'
: 'Nur der Team-Leader (oder Admin) darf wählen'
}
>
<div className="text-sm font-medium">{fmt(map)}</div>
<div className="text-xs opacity-60">{map}</div>
{taken && (
<span className="absolute top-1 right-1 text-[10px] px-1.5 py-0.5 rounded bg-neutral-800 text-white dark:bg-neutral-200 dark:text-neutral-900">
vergeben
</span>
)}
</button>
)
})}
</div>
{/* Footer */}
<div className="mt-4 text-sm flex flex-wrap items-center gap-3">
{state.locked ? (
<span className="px-2 py-1 rounded bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-200">
Veto abgeschlossen
</span>
) : isOpen ? (
isMyTurn ? (
<span className="px-2 py-1 rounded bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-200">
Du bist am Zug (Leader/Admin)
</span>
) : (
<span className="px-2 py-1 rounded bg-neutral-100 text-neutral-800 dark:bg-neutral-800 dark:text-neutral-200">
Wartet auf&nbsp;
{currentStep?.teamId === match.teamA?.id ? match.teamA.name : match.teamB.name}
&nbsp;(Leader/Admin)
</span>
)
) : null}
<Button size="sm" variant="soft" onClick={load} disabled={isLoading}>
{isLoading ? 'Aktualisieren …' : 'Aktualisieren'}
</Button>
</div>
</>
)}
</div>
)
}
function formatCountdown(ms: number) {
if (ms <= 0) return '0:00:00'
const totalSec = Math.floor(ms / 1000)
const h = Math.floor(totalSec / 3600)
const m = Math.floor((totalSec % 3600) / 60)
const s = totalSec % 60
const pad = (n:number)=>String(n).padStart(2,'0')
return `${h}:${pad(m)}:${pad(s)}`
}

View File

@ -19,6 +19,8 @@ import type { EditSide } from './EditMatchPlayersModal' // 'A' | 'B'
import type { Match, MatchPlayer } from '../types/match'
import Button from './Button'
import { mapNameMap } from '../lib/mapNameMap'
import MapVotePanel from './MapVotePanel'
/* ─────────────────── Hilfsfunktionen ────────────────────────── */
const kdr = (k?: number, d?: number) =>
@ -35,15 +37,29 @@ const adr = (dmg?: number, rounds?: number) =>
export function MatchDetails ({ match }: { match: Match }) {
const { data: session } = useSession()
const router = useRouter()
const isAdmin = !!session?.user?.isAdmin
/* ─── Rollen & Rechte ─────────────────────────────────────── */
const me = session?.user
const userId = me?.steamId
const isAdmin = me?.isAdmin
const isLeaderA = !!userId && userId === match.teamA?.leader
const isLeaderB = !!userId && userId === match.teamB?.leader
const canEditA = isAdmin || isLeaderA
const canEditB = isAdmin || isLeaderB
const isMapVoteOpen = !!match.mapVote?.isOpen
console.log("Mapvote offen?: ", isMapVoteOpen);
/* ─── Map ─────────────────────────────────────────────────── */
const normalizeMapKey = (raw?: string) =>
(raw ?? '')
.toLowerCase()
.replace(/\.bsp$/,'')
.replace(/^.*\//,'')
const mapKey = normalizeMapKey(match.map)
const mapLabel = mapNameMap[mapKey]?.name ?? (match.map ?? 'Unbekannte Map')
/* ─── Match-Zeitpunkt ─────────────────────────────────────── */
const dateString = match.matchDate ?? match.demoDate
@ -63,6 +79,24 @@ export function MatchDetails ({ match }: { match: Match }) {
</colgroup>
)
/* ─── Match löschen ────────────────────────────────────────── */
const handleDelete = async () => {
if (!confirm('Match wirklich löschen? Das kann nicht rückgängig gemacht werden.')) return
try {
const res = await fetch(`/api/matches/${match.id}/delete`, { method: 'POST' })
if (!res.ok) {
const j = await res.json().catch(() => ({}))
alert(j.message ?? 'Löschen fehlgeschlagen')
return
}
// Zurück zur Matchliste
router.push('/schedule') // ggf. an deinen Pfad anpassen
} catch (e) {
console.error('[MatchDetails] delete failed', e)
alert('Löschen fehlgeschlagen.')
}
}
/* ─── Spieler-Tabelle ─────────────────────────────────────── */
const renderTable = (players: MatchPlayer[]) => {
const sorted = [...players].sort(
@ -147,8 +181,17 @@ export function MatchDetails ({ match }: { match: Match }) {
return (
<div className="space-y-6">
<h1 className="text-2xl font-bold">
Match auf {match.map} ({match.matchType})
Match auf {mapLabel} ({match.matchType})
</h1>
{isAdmin && (
<Button
onClick={handleDelete}
className="bg-red-600 hover:bg-red-700 text-white px-3 py-1.5 rounded-md"
>
Match löschen
</Button>
)}
<p className="text-sm text-gray-500">Datum: {readableDate}</p>
@ -161,6 +204,10 @@ export function MatchDetails ({ match }: { match: Match }) {
<strong>Score:</strong> {match.scoreA ?? 0}:{match.scoreB ?? 0}
</div>
{/* Map-Vote Panel nur anzeigen, wenn freigegeben */}
{isMapVoteOpen && <MapVotePanel match={match} />}
{/* ───────── Team-Blöcke ───────── */}
<div className="border-t pt-4 mt-4 space-y-10">
{/* Team A */}

21
src/app/types/mapvote.ts Normal file
View File

@ -0,0 +1,21 @@
// z.B. /app/types/mapvote.ts
export type VetoAction = 'ban' | 'pick' | 'decider';
export type MapVetoStep = {
index: number;
action: VetoAction; // 'ban' | 'pick' | 'decider'
teamId?: string | null; // wer ist dran (bei 'decider' meist null)
map?: string | null; // gewählte Map (wenn ausgeführt)
byUser?: string | null; // steamId des Klickers
at?: string | null; // ISO-Zeitpunkt
};
export type MapVetoState = {
matchId: string;
bestOf: 3|5;
mapPool: string[]; // z.B. ['de_inferno','de_mirage',...]
steps: MapVetoStep[]; // komplette Reihenfolge inkl. 'decider'
currentIndex: number; // nächste auszuführende Stufe
opensAt: string; // ISO: matchDate - 60min
locked: boolean; // fertig / gesperrt
};

View File

@ -6,21 +6,30 @@ export type Match = {
/* Basis-Infos ---------------------------------------------------- */
id : string
title : string
demoDate : string // ⇐ Backend kommt als ISO-String
demoDate : string
description?: string
map : string
matchType : 'premier' | 'competitive' | 'community' | string
roundCount : number
// ⬇️ neu/optional, damit Alt-Daten weiter kompilen
bestOf? : 1 | 3 | 5
matchDate? : string
/* Ergebnis ------------------------------------------------------- */
scoreA? : number | null
scoreB? : number | null
/** CT | T | Draw | null null, solange noch kein Ergebnis vorliegt */
winnerTeam? : 'CT' | 'T' | 'Draw' | null
/* Teams ---------------------------------------------------------- */
teamA: TeamMatches
teamB: TeamMatches
mapVote?: {
status: 'not_started' | 'in_progress' | 'completed' | null
opensAt: string | null
isOpen: boolean | null
} | null
}
/* --------------------------------------------------------------- */

File diff suppressed because one or more lines are too long

View File

@ -180,6 +180,8 @@ exports.Prisma.MatchScalarFieldEnum = {
roundCount: 'roundCount',
roundHistory: 'roundHistory',
winnerTeam: 'winnerTeam',
bestOf: 'bestOf',
matchDate: 'matchDate',
createdAt: 'createdAt',
updatedAt: 'updatedAt'
};
@ -274,6 +276,29 @@ exports.Prisma.ServerRequestScalarFieldEnum = {
createdAt: 'createdAt'
};
exports.Prisma.MapVetoScalarFieldEnum = {
id: 'id',
matchId: 'matchId',
bestOf: 'bestOf',
mapPool: 'mapPool',
currentIdx: 'currentIdx',
locked: 'locked',
opensAt: 'opensAt',
createdAt: 'createdAt',
updatedAt: 'updatedAt'
};
exports.Prisma.MapVetoStepScalarFieldEnum = {
id: 'id',
vetoId: 'vetoId',
order: 'order',
action: 'action',
teamId: 'teamId',
map: 'map',
chosenAt: 'chosenAt',
chosenBy: 'chosenBy'
};
exports.Prisma.SortOrder = {
asc: 'asc',
desc: 'desc'
@ -307,6 +332,12 @@ exports.ScheduleStatus = exports.$Enums.ScheduleStatus = {
COMPLETED: 'COMPLETED'
};
exports.MapVetoAction = exports.$Enums.MapVetoAction = {
BAN: 'BAN',
PICK: 'PICK',
DECIDER: 'DECIDER'
};
exports.Prisma.ModelName = {
User: 'User',
Team: 'Team',
@ -318,7 +349,9 @@ exports.Prisma.ModelName = {
RankHistory: 'RankHistory',
Schedule: 'Schedule',
DemoFile: 'DemoFile',
ServerRequest: 'ServerRequest'
ServerRequest: 'ServerRequest',
MapVeto: 'MapVeto',
MapVetoStep: 'MapVetoStep'
};
/**

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

View File

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

View File

@ -43,6 +43,8 @@ model User {
createdSchedules Schedule[] @relation("CreatedSchedules")
confirmedSchedules Schedule[] @relation("ConfirmedSchedules")
mapVetoChoices MapVoteStep[] @relation("VetoStepChooser")
}
model Team {
@ -65,6 +67,8 @@ model Team {
schedulesAsTeamA Schedule[] @relation("ScheduleTeamA")
schedulesAsTeamB Schedule[] @relation("ScheduleTeamB")
mapVetoSteps MapVoteStep[] @relation("VetoStepTeam")
}
model TeamInvite {
@ -98,6 +102,10 @@ model Notification {
// ──────────────────────────────────────────────
//
// ──────────────────────────────────────────────
// 🎮 Matches
// ──────────────────────────────────────────────
model Match {
id String @id @default(uuid())
title String
@ -128,6 +136,10 @@ model Match {
roundHistory Json?
winnerTeam String?
bestOf Int @default(3) // 1 | 3 | 5 app-seitig validieren
matchDate DateTime? // geplante Startzeit (separat von demoDate)
mapVeto MapVote? // 1:1 Map-Vote-Status
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@ -280,3 +292,56 @@ model ServerRequest {
@@unique([steamId, matchId])
}
// ──────────────────────────────────────────────
// 🗺️ Map-Vote
// ──────────────────────────────────────────────
enum MapVoteAction {
BAN
PICK
DECIDER
}
model MapVote {
id String @id @default(uuid())
matchId String @unique
match Match @relation(fields: [matchId], references: [id])
// Basiszustand
bestOf Int @default(3)
mapPool String[] // z.B. ["de_inferno","de_mirage",...]
currentIdx Int @default(0)
locked Boolean @default(false)
// Optional: serverseitig speichern, statt im UI zu berechnen
opensAt DateTime?
steps MapVoteStep[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model MapVoteStep {
id String @id @default(uuid())
vetoId String
order Int
action MapVoteAction
// Team, das am Zug ist (kann bei DECIDER null sein)
teamId String?
team Team? @relation("VetoStepTeam", fields: [teamId], references: [id])
// Ergebnis & wer gewählt hat
map String?
chosenAt DateTime?
chosenBy String?
chooser User? @relation("VetoStepChooser", fields: [chosenBy], references: [steamId])
veto MapVote @relation(fields: [vetoId], references: [id])
@@unique([vetoId, order])
@@index([teamId])
@@index([chosenBy])
}

View File

@ -180,6 +180,8 @@ exports.Prisma.MatchScalarFieldEnum = {
roundCount: 'roundCount',
roundHistory: 'roundHistory',
winnerTeam: 'winnerTeam',
bestOf: 'bestOf',
matchDate: 'matchDate',
createdAt: 'createdAt',
updatedAt: 'updatedAt'
};
@ -274,6 +276,29 @@ exports.Prisma.ServerRequestScalarFieldEnum = {
createdAt: 'createdAt'
};
exports.Prisma.MapVetoScalarFieldEnum = {
id: 'id',
matchId: 'matchId',
bestOf: 'bestOf',
mapPool: 'mapPool',
currentIdx: 'currentIdx',
locked: 'locked',
opensAt: 'opensAt',
createdAt: 'createdAt',
updatedAt: 'updatedAt'
};
exports.Prisma.MapVetoStepScalarFieldEnum = {
id: 'id',
vetoId: 'vetoId',
order: 'order',
action: 'action',
teamId: 'teamId',
map: 'map',
chosenAt: 'chosenAt',
chosenBy: 'chosenBy'
};
exports.Prisma.SortOrder = {
asc: 'asc',
desc: 'desc'
@ -307,6 +332,12 @@ exports.ScheduleStatus = exports.$Enums.ScheduleStatus = {
COMPLETED: 'COMPLETED'
};
exports.MapVetoAction = exports.$Enums.MapVetoAction = {
BAN: 'BAN',
PICK: 'PICK',
DECIDER: 'DECIDER'
};
exports.Prisma.ModelName = {
User: 'User',
Team: 'Team',
@ -318,7 +349,9 @@ exports.Prisma.ModelName = {
RankHistory: 'RankHistory',
Schedule: 'Schedule',
DemoFile: 'DemoFile',
ServerRequest: 'ServerRequest'
ServerRequest: 'ServerRequest',
MapVeto: 'MapVeto',
MapVetoStep: 'MapVetoStep'
};
/**