updated for build

This commit is contained in:
Linrador 2025-10-15 20:20:34 +02:00
parent 19bf9f7c9e
commit 86e9b53b78
30 changed files with 1056 additions and 640 deletions

View File

@ -144,33 +144,45 @@ export default function InvitePlayersModal({ show, onClose, onSuccess, team, dir
async function fetchUsers(opts: { resetLayout: boolean }) {
try {
// evtl. laufende Anfrage abbrechen
abortRef.current?.abort();
const ctrl = new AbortController();
abortRef.current = ctrl;
// aktuelle Grid-Höhe merken (Anti-Jump)
if (gridRef.current) setGridHoldHeight(gridRef.current.clientHeight);
setIsFetching(true);
// Spinner: verzögert einblenden + Mindestdauer
if (spinnerShowTimer.current) window.clearTimeout(spinnerShowTimer.current);
spinnerShowTimer.current = window.setTimeout(() => {
setSpinnerVisible(true);
spinnerShownAt.current = Date.now();
}, SPINNER_DELAY_MS);
const qs = new URLSearchParams({ teamId: team.id });
// 🔁 NEU: teamId im Pfad, nicht als Query-Param
const qs = new URLSearchParams();
if (onlyFree) qs.set('onlyFree', 'true');
const res = await fetch(`/api/team/available-users?${qs.toString()}`, {
const url = `/api/team/${encodeURIComponent(team.id)}/available-users${
qs.toString() ? `?${qs.toString()}` : ''
}`;
const res = await fetch(url, {
signal: ctrl.signal,
cache: 'no-store',
});
if (!res.ok) throw new Error('load failed');
const dataUnknown: unknown = await res.json();
const users = (isRecord(dataUnknown) && Array.isArray(dataUnknown.users))
? (dataUnknown.users as Player[])
: [];
let users: Player[] = [];
if (isRecord(dataUnknown)) {
const maybeUsers = (dataUnknown as UnknownRec).users;
if (Array.isArray(maybeUsers)) {
users = maybeUsers as Player[];
}
}
startTransition(() => {
setAllUsers(users);
@ -186,41 +198,43 @@ export default function InvitePlayersModal({ show, onClose, onSuccess, team, dir
}
});
} catch (e: unknown) {
if (isRecord(e) && e.name === 'AbortError') return;
console.error('Fehler beim Laden der Benutzer:', e);
} finally {
setIsFetching(false)
abortRef.current = null
// 🔽 Spinner Mindestdauer respektieren
const hide = () => {
setSpinnerVisible(false)
spinnerShownAt.current = null
setGridHoldHeight(0)
// sauberer Abort-Check ohne "any"
if (
(e instanceof DOMException && e.name === 'AbortError') ||
(typeof (e as { name?: unknown })?.name === 'string' && (e as { name: string }).name === 'AbortError')
) {
return;
}
} finally {
setIsFetching(false);
abortRef.current = null;
// Spinner-Mindestdauer respektieren & aufräumen
const hide = () => {
setSpinnerVisible(false);
spinnerShownAt.current = null;
setGridHoldHeight(0);
};
if (spinnerShowTimer.current) {
// Wenn der Delay-Timer noch nicht gefeuert hat: einfach abbrechen
window.clearTimeout(spinnerShowTimer.current)
spinnerShowTimer.current = null
// Der Spinner wurde evtl. nie sichtbar -> direkt aufräumen
window.clearTimeout(spinnerShowTimer.current);
spinnerShowTimer.current = null;
if (!spinnerShownAt.current) {
hide()
return
hide();
return;
}
}
if (spinnerShownAt.current) {
const elapsed = Date.now() - spinnerShownAt.current
const remain = Math.max(0, SPINNER_MIN_MS - elapsed)
const elapsed = Date.now() - spinnerShownAt.current;
const remain = Math.max(0, SPINNER_MIN_MS - elapsed);
if (remain > 0) {
window.setTimeout(hide, remain)
window.setTimeout(hide, remain);
} else {
hide()
hide();
}
} else {
// Falls er nie sichtbar war
hide()
hide();
}
}
}
@ -246,7 +260,9 @@ export default function InvitePlayersModal({ show, onClose, onSuccess, team, dir
try {
setIsInviting(true);
const url = directAdd ? '/api/team/add-players' : '/api/team/invite';
const url = directAdd
? `/api/team/${encodeURIComponent(team.id)}/add-players`
: `/api/team/${encodeURIComponent(team.id)}/invite`
const body = directAdd
? { teamId: team.id, steamIds: ids }
: { teamId: team.id, userIds: ids, invitedBy: steamId };

View File

@ -1,6 +1,6 @@
'use client'
import { useState, useEffect } from 'react'
import { useState, useEffect, useMemo } from 'react'
import Modal from './Modal'
import MiniCard from './MiniCard'
import { useSession } from 'next-auth/react'
@ -21,25 +21,59 @@ export default function LeaveTeamModal({ show, onClose, onSuccess, team }: Props
const [newLeaderId, setNewLeaderId] = useState<string>('')
const [, setIsSubmitting] = useState(false)
// Leader-IDs anderer Teams laden (aktuelles Team per ?exclude= ausnehmen)
const [leaderIds, setLeaderIds] = useState<Set<string>>(new Set())
useEffect(() => {
if (show && team.leader?.steamId) {
// ⬅︎ Player -> steamId
setNewLeaderId(team.leader.steamId)
}
}, [show, team.leader?.steamId])
if (!show) return
let alive = true
;(async () => {
try {
const res = await fetch(`/api/team/leader-ids?exclude=${encodeURIComponent(team.id)}`, {
cache: 'no-store',
credentials: 'same-origin',
})
if (!res.ok) throw new Error('leader-ids failed')
const j = await res.json().catch(() => ({}))
const ids: string[] = Array.isArray(j.leaderIds) ? j.leaderIds : []
if (alive) setLeaderIds(new Set(ids))
} catch (e) {
console.warn('[LeaveTeamModal] leader-ids:', e)
if (alive) setLeaderIds(new Set())
}
})()
return () => { alive = false }
}, [show, team.id])
// Kandidaten: alle Mitglieder außer ich + nicht bereits Leader woanders
const candidates: Player[] = useMemo(() => {
const all = [...(team.activePlayers ?? []), ...(team.inactivePlayers ?? [])]
return all.filter(p => p.steamId !== steamId && !leaderIds.has(p.steamId))
}, [team.activePlayers, team.inactivePlayers, steamId, leaderIds])
// Beim Öffnen ersten gültigen Kandidaten vorwählen
useEffect(() => {
if (!show) return
setNewLeaderId(candidates[0]?.steamId ?? '')
}, [show, candidates])
const handleLeave = async () => {
if (!steamId) return
setIsSubmitting(true)
try {
const iAmLeader = team.leader?.steamId === steamId // ⬅︎ Player vergleichen über steamId
const success = await leaveTeam(steamId, iAmLeader ? newLeaderId : undefined)
if (success) {
const iAmLeader = team.leader?.steamId === steamId
if (iAmLeader && !newLeaderId) {
alert('Bitte wähle eine:n neue:n Teamleader:in aus.')
return
}
// WICHTIG: team.id mitgeben
const ok = await leaveTeam({ steamId, teamId: team.id, newLeaderId: iAmLeader ? newLeaderId : undefined })
if (ok) {
onSuccess()
onClose()
}
} catch (err) {
console.error('Fehler beim Verlassen:', err)
alert('Aktion fehlgeschlagen.')
} finally {
setIsSubmitting(false)
}
@ -54,18 +88,25 @@ export default function LeaveTeamModal({ show, onClose, onSuccess, team }: Props
onSave={handleLeave}
closeButtonColor="red"
closeButtonTitle="Team verlassen"
disableSave={(team.leader?.steamId === steamId) && candidates.length === 0}
>
{team.leader?.steamId === steamId ? (
<p className="text-sm text-gray-700 dark:text-neutral-300">
Du bist der Teamleader. Bitte wähle ein anderes Mitglied aus, das die Rolle des Leaders übernehmen soll:
</p>
) : (
<p className="text-sm text-gray-700 dark:text-neutral-300">
Willst du das Team wirklich verlassen?
</p>
)}
<div className="grid grid-cols-2 sm:grid-cols-3 gap-4 mt-4">
{[
...(team.activePlayers ?? []),
...(team.inactivePlayers ?? []),
]
.filter((player) => player.steamId !== steamId)
.map((player: Player) => (
{candidates.length === 0 ? (
<div className="mt-4 text-sm text-amber-600 dark:text-amber-400">
Es gibt aktuell kein Mitglied, das nicht bereits Leader eines anderen Teams ist.
</div>
) : (
<div className="grid grid-cols-2 sm:grid-cols-3 gap-4 mt-4">
{candidates.map((player: Player) => (
<MiniCard
key={player.steamId}
steamId={player.steamId}
@ -82,7 +123,8 @@ export default function LeaveTeamModal({ show, onClose, onSuccess, team }: Props
hideActions={true}
/>
))}
</div>
</div>
)}
</Modal>
)
}

View File

@ -17,6 +17,7 @@ type Notification = {
actionType?: string
actionData?: string
createdAt?: string
teamId?: string
}
type ActionData =
@ -80,14 +81,22 @@ export default function NotificationBell() {
const res = await fetch('/api/notifications')
if (!res.ok) throw new Error('Fehler beim Laden')
const data: NotificationsResponse = await res.json()
const loaded: Notification[] = data.notifications.map((n: ApiNotification) => ({
id: n.id,
text: n.message,
read: n.read,
actionType: n.actionType,
actionData: n.actionData,
createdAt: n.createdAt,
}))
const loaded: Notification[] = data.notifications.map((n: ApiNotification) => {
let teamId: string | undefined
try {
const a = n.actionData ? JSON.parse(n.actionData) : null
if (a && typeof a === 'object' && typeof a.teamId === 'string') teamId = a.teamId
} catch {}
return {
id: n.id,
text: n.message,
read: n.read,
actionType: n.actionType,
actionData: n.actionData,
createdAt: n.createdAt,
teamId,
}
})
setNotifications(loaded)
} catch (err) {
console.error('[NotificationBell] Fehler beim Laden:', err)
@ -115,6 +124,7 @@ export default function NotificationBell() {
actionType: data?.actionType,
actionData: data?.actionData,
createdAt: data?.createdAt ?? new Date().toISOString(),
teamId: data?.teamId,
};
setNotifications(prev => [newNotification, ...prev]);
@ -171,7 +181,7 @@ export default function NotificationBell() {
let kind: ActionData['kind'] | undefined
let invitationId: string | undefined
let requestId: string | undefined
let teamId: string | undefined
let teamId: string | undefined = n.teamId
try {
const data = JSON.parse(n.actionData) as ActionData | string
@ -180,7 +190,7 @@ export default function NotificationBell() {
kind = data.kind
if (data.kind === 'invite') invitationId = data.inviteId
if (data.kind === 'join-request') requestId = data.requestId
teamId = data.teamId
teamId = teamId ?? data.teamId
} else if (typeof data === 'string') {
// nackte ID: sowohl als invitationId als auch requestId nutzbar
invitationId = data
@ -205,6 +215,10 @@ export default function NotificationBell() {
console.warn('[NotificationBell] requestId fehlt')
return
}
if (kind === 'join-request' && !teamId) {
console.warn('[NotificationBell] teamId fehlt')
return
}
// Optimistic Update (Buttons ausblenden)
const snapshot = notifications
@ -220,9 +234,9 @@ export default function NotificationBell() {
if (kind === 'join-request') {
if (action === 'accept') {
await apiJSON('/api/team/request-join/accept', { requestId, teamId })
await apiJSON(`/api/team/${encodeURIComponent(teamId!)}/request-join/accept`, { requestId })
} else {
await apiJSON('/api/team/request-join/reject', { requestId })
await apiJSON(`/api/team/${encodeURIComponent(teamId!)}/request-join/reject`, { requestId })
}
setNotifications(prev => prev.filter(x => x.id !== n.id))
if (action === 'accept') router.refresh()

View File

@ -137,10 +137,10 @@ export default function TeamCard({
onUpdateInvitation(team.id, null)
} else {
if (isInviteOnly) return
await fetch('/api/team/request-join', {
await fetch(`/api/team/${encodeURIComponent(team.id)}/request-join`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ teamId: team.id }),
body: JSON.stringify({}),
})
onUpdateInvitation(team.id, 'pending')
}

View File

@ -26,6 +26,7 @@ import TeamPremierRankBadge from './TeamPremierRankBadge'
import Link from 'next/link'
import { useTeamStore } from '@/lib/stores'
import { useSSEStore } from '@/lib/useSSEStore'
import TransferLeaderModal from './TransferLeaderModal'
import {
TEAM_EVENTS,
SELF_EVENTS,
@ -334,11 +335,10 @@ function TeamMemberViewBody({
const updateTeamMembers = async (tId: string, active: Player[], inactive: Player[]) => {
try {
const res = await fetch('/api/team/update-players', {
const res = await fetch(`/api/team/${tId}/update-players`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
teamId: tId,
activePlayers: active.map(p => p.steamId),
inactivePlayers: inactive.map(p => p.steamId),
}),
@ -451,33 +451,15 @@ function TeamMemberViewBody({
setActivePlayers(newActive)
setInactivePlayers(newInactive)
await fetch('/api/team/kick', {
await fetch(`/api/team/${teamId}/kick`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ steamId: kickCandidate.steamId, teamId }),
body: JSON.stringify({ steamId: kickCandidate.steamId }),
})
await updateTeamMembers(team.id, newActive, newInactive)
setKickCandidate(null)
}
const promoteToLeader = async (newLeaderId: string) => {
try {
const res = await fetch('/api/team/transfer-leader', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ teamId, newLeaderSteamId: newLeaderId }),
})
if (!res.ok) {
const data: unknown = await res.json().catch(() => ({}))
console.error('Fehler bei Leader-Übertragung:', (data as { message?: string }).message)
return
}
await handleReload()
} catch (err) {
console.error('Fehler bei Leader-Übertragung:', err)
}
}
type DownscaleOpts = {
size?: number
quality?: number
@ -490,12 +472,12 @@ function TeamMemberViewBody({
try {
setSavingPolicy(true)
const res = await fetch('/api/team/update-join-policy', {
const res = await fetch(`/api/team/${teamId}/update-join-policy`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'same-origin',
cache: 'no-store',
body: JSON.stringify({ teamId, joinPolicy: next }),
body: JSON.stringify({ joinPolicy: next }),
})
if (!res.ok) {
@ -641,10 +623,9 @@ function TeamMemberViewBody({
return new Promise<void>((resolve, reject) => {
const formData = new FormData()
formData.append('logo', file)
formData.append('teamId', teamId)
const xhr = new XMLHttpRequest()
xhr.open('POST', '/api/team/upload-logo')
xhr.open('POST', `/api/team/${teamId}/upload-logo`)
xhr.upload.onprogress = (e) => {
if (e.lengthComputable) setUploadPct(Math.round((e.loaded / e.total) * 100))
else setUploadPct(p => (p < 90 ? p + 1 : p))
@ -952,7 +933,7 @@ function TeamMemberViewBody({
onClick={async () => {
if (isLeader) setShowLeaveModal(true)
else {
try { await leaveTeam(currentUserSteamId) }
try { await leaveTeam({ steamId: currentUserSteamId, teamId: team.id }) }
catch (err) { console.error('Fehler beim Verlassen:', err) }
}
}}
@ -1096,39 +1077,17 @@ function TeamMemberViewBody({
/>
)}
{canManage && promoteCandidate && (
<Modal
id={`modal-promote-player-${promoteCandidate.steamId}`}
title="Leader übertragen"
show={true}
{canManage && (
<TransferLeaderModal
show={!!promoteCandidate}
team={team}
preselectId={promoteCandidate?.steamId}
onClose={() => setPromoteCandidate(null)}
onSave={async () => {
await promoteToLeader(promoteCandidate.steamId)
onSuccess={async () => {
await handleReload()
setPromoteCandidate(null)
}}
closeButtonTitle="Übertragen"
closeButtonColor="blue"
>
<div className="flex justify-center mb-4">
<MiniCard
steamId={promoteCandidate.steamId}
title={promoteCandidate.name}
avatar={promoteCandidate.avatar}
location={promoteCandidate.location}
selected={false}
onSelect={() => {}}
draggable={false}
rank={promoteCandidate.premierRank}
currentUserSteamId={currentUserSteamId}
teamLeaderSteamId={team.leader?.steamId}
hideActions
isSelectable={false}
/>
</div>
<p className="text-sm text-gray-700 dark:text-neutral-300">
Möchtest du <strong>{promoteCandidate.name}</strong> wirklich zum Team-Leader machen?
</p>
</Modal>
/>
)}
{canManage && kickCandidate && (
@ -1170,13 +1129,12 @@ function TeamMemberViewBody({
show={showDeleteModal}
onClose={() => setShowDeleteModal(false)}
onSave={async () => {
await fetch('/api/team/delete', {
await fetch(`/api/team/${team.id}/delete`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ teamId: team.id }),
})
setShowDeleteModal(false)
window.location.href = '/team'
window.location.href = '/teams'
}}
closeButtonTitle="Team löschen"
closeButtonColor="red"

View File

@ -65,6 +65,7 @@ export default function TeamPlayerCard({
alt={name}
size={48}
showStatus
isLeader={isLeader}
/>
<div className="min-w-0 flex-1">

View File

@ -0,0 +1,126 @@
'use client'
import { useEffect, useMemo, useState } from 'react'
import Modal from './Modal'
import MiniCard from './MiniCard'
import type { Team, Player } from '@/types/team'
import { transferLeader } from '@/lib/sse-actions'
/** Holt Leader-IDs anderer Teams; optional aktuelles Team via ?exclude= ausnehmen */
async function getOtherTeamLeaderIds(excludeTeamId?: string): Promise<Set<string>> {
try {
const qs = excludeTeamId ? `?exclude=${encodeURIComponent(excludeTeamId)}` : ''
const res = await fetch(`/api/team/leader-ids${qs}`, {
cache: 'no-store',
credentials: 'same-origin',
})
if (!res.ok) return new Set()
const data: unknown = await res.json().catch(() => ({}))
let list: string[] = []
if (typeof data === 'object' && data !== null) {
const v = (data as Record<string, unknown>).leaderIds
if (Array.isArray(v) && v.every(x => typeof x === 'string')) {
list = v
}
}
return new Set(list.filter(Boolean))
} catch {
return new Set()
}
}
type Props = {
show: boolean
team: Team
preselectId?: string
onClose: () => void
onSuccess: () => void
}
export default function TransferLeaderModal({ show, team, preselectId, onClose, onSuccess }: Props) {
const [busy, setBusy] = useState(false)
const [otherLeaderIds, setOtherLeaderIds] = useState<Set<string>>(new Set())
const [selected, setSelected] = useState<string>('')
// andere Leader laden
useEffect(() => {
if (!show) return
let alive = true
;(async () => {
const s = await getOtherTeamLeaderIds(team.id)
if (alive) setOtherLeaderIds(s)
})()
return () => { alive = false }
}, [show, team.id])
const candidates: Player[] = useMemo(() => {
const list = [...(team.activePlayers ?? []), ...(team.inactivePlayers ?? [])]
return list
.filter(p => p.steamId !== team.leader?.steamId) // nicht der aktuelle Leader
.filter(p => !otherLeaderIds.has(p.steamId)) // nicht schon woanders Leader
.sort((a,b) => a.name.localeCompare(b.name))
}, [team.activePlayers, team.inactivePlayers, team.leader?.steamId, otherLeaderIds])
// Vorauswahl
useEffect(() => {
if (!show) return
const prefer = preselectId && candidates.some(c => c.steamId === preselectId)
? preselectId
: candidates[0]?.steamId ?? ''
setSelected(prefer)
}, [show, preselectId, candidates])
const onSave = async () => {
if (!selected) return
setBusy(true)
try {
const ok = await transferLeader(team.id, selected)
if (!ok) throw new Error('Übertragen fehlgeschlagen')
onSuccess()
onClose()
} catch (e) {
alert((e as Error).message || 'Übertragen fehlgeschlagen')
} finally {
setBusy(false)
}
}
return (
<Modal
id="transfer-leader-modal"
title="Leader übertragen"
show={show}
onClose={onClose}
onSave={onSave}
closeButtonTitle={busy ? 'Speichern…' : 'Übertragen'}
closeButtonColor="blue"
>
{candidates.length === 0 ? (
<p className="text-sm text-amber-600 dark:text-amber-400">
Es gibt aktuell kein Mitglied, das nicht bereits Leader eines anderen Teams ist.
</p>
) : (
<div className="grid grid-cols-2 sm:grid-cols-3 gap-4 mt-2">
{candidates.map(p => (
<MiniCard
key={p.steamId}
steamId={p.steamId}
title={p.name}
avatar={p.avatar}
location={p.location}
selected={selected === p.steamId}
onSelect={() => setSelected(p.steamId)}
draggable={false}
rank={p.premierRank}
currentUserSteamId={team.leader?.steamId ?? ''}
teamLeaderSteamId={team.leader?.steamId}
hideActions
/>
))}
</div>
)}
</Modal>
)
}

View File

@ -3,24 +3,40 @@ import { headers } from 'next/headers'
import { notFound } from 'next/navigation'
import { MatchProvider } from './MatchContext'
import type { Match } from '../../../../types/match'
import { Agent } from 'undici'
export const dynamic = 'force-dynamic'
export const revalidate = 0
async function loadMatch(matchId: string): Promise<Match | null> {
async function buildOrigin(): Promise<string> {
const h = await headers()
const proto = (h.get('x-forwarded-proto') ?? 'http').split(',')[0].trim()
const host = (h.get('x-forwarded-host') ?? h.get('host') ?? '').split(',')[0].trim()
const base = host ? `${proto}://${host}` : (process.env.NEXTAUTH_URL ?? 'http://localhost:3000')
if (host) return `${proto}://${host}`
const insecure = new Agent({ connect: { rejectUnauthorized: false } })
const init: RequestInit & { dispatcher?: Agent } = { cache: 'no-store' }
if (base.startsWith('https://') && process.env.NODE_ENV !== 'production') {
init.dispatcher = insecure
// Fallbacks (lokale Entwicklung / SSR-Tools)
return (
process.env.NEXT_PUBLIC_SITE_URL ||
process.env.NEXTAUTH_URL ||
'http://localhost:3000'
)
}
async function loadMatch(matchId: string): Promise<Match | null> {
const origin = await buildOrigin()
// ⚠️ Dev-Only: Selbstsignierte Zertifikate erlauben
const allowInsecure =
process.env.NODE_ENV !== 'production' ||
process.env.ALLOW_INSECURE_FETCH === '1'
if (origin.startsWith('https://') && allowInsecure) {
// global für den Node-Prozess bitte NICHT in Production setzen
process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0'
}
const res = await fetch(`${base}/api/matches/${matchId}`, init)
const res = await fetch(`${origin}/api/matches/${encodeURIComponent(matchId)}`, {
cache: 'no-store',
})
if (!res.ok) return null
return res.json()
}

View File

@ -26,9 +26,20 @@ function uniqBySteamId<T extends { steamId: string }>(list: T[]): T[] {
}
return out
}
// Sichere Fehlermeldung aus unknown extrahieren (vermeidet "any")
function getErrorMessage(data: unknown, fallback: string): string {
if (typeof data === 'object' && data !== null && 'message' in data) {
const m = (data as Record<string, unknown>).message
if (typeof m === 'string') return m
}
return fallback
}
function byName<T extends { name: string }>(a: T, b: T) {
return a.name.localeCompare(b.name, 'de', { sensitivity: 'base' })
}
function classNames(...xs: Array<string | false | null | undefined>) {
return xs.filter(Boolean).join(' ')
}
@ -37,7 +48,9 @@ type TeamDetailClientProps = {
teamId: string
/** Darf fehlen wird dann via /api/user ermittelt */
currentUserSteamId?: string | null
/** Einladung des Users für dieses Team: string = echte Einladung, 'pending' = offene Anfrage, null = nichts */
invitationId?: string | 'pending' | null
/** Falls false, kann der User generell keine Join-Requests stellen (z. B. schon in einem Team) */
canRequestJoin?: boolean
}
@ -100,6 +113,41 @@ export default function TeamDetailClient({
return () => ac.abort()
}, [teamId, router])
// ⬇️ Pending/Invite-Status nach Page-Reload wiederherstellen
useEffect(() => {
if (!team?.id) return
let cancelled = false
;(async () => {
try {
const res = await fetch('/api/user/invitations', {
cache: 'no-store',
credentials: 'same-origin',
})
if (!res.ok) return
const data = (await res.json()) as {
invitations: Array<{ id: string; teamId: string; type: 'team-invite' | 'team-join-request' }>
}
const match = data.invitations.find(inv => inv.teamId === team.id)
if (cancelled) return
if (!match) {
setInvitationId(null)
} else if (match.type === 'team-invite') {
// echte Einladung → Button zeigt "Einladung ablehnen"
setInvitationId(match.id)
} else if (match.type === 'team-join-request') {
// eigene Beitrittsanfrage → Button zeigt "Angefragt (zurückziehen)"
setInvitationId('pending')
}
} catch {
/* ignore */
}
})()
return () => { cancelled = true }
}, [team?.id])
/* ---------- Ableitungen ---------- */
// Counts (ohne Eingeladene)
@ -154,6 +202,7 @@ export default function TeamDetailClient({
const isInviteOnly = team?.joinPolicy === 'INVITE_ONLY'
const hasRealInvitation = Boolean(invitationId && invitationId !== 'pending')
const hasPendingRequest = invitationId === 'pending'
const isRequested = hasRealInvitation || hasPendingRequest
/* ---------- Aktionen ---------- */
@ -165,7 +214,8 @@ export default function TeamDetailClient({
setJoining(true)
try {
await leaveTeam(mySteamId)
// team.id mitgeben damit findet die Route garantiert das richtige Team
await leaveTeam({ steamId: mySteamId, teamId: team.id })
const updated = await reloadTeam(team.id)
if (updated) setTeam(updated)
} catch (err) {
@ -176,7 +226,7 @@ export default function TeamDetailClient({
}
}
// Falls du später wieder Join zeigen willst: Handler bleibt nutzbar
// Join / Einladung / Zurückziehen (wie TeamCard)
const handleJoinBranches = async () => {
if (!team || joining) return
setJoining(true)
@ -185,6 +235,7 @@ export default function TeamDetailClient({
await fetch('/api/user/invitations/reject', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'same-origin',
body: JSON.stringify({ invitationId }),
})
setInvitationId(null)
@ -192,17 +243,24 @@ export default function TeamDetailClient({
await fetch('/api/user/invitations/revoke', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'same-origin',
body: JSON.stringify({ teamId: team.id, type: 'team-join-request' }),
})
setInvitationId(null)
} else {
if (isInviteOnly) return
await fetch('/api/team/request-join', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ teamId: team.id }),
})
setInvitationId('pending')
if (!isInviteOnly) {
const res = await fetch(`/api/team/${encodeURIComponent(team.id)}/request-join`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'same-origin',
body: JSON.stringify({}),
})
if (!res.ok) {
const data: unknown = await res.json().catch(() => ({}))
throw new Error(getErrorMessage(data, 'Anfrage fehlgeschlagen'))
}
setInvitationId('pending')
}
}
const updated = await reloadTeam(team.id)
if (updated) setTeam(updated)
@ -234,7 +292,30 @@ export default function TeamDetailClient({
}
if (!team) return null
const goToProfile = (steamId: string) => router.push(`/profile/${steamId}`)
// ====== Join-Button-Zustände (analog TeamCard) ======
const isJoinDisabled =
joining ||
isMemberOfThisTeam ||
!canRequestJoin ||
(isInviteOnly && !hasRealInvitation && !hasPendingRequest)
const joinButtonLabel = joining ? (
<>
<span className="animate-spin inline-block size-4 border-[3px] border-current border-t-transparent rounded-full mr-1" />
Lädt
</>
) : hasRealInvitation ? (
'Einladung ablehnen'
) : hasPendingRequest ? (
'Angefragt (zurückziehen)'
) : isInviteOnly ? (
'Nur Einladungen'
) : (
'Beitritt anfragen'
)
const joinButtonColor: 'blue' | 'red' | 'gray' =
hasRealInvitation ? 'red' : isJoinDisabled ? 'gray' : isRequested ? 'gray' : 'blue'
return (
<Card maxWidth="auto">
@ -260,9 +341,9 @@ export default function TeamDetailClient({
</p>
</div>
{/* Button nur anzeigen, wenn Mitglied */}
{isMemberOfThisTeam && (
<div className="ml-auto">
{/* Rechts: Action-Button */}
<div className="ml-auto">
{isMemberOfThisTeam ? (
<Button
size="sm"
color="red"
@ -279,8 +360,19 @@ export default function TeamDetailClient({
'Team verlassen'
)}
</Button>
</div>
)}
) : (
<Button
size="sm"
color={joinButtonColor}
disabled={isJoinDisabled}
onClick={(e) => { e.preventDefault(); handleJoinBranches() }}
title={typeof joinButtonLabel === 'string' ? joinButtonLabel : undefined}
aria-disabled={isJoinDisabled ? 'true' : undefined}
>
{joinButtonLabel}
</Button>
)}
</div>
</div>
{/* Toolbar */}
@ -304,17 +396,17 @@ export default function TeamDetailClient({
{/* Segment */}
<div className="inline-flex overflow-hidden rounded-lg border border-gray-200 dark:border-neutral-700">
{[
{([
{ key: 'all', label: `Alle (${counts.all})` },
{ key: 'active', label: `Aktiv (${counts.active})` },
{ key: 'inactive', label: `Inaktiv (${counts.inactive})` },
].map((opt) => (
] as const).map((opt) => (
<button
key={opt.key}
onClick={() => setSeg(opt.key as 'all' | 'active' | 'inactive')}
onClick={() => setSeg(opt.key)}
className={classNames(
'px-3 py-1.5 text-sm transition',
seg === (opt.key as 'all' | 'active' | 'inactive')
seg === opt.key
? 'bg-gray-100 text-gray-900 dark:bg-neutral-700 dark:text-white'
: 'text-gray-600 hover:bg-gray-50 dark:text-neutral-300 dark:hover:bg-neutral-800'
)}
@ -359,7 +451,7 @@ export default function TeamDetailClient({
</div>
)}
{/* Leader-Leave-Modal (wie in TeamMemberView) */}
{/* Leader-Leave-Modal */}
{isLeader && (
<LeaveTeamModal
show={showLeaveModal}

View File

@ -71,17 +71,25 @@ export default function TeamsPage() {
const [tJson, iJson, uJson]: [TeamsJson, InvitesJson, UserJson] = await Promise.all([
tRes.json(),
iRes.json().catch<unknown>(() => ({})),
iRes.ok ? iRes.json().catch<unknown>(() => ({})) : Promise.resolve({} as unknown),
uRes.ok ? uRes.json() : Promise.resolve({} as unknown),
])
const nextTeams = parseTeams(tJson)
const invites = parseInvites(iJson)
// 🔧 WICHTIG: 'team-join-request' → 'pending', 'team-invite' → ID
const nextMap: Record<string, string> = {}
for (const inv of invites) {
if (inv?.type === 'team-join-request' && inv.teamId && inv.id) {
nextMap[inv.teamId] = inv.id
const teamId = inv?.teamId
const type = inv?.type
const id = inv?.id
if (!teamId) continue
if (type === 'team-join-request') {
nextMap[teamId] = 'pending'
} else if (type === 'team-invite' && id) {
nextMap[teamId] = id
}
}

View File

@ -0,0 +1,33 @@
// /src/app/api/team/[teamId]/add-players/route.ts
import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/prisma'
import { sendServerSSEMessage } from '@/lib/sse-server-client'
export async function POST(
req: NextRequest,
ctx: { params: Promise<{ teamId: string }> }
) {
const { teamId } = await ctx.params
const { steamIds }: { steamIds: string[] } = await req.json()
if (!teamId || !Array.isArray(steamIds) || steamIds.length === 0) {
return NextResponse.json({ message: 'Ungültige Parameter' }, { status: 400 })
}
// Spieler anhängen
await prisma.team.update({
where: { id: teamId },
data : { inactivePlayers: { push: steamIds } },
})
// (Optional, aber empfehlenswert): user.teamId für alle setzen
await prisma.user.updateMany({
where: { steamId: { in: steamIds } },
data : { teamId },
})
await sendServerSSEMessage({ type: 'team-member-joined', teamId, users: steamIds })
await sendServerSSEMessage({ type: 'team-updated', teamId })
return NextResponse.json({ ok: true, results: steamIds.map(sid => ({ steamId: sid, ok: true })) })
}

View File

@ -1,20 +1,28 @@
// /src/app/api/team/[teamId]/available-users/route.ts
import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/prisma'
export async function GET(req: NextRequest) {
export const dynamic = 'force-dynamic'
export async function GET(
req: NextRequest,
ctx: { params: Promise<{ teamId: string }> }
) {
try {
const { searchParams } = new URL(req.url)
const teamId = searchParams.get('teamId')
const onlyFree = (searchParams.get('onlyFree') ?? '').toLowerCase() === 'true'
if (!teamId) {
// 🔁 teamId kommt jetzt aus den Route-Parametern
const { teamId } = await ctx.params
const tid = teamId?.trim()
if (!tid) {
return NextResponse.json({ message: 'teamId fehlt' }, { status: 400 })
}
// 1) Team laden (für den Standard-Fall weiterhin nötig)
const team = await prisma.team.findUnique({
where: { id: teamId },
select: { activePlayers: true, inactivePlayers: true }
where: { id: tid },
select: { activePlayers: true, inactivePlayers: true },
})
if (!team) {
return NextResponse.json({ message: 'Team nicht gefunden' }, { status: 404 })
@ -23,9 +31,9 @@ export async function GET(req: NextRequest) {
let excludeIds: string[] = []
if (onlyFree) {
// 2a) Nur Spieler ohne Team => ALLE Teammitglieder (aus allen Teams) ausschließen
// 2a) Nur Spieler ohne Team: alle, die irgendwo Mitglied sind, ausschließen
const allTeams = await prisma.team.findMany({
select: { activePlayers: true, inactivePlayers: true }
select: { activePlayers: true, inactivePlayers: true },
})
const occupied = new Set<string>()
for (const t of allTeams) {
@ -33,40 +41,43 @@ export async function GET(req: NextRequest) {
for (const id of (t.inactivePlayers ?? [])) occupied.add(id)
}
excludeIds = Array.from(occupied)
// Hinweis: Pending-Invites zählen nicht als "hat Team" werden hier NICHT ausgeschlossen.
// Hinweis: Pending-Invites zählen nicht als „hat Team“ werden hier NICHT ausgeschlossen.
} else {
// 2b) Standard: Mitglieder + bereits eingeladene dieses Teams ausschließen
// 2b) Standard: Mitglieder + bereits (für dieses Team) eingeladene ausschließen
const members = new Set<string>([
...(team.activePlayers ?? []),
...(team.inactivePlayers ?? [])
...(team.inactivePlayers ?? []),
])
const pendingInvites = await prisma.teamInvite.findMany({
where: { teamId },
select: { steamId: true }
where: { teamId: tid },
select: { steamId: true },
})
const invited = new Set<string>(pendingInvites.map(i => i.steamId))
excludeIds = Array.from(new Set([...members, ...invited]))
}
// 3) Kandidaten: nur einladbare, nicht in excludeIds
// 3) Kandidaten: einladbare Nutzer, die nicht ausgeschlossen sind
const availableUsers = await prisma.user.findMany({
where: {
canBeInvited: true,
steamId: { notIn: excludeIds }
steamId: { notIn: excludeIds },
},
select: {
steamId: true,
name: true,
avatar: true,
location: true,
premierRank: true
premierRank: true,
},
orderBy: { name: 'asc' }
orderBy: { name: 'asc' },
})
return NextResponse.json({ users: availableUsers })
return NextResponse.json(
{ users: availableUsers },
{ headers: { 'Cache-Control': 'no-store' } }
)
} catch (error) {
console.error('Fehler beim Laden der verfügbaren Benutzer:', error)
return NextResponse.json({ message: 'Fehler beim Laden' }, { status: 500 })

View File

@ -1,4 +1,4 @@
// /src/app/api/team/delete/route.ts
// /src/app/api/team/[teamId]/delete/route.ts
import { NextResponse, type NextRequest } from 'next/server'
import { getServerSession } from 'next-auth'
import { sessionAuthOptions } from '@/lib/auth'
@ -7,7 +7,10 @@ import { sendServerSSEMessage } from '@/lib/sse-server-client'
export const dynamic = 'force-dynamic'
export async function POST(req: NextRequest) {
export async function POST(
req: NextRequest,
ctx: { params: Promise<{ teamId: string }> }
) {
try {
const session = await getServerSession(sessionAuthOptions)
const steamId = session?.user?.steamId
@ -16,7 +19,7 @@ export async function POST(req: NextRequest) {
return NextResponse.json({ error: 'Nicht eingeloggt' }, { status: 401 })
}
const { teamId } = await req.json().catch(() => ({}))
const { teamId } = await ctx.params
if (!teamId) {
return NextResponse.json({ error: 'Team-ID fehlt' }, { status: 400 })
}

View File

@ -1,4 +1,4 @@
// /api/team/invite/route.ts
// /src/app/api/team/[teamId]/invite/route.ts
import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/prisma'
import { sendServerSSEMessage } from '@/lib/sse-server-client'
@ -11,9 +11,13 @@ type ResultReason =
| 'duplicate'
| 'ok'
export async function POST(req: NextRequest) {
export async function POST(
req: NextRequest,
ctx: { params: Promise<{ teamId: string }> }
) {
try {
const { teamId, userIds: rawUserIds, invitedBy } = await req.json()
const { teamId } = await ctx.params
const { userIds: rawUserIds, invitedBy } = await req.json()
if (!teamId || !Array.isArray(rawUserIds) || rawUserIds.length === 0) {
return NextResponse.json({ message: 'Fehlende Daten' }, { status: 400 })
@ -97,11 +101,10 @@ export async function POST(req: NextRequest) {
})
}
// Optional: Team-Update an Leader
await sendServerSSEMessage({
type: 'team-updated',
teamId,
targetUserIds: team.leader?.steamId
targetUserIds: team.leader?.steamId ? [team.leader.steamId] : []
})
const okCount = targetIds.length

View File

@ -1,4 +1,4 @@
// /src/app/api/team/kick/route.ts
// /src/app/api/team/[teamId]/kick/route.ts
import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/prisma'
import { sendServerSSEMessage } from '@/lib/sse-server-client'
@ -6,12 +6,16 @@ import { removePlayerFromMatches } from '@/lib/removePlayerFromMatches'
export const dynamic = 'force-dynamic'
export async function POST(req: NextRequest) {
export async function POST(
req: NextRequest,
ctx: { params: Promise<{ teamId: string }> }
) {
try {
/* 1) Payload prüfen */
const { teamId, steamId } = await req.json()
if (!teamId || !steamId) {
return NextResponse.json({ message: 'Fehlende Daten' }, { status: 400 })
const { teamId } = await ctx.params
const { steamId } = await req.json()
if (!steamId) {
return NextResponse.json({ message: 'Fehlende SteamID' }, { status: 400 })
}
/* 2) Team + User laden */

View File

@ -0,0 +1,242 @@
// /src/app/api/team/[teamId]/leave/route.ts
import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/prisma'
import { removePlayerFromMatches } from '@/lib/removePlayerFromMatches'
import { sendServerSSEMessage } from '@/lib/sse-server-client'
import { PrismaClientKnownRequestError } from '@prisma/client/runtime/library'
export const dynamic = 'force-dynamic'
export async function POST(
req: NextRequest,
ctx: { params: Promise<{ teamId: string }> }
) {
try {
const body = (await req.json().catch(() => ({}))) as { steamId?: string; newLeaderId?: string; teamId?: string }
const steamId = body.steamId?.trim()
const newLeaderId = body.newLeaderId?.trim()
const teamIdFromBody = body.teamId?.trim()
const { teamId } = await ctx.params
const teamIdParam = teamId?.trim()
if (!steamId) {
return NextResponse.json({ message: 'Steam-ID fehlt' }, { status: 400 })
}
// User (für Name + evtl. user.teamId)
const user = await prisma.user.findUnique({
where: { steamId },
select: { name: true, teamId: true },
})
const userName = user?.name ?? 'Ein Spieler'
// 1) Team ermitteln: Body.teamId -> user.teamId -> Mitgliedschaft/Leader
const preferredTeamId = teamIdParam || teamIdFromBody || user?.teamId || null
let team =
preferredTeamId
? await prisma.team.findUnique({
where: { id: preferredTeamId },
select: {
id: true,
name: true,
leaderId: true,
activePlayers: true,
inactivePlayers: true,
},
})
: null
if (!team) {
team = await prisma.team.findFirst({
where: {
OR: [
{ leaderId: steamId },
{ activePlayers: { has: steamId } },
{ inactivePlayers: { has: steamId } },
],
},
select: {
id: true,
name: true,
leaderId: true,
activePlayers: true,
inactivePlayers: true,
},
})
}
if (!team) {
return NextResponse.json(
{ message: 'Kein Team gefunden' },
{ status: 404, headers: { 'Cache-Control': 'no-store' } },
)
}
const teamName = team.name ?? 'Dein Team'
const isLeader = team.leaderId === steamId
// 2) Leader-Sonderfall validieren
if (isLeader) {
if (!newLeaderId) {
return NextResponse.json(
{ message: 'Leader kann das Team nicht ohne Übergabe verlassen' },
{ status: 400 },
)
}
if (newLeaderId === steamId) {
return NextResponse.json(
{ message: 'Neuer Leader darf nicht identisch mit dem aktuellen Leader sein' },
{ status: 400 },
)
}
const isNewLeaderMember =
(team.activePlayers ?? []).includes(newLeaderId) ||
(team.inactivePlayers ?? []).includes(newLeaderId)
if (!isNewLeaderMember) {
return NextResponse.json(
{ message: 'Neuer Leader muss Mitglied des Teams sein' },
{ status: 400 },
)
}
// Prüfen, ob neuer Leader bereits ein anderes Team leitet
const conflict = await prisma.team.findFirst({
where: { leaderId: newLeaderId, NOT: { id: team.id } },
select: { id: true, name: true },
})
if (conflict) {
return NextResponse.json(
{ message: 'Der gewählte Spieler ist bereits Leader eines anderen Teams.' },
{ status: 409 },
)
}
}
// 3) Transaktion
const txResult = await prisma.$transaction(async (tx) => {
if (isLeader && newLeaderId) {
await tx.team.update({
where: { id: team!.id },
data: { leaderId: newLeaderId },
})
}
const nextActive = (team!.activePlayers ?? []).filter((id) => id !== steamId)
const nextInactive = (team!.inactivePlayers ?? []).filter((id) => id !== steamId)
await tx.team.update({
where: { id: team!.id },
data: {
activePlayers: { set: nextActive },
inactivePlayers: { set: nextInactive },
},
})
await tx.user.update({
where: { steamId },
data: { teamId: null },
})
await tx.teamInvite.deleteMany({
where: { teamId: team!.id, steamId },
})
return {
nextActive,
nextInactive,
leaderId: isLeader && newLeaderId ? newLeaderId : team!.leaderId,
}
})
// 4) Offene Matches aufräumen
await removePlayerFromMatches(team.id, steamId)
// 5) Notifications & SSE
const leaveN = await prisma.notification.create({
data: {
steamId,
title: 'Teamupdate',
message: `Du hast das Team „${teamName}“ verlassen.`,
actionType: 'team-left-self',
actionData: team.id,
},
})
await sendServerSSEMessage({
type: 'notification',
targetUserIds: [steamId],
message: leaveN.message,
id: leaveN.id,
actionType: leaveN.actionType ?? undefined,
actionData: leaveN.actionData ?? undefined,
createdAt: leaveN.createdAt.toISOString(),
})
await sendServerSSEMessage({
type: 'team-left-self',
teamId: team.id,
targetUserIds: [steamId],
})
const remaining = Array.from(
new Set(
[
txResult.leaderId,
...(txResult.nextActive ?? []),
...(txResult.nextInactive ?? []),
].filter(Boolean) as string[],
),
).filter((id) => id !== steamId)
if (remaining.length) {
const created = await Promise.all(
remaining.map((uid) =>
prisma.notification.create({
data: {
steamId: uid,
title: 'Teamupdate',
message: `${userName} hat das Team verlassen.`,
actionType: 'team-member-left',
actionData: steamId,
},
}),
),
)
await Promise.all(
created.map((n) =>
sendServerSSEMessage({
type: 'notification',
targetUserIds: [n.steamId],
message: n.message,
id: n.id,
actionType: n.actionType ?? undefined,
actionData: n.actionData ?? undefined,
createdAt: n.createdAt.toISOString(),
}),
),
)
await sendServerSSEMessage({
type: 'team-updated',
teamId: team.id,
targetUserIds: remaining,
})
}
return NextResponse.json(
{ message: 'Erfolgreich aus dem Team ausgetreten' },
{ headers: { 'Cache-Control': 'no-store' } },
)
} catch (err: unknown) {
// Prisma-Unique-Fehler abfangen (v5)
if (err instanceof PrismaClientKnownRequestError && err.code === 'P2002') {
return NextResponse.json(
{ message: 'Konflikt: Ein Eindeutigkeitskriterium wurde verletzt (vermutlich leaderId ist bereits vergeben).' },
{ status: 409 },
)
}
console.error('[LEAVE] Fehler:', err)
return NextResponse.json({ message: 'Serverfehler' }, { status: 500 })
}
}

View File

@ -1,33 +1,26 @@
// /src/app/api/team/rename/route.ts
// /src/app/api/team/[teamId]/rename/route.ts
import { NextResponse, type NextRequest } from 'next/server'
import { prisma } from '@/lib/prisma'
import { sendServerSSEMessage } from '@/lib/sse-server-client'
export const dynamic = 'force-dynamic'
// kleiner Body-Parser für sichere Typen
function parseBody(v: unknown): { teamId?: string; newName?: string } {
if (!v || typeof v !== 'object') return {}
const r = v as Record<string, unknown>
return {
teamId : typeof r.teamId === 'string' ? r.teamId : undefined,
newName: typeof r.newName === 'string' ? r.newName : undefined,
}
}
// Prisma-Fehler-Shape (ohne Import von Prisma-Typen)
type KnownPrismaError = { code: string; meta?: Record<string, unknown> }
function isKnownPrismaError(e: unknown): e is KnownPrismaError {
return !!e && typeof e === 'object' && 'code' in e && typeof (e as { code: unknown }).code === 'string'
}
export async function POST(req: NextRequest) {
export async function POST(
req: NextRequest,
ctx: { params: Promise<{ teamId: string }> }
) {
try {
const raw = await req.json().catch(() => null)
const { teamId, newName } = parseBody(raw)
const { teamId } = await ctx.params
const { newName } = (await req.json().catch(() => ({}))) as { newName?: string }
const name = (newName ?? '').trim()
if (!teamId || !name) {
if (!name) {
return NextResponse.json({ error: 'Fehlende Parameter' }, { status: 400 })
}

View File

@ -1,26 +1,25 @@
// /src/app/api/team/request-join/[action]/route.ts
// /src/app/api/team/[teamId]/request-join/[action]/route.ts
import { NextResponse, type NextRequest } from 'next/server'
import { prisma } from '@/lib/prisma'
import { getServerSession } from 'next-auth'
import { sessionAuthOptions } from '@/lib/auth'
import { sendServerSSEMessage } from '@/lib/sse-server-client'
import type { AsyncParams } from '@/types/next'
export const dynamic = 'force-dynamic'
export async function POST(
req: NextRequest,
ctx: AsyncParams<{ action: 'accept' | 'reject' }>
ctx: { params: Promise<{ teamId: string; action: 'accept' | 'reject' }> }
) {
try {
const { teamId, action } = await ctx.params
const session = await getServerSession(sessionAuthOptions)
const leaderSteamId = session?.user?.steamId
if (!leaderSteamId) {
return NextResponse.json({ message: 'Nicht eingeloggt' }, { status: 401 })
}
const { action } = await ctx.params
const { requestId, teamId: teamIdFromBody } = await req.json().catch(() => ({}))
const { requestId } = await req.json().catch(() => ({}))
if (!requestId) {
return NextResponse.json({ message: 'requestId fehlt' }, { status: 400 })
}
@ -31,8 +30,9 @@ export async function POST(
if (joinInv.type !== 'team-join-request') {
return NextResponse.json({ message: 'Invitation ist keine Beitrittsanfrage' }, { status: 400 })
}
const teamId = joinInv.teamId ?? teamIdFromBody
if (joinInv.teamId && joinInv.teamId !== teamId) {
return NextResponse.json({ message: 'Falsches Team' }, { status: 400 })
}
if (!teamId) return NextResponse.json({ message: 'teamId fehlt/ungültig' }, { status: 400 })
// Team + Leader prüfen
@ -146,7 +146,7 @@ export async function POST(
return NextResponse.json({ message: 'Beitrittsanfrage angenommen' }, { status: 200 })
} catch (err) {
console.error('[POST] /api/team/request-join/[action]', err)
console.error('[POST] /api/team/[teamId]/request-join/[action]', err)
return NextResponse.json({ message: 'Serverfehler' }, { status: 500 })
}
}

View File

@ -1,12 +1,16 @@
// /src/app/api/team/request-join/route.ts
// /src/app/api/team/[teamId]/request-join/route.ts
import { NextResponse, type NextRequest } from 'next/server'
import { prisma } from '@/lib/prisma'
import { getServerSession } from 'next-auth'
import { sessionAuthOptions } from '@/lib/auth'
import { sendServerSSEMessage } from '@/lib/sse-server-client'
export async function POST(req: NextRequest) {
export async function POST(
req: NextRequest,
ctx: { params: Promise<{ teamId: string }> }
) {
try {
const { teamId } = await ctx.params
// ── Session ──────────────────────────────────────────────
const session = await getServerSession(sessionAuthOptions)
if (!session?.user?.steamId) {
@ -15,13 +19,12 @@ export async function POST(req: NextRequest) {
const requesterSteamId = session.user.steamId
// ── Body ────────────────────────────────────────────────
const { teamId } = await req.json()
if (!teamId) {
return NextResponse.json({ message: 'teamId fehlt' }, { status: 400 })
}
// ── Daten holen ─────────────────────────────────────────
const [team, requester] = await Promise.all([
const [team] = await Promise.all([
prisma.team.findUnique({
where: { id: teamId },
select: {
@ -43,11 +46,6 @@ export async function POST(req: NextRequest) {
return NextResponse.json({ message: 'Team nicht gefunden' }, { status: 404 })
}
// ── Bereits in irgendeinem Team? ─────────────────────────
if (requester?.teamId && requester.teamId !== team.id) {
return NextResponse.json({ message: 'Du bist bereits in einem anderen Team' }, { status: 400 })
}
// ── Schon (irgendwie) Mitglied dieses Teams? ────────────
if (
requesterSteamId === team.leaderId ||
@ -102,7 +100,7 @@ export async function POST(req: NextRequest) {
return NextResponse.json({ message: 'Anfrage gesendet' }, { status: 200 })
} catch (err) {
console.error('POST /api/team/request-join', err)
console.error('POST /api/team/[teamId]/request-join', err)
return NextResponse.json({ message: 'Interner Serverfehler' }, { status: 500 })
}
}

View File

@ -1,4 +1,4 @@
// /src/app/api/team/transfer-leader/route.ts
// /src/app/api/team/[teamId]/transfer-leader/route.ts
import { prisma } from '@/lib/prisma'
import { NextResponse, type NextRequest } from 'next/server'
import { sendServerSSEMessage } from '@/lib/sse-server-client'
@ -7,16 +7,6 @@ export const dynamic = 'force-dynamic'
type KnownPrismaErrorShape = { code: string; meta?: Record<string, unknown> };
// kleines Body-Parser-Helper
function parseBody(v: unknown): { teamId?: string; newLeaderSteamId?: string } {
if (!v || typeof v !== 'object') return {}
const r = v as Record<string, unknown>
return {
teamId: typeof r.teamId === 'string' ? r.teamId : undefined,
newLeaderSteamId: typeof r.newLeaderSteamId === 'string' ? r.newLeaderSteamId : undefined,
}
}
// Type Guard für PrismaClientKnownRequestError
function isKnownPrismaError(e: unknown): e is KnownPrismaErrorShape {
return !!e
@ -25,14 +15,14 @@ function isKnownPrismaError(e: unknown): e is KnownPrismaErrorShape {
&& typeof (e as { code: unknown }).code === 'string';
}
export async function POST(req: NextRequest) {
export async function POST(
req: NextRequest,
ctx: { params: Promise<{ teamId: string }> }
) {
try {
const raw = await req.json().catch(() => null)
const { teamId, newLeaderSteamId } = parseBody(raw)
if (!teamId || !newLeaderSteamId) {
return NextResponse.json({ message: 'Fehlende Parameter' }, { status: 400 })
}
const { teamId } = await ctx.params
const { newLeaderSteamId } = (await req.json().catch(() => ({}))) as { newLeaderSteamId?: string }
if (!newLeaderSteamId) return NextResponse.json({ message: 'Fehlende Parameter' }, { status: 400 })
const team = await prisma.team.findUnique({
where: { id: teamId },

View File

@ -1,4 +1,4 @@
// /src/app/api/team/update-join-policy/route.ts
// /src/app/api/team/[teamId]/update-join-policy/route.ts
import { NextResponse, type NextRequest } from 'next/server'
import { prisma } from '@/lib/prisma'
import { getServerSession } from 'next-auth'
@ -12,18 +12,10 @@ export const dynamic = 'force-dynamic'
// Einmal zentral definieren und später benutzen
const ALLOWED: readonly TeamJoinPolicy[] = ['REQUEST', 'INVITE_ONLY'] as const
type Body = { teamId?: string; joinPolicy?: TeamJoinPolicy }
function parseBody(v: unknown): Body {
if (!v || typeof v !== 'object') return {}
const r = v as Record<string, unknown>
return {
teamId: typeof r.teamId === 'string' ? r.teamId : undefined,
joinPolicy: typeof r.joinPolicy === 'string' ? (r.joinPolicy as TeamJoinPolicy) : undefined,
}
}
export async function POST(req: NextRequest) {
export async function POST(
req: NextRequest,
ctx: { params: Promise<{ teamId: string }> }
) {
try {
const session = await getServerSession(sessionAuthOptions)
@ -32,8 +24,8 @@ export async function POST(req: NextRequest) {
return NextResponse.json({ message: 'Nicht eingeloggt' }, { status: 401 })
}
const raw: unknown = await req.json().catch(() => null)
const { teamId, joinPolicy } = parseBody(raw)
const { teamId } = await ctx.params
const { joinPolicy } = (await req.json().catch(() => ({}))) as { joinPolicy?: TeamJoinPolicy }
if (!teamId) {
return NextResponse.json({ message: 'teamId fehlt' }, { status: 400 })

View File

@ -1,12 +1,18 @@
// ✅ /api/team/update-players/route.ts
// /src/app/api/team/[teamId]/update-players/route.ts
import { prisma } from '@/lib/prisma'
import { sendServerSSEMessage } from '@/lib/sse-server-client'
import { NextResponse, type NextRequest } from 'next/server'
export async function POST(req: NextRequest) {
export async function POST(
req: NextRequest,
ctx: { params: Promise<{ teamId: string }> }
) {
try {
const { teamId, activePlayers, inactivePlayers, invitedPlayers } = await req.json()
if (!teamId || !Array.isArray(activePlayers) || !Array.isArray(inactivePlayers)) {
const { teamId } = await ctx.params
const { activePlayers, inactivePlayers, invitedPlayers } =
(await req.json().catch(() => ({}))) as { activePlayers?: string[]; inactivePlayers?: string[]; invitedPlayers?: string[] }
if (!Array.isArray(activePlayers) || !Array.isArray(inactivePlayers)) {
return NextResponse.json({ error: 'Ungültige Eingabedaten' }, { status: 400 })
}

View File

@ -1,4 +1,4 @@
// /api/team/upload-logo/route.ts
// /src/app/api/team/[teamId]/upload-logo/route.ts
import { NextResponse, type NextRequest } from 'next/server'
import { writeFile, mkdir, unlink } from 'fs/promises'
import { join, dirname } from 'path'
@ -8,14 +8,15 @@ import { prisma } from '@/lib/prisma'
export const runtime = 'nodejs' // wichtig, falls du irgendwo Edge aktiviert hast
export async function POST(req: NextRequest) {
export async function POST(
req: NextRequest,
ctx: { params: Promise<{ teamId: string }> }
) {
const { teamId } = await ctx.params
const formData = await req.formData()
const file = formData.get('logo') as File | null
const teamId = formData.get('teamId') as string | null
const file = formData.get('logo') as File | null
if (!file || !teamId) {
return NextResponse.json({ message: 'Ungültige Daten' }, { status: 400 })
}
if (!file) return NextResponse.json({ message: 'Ungültige Daten' }, { status: 400 })
// Bytes lesen
const inputBuffer = Buffer.from(await file.arrayBuffer())

View File

@ -1,49 +0,0 @@
// /src/app/api/team/add-players/route.ts
import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/prisma'
import { sendServerSSEMessage } from '@/lib/sse-server-client'
export async function POST(req: NextRequest) {
const { teamId, steamIds }: { teamId: string; steamIds: string[] } = await req.json()
if (!teamId || !Array.isArray(steamIds) || steamIds.length === 0) {
return NextResponse.json({ message: 'Ungültige Parameter' }, { status: 400 })
}
/* ▸ Spieler anhängen -------------------------------------------------------- */
await prisma.team.update({
where: { id: teamId },
data : { inactivePlayers: { push: steamIds } },
})
/* ▸ Frisches Team laden, um alle Player-IDs zu haben ----------------------- */
const team = await prisma.team.findUnique({
where : { id: teamId },
select: { // nur das Nötigste
id : true,
activePlayers : true,
inactivePlayers: true,
},
})
if (!team) {
return NextResponse.json({ message: 'Team nicht gefunden' }, { status: 404 })
}
/* ▸ SSE-Push --------------------------------------------------------------- */
await sendServerSSEMessage({
type : 'team-member-joined',
teamId,
users : steamIds,
})
await sendServerSSEMessage({
type : 'team-updated',
teamId,
})
return NextResponse.json({
ok: true,
results: steamIds.map(sid => ({ steamId: sid, ok: true }))
})
}

View File

@ -1,56 +0,0 @@
import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/prisma'
export async function GET(req: NextRequest) {
const teamId = req.nextUrl.searchParams.get('id')
if (!teamId) {
return NextResponse.json({ message: 'Team-ID fehlt.' }, { status: 400 })
}
try {
const team = await prisma.team.findUnique({
where: { id: teamId },
include: {
leader: {
select: {
steamId: true,
name: true,
avatar: true,
location: true,
},
},
},
})
if (!team) {
return NextResponse.json({ message: 'Team nicht gefunden.' }, { status: 404 })
}
const steamIds = [...team.activePlayers, ...team.inactivePlayers]
const players = await prisma.user.findMany({
where: { steamId: { in: steamIds } },
select: {
steamId: true,
name: true,
avatar: true,
location: true,
},
})
return NextResponse.json({
team: {
id: team.id,
teamname: team.name,
logo: team.logo,
leader: team.leader,
activePlayers: players.filter(p => team.activePlayers.includes(p.steamId)),
inactivePlayers: players.filter(p => team.inactivePlayers.includes(p.steamId)),
},
})
} catch (err) {
console.error('[GET /api/team/get] Fehler:', err)
return NextResponse.json({ message: 'Interner Serverfehler' }, { status: 500 })
}
}

View File

@ -0,0 +1,31 @@
// /src/app/api/team/leader-ids/route.ts
import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/prisma'
export const dynamic = 'force-dynamic'
export async function GET(req: NextRequest) {
try {
const url = new URL(req.url)
const exclude = url.searchParams.get('exclude') ?? undefined
const teams = await prisma.team.findMany({
where: exclude ? { NOT: { id: exclude } } : {},
select: { leaderId: true },
})
const leaderIds = Array.from(
new Set(
teams.map(t => t.leaderId).filter(Boolean) as string[]
)
)
return NextResponse.json(
{ leaderIds },
{ headers: { 'Cache-Control': 'no-store' } },
)
} catch (err) {
console.error('GET /api/team/leader-ids failed:', err)
return NextResponse.json({ message: 'Serverfehler' }, { status: 500 })
}
}

View File

@ -0,0 +1,39 @@
// /src/app/api/team/leader-map/route.ts
import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/prisma'
export const dynamic = 'force-dynamic'
export async function POST(req: NextRequest) {
try {
const body = (await req.json().catch(() => ({}))) as {
steamIds?: string[]
excludeTeamId?: string
}
const steamIds = Array.isArray(body.steamIds) ? body.steamIds.filter(Boolean) : []
const excludeTeamId = body.excludeTeamId?.trim()
if (!steamIds.length) {
return NextResponse.json({ blocked: [] }, { headers: { 'Cache-Control': 'no-store' } })
}
const rows = await prisma.team.findMany({
where: {
leaderId: { in: steamIds },
...(excludeTeamId ? { NOT: { id: excludeTeamId } } : {}),
},
select: { leaderId: true },
})
const blocked = Array.from(new Set(rows.map(r => r.leaderId).filter(Boolean))) as string[]
return NextResponse.json(
{ blocked },
{ headers: { 'Cache-Control': 'no-store' } },
)
} catch (err) {
console.error('[leader-map] error:', err)
return NextResponse.json({ blocked: [] }, { status: 200 }) // fail-soft: lieber nichts filtern als 500
}
}

View File

@ -1,163 +0,0 @@
// /src/app/api/team/leave/route.ts
import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/prisma'
import { removePlayerFromMatches } from '@/lib/removePlayerFromMatches'
import { sendServerSSEMessage } from '@/lib/sse-server-client'
export const dynamic = 'force-dynamic'
export async function POST(req: NextRequest) {
try {
const { steamId } = await req.json()
if (!steamId) {
return NextResponse.json({ message: 'Steam-ID fehlt' }, { status: 400 })
}
/* 1) Team ermitteln, in dem der User aktuell ist (inkl. Leader) */
const team = await prisma.team.findFirst({
where: {
OR: [
{ activePlayers: { has: steamId } },
{ inactivePlayers: { has: steamId } },
],
},
select: {
id: true,
name: true,
leaderId: true,
activePlayers: true,
inactivePlayers: true,
},
})
if (!team) {
return NextResponse.json(
{ message: 'Kein Team gefunden' },
{ status: 404, headers: { 'Cache-Control': 'no-store' } },
)
}
const user = await prisma.user.findUnique({
where: { steamId },
select: { name: true },
})
const userName = user?.name ?? 'Ein Spieler'
const teamName = team.name ?? 'Dein Team'
/* 2) Atomar: User aus Team entfernen, User.teamId null, Invites aufräumen */
const txResult = await prisma.$transaction(async (tx) => {
const nextActive = team.activePlayers.filter((id) => id !== steamId)
const nextInactive = team.inactivePlayers.filter((id) => id !== steamId)
// Team-Listen aktualisieren (keine automatische Leader-Änderung)
await tx.team.update({
where: { id: team.id },
data: {
activePlayers: { set: nextActive },
inactivePlayers: { set: nextInactive },
},
})
// User vom Team lösen (idempotent)
await tx.user.update({
where: { steamId },
data: { teamId: null },
})
// Eventuelle Einladungen zu diesem Team für den User aufräumen
await tx.teamInvite.deleteMany({
where: { teamId: team.id, steamId },
})
return { nextActive, nextInactive }
})
/* 3) Spieler aus offenen Matches entfernen */
await removePlayerFromMatches(team.id, steamId)
/* 4) Notifications & SSE in Echtzeit */
// a) an den Leaver: sichtbare Notification + Self-Event (UI räumen)
const leaveN = await prisma.notification.create({
data: {
steamId,
title: 'Teamupdate',
message: `Du hast das Team „${teamName}“ verlassen.`,
actionType: 'team-left-self',
actionData: team.id,
},
})
// sichtbare Toast/Dropdown-Notification
await sendServerSSEMessage({
type: 'notification',
targetUserIds: [steamId],
message: leaveN.message,
id: leaveN.id,
actionType: leaveN.actionType ?? undefined,
actionData: leaveN.actionData ?? undefined,
createdAt: leaveN.createdAt.toISOString(),
})
// Self-Event für Store-Reset/Redirect
await sendServerSSEMessage({
type: 'team-left-self',
teamId: team.id,
targetUserIds: [steamId],
})
// b) an die Verbleibenden (inkl. Leader): sichtbare Info in Echtzeit
const remaining = Array.from(
new Set(
[
team.leaderId,
...(txResult.nextActive ?? []),
...(txResult.nextInactive ?? []),
].filter(Boolean) as string[],
),
).filter((id) => id !== steamId)
if (remaining.length) {
const created = await Promise.all(
remaining.map((uid) =>
prisma.notification.create({
data: {
steamId: uid,
title: 'Teamupdate',
message: `${userName} hat das Team verlassen.`,
actionType: 'team-member-left',
actionData: steamId,
},
}),
),
)
// sofort zustellen (sichtbar im NotificationCenter)
await Promise.all(
created.map((n) =>
sendServerSSEMessage({
type: 'notification',
targetUserIds: [n.steamId],
message: n.message,
id: n.id,
actionType: n.actionType ?? undefined,
actionData: n.actionData ?? undefined,
createdAt: n.createdAt.toISOString(),
}),
),
)
// zentrales UI-Refresh-Signal
await sendServerSSEMessage({
type: 'team-updated',
teamId: team.id,
targetUserIds: remaining,
})
}
return NextResponse.json(
{ message: 'Erfolgreich aus dem Team ausgetreten' },
{ headers: { 'Cache-Control': 'no-store' } },
)
} catch (err) {
console.error('[LEAVE] Fehler:', err)
return NextResponse.json({ message: 'Serverfehler' }, { status: 500 })
}
}

View File

@ -3,6 +3,8 @@ import { NextResponse, type NextRequest } from 'next/server'
import { prisma } from '@/lib/prisma'
import { sendServerSSEMessage } from '@/lib/sse-server-client'
import type { AsyncParams } from '@/types/next'
import { getServerSession } from 'next-auth'
import { sessionAuthOptions } from '@/lib/auth'
export const dynamic = 'force-dynamic'
@ -13,47 +15,63 @@ export async function POST(
const { action } = await ctx.params
try {
const session = await getServerSession(sessionAuthOptions)
const sessionSteamId = session?.user?.steamId
const body = (await req.json().catch(() => ({}))) as Partial<{
invitationId: string
teamId: string
steamId: string
type: 'team-invite' | 'team-join-request'
}>
const incomingInvitationId = body.invitationId?.trim() || undefined
const fallbackTeamId = body.teamId?.trim() || undefined
const fallbackSteamId = body.steamId?.trim() || undefined
const incomingInvitationId = body.invitationId?.trim()
const fallbackTeamId = body.teamId?.trim()
const fallbackSteamId = body.steamId?.trim() || sessionSteamId
const requestedType = body.type
// Einladung auflösen (bevorzugt per ID, sonst per teamId+steamId)
const invitation =
incomingInvitationId
? await prisma.teamInvite.findUnique({ where: { id: incomingInvitationId } })
: (fallbackTeamId && fallbackSteamId
? await prisma.teamInvite.findFirst({
where: {
type: 'team-invite',
teamId: fallbackTeamId,
steamId: fallbackSteamId,
},
})
: null)
// ---- Invitation auflösen -----------------------------------------
let invitation = null as (Awaited<ReturnType<typeof prisma.teamInvite.findUnique>> | null)
if (!invitation) {
return NextResponse.json({ message: 'Einladung nicht gefunden' }, { status: 404 })
if (incomingInvitationId) {
invitation = await prisma.teamInvite.findUnique({ where: { id: incomingInvitationId } })
}
if (invitation.type !== 'team-invite') {
return NextResponse.json({ message: 'Ungültiger Einladungstyp' }, { status: 400 })
if (!invitation && fallbackTeamId && fallbackSteamId) {
// Wenn ein Typ angegeben ist, suche strikt danach …
if (requestedType === 'team-join-request' || requestedType === 'team-invite') {
invitation = await prisma.teamInvite.findFirst({
where: { teamId: fallbackTeamId, steamId: fallbackSteamId, type: requestedType },
})
} else {
// … sonst versuche zuerst die Join-Request, dann die „echte“ Einladung
invitation =
(await prisma.teamInvite.findFirst({
where: { teamId: fallbackTeamId, steamId: fallbackSteamId, type: 'team-join-request' },
})) ||
(await prisma.teamInvite.findFirst({
where: { teamId: fallbackTeamId, steamId: fallbackSteamId, type: 'team-invite' },
}))
}
}
if (!invitation) {
return NextResponse.json({ message: 'Einladung/Anfrage nicht gefunden' }, { status: 404 })
}
const invitationId = invitation.id
const invitedUserSteamId = invitation.steamId
const teamId = invitation.teamId
const invitedUserSteamId = invitation.steamId
if (!teamId) {
return NextResponse.json({ message: 'Einladung ohne Team' }, { status: 400 })
return NextResponse.json({ message: 'Eintrag ohne Team' }, { status: 400 })
}
/* ───────────────────────────── ACCEPT ───────────────────────────── */
if (action === 'accept') {
if (invitation.type !== 'team-invite') {
return NextResponse.json({ message: 'Nur Team-Einladungen können angenommen werden' }, { status: 400 })
}
const result = await prisma.$transaction(async (tx) => {
const teamBefore = await tx.team.findUnique({
where: { id: teamId },
@ -61,14 +79,16 @@ export async function POST(
})
if (!teamBefore) throw new Error('Team nicht gefunden')
// 1) User ins Team hängen
// 1) (Optional) user.teamId pflegen falls du Multi-Teams erlaubst, entferne das Feld oder diese Zeile
await tx.user.update({
where: { steamId: invitedUserSteamId },
data: { teamId },
data: { teamId }, // <- bei Multi-Teams evtl. löschen
})
// 2) Inaktive Liste updaten (ohne Duplikate)
const nextInactive = Array.from(new Set([...(teamBefore.inactivePlayers ?? []), invitedUserSteamId]))
const nextInactive = Array.from(
new Set([...(teamBefore.inactivePlayers ?? []), invitedUserSteamId])
)
await tx.team.update({
where: { id: teamId },
data: { inactivePlayers: nextInactive },
@ -83,14 +103,9 @@ export async function POST(
// 4) Andere offenen Team-Einladungen für diesen User entfernen
const otherInvites = await tx.teamInvite.findMany({
where: {
steamId: invitedUserSteamId,
type: 'team-invite',
NOT: { id: invitationId },
},
where: { steamId: invitedUserSteamId, type: 'team-invite', NOT: { id: invitationId } },
select: { id: true, teamId: true },
})
if (otherInvites.length) {
await tx.teamInvite.deleteMany({ where: { id: { in: otherInvites.map(o => o.id) } } })
await tx.notification.updateMany({
@ -102,13 +117,15 @@ export async function POST(
return { teamBefore, nextInactive, otherInvites }
})
const allMembers = Array.from(new Set(
[
result.teamBefore.leaderId,
...(result.teamBefore.activePlayers ?? []),
...result.nextInactive,
].filter(Boolean) as string[]
))
const allMembers = Array.from(
new Set(
[
result.teamBefore.leaderId,
...(result.teamBefore.activePlayers ?? []),
...result.nextInactive,
].filter(Boolean) as string[]
)
)
const joinedNotif = await prisma.notification.create({
data: {
@ -169,48 +186,17 @@ export async function POST(
targetUserIds: allMembers,
})
if (result.otherInvites.length) {
const byTeam = new Map<string, string[]>()
for (const o of result.otherInvites) {
if (!o.teamId) continue
byTeam.set(o.teamId, [...(byTeam.get(o.teamId) ?? []), o.id])
}
const otherTeams = await prisma.team.findMany({
where: { id: { in: Array.from(byTeam.keys()) } },
select: { id: true, leaderId: true, activePlayers: true, inactivePlayers: true },
})
for (const t of otherTeams) {
const recipients = Array.from(new Set(
[t.leaderId, ...(t.activePlayers ?? []), ...(t.inactivePlayers ?? [])].filter(Boolean) as string[]
))
const inviteIds = byTeam.get(t.id) ?? []
await Promise.all(inviteIds.map(invId =>
sendServerSSEMessage({
type: 'team-invite-revoked',
targetUserIds: recipients,
invitationId: invId,
teamId: t.id,
})
))
if (recipients.length) {
await sendServerSSEMessage({
type: 'team-updated',
teamId: t.id,
targetUserIds: recipients,
})
}
}
}
// In anderen Teams eingegangene EINLADUNGEN gelöscht → optional: SSE wie gehabt
return NextResponse.json({ message: 'Einladung angenommen' })
}
/* ───────────────────────────── REJECT ──────────────────────────── */
if (action === 'reject') {
if (invitation.type !== 'team-invite') {
return NextResponse.json({ message: 'Nur Team-Einladungen können abgelehnt werden' }, { status: 400 })
}
await prisma.teamInvite.delete({ where: { id: invitationId } })
await prisma.notification.updateMany({
where: { actionData: invitationId },
@ -243,6 +229,7 @@ export async function POST(
/* ───────────────────────────── REVOKE ──────────────────────────── */
if (action === 'revoke') {
// Zwei Fälle: join-request (Spieler zieht seine Anfrage zurück) ODER team-invite (Leader widerruft Einladung)
await prisma.teamInvite.delete({ where: { id: invitationId } })
await prisma.notification.updateMany({
where: { actionData: invitationId },
@ -253,34 +240,57 @@ export async function POST(
where: { id: teamId },
select: { leaderId: true, activePlayers: true, inactivePlayers: true },
})
const admins = await prisma.user.findMany({
where: { isAdmin: true },
select: { steamId: true },
})
const targetUserIds = Array.from(new Set([
team?.leaderId,
...(team?.activePlayers ?? []),
...(team?.inactivePlayers ?? []),
...admins.map(a => a.steamId),
].filter(Boolean) as string[]))
if (invitation.type === 'team-join-request') {
// Notify Leader (und optional Team), dass die Anfrage zurückgezogen wurde
const recipients = Array.from(new Set(
[team?.leaderId /*, ...(team?.activePlayers ?? []), ...(team?.inactivePlayers ?? [])*/]
.filter(Boolean) as string[]
))
await sendServerSSEMessage({
type: 'team-invite-revoked',
targetUserIds: [invitedUserSteamId],
invitationId,
teamId,
})
if (recipients.length) {
await sendServerSSEMessage({
type: 'team-join-request-revoked',
targetUserIds: recipients,
invitationId,
teamId,
})
await sendServerSSEMessage({
type: 'team-updated',
teamId,
targetUserIds: recipients,
})
}
if (targetUserIds.length) {
return NextResponse.json({ message: 'Anfrage zurückgezogen' })
} else {
// invitation.type === 'team-invite' → Einladungswiderruf
const admins = await prisma.user.findMany({ where: { isAdmin: true }, select: { steamId: true } })
const targetUserIds = Array.from(new Set([
team?.leaderId,
...(team?.activePlayers ?? []),
...(team?.inactivePlayers ?? []),
...admins.map(a => a.steamId),
].filter(Boolean) as string[]))
// Benachrichtige den eingeladenen User separat
await sendServerSSEMessage({
type: 'team-updated',
type: 'team-invite-revoked',
targetUserIds: [invitedUserSteamId],
invitationId,
teamId,
targetUserIds,
})
}
return NextResponse.json({ message: 'Einladung gelöscht' })
if (targetUserIds.length) {
await sendServerSSEMessage({
type: 'team-updated',
teamId,
targetUserIds,
})
}
return NextResponse.json({ message: 'Einladung widerrufen' })
}
}
return NextResponse.json({ message: 'Ungültige Aktion' }, { status: 400 })

View File

@ -2,6 +2,23 @@
import { Player, Team } from '@/types/team'
// ── Helpers, um ohne "any" zu arbeiten ───────────────────────────────────────
async function readJsonSafe(res: Response): Promise<unknown> {
// Gibt {} zurück, wenn JSON parse fehlschlägt
return res.json().catch(() => ({}))
}
function getErrorMessage(data: unknown, fallback: string): string {
if (typeof data === 'object' && data !== null && 'message' in data) {
const m = (data as Record<string, unknown>).message
if (typeof m === 'string') return m
}
return fallback
}
// ─────────────────────────────────────────────────────────────────────────────
// 🔄 Team laden
export async function reloadTeam(teamId: string): Promise<Team | null> {
try {
@ -88,15 +105,16 @@ export async function revokeInvitation(invitationId: string): Promise<boolean> {
// ✏️ Team umbenennen
export async function renameTeam(teamId: string, newName: string): Promise<boolean> {
try {
const res = await fetch('/api/team/rename', {
const res = await fetch(`/api/team/${encodeURIComponent(teamId)}/rename`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'same-origin',
body: JSON.stringify({ teamId, newName }),
})
if (!res.ok) {
const data = await res.json()
throw new Error(data.message || 'Fehler beim Umbenennen')
const data = await readJsonSafe(res)
throw new Error(getErrorMessage(data, 'Fehler beim Umbenennen'))
}
return true
@ -107,21 +125,30 @@ export async function renameTeam(teamId: string, newName: string): Promise<boole
}
// 🚪 Team verlassen
export async function leaveTeam(steamId: string, newLeaderId?: string): Promise<boolean> {
try {
const body = newLeaderId ? { steamId, newLeaderId } : { steamId }
// Vorher:
// export async function leaveTeam(steamId: string, teamId: string, newLeaderId?: string)
const res = await fetch('/api/team/leave', {
export async function leaveTeam(args: {
steamId: string
teamId: string
newLeaderId?: string
}): Promise<boolean> {
const { steamId, teamId, newLeaderId } = args
try {
const body: Record<string, unknown> = { steamId, teamId }
if (newLeaderId) body.newLeaderId = newLeaderId
const res = await fetch(`/api/team/${encodeURIComponent(teamId)}/leave`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'same-origin',
body: JSON.stringify(body),
})
if (!res.ok) {
const data = await res.json()
throw new Error(data.message || 'Fehler beim Verlassen')
const data = await readJsonSafe(res)
throw new Error(getErrorMessage(data, 'Fehler beim Verlassen'))
}
return true
} catch (err) {
console.error('leaveTeam:', err)
@ -129,18 +156,19 @@ export async function leaveTeam(steamId: string, newLeaderId?: string): Promise<
}
}
// 👢 Spieler kicken
export async function kickPlayer(teamId: string, steamId: string): Promise<boolean> {
try {
const res = await fetch('/api/team/kick', {
const res = await fetch(`/api/team/${encodeURIComponent(teamId)}/kick`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ teamId, steamId }),
})
if (!res.ok) {
const data = await res.json()
throw new Error(data.message || 'Fehler beim Kicken')
const data = await readJsonSafe(res)
throw new Error(getErrorMessage(data, 'Fehler beim Kicken'))
}
return true
@ -153,15 +181,16 @@ export async function kickPlayer(teamId: string, steamId: string): Promise<boole
// 👑 Leader übertragen
export async function transferLeader(teamId: string, newLeaderSteamId: string): Promise<boolean> {
try {
const res = await fetch('/api/team/transfer-leader', {
const res = await fetch(`/api/team/${encodeURIComponent(teamId)}/transfer-leader`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'same-origin',
body: JSON.stringify({ teamId, newLeaderSteamId }),
})
if (!res.ok) {
const data = await res.json()
throw new Error(data.message || 'Fehler beim Übertragen der Leader-Rolle')
const data = await readJsonSafe(res)
throw new Error(getErrorMessage(data, 'Fehler beim Übertragen der Leader-Rolle'))
}
return true
@ -171,18 +200,44 @@ export async function transferLeader(teamId: string, newLeaderSteamId: string):
}
}
// Lädt die SteamIDs aller Leader anderer Teams (aktuelles Team via exclude ausnehmen)
export async function getOtherTeamLeaderIds(excludeTeamId?: string): Promise<Set<string>> {
try {
const qs = excludeTeamId ? `?exclude=${encodeURIComponent(excludeTeamId)}` : ''
const res = await fetch(`/api/team/leader-ids${qs}`, {
cache: 'no-store',
credentials: 'same-origin',
})
if (!res.ok) throw new Error('leader-ids fetch failed')
const j: unknown = await readJsonSafe(res)
let ids: string[] = []
if (typeof j === 'object' && j !== null) {
const v = (j as Record<string, unknown>).leaderIds
if (Array.isArray(v) && v.every((x) => typeof x === 'string')) {
ids = v
}
}
return new Set(ids)
} catch (err) {
console.warn('[sse-actions] getOtherTeamLeaderIds:', err)
return new Set()
}
}
// 🗑️ Team löschen
export async function deleteTeam(teamId: string): Promise<boolean> {
try {
const res = await fetch('/api/team/delete', {
const res = await fetch(`/api/team/${encodeURIComponent(teamId)}/delete`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'same-origin',
body: JSON.stringify({ teamId }),
})
if (!res.ok) {
const data = await res.json()
throw new Error(data.message || 'Fehler beim Löschen')
const data = await readJsonSafe(res)
throw new Error(getErrorMessage(data, 'Fehler beim Löschen'))
}
return true