updated for build
This commit is contained in:
parent
19bf9f7c9e
commit
86e9b53b78
@ -144,33 +144,45 @@ export default function InvitePlayersModal({ show, onClose, onSuccess, team, dir
|
|||||||
|
|
||||||
async function fetchUsers(opts: { resetLayout: boolean }) {
|
async function fetchUsers(opts: { resetLayout: boolean }) {
|
||||||
try {
|
try {
|
||||||
|
// evtl. laufende Anfrage abbrechen
|
||||||
abortRef.current?.abort();
|
abortRef.current?.abort();
|
||||||
const ctrl = new AbortController();
|
const ctrl = new AbortController();
|
||||||
abortRef.current = ctrl;
|
abortRef.current = ctrl;
|
||||||
|
|
||||||
|
// aktuelle Grid-Höhe merken (Anti-Jump)
|
||||||
if (gridRef.current) setGridHoldHeight(gridRef.current.clientHeight);
|
if (gridRef.current) setGridHoldHeight(gridRef.current.clientHeight);
|
||||||
|
|
||||||
setIsFetching(true);
|
setIsFetching(true);
|
||||||
|
|
||||||
|
// Spinner: verzögert einblenden + Mindestdauer
|
||||||
if (spinnerShowTimer.current) window.clearTimeout(spinnerShowTimer.current);
|
if (spinnerShowTimer.current) window.clearTimeout(spinnerShowTimer.current);
|
||||||
spinnerShowTimer.current = window.setTimeout(() => {
|
spinnerShowTimer.current = window.setTimeout(() => {
|
||||||
setSpinnerVisible(true);
|
setSpinnerVisible(true);
|
||||||
spinnerShownAt.current = Date.now();
|
spinnerShownAt.current = Date.now();
|
||||||
}, SPINNER_DELAY_MS);
|
}, 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');
|
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,
|
signal: ctrl.signal,
|
||||||
cache: 'no-store',
|
cache: 'no-store',
|
||||||
});
|
});
|
||||||
if (!res.ok) throw new Error('load failed');
|
if (!res.ok) throw new Error('load failed');
|
||||||
|
|
||||||
const dataUnknown: unknown = await res.json();
|
const dataUnknown: unknown = await res.json();
|
||||||
const users = (isRecord(dataUnknown) && Array.isArray(dataUnknown.users))
|
let users: Player[] = [];
|
||||||
? (dataUnknown.users as Player[])
|
if (isRecord(dataUnknown)) {
|
||||||
: [];
|
const maybeUsers = (dataUnknown as UnknownRec).users;
|
||||||
|
if (Array.isArray(maybeUsers)) {
|
||||||
|
users = maybeUsers as Player[];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
startTransition(() => {
|
startTransition(() => {
|
||||||
setAllUsers(users);
|
setAllUsers(users);
|
||||||
@ -186,41 +198,43 @@ export default function InvitePlayersModal({ show, onClose, onSuccess, team, dir
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
if (isRecord(e) && e.name === 'AbortError') return;
|
// sauberer Abort-Check ohne "any"
|
||||||
console.error('Fehler beim Laden der Benutzer:', e);
|
if (
|
||||||
} finally {
|
(e instanceof DOMException && e.name === 'AbortError') ||
|
||||||
setIsFetching(false)
|
(typeof (e as { name?: unknown })?.name === 'string' && (e as { name: string }).name === 'AbortError')
|
||||||
abortRef.current = null
|
) {
|
||||||
|
return;
|
||||||
// 🔽 Spinner Mindestdauer respektieren
|
|
||||||
const hide = () => {
|
|
||||||
setSpinnerVisible(false)
|
|
||||||
spinnerShownAt.current = null
|
|
||||||
setGridHoldHeight(0)
|
|
||||||
}
|
}
|
||||||
|
} finally {
|
||||||
|
setIsFetching(false);
|
||||||
|
abortRef.current = null;
|
||||||
|
|
||||||
|
// Spinner-Mindestdauer respektieren & aufräumen
|
||||||
|
const hide = () => {
|
||||||
|
setSpinnerVisible(false);
|
||||||
|
spinnerShownAt.current = null;
|
||||||
|
setGridHoldHeight(0);
|
||||||
|
};
|
||||||
|
|
||||||
if (spinnerShowTimer.current) {
|
if (spinnerShowTimer.current) {
|
||||||
// Wenn der Delay-Timer noch nicht gefeuert hat: einfach abbrechen
|
window.clearTimeout(spinnerShowTimer.current);
|
||||||
window.clearTimeout(spinnerShowTimer.current)
|
spinnerShowTimer.current = null;
|
||||||
spinnerShowTimer.current = null
|
|
||||||
// Der Spinner wurde evtl. nie sichtbar -> direkt aufräumen
|
|
||||||
if (!spinnerShownAt.current) {
|
if (!spinnerShownAt.current) {
|
||||||
hide()
|
hide();
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (spinnerShownAt.current) {
|
if (spinnerShownAt.current) {
|
||||||
const elapsed = Date.now() - spinnerShownAt.current
|
const elapsed = Date.now() - spinnerShownAt.current;
|
||||||
const remain = Math.max(0, SPINNER_MIN_MS - elapsed)
|
const remain = Math.max(0, SPINNER_MIN_MS - elapsed);
|
||||||
if (remain > 0) {
|
if (remain > 0) {
|
||||||
window.setTimeout(hide, remain)
|
window.setTimeout(hide, remain);
|
||||||
} else {
|
} else {
|
||||||
hide()
|
hide();
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Falls er nie sichtbar war
|
hide();
|
||||||
hide()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -246,7 +260,9 @@ export default function InvitePlayersModal({ show, onClose, onSuccess, team, dir
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
setIsInviting(true);
|
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
|
const body = directAdd
|
||||||
? { teamId: team.id, steamIds: ids }
|
? { teamId: team.id, steamIds: ids }
|
||||||
: { teamId: team.id, userIds: ids, invitedBy: steamId };
|
: { teamId: team.id, userIds: ids, invitedBy: steamId };
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect, useMemo } from 'react'
|
||||||
import Modal from './Modal'
|
import Modal from './Modal'
|
||||||
import MiniCard from './MiniCard'
|
import MiniCard from './MiniCard'
|
||||||
import { useSession } from 'next-auth/react'
|
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 [newLeaderId, setNewLeaderId] = useState<string>('')
|
||||||
const [, setIsSubmitting] = useState(false)
|
const [, setIsSubmitting] = useState(false)
|
||||||
|
|
||||||
|
// Leader-IDs anderer Teams laden (aktuelles Team per ?exclude= ausnehmen)
|
||||||
|
const [leaderIds, setLeaderIds] = useState<Set<string>>(new Set())
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (show && team.leader?.steamId) {
|
if (!show) return
|
||||||
// ⬅︎ Player -> steamId
|
let alive = true
|
||||||
setNewLeaderId(team.leader.steamId)
|
;(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())
|
||||||
}
|
}
|
||||||
}, [show, team.leader?.steamId])
|
})()
|
||||||
|
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 () => {
|
const handleLeave = async () => {
|
||||||
if (!steamId) return
|
if (!steamId) return
|
||||||
setIsSubmitting(true)
|
setIsSubmitting(true)
|
||||||
try {
|
try {
|
||||||
const iAmLeader = team.leader?.steamId === steamId // ⬅︎ Player vergleichen über steamId
|
const iAmLeader = team.leader?.steamId === steamId
|
||||||
const success = await leaveTeam(steamId, iAmLeader ? newLeaderId : undefined)
|
if (iAmLeader && !newLeaderId) {
|
||||||
if (success) {
|
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()
|
onSuccess()
|
||||||
onClose()
|
onClose()
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Fehler beim Verlassen:', err)
|
console.error('Fehler beim Verlassen:', err)
|
||||||
|
alert('Aktion fehlgeschlagen.')
|
||||||
} finally {
|
} finally {
|
||||||
setIsSubmitting(false)
|
setIsSubmitting(false)
|
||||||
}
|
}
|
||||||
@ -54,18 +88,25 @@ export default function LeaveTeamModal({ show, onClose, onSuccess, team }: Props
|
|||||||
onSave={handleLeave}
|
onSave={handleLeave}
|
||||||
closeButtonColor="red"
|
closeButtonColor="red"
|
||||||
closeButtonTitle="Team verlassen"
|
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">
|
<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:
|
Du bist der Teamleader. Bitte wähle ein anderes Mitglied aus, das die Rolle des Leaders übernehmen soll:
|
||||||
</p>
|
</p>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-gray-700 dark:text-neutral-300">
|
||||||
|
Willst du das Team wirklich verlassen?
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{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">
|
<div className="grid grid-cols-2 sm:grid-cols-3 gap-4 mt-4">
|
||||||
{[
|
{candidates.map((player: Player) => (
|
||||||
...(team.activePlayers ?? []),
|
|
||||||
...(team.inactivePlayers ?? []),
|
|
||||||
]
|
|
||||||
.filter((player) => player.steamId !== steamId)
|
|
||||||
.map((player: Player) => (
|
|
||||||
<MiniCard
|
<MiniCard
|
||||||
key={player.steamId}
|
key={player.steamId}
|
||||||
steamId={player.steamId}
|
steamId={player.steamId}
|
||||||
@ -83,6 +124,7 @@ export default function LeaveTeamModal({ show, onClose, onSuccess, team }: Props
|
|||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</Modal>
|
</Modal>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -17,6 +17,7 @@ type Notification = {
|
|||||||
actionType?: string
|
actionType?: string
|
||||||
actionData?: string
|
actionData?: string
|
||||||
createdAt?: string
|
createdAt?: string
|
||||||
|
teamId?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
type ActionData =
|
type ActionData =
|
||||||
@ -80,14 +81,22 @@ export default function NotificationBell() {
|
|||||||
const res = await fetch('/api/notifications')
|
const res = await fetch('/api/notifications')
|
||||||
if (!res.ok) throw new Error('Fehler beim Laden')
|
if (!res.ok) throw new Error('Fehler beim Laden')
|
||||||
const data: NotificationsResponse = await res.json()
|
const data: NotificationsResponse = await res.json()
|
||||||
const loaded: Notification[] = data.notifications.map((n: ApiNotification) => ({
|
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,
|
id: n.id,
|
||||||
text: n.message,
|
text: n.message,
|
||||||
read: n.read,
|
read: n.read,
|
||||||
actionType: n.actionType,
|
actionType: n.actionType,
|
||||||
actionData: n.actionData,
|
actionData: n.actionData,
|
||||||
createdAt: n.createdAt,
|
createdAt: n.createdAt,
|
||||||
}))
|
teamId,
|
||||||
|
}
|
||||||
|
})
|
||||||
setNotifications(loaded)
|
setNotifications(loaded)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[NotificationBell] Fehler beim Laden:', err)
|
console.error('[NotificationBell] Fehler beim Laden:', err)
|
||||||
@ -115,6 +124,7 @@ export default function NotificationBell() {
|
|||||||
actionType: data?.actionType,
|
actionType: data?.actionType,
|
||||||
actionData: data?.actionData,
|
actionData: data?.actionData,
|
||||||
createdAt: data?.createdAt ?? new Date().toISOString(),
|
createdAt: data?.createdAt ?? new Date().toISOString(),
|
||||||
|
teamId: data?.teamId,
|
||||||
};
|
};
|
||||||
|
|
||||||
setNotifications(prev => [newNotification, ...prev]);
|
setNotifications(prev => [newNotification, ...prev]);
|
||||||
@ -171,7 +181,7 @@ export default function NotificationBell() {
|
|||||||
let kind: ActionData['kind'] | undefined
|
let kind: ActionData['kind'] | undefined
|
||||||
let invitationId: string | undefined
|
let invitationId: string | undefined
|
||||||
let requestId: string | undefined
|
let requestId: string | undefined
|
||||||
let teamId: string | undefined
|
let teamId: string | undefined = n.teamId
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const data = JSON.parse(n.actionData) as ActionData | string
|
const data = JSON.parse(n.actionData) as ActionData | string
|
||||||
@ -180,7 +190,7 @@ export default function NotificationBell() {
|
|||||||
kind = data.kind
|
kind = data.kind
|
||||||
if (data.kind === 'invite') invitationId = data.inviteId
|
if (data.kind === 'invite') invitationId = data.inviteId
|
||||||
if (data.kind === 'join-request') requestId = data.requestId
|
if (data.kind === 'join-request') requestId = data.requestId
|
||||||
teamId = data.teamId
|
teamId = teamId ?? data.teamId
|
||||||
} else if (typeof data === 'string') {
|
} else if (typeof data === 'string') {
|
||||||
// nackte ID: sowohl als invitationId als auch requestId nutzbar
|
// nackte ID: sowohl als invitationId als auch requestId nutzbar
|
||||||
invitationId = data
|
invitationId = data
|
||||||
@ -205,6 +215,10 @@ export default function NotificationBell() {
|
|||||||
console.warn('[NotificationBell] requestId fehlt')
|
console.warn('[NotificationBell] requestId fehlt')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if (kind === 'join-request' && !teamId) {
|
||||||
|
console.warn('[NotificationBell] teamId fehlt')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// Optimistic Update (Buttons ausblenden)
|
// Optimistic Update (Buttons ausblenden)
|
||||||
const snapshot = notifications
|
const snapshot = notifications
|
||||||
@ -220,9 +234,9 @@ export default function NotificationBell() {
|
|||||||
|
|
||||||
if (kind === 'join-request') {
|
if (kind === 'join-request') {
|
||||||
if (action === 'accept') {
|
if (action === 'accept') {
|
||||||
await apiJSON('/api/team/request-join/accept', { requestId, teamId })
|
await apiJSON(`/api/team/${encodeURIComponent(teamId!)}/request-join/accept`, { requestId })
|
||||||
} else {
|
} 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))
|
setNotifications(prev => prev.filter(x => x.id !== n.id))
|
||||||
if (action === 'accept') router.refresh()
|
if (action === 'accept') router.refresh()
|
||||||
|
|||||||
@ -137,10 +137,10 @@ export default function TeamCard({
|
|||||||
onUpdateInvitation(team.id, null)
|
onUpdateInvitation(team.id, null)
|
||||||
} else {
|
} else {
|
||||||
if (isInviteOnly) return
|
if (isInviteOnly) return
|
||||||
await fetch('/api/team/request-join', {
|
await fetch(`/api/team/${encodeURIComponent(team.id)}/request-join`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ teamId: team.id }),
|
body: JSON.stringify({}),
|
||||||
})
|
})
|
||||||
onUpdateInvitation(team.id, 'pending')
|
onUpdateInvitation(team.id, 'pending')
|
||||||
}
|
}
|
||||||
|
|||||||
@ -26,6 +26,7 @@ import TeamPremierRankBadge from './TeamPremierRankBadge'
|
|||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import { useTeamStore } from '@/lib/stores'
|
import { useTeamStore } from '@/lib/stores'
|
||||||
import { useSSEStore } from '@/lib/useSSEStore'
|
import { useSSEStore } from '@/lib/useSSEStore'
|
||||||
|
import TransferLeaderModal from './TransferLeaderModal'
|
||||||
import {
|
import {
|
||||||
TEAM_EVENTS,
|
TEAM_EVENTS,
|
||||||
SELF_EVENTS,
|
SELF_EVENTS,
|
||||||
@ -334,11 +335,10 @@ function TeamMemberViewBody({
|
|||||||
|
|
||||||
const updateTeamMembers = async (tId: string, active: Player[], inactive: Player[]) => {
|
const updateTeamMembers = async (tId: string, active: Player[], inactive: Player[]) => {
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/team/update-players', {
|
const res = await fetch(`/api/team/${tId}/update-players`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
teamId: tId,
|
|
||||||
activePlayers: active.map(p => p.steamId),
|
activePlayers: active.map(p => p.steamId),
|
||||||
inactivePlayers: inactive.map(p => p.steamId),
|
inactivePlayers: inactive.map(p => p.steamId),
|
||||||
}),
|
}),
|
||||||
@ -451,33 +451,15 @@ function TeamMemberViewBody({
|
|||||||
setActivePlayers(newActive)
|
setActivePlayers(newActive)
|
||||||
setInactivePlayers(newInactive)
|
setInactivePlayers(newInactive)
|
||||||
|
|
||||||
await fetch('/api/team/kick', {
|
await fetch(`/api/team/${teamId}/kick`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ steamId: kickCandidate.steamId, teamId }),
|
body: JSON.stringify({ steamId: kickCandidate.steamId }),
|
||||||
})
|
})
|
||||||
await updateTeamMembers(team.id, newActive, newInactive)
|
await updateTeamMembers(team.id, newActive, newInactive)
|
||||||
setKickCandidate(null)
|
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 = {
|
type DownscaleOpts = {
|
||||||
size?: number
|
size?: number
|
||||||
quality?: number
|
quality?: number
|
||||||
@ -490,12 +472,12 @@ function TeamMemberViewBody({
|
|||||||
try {
|
try {
|
||||||
setSavingPolicy(true)
|
setSavingPolicy(true)
|
||||||
|
|
||||||
const res = await fetch('/api/team/update-join-policy', {
|
const res = await fetch(`/api/team/${teamId}/update-join-policy`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
credentials: 'same-origin',
|
credentials: 'same-origin',
|
||||||
cache: 'no-store',
|
cache: 'no-store',
|
||||||
body: JSON.stringify({ teamId, joinPolicy: next }),
|
body: JSON.stringify({ joinPolicy: next }),
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
@ -641,10 +623,9 @@ function TeamMemberViewBody({
|
|||||||
return new Promise<void>((resolve, reject) => {
|
return new Promise<void>((resolve, reject) => {
|
||||||
const formData = new FormData()
|
const formData = new FormData()
|
||||||
formData.append('logo', file)
|
formData.append('logo', file)
|
||||||
formData.append('teamId', teamId)
|
|
||||||
|
|
||||||
const xhr = new XMLHttpRequest()
|
const xhr = new XMLHttpRequest()
|
||||||
xhr.open('POST', '/api/team/upload-logo')
|
xhr.open('POST', `/api/team/${teamId}/upload-logo`)
|
||||||
xhr.upload.onprogress = (e) => {
|
xhr.upload.onprogress = (e) => {
|
||||||
if (e.lengthComputable) setUploadPct(Math.round((e.loaded / e.total) * 100))
|
if (e.lengthComputable) setUploadPct(Math.round((e.loaded / e.total) * 100))
|
||||||
else setUploadPct(p => (p < 90 ? p + 1 : p))
|
else setUploadPct(p => (p < 90 ? p + 1 : p))
|
||||||
@ -952,7 +933,7 @@ function TeamMemberViewBody({
|
|||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
if (isLeader) setShowLeaveModal(true)
|
if (isLeader) setShowLeaveModal(true)
|
||||||
else {
|
else {
|
||||||
try { await leaveTeam(currentUserSteamId) }
|
try { await leaveTeam({ steamId: currentUserSteamId, teamId: team.id }) }
|
||||||
catch (err) { console.error('Fehler beim Verlassen:', err) }
|
catch (err) { console.error('Fehler beim Verlassen:', err) }
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
@ -1096,39 +1077,17 @@ function TeamMemberViewBody({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{canManage && promoteCandidate && (
|
{canManage && (
|
||||||
<Modal
|
<TransferLeaderModal
|
||||||
id={`modal-promote-player-${promoteCandidate.steamId}`}
|
show={!!promoteCandidate}
|
||||||
title="Leader übertragen"
|
team={team}
|
||||||
show={true}
|
preselectId={promoteCandidate?.steamId}
|
||||||
onClose={() => setPromoteCandidate(null)}
|
onClose={() => setPromoteCandidate(null)}
|
||||||
onSave={async () => {
|
onSuccess={async () => {
|
||||||
await promoteToLeader(promoteCandidate.steamId)
|
await handleReload()
|
||||||
setPromoteCandidate(null)
|
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 && (
|
{canManage && kickCandidate && (
|
||||||
@ -1170,13 +1129,12 @@ function TeamMemberViewBody({
|
|||||||
show={showDeleteModal}
|
show={showDeleteModal}
|
||||||
onClose={() => setShowDeleteModal(false)}
|
onClose={() => setShowDeleteModal(false)}
|
||||||
onSave={async () => {
|
onSave={async () => {
|
||||||
await fetch('/api/team/delete', {
|
await fetch(`/api/team/${team.id}/delete`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ teamId: team.id }),
|
|
||||||
})
|
})
|
||||||
setShowDeleteModal(false)
|
setShowDeleteModal(false)
|
||||||
window.location.href = '/team'
|
window.location.href = '/teams'
|
||||||
}}
|
}}
|
||||||
closeButtonTitle="Team löschen"
|
closeButtonTitle="Team löschen"
|
||||||
closeButtonColor="red"
|
closeButtonColor="red"
|
||||||
|
|||||||
@ -65,6 +65,7 @@ export default function TeamPlayerCard({
|
|||||||
alt={name}
|
alt={name}
|
||||||
size={48}
|
size={48}
|
||||||
showStatus
|
showStatus
|
||||||
|
isLeader={isLeader}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
|
|||||||
126
src/app/[locale]/components/TransferLeaderModal.tsx
Normal file
126
src/app/[locale]/components/TransferLeaderModal.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -3,24 +3,40 @@ import { headers } from 'next/headers'
|
|||||||
import { notFound } from 'next/navigation'
|
import { notFound } from 'next/navigation'
|
||||||
import { MatchProvider } from './MatchContext'
|
import { MatchProvider } from './MatchContext'
|
||||||
import type { Match } from '../../../../types/match'
|
import type { Match } from '../../../../types/match'
|
||||||
import { Agent } from 'undici'
|
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic'
|
export const dynamic = 'force-dynamic'
|
||||||
export const revalidate = 0
|
export const revalidate = 0
|
||||||
|
|
||||||
async function loadMatch(matchId: string): Promise<Match | null> {
|
async function buildOrigin(): Promise<string> {
|
||||||
const h = await headers()
|
const h = await headers()
|
||||||
const proto = (h.get('x-forwarded-proto') ?? 'http').split(',')[0].trim()
|
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 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 } })
|
// Fallbacks (lokale Entwicklung / SSR-Tools)
|
||||||
const init: RequestInit & { dispatcher?: Agent } = { cache: 'no-store' }
|
return (
|
||||||
if (base.startsWith('https://') && process.env.NODE_ENV !== 'production') {
|
process.env.NEXT_PUBLIC_SITE_URL ||
|
||||||
init.dispatcher = insecure
|
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
|
if (!res.ok) return null
|
||||||
return res.json()
|
return res.json()
|
||||||
}
|
}
|
||||||
|
|||||||
@ -26,9 +26,20 @@ function uniqBySteamId<T extends { steamId: string }>(list: T[]): T[] {
|
|||||||
}
|
}
|
||||||
return out
|
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) {
|
function byName<T extends { name: string }>(a: T, b: T) {
|
||||||
return a.name.localeCompare(b.name, 'de', { sensitivity: 'base' })
|
return a.name.localeCompare(b.name, 'de', { sensitivity: 'base' })
|
||||||
}
|
}
|
||||||
|
|
||||||
function classNames(...xs: Array<string | false | null | undefined>) {
|
function classNames(...xs: Array<string | false | null | undefined>) {
|
||||||
return xs.filter(Boolean).join(' ')
|
return xs.filter(Boolean).join(' ')
|
||||||
}
|
}
|
||||||
@ -37,7 +48,9 @@ type TeamDetailClientProps = {
|
|||||||
teamId: string
|
teamId: string
|
||||||
/** Darf fehlen – wird dann via /api/user ermittelt */
|
/** Darf fehlen – wird dann via /api/user ermittelt */
|
||||||
currentUserSteamId?: string | null
|
currentUserSteamId?: string | null
|
||||||
|
/** Einladung des Users für dieses Team: string = echte Einladung, 'pending' = offene Anfrage, null = nichts */
|
||||||
invitationId?: string | 'pending' | null
|
invitationId?: string | 'pending' | null
|
||||||
|
/** Falls false, kann der User generell keine Join-Requests stellen (z. B. schon in einem Team) */
|
||||||
canRequestJoin?: boolean
|
canRequestJoin?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -100,6 +113,41 @@ export default function TeamDetailClient({
|
|||||||
return () => ac.abort()
|
return () => ac.abort()
|
||||||
}, [teamId, router])
|
}, [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 ---------- */
|
/* ---------- Ableitungen ---------- */
|
||||||
|
|
||||||
// Counts (ohne Eingeladene)
|
// Counts (ohne Eingeladene)
|
||||||
@ -154,6 +202,7 @@ export default function TeamDetailClient({
|
|||||||
const isInviteOnly = team?.joinPolicy === 'INVITE_ONLY'
|
const isInviteOnly = team?.joinPolicy === 'INVITE_ONLY'
|
||||||
const hasRealInvitation = Boolean(invitationId && invitationId !== 'pending')
|
const hasRealInvitation = Boolean(invitationId && invitationId !== 'pending')
|
||||||
const hasPendingRequest = invitationId === 'pending'
|
const hasPendingRequest = invitationId === 'pending'
|
||||||
|
const isRequested = hasRealInvitation || hasPendingRequest
|
||||||
|
|
||||||
/* ---------- Aktionen ---------- */
|
/* ---------- Aktionen ---------- */
|
||||||
|
|
||||||
@ -165,7 +214,8 @@ export default function TeamDetailClient({
|
|||||||
|
|
||||||
setJoining(true)
|
setJoining(true)
|
||||||
try {
|
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)
|
const updated = await reloadTeam(team.id)
|
||||||
if (updated) setTeam(updated)
|
if (updated) setTeam(updated)
|
||||||
} catch (err) {
|
} 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 () => {
|
const handleJoinBranches = async () => {
|
||||||
if (!team || joining) return
|
if (!team || joining) return
|
||||||
setJoining(true)
|
setJoining(true)
|
||||||
@ -185,6 +235,7 @@ export default function TeamDetailClient({
|
|||||||
await fetch('/api/user/invitations/reject', {
|
await fetch('/api/user/invitations/reject', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
credentials: 'same-origin',
|
||||||
body: JSON.stringify({ invitationId }),
|
body: JSON.stringify({ invitationId }),
|
||||||
})
|
})
|
||||||
setInvitationId(null)
|
setInvitationId(null)
|
||||||
@ -192,18 +243,25 @@ export default function TeamDetailClient({
|
|||||||
await fetch('/api/user/invitations/revoke', {
|
await fetch('/api/user/invitations/revoke', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
credentials: 'same-origin',
|
||||||
body: JSON.stringify({ teamId: team.id, type: 'team-join-request' }),
|
body: JSON.stringify({ teamId: team.id, type: 'team-join-request' }),
|
||||||
})
|
})
|
||||||
setInvitationId(null)
|
setInvitationId(null)
|
||||||
} else {
|
} else {
|
||||||
if (isInviteOnly) return
|
if (!isInviteOnly) {
|
||||||
await fetch('/api/team/request-join', {
|
const res = await fetch(`/api/team/${encodeURIComponent(team.id)}/request-join`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ teamId: team.id }),
|
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')
|
setInvitationId('pending')
|
||||||
}
|
}
|
||||||
|
}
|
||||||
const updated = await reloadTeam(team.id)
|
const updated = await reloadTeam(team.id)
|
||||||
if (updated) setTeam(updated)
|
if (updated) setTeam(updated)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@ -234,7 +292,30 @@ export default function TeamDetailClient({
|
|||||||
}
|
}
|
||||||
if (!team) return null
|
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 (
|
return (
|
||||||
<Card maxWidth="auto">
|
<Card maxWidth="auto">
|
||||||
@ -260,9 +341,9 @@ export default function TeamDetailClient({
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Button nur anzeigen, wenn Mitglied */}
|
{/* Rechts: Action-Button */}
|
||||||
{isMemberOfThisTeam && (
|
|
||||||
<div className="ml-auto">
|
<div className="ml-auto">
|
||||||
|
{isMemberOfThisTeam ? (
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
color="red"
|
color="red"
|
||||||
@ -279,9 +360,20 @@ export default function TeamDetailClient({
|
|||||||
'Team verlassen'
|
'Team verlassen'
|
||||||
)}
|
)}
|
||||||
</Button>
|
</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>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Toolbar */}
|
{/* Toolbar */}
|
||||||
<div className="mb-5 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
<div className="mb-5 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||||
@ -304,17 +396,17 @@ export default function TeamDetailClient({
|
|||||||
|
|
||||||
{/* Segment */}
|
{/* Segment */}
|
||||||
<div className="inline-flex overflow-hidden rounded-lg border border-gray-200 dark:border-neutral-700">
|
<div className="inline-flex overflow-hidden rounded-lg border border-gray-200 dark:border-neutral-700">
|
||||||
{[
|
{([
|
||||||
{ key: 'all', label: `Alle (${counts.all})` },
|
{ key: 'all', label: `Alle (${counts.all})` },
|
||||||
{ key: 'active', label: `Aktiv (${counts.active})` },
|
{ key: 'active', label: `Aktiv (${counts.active})` },
|
||||||
{ key: 'inactive', label: `Inaktiv (${counts.inactive})` },
|
{ key: 'inactive', label: `Inaktiv (${counts.inactive})` },
|
||||||
].map((opt) => (
|
] as const).map((opt) => (
|
||||||
<button
|
<button
|
||||||
key={opt.key}
|
key={opt.key}
|
||||||
onClick={() => setSeg(opt.key as 'all' | 'active' | 'inactive')}
|
onClick={() => setSeg(opt.key)}
|
||||||
className={classNames(
|
className={classNames(
|
||||||
'px-3 py-1.5 text-sm transition',
|
'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'
|
? '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'
|
: '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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Leader-Leave-Modal (wie in TeamMemberView) */}
|
{/* Leader-Leave-Modal */}
|
||||||
{isLeader && (
|
{isLeader && (
|
||||||
<LeaveTeamModal
|
<LeaveTeamModal
|
||||||
show={showLeaveModal}
|
show={showLeaveModal}
|
||||||
|
|||||||
@ -71,17 +71,25 @@ export default function TeamsPage() {
|
|||||||
|
|
||||||
const [tJson, iJson, uJson]: [TeamsJson, InvitesJson, UserJson] = await Promise.all([
|
const [tJson, iJson, uJson]: [TeamsJson, InvitesJson, UserJson] = await Promise.all([
|
||||||
tRes.json(),
|
tRes.json(),
|
||||||
iRes.json().catch<unknown>(() => ({})),
|
iRes.ok ? iRes.json().catch<unknown>(() => ({})) : Promise.resolve({} as unknown),
|
||||||
uRes.ok ? uRes.json() : Promise.resolve({} as unknown),
|
uRes.ok ? uRes.json() : Promise.resolve({} as unknown),
|
||||||
])
|
])
|
||||||
|
|
||||||
const nextTeams = parseTeams(tJson)
|
const nextTeams = parseTeams(tJson)
|
||||||
const invites = parseInvites(iJson)
|
const invites = parseInvites(iJson)
|
||||||
|
|
||||||
|
// 🔧 WICHTIG: 'team-join-request' → 'pending', 'team-invite' → ID
|
||||||
const nextMap: Record<string, string> = {}
|
const nextMap: Record<string, string> = {}
|
||||||
for (const inv of invites) {
|
for (const inv of invites) {
|
||||||
if (inv?.type === 'team-join-request' && inv.teamId && inv.id) {
|
const teamId = inv?.teamId
|
||||||
nextMap[inv.teamId] = inv.id
|
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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
33
src/app/api/team/[teamId]/add-players/route.ts
Normal file
33
src/app/api/team/[teamId]/add-players/route.ts
Normal 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 })) })
|
||||||
|
}
|
||||||
@ -1,20 +1,28 @@
|
|||||||
|
// /src/app/api/team/[teamId]/available-users/route.ts
|
||||||
import { NextRequest, NextResponse } from 'next/server'
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
import { prisma } from '@/lib/prisma'
|
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 {
|
try {
|
||||||
const { searchParams } = new URL(req.url)
|
const { searchParams } = new URL(req.url)
|
||||||
const teamId = searchParams.get('teamId')
|
|
||||||
const onlyFree = (searchParams.get('onlyFree') ?? '').toLowerCase() === 'true'
|
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 })
|
return NextResponse.json({ message: 'teamId fehlt' }, { status: 400 })
|
||||||
}
|
}
|
||||||
|
|
||||||
// 1) Team laden (für den Standard-Fall weiterhin nötig)
|
// 1) Team laden (für den Standard-Fall weiterhin nötig)
|
||||||
const team = await prisma.team.findUnique({
|
const team = await prisma.team.findUnique({
|
||||||
where: { id: teamId },
|
where: { id: tid },
|
||||||
select: { activePlayers: true, inactivePlayers: true }
|
select: { activePlayers: true, inactivePlayers: true },
|
||||||
})
|
})
|
||||||
if (!team) {
|
if (!team) {
|
||||||
return NextResponse.json({ message: 'Team nicht gefunden' }, { status: 404 })
|
return NextResponse.json({ message: 'Team nicht gefunden' }, { status: 404 })
|
||||||
@ -23,9 +31,9 @@ export async function GET(req: NextRequest) {
|
|||||||
let excludeIds: string[] = []
|
let excludeIds: string[] = []
|
||||||
|
|
||||||
if (onlyFree) {
|
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({
|
const allTeams = await prisma.team.findMany({
|
||||||
select: { activePlayers: true, inactivePlayers: true }
|
select: { activePlayers: true, inactivePlayers: true },
|
||||||
})
|
})
|
||||||
const occupied = new Set<string>()
|
const occupied = new Set<string>()
|
||||||
for (const t of allTeams) {
|
for (const t of allTeams) {
|
||||||
@ -33,40 +41,43 @@ export async function GET(req: NextRequest) {
|
|||||||
for (const id of (t.inactivePlayers ?? [])) occupied.add(id)
|
for (const id of (t.inactivePlayers ?? [])) occupied.add(id)
|
||||||
}
|
}
|
||||||
excludeIds = Array.from(occupied)
|
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 {
|
} 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>([
|
const members = new Set<string>([
|
||||||
...(team.activePlayers ?? []),
|
...(team.activePlayers ?? []),
|
||||||
...(team.inactivePlayers ?? [])
|
...(team.inactivePlayers ?? []),
|
||||||
])
|
])
|
||||||
|
|
||||||
const pendingInvites = await prisma.teamInvite.findMany({
|
const pendingInvites = await prisma.teamInvite.findMany({
|
||||||
where: { teamId },
|
where: { teamId: tid },
|
||||||
select: { steamId: true }
|
select: { steamId: true },
|
||||||
})
|
})
|
||||||
const invited = new Set<string>(pendingInvites.map(i => i.steamId))
|
const invited = new Set<string>(pendingInvites.map(i => i.steamId))
|
||||||
|
|
||||||
excludeIds = Array.from(new Set([...members, ...invited]))
|
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({
|
const availableUsers = await prisma.user.findMany({
|
||||||
where: {
|
where: {
|
||||||
canBeInvited: true,
|
canBeInvited: true,
|
||||||
steamId: { notIn: excludeIds }
|
steamId: { notIn: excludeIds },
|
||||||
},
|
},
|
||||||
select: {
|
select: {
|
||||||
steamId: true,
|
steamId: true,
|
||||||
name: true,
|
name: true,
|
||||||
avatar: true,
|
avatar: true,
|
||||||
location: 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) {
|
} catch (error) {
|
||||||
console.error('Fehler beim Laden der verfügbaren Benutzer:', error)
|
console.error('Fehler beim Laden der verfügbaren Benutzer:', error)
|
||||||
return NextResponse.json({ message: 'Fehler beim Laden' }, { status: 500 })
|
return NextResponse.json({ message: 'Fehler beim Laden' }, { status: 500 })
|
||||||
@ -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 { NextResponse, type NextRequest } from 'next/server'
|
||||||
import { getServerSession } from 'next-auth'
|
import { getServerSession } from 'next-auth'
|
||||||
import { sessionAuthOptions } from '@/lib/auth'
|
import { sessionAuthOptions } from '@/lib/auth'
|
||||||
@ -7,7 +7,10 @@ import { sendServerSSEMessage } from '@/lib/sse-server-client'
|
|||||||
|
|
||||||
export const dynamic = 'force-dynamic'
|
export const dynamic = 'force-dynamic'
|
||||||
|
|
||||||
export async function POST(req: NextRequest) {
|
export async function POST(
|
||||||
|
req: NextRequest,
|
||||||
|
ctx: { params: Promise<{ teamId: string }> }
|
||||||
|
) {
|
||||||
try {
|
try {
|
||||||
const session = await getServerSession(sessionAuthOptions)
|
const session = await getServerSession(sessionAuthOptions)
|
||||||
const steamId = session?.user?.steamId
|
const steamId = session?.user?.steamId
|
||||||
@ -16,7 +19,7 @@ export async function POST(req: NextRequest) {
|
|||||||
return NextResponse.json({ error: 'Nicht eingeloggt' }, { status: 401 })
|
return NextResponse.json({ error: 'Nicht eingeloggt' }, { status: 401 })
|
||||||
}
|
}
|
||||||
|
|
||||||
const { teamId } = await req.json().catch(() => ({}))
|
const { teamId } = await ctx.params
|
||||||
if (!teamId) {
|
if (!teamId) {
|
||||||
return NextResponse.json({ error: 'Team-ID fehlt' }, { status: 400 })
|
return NextResponse.json({ error: 'Team-ID fehlt' }, { status: 400 })
|
||||||
}
|
}
|
||||||
@ -1,4 +1,4 @@
|
|||||||
// /api/team/invite/route.ts
|
// /src/app/api/team/[teamId]/invite/route.ts
|
||||||
import { NextRequest, NextResponse } from 'next/server'
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
import { prisma } from '@/lib/prisma'
|
import { prisma } from '@/lib/prisma'
|
||||||
import { sendServerSSEMessage } from '@/lib/sse-server-client'
|
import { sendServerSSEMessage } from '@/lib/sse-server-client'
|
||||||
@ -11,9 +11,13 @@ type ResultReason =
|
|||||||
| 'duplicate'
|
| 'duplicate'
|
||||||
| 'ok'
|
| 'ok'
|
||||||
|
|
||||||
export async function POST(req: NextRequest) {
|
export async function POST(
|
||||||
|
req: NextRequest,
|
||||||
|
ctx: { params: Promise<{ teamId: string }> }
|
||||||
|
) {
|
||||||
try {
|
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) {
|
if (!teamId || !Array.isArray(rawUserIds) || rawUserIds.length === 0) {
|
||||||
return NextResponse.json({ message: 'Fehlende Daten' }, { status: 400 })
|
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({
|
await sendServerSSEMessage({
|
||||||
type: 'team-updated',
|
type: 'team-updated',
|
||||||
teamId,
|
teamId,
|
||||||
targetUserIds: team.leader?.steamId
|
targetUserIds: team.leader?.steamId ? [team.leader.steamId] : []
|
||||||
})
|
})
|
||||||
|
|
||||||
const okCount = targetIds.length
|
const okCount = targetIds.length
|
||||||
@ -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 { NextRequest, NextResponse } from 'next/server'
|
||||||
import { prisma } from '@/lib/prisma'
|
import { prisma } from '@/lib/prisma'
|
||||||
import { sendServerSSEMessage } from '@/lib/sse-server-client'
|
import { sendServerSSEMessage } from '@/lib/sse-server-client'
|
||||||
@ -6,12 +6,16 @@ import { removePlayerFromMatches } from '@/lib/removePlayerFromMatches'
|
|||||||
|
|
||||||
export const dynamic = 'force-dynamic'
|
export const dynamic = 'force-dynamic'
|
||||||
|
|
||||||
export async function POST(req: NextRequest) {
|
export async function POST(
|
||||||
|
req: NextRequest,
|
||||||
|
ctx: { params: Promise<{ teamId: string }> }
|
||||||
|
) {
|
||||||
try {
|
try {
|
||||||
/* 1) Payload prüfen */
|
/* 1) Payload prüfen */
|
||||||
const { teamId, steamId } = await req.json()
|
const { teamId } = await ctx.params
|
||||||
if (!teamId || !steamId) {
|
const { steamId } = await req.json()
|
||||||
return NextResponse.json({ message: 'Fehlende Daten' }, { status: 400 })
|
if (!steamId) {
|
||||||
|
return NextResponse.json({ message: 'Fehlende SteamID' }, { status: 400 })
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 2) Team + User laden */
|
/* 2) Team + User laden */
|
||||||
242
src/app/api/team/[teamId]/leave/route.ts
Normal file
242
src/app/api/team/[teamId]/leave/route.ts
Normal 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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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 { NextResponse, type NextRequest } from 'next/server'
|
||||||
import { prisma } from '@/lib/prisma'
|
import { prisma } from '@/lib/prisma'
|
||||||
import { sendServerSSEMessage } from '@/lib/sse-server-client'
|
import { sendServerSSEMessage } from '@/lib/sse-server-client'
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic'
|
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)
|
// Prisma-Fehler-Shape (ohne Import von Prisma-Typen)
|
||||||
type KnownPrismaError = { code: string; meta?: Record<string, unknown> }
|
type KnownPrismaError = { code: string; meta?: Record<string, unknown> }
|
||||||
function isKnownPrismaError(e: unknown): e is KnownPrismaError {
|
function isKnownPrismaError(e: unknown): e is KnownPrismaError {
|
||||||
return !!e && typeof e === 'object' && 'code' in e && typeof (e as { code: unknown }).code === 'string'
|
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 {
|
try {
|
||||||
const raw = await req.json().catch(() => null)
|
const { teamId } = await ctx.params
|
||||||
const { teamId, newName } = parseBody(raw)
|
const { newName } = (await req.json().catch(() => ({}))) as { newName?: string }
|
||||||
const name = (newName ?? '').trim()
|
const name = (newName ?? '').trim()
|
||||||
|
|
||||||
if (!teamId || !name) {
|
if (!name) {
|
||||||
return NextResponse.json({ error: 'Fehlende Parameter' }, { status: 400 })
|
return NextResponse.json({ error: 'Fehlende Parameter' }, { status: 400 })
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -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 { NextResponse, type NextRequest } from 'next/server'
|
||||||
import { prisma } from '@/lib/prisma'
|
import { prisma } from '@/lib/prisma'
|
||||||
import { getServerSession } from 'next-auth'
|
import { getServerSession } from 'next-auth'
|
||||||
import { sessionAuthOptions } from '@/lib/auth'
|
import { sessionAuthOptions } from '@/lib/auth'
|
||||||
import { sendServerSSEMessage } from '@/lib/sse-server-client'
|
import { sendServerSSEMessage } from '@/lib/sse-server-client'
|
||||||
import type { AsyncParams } from '@/types/next'
|
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic'
|
export const dynamic = 'force-dynamic'
|
||||||
|
|
||||||
export async function POST(
|
export async function POST(
|
||||||
req: NextRequest,
|
req: NextRequest,
|
||||||
ctx: AsyncParams<{ action: 'accept' | 'reject' }>
|
ctx: { params: Promise<{ teamId: string; action: 'accept' | 'reject' }> }
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
|
const { teamId, action } = await ctx.params
|
||||||
const session = await getServerSession(sessionAuthOptions)
|
const session = await getServerSession(sessionAuthOptions)
|
||||||
const leaderSteamId = session?.user?.steamId
|
const leaderSteamId = session?.user?.steamId
|
||||||
if (!leaderSteamId) {
|
if (!leaderSteamId) {
|
||||||
return NextResponse.json({ message: 'Nicht eingeloggt' }, { status: 401 })
|
return NextResponse.json({ message: 'Nicht eingeloggt' }, { status: 401 })
|
||||||
}
|
}
|
||||||
|
|
||||||
const { action } = await ctx.params
|
const { requestId } = await req.json().catch(() => ({}))
|
||||||
const { requestId, teamId: teamIdFromBody } = await req.json().catch(() => ({}))
|
|
||||||
if (!requestId) {
|
if (!requestId) {
|
||||||
return NextResponse.json({ message: 'requestId fehlt' }, { status: 400 })
|
return NextResponse.json({ message: 'requestId fehlt' }, { status: 400 })
|
||||||
}
|
}
|
||||||
@ -31,8 +30,9 @@ export async function POST(
|
|||||||
if (joinInv.type !== 'team-join-request') {
|
if (joinInv.type !== 'team-join-request') {
|
||||||
return NextResponse.json({ message: 'Invitation ist keine Beitrittsanfrage' }, { status: 400 })
|
return NextResponse.json({ message: 'Invitation ist keine Beitrittsanfrage' }, { status: 400 })
|
||||||
}
|
}
|
||||||
|
if (joinInv.teamId && joinInv.teamId !== teamId) {
|
||||||
const teamId = joinInv.teamId ?? teamIdFromBody
|
return NextResponse.json({ message: 'Falsches Team' }, { status: 400 })
|
||||||
|
}
|
||||||
if (!teamId) return NextResponse.json({ message: 'teamId fehlt/ungültig' }, { status: 400 })
|
if (!teamId) return NextResponse.json({ message: 'teamId fehlt/ungültig' }, { status: 400 })
|
||||||
|
|
||||||
// Team + Leader prüfen
|
// Team + Leader prüfen
|
||||||
@ -146,7 +146,7 @@ export async function POST(
|
|||||||
|
|
||||||
return NextResponse.json({ message: 'Beitrittsanfrage angenommen' }, { status: 200 })
|
return NextResponse.json({ message: 'Beitrittsanfrage angenommen' }, { status: 200 })
|
||||||
} catch (err) {
|
} 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 })
|
return NextResponse.json({ message: 'Serverfehler' }, { status: 500 })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -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 { NextResponse, type NextRequest } from 'next/server'
|
||||||
import { prisma } from '@/lib/prisma'
|
import { prisma } from '@/lib/prisma'
|
||||||
import { getServerSession } from 'next-auth'
|
import { getServerSession } from 'next-auth'
|
||||||
import { sessionAuthOptions } from '@/lib/auth'
|
import { sessionAuthOptions } from '@/lib/auth'
|
||||||
import { sendServerSSEMessage } from '@/lib/sse-server-client'
|
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 {
|
try {
|
||||||
|
const { teamId } = await ctx.params
|
||||||
// ── Session ──────────────────────────────────────────────
|
// ── Session ──────────────────────────────────────────────
|
||||||
const session = await getServerSession(sessionAuthOptions)
|
const session = await getServerSession(sessionAuthOptions)
|
||||||
if (!session?.user?.steamId) {
|
if (!session?.user?.steamId) {
|
||||||
@ -15,13 +19,12 @@ export async function POST(req: NextRequest) {
|
|||||||
const requesterSteamId = session.user.steamId
|
const requesterSteamId = session.user.steamId
|
||||||
|
|
||||||
// ── Body ────────────────────────────────────────────────
|
// ── Body ────────────────────────────────────────────────
|
||||||
const { teamId } = await req.json()
|
|
||||||
if (!teamId) {
|
if (!teamId) {
|
||||||
return NextResponse.json({ message: 'teamId fehlt' }, { status: 400 })
|
return NextResponse.json({ message: 'teamId fehlt' }, { status: 400 })
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Daten holen ─────────────────────────────────────────
|
// ── Daten holen ─────────────────────────────────────────
|
||||||
const [team, requester] = await Promise.all([
|
const [team] = await Promise.all([
|
||||||
prisma.team.findUnique({
|
prisma.team.findUnique({
|
||||||
where: { id: teamId },
|
where: { id: teamId },
|
||||||
select: {
|
select: {
|
||||||
@ -43,11 +46,6 @@ export async function POST(req: NextRequest) {
|
|||||||
return NextResponse.json({ message: 'Team nicht gefunden' }, { status: 404 })
|
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? ────────────
|
// ── Schon (irgendwie) Mitglied dieses Teams? ────────────
|
||||||
if (
|
if (
|
||||||
requesterSteamId === team.leaderId ||
|
requesterSteamId === team.leaderId ||
|
||||||
@ -102,7 +100,7 @@ export async function POST(req: NextRequest) {
|
|||||||
|
|
||||||
return NextResponse.json({ message: 'Anfrage gesendet' }, { status: 200 })
|
return NextResponse.json({ message: 'Anfrage gesendet' }, { status: 200 })
|
||||||
} catch (err) {
|
} 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 })
|
return NextResponse.json({ message: 'Interner Serverfehler' }, { status: 500 })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -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 { prisma } from '@/lib/prisma'
|
||||||
import { NextResponse, type NextRequest } from 'next/server'
|
import { NextResponse, type NextRequest } from 'next/server'
|
||||||
import { sendServerSSEMessage } from '@/lib/sse-server-client'
|
import { sendServerSSEMessage } from '@/lib/sse-server-client'
|
||||||
@ -7,16 +7,6 @@ export const dynamic = 'force-dynamic'
|
|||||||
|
|
||||||
type KnownPrismaErrorShape = { code: string; meta?: Record<string, unknown> };
|
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
|
// Type Guard für PrismaClientKnownRequestError
|
||||||
function isKnownPrismaError(e: unknown): e is KnownPrismaErrorShape {
|
function isKnownPrismaError(e: unknown): e is KnownPrismaErrorShape {
|
||||||
return !!e
|
return !!e
|
||||||
@ -25,14 +15,14 @@ function isKnownPrismaError(e: unknown): e is KnownPrismaErrorShape {
|
|||||||
&& typeof (e as { code: unknown }).code === 'string';
|
&& 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 {
|
try {
|
||||||
const raw = await req.json().catch(() => null)
|
const { teamId } = await ctx.params
|
||||||
const { teamId, newLeaderSteamId } = parseBody(raw)
|
const { newLeaderSteamId } = (await req.json().catch(() => ({}))) as { newLeaderSteamId?: string }
|
||||||
|
if (!newLeaderSteamId) return NextResponse.json({ message: 'Fehlende Parameter' }, { status: 400 })
|
||||||
if (!teamId || !newLeaderSteamId) {
|
|
||||||
return NextResponse.json({ message: 'Fehlende Parameter' }, { status: 400 })
|
|
||||||
}
|
|
||||||
|
|
||||||
const team = await prisma.team.findUnique({
|
const team = await prisma.team.findUnique({
|
||||||
where: { id: teamId },
|
where: { id: teamId },
|
||||||
@ -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 { NextResponse, type NextRequest } from 'next/server'
|
||||||
import { prisma } from '@/lib/prisma'
|
import { prisma } from '@/lib/prisma'
|
||||||
import { getServerSession } from 'next-auth'
|
import { getServerSession } from 'next-auth'
|
||||||
@ -12,18 +12,10 @@ export const dynamic = 'force-dynamic'
|
|||||||
// Einmal zentral definieren und später benutzen
|
// Einmal zentral definieren und später benutzen
|
||||||
const ALLOWED: readonly TeamJoinPolicy[] = ['REQUEST', 'INVITE_ONLY'] as const
|
const ALLOWED: readonly TeamJoinPolicy[] = ['REQUEST', 'INVITE_ONLY'] as const
|
||||||
|
|
||||||
type Body = { teamId?: string; joinPolicy?: TeamJoinPolicy }
|
export async function POST(
|
||||||
|
req: NextRequest,
|
||||||
function parseBody(v: unknown): Body {
|
ctx: { params: Promise<{ teamId: string }> }
|
||||||
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) {
|
|
||||||
try {
|
try {
|
||||||
const session = await getServerSession(sessionAuthOptions)
|
const session = await getServerSession(sessionAuthOptions)
|
||||||
|
|
||||||
@ -32,8 +24,8 @@ export async function POST(req: NextRequest) {
|
|||||||
return NextResponse.json({ message: 'Nicht eingeloggt' }, { status: 401 })
|
return NextResponse.json({ message: 'Nicht eingeloggt' }, { status: 401 })
|
||||||
}
|
}
|
||||||
|
|
||||||
const raw: unknown = await req.json().catch(() => null)
|
const { teamId } = await ctx.params
|
||||||
const { teamId, joinPolicy } = parseBody(raw)
|
const { joinPolicy } = (await req.json().catch(() => ({}))) as { joinPolicy?: TeamJoinPolicy }
|
||||||
|
|
||||||
if (!teamId) {
|
if (!teamId) {
|
||||||
return NextResponse.json({ message: 'teamId fehlt' }, { status: 400 })
|
return NextResponse.json({ message: 'teamId fehlt' }, { status: 400 })
|
||||||
@ -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 { prisma } from '@/lib/prisma'
|
||||||
import { sendServerSSEMessage } from '@/lib/sse-server-client'
|
import { sendServerSSEMessage } from '@/lib/sse-server-client'
|
||||||
import { NextResponse, type NextRequest } from 'next/server'
|
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 {
|
try {
|
||||||
const { teamId, activePlayers, inactivePlayers, invitedPlayers } = await req.json()
|
const { teamId } = await ctx.params
|
||||||
if (!teamId || !Array.isArray(activePlayers) || !Array.isArray(inactivePlayers)) {
|
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 })
|
return NextResponse.json({ error: 'Ungültige Eingabedaten' }, { status: 400 })
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -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 { NextResponse, type NextRequest } from 'next/server'
|
||||||
import { writeFile, mkdir, unlink } from 'fs/promises'
|
import { writeFile, mkdir, unlink } from 'fs/promises'
|
||||||
import { join, dirname } from 'path'
|
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 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 formData = await req.formData()
|
||||||
const file = formData.get('logo') as File | null
|
const file = formData.get('logo') as File | null
|
||||||
const teamId = formData.get('teamId') as string | null
|
|
||||||
|
|
||||||
if (!file || !teamId) {
|
if (!file) return NextResponse.json({ message: 'Ungültige Daten' }, { status: 400 })
|
||||||
return NextResponse.json({ message: 'Ungültige Daten' }, { status: 400 })
|
|
||||||
}
|
|
||||||
|
|
||||||
// Bytes lesen
|
// Bytes lesen
|
||||||
const inputBuffer = Buffer.from(await file.arrayBuffer())
|
const inputBuffer = Buffer.from(await file.arrayBuffer())
|
||||||
@ -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 }))
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@ -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 })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
31
src/app/api/team/leader-ids/route.ts
Normal file
31
src/app/api/team/leader-ids/route.ts
Normal 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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
39
src/app/api/team/leader-map/route.ts
Normal file
39
src/app/api/team/leader-map/route.ts
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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 })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -3,6 +3,8 @@ import { NextResponse, type NextRequest } from 'next/server'
|
|||||||
import { prisma } from '@/lib/prisma'
|
import { prisma } from '@/lib/prisma'
|
||||||
import { sendServerSSEMessage } from '@/lib/sse-server-client'
|
import { sendServerSSEMessage } from '@/lib/sse-server-client'
|
||||||
import type { AsyncParams } from '@/types/next'
|
import type { AsyncParams } from '@/types/next'
|
||||||
|
import { getServerSession } from 'next-auth'
|
||||||
|
import { sessionAuthOptions } from '@/lib/auth'
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic'
|
export const dynamic = 'force-dynamic'
|
||||||
|
|
||||||
@ -13,47 +15,63 @@ export async function POST(
|
|||||||
const { action } = await ctx.params
|
const { action } = await ctx.params
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
const session = await getServerSession(sessionAuthOptions)
|
||||||
|
const sessionSteamId = session?.user?.steamId
|
||||||
|
|
||||||
const body = (await req.json().catch(() => ({}))) as Partial<{
|
const body = (await req.json().catch(() => ({}))) as Partial<{
|
||||||
invitationId: string
|
invitationId: string
|
||||||
teamId: string
|
teamId: string
|
||||||
steamId: string
|
steamId: string
|
||||||
|
type: 'team-invite' | 'team-join-request'
|
||||||
}>
|
}>
|
||||||
|
|
||||||
const incomingInvitationId = body.invitationId?.trim() || undefined
|
const incomingInvitationId = body.invitationId?.trim()
|
||||||
const fallbackTeamId = body.teamId?.trim() || undefined
|
const fallbackTeamId = body.teamId?.trim()
|
||||||
const fallbackSteamId = body.steamId?.trim() || undefined
|
const fallbackSteamId = body.steamId?.trim() || sessionSteamId
|
||||||
|
const requestedType = body.type
|
||||||
|
|
||||||
// Einladung auflösen (bevorzugt per ID, sonst per teamId+steamId)
|
// ---- Invitation auflösen -----------------------------------------
|
||||||
const invitation =
|
let invitation = null as (Awaited<ReturnType<typeof prisma.teamInvite.findUnique>> | null)
|
||||||
incomingInvitationId
|
|
||||||
? await prisma.teamInvite.findUnique({ where: { id: incomingInvitationId } })
|
|
||||||
: (fallbackTeamId && fallbackSteamId
|
|
||||||
? await prisma.teamInvite.findFirst({
|
|
||||||
where: {
|
|
||||||
type: 'team-invite',
|
|
||||||
teamId: fallbackTeamId,
|
|
||||||
steamId: fallbackSteamId,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
: null)
|
|
||||||
|
|
||||||
if (!invitation) {
|
if (incomingInvitationId) {
|
||||||
return NextResponse.json({ message: 'Einladung nicht gefunden' }, { status: 404 })
|
invitation = await prisma.teamInvite.findUnique({ where: { id: incomingInvitationId } })
|
||||||
}
|
}
|
||||||
|
|
||||||
if (invitation.type !== 'team-invite') {
|
if (!invitation && fallbackTeamId && fallbackSteamId) {
|
||||||
return NextResponse.json({ message: 'Ungültiger Einladungstyp' }, { status: 400 })
|
// 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 invitationId = invitation.id
|
||||||
const invitedUserSteamId = invitation.steamId
|
|
||||||
const teamId = invitation.teamId
|
const teamId = invitation.teamId
|
||||||
|
const invitedUserSteamId = invitation.steamId
|
||||||
if (!teamId) {
|
if (!teamId) {
|
||||||
return NextResponse.json({ message: 'Einladung ohne Team' }, { status: 400 })
|
return NextResponse.json({ message: 'Eintrag ohne Team' }, { status: 400 })
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ───────────────────────────── ACCEPT ───────────────────────────── */
|
/* ───────────────────────────── ACCEPT ───────────────────────────── */
|
||||||
if (action === '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 result = await prisma.$transaction(async (tx) => {
|
||||||
const teamBefore = await tx.team.findUnique({
|
const teamBefore = await tx.team.findUnique({
|
||||||
where: { id: teamId },
|
where: { id: teamId },
|
||||||
@ -61,14 +79,16 @@ export async function POST(
|
|||||||
})
|
})
|
||||||
if (!teamBefore) throw new Error('Team nicht gefunden')
|
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({
|
await tx.user.update({
|
||||||
where: { steamId: invitedUserSteamId },
|
where: { steamId: invitedUserSteamId },
|
||||||
data: { teamId },
|
data: { teamId }, // <- bei Multi-Teams evtl. löschen
|
||||||
})
|
})
|
||||||
|
|
||||||
// 2) Inaktive Liste updaten (ohne Duplikate)
|
// 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({
|
await tx.team.update({
|
||||||
where: { id: teamId },
|
where: { id: teamId },
|
||||||
data: { inactivePlayers: nextInactive },
|
data: { inactivePlayers: nextInactive },
|
||||||
@ -83,14 +103,9 @@ export async function POST(
|
|||||||
|
|
||||||
// 4) Andere offenen Team-Einladungen für diesen User entfernen
|
// 4) Andere offenen Team-Einladungen für diesen User entfernen
|
||||||
const otherInvites = await tx.teamInvite.findMany({
|
const otherInvites = await tx.teamInvite.findMany({
|
||||||
where: {
|
where: { steamId: invitedUserSteamId, type: 'team-invite', NOT: { id: invitationId } },
|
||||||
steamId: invitedUserSteamId,
|
|
||||||
type: 'team-invite',
|
|
||||||
NOT: { id: invitationId },
|
|
||||||
},
|
|
||||||
select: { id: true, teamId: true },
|
select: { id: true, teamId: true },
|
||||||
})
|
})
|
||||||
|
|
||||||
if (otherInvites.length) {
|
if (otherInvites.length) {
|
||||||
await tx.teamInvite.deleteMany({ where: { id: { in: otherInvites.map(o => o.id) } } })
|
await tx.teamInvite.deleteMany({ where: { id: { in: otherInvites.map(o => o.id) } } })
|
||||||
await tx.notification.updateMany({
|
await tx.notification.updateMany({
|
||||||
@ -102,13 +117,15 @@ export async function POST(
|
|||||||
return { teamBefore, nextInactive, otherInvites }
|
return { teamBefore, nextInactive, otherInvites }
|
||||||
})
|
})
|
||||||
|
|
||||||
const allMembers = Array.from(new Set(
|
const allMembers = Array.from(
|
||||||
|
new Set(
|
||||||
[
|
[
|
||||||
result.teamBefore.leaderId,
|
result.teamBefore.leaderId,
|
||||||
...(result.teamBefore.activePlayers ?? []),
|
...(result.teamBefore.activePlayers ?? []),
|
||||||
...result.nextInactive,
|
...result.nextInactive,
|
||||||
].filter(Boolean) as string[]
|
].filter(Boolean) as string[]
|
||||||
))
|
)
|
||||||
|
)
|
||||||
|
|
||||||
const joinedNotif = await prisma.notification.create({
|
const joinedNotif = await prisma.notification.create({
|
||||||
data: {
|
data: {
|
||||||
@ -169,48 +186,17 @@ export async function POST(
|
|||||||
targetUserIds: allMembers,
|
targetUserIds: allMembers,
|
||||||
})
|
})
|
||||||
|
|
||||||
if (result.otherInvites.length) {
|
// In anderen Teams eingegangene EINLADUNGEN gelöscht → optional: SSE wie gehabt
|
||||||
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,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.json({ message: 'Einladung angenommen' })
|
return NextResponse.json({ message: 'Einladung angenommen' })
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ───────────────────────────── REJECT ──────────────────────────── */
|
/* ───────────────────────────── REJECT ──────────────────────────── */
|
||||||
if (action === '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.teamInvite.delete({ where: { id: invitationId } })
|
||||||
await prisma.notification.updateMany({
|
await prisma.notification.updateMany({
|
||||||
where: { actionData: invitationId },
|
where: { actionData: invitationId },
|
||||||
@ -243,6 +229,7 @@ export async function POST(
|
|||||||
|
|
||||||
/* ───────────────────────────── REVOKE ──────────────────────────── */
|
/* ───────────────────────────── REVOKE ──────────────────────────── */
|
||||||
if (action === '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.teamInvite.delete({ where: { id: invitationId } })
|
||||||
await prisma.notification.updateMany({
|
await prisma.notification.updateMany({
|
||||||
where: { actionData: invitationId },
|
where: { actionData: invitationId },
|
||||||
@ -253,11 +240,32 @@ export async function POST(
|
|||||||
where: { id: teamId },
|
where: { id: teamId },
|
||||||
select: { leaderId: true, activePlayers: true, inactivePlayers: true },
|
select: { leaderId: true, activePlayers: true, inactivePlayers: true },
|
||||||
})
|
})
|
||||||
const admins = await prisma.user.findMany({
|
|
||||||
where: { isAdmin: true },
|
|
||||||
select: { steamId: true },
|
|
||||||
})
|
|
||||||
|
|
||||||
|
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[]
|
||||||
|
))
|
||||||
|
|
||||||
|
if (recipients.length) {
|
||||||
|
await sendServerSSEMessage({
|
||||||
|
type: 'team-join-request-revoked',
|
||||||
|
targetUserIds: recipients,
|
||||||
|
invitationId,
|
||||||
|
teamId,
|
||||||
|
})
|
||||||
|
await sendServerSSEMessage({
|
||||||
|
type: 'team-updated',
|
||||||
|
teamId,
|
||||||
|
targetUserIds: recipients,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
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([
|
const targetUserIds = Array.from(new Set([
|
||||||
team?.leaderId,
|
team?.leaderId,
|
||||||
...(team?.activePlayers ?? []),
|
...(team?.activePlayers ?? []),
|
||||||
@ -265,6 +273,7 @@ export async function POST(
|
|||||||
...admins.map(a => a.steamId),
|
...admins.map(a => a.steamId),
|
||||||
].filter(Boolean) as string[]))
|
].filter(Boolean) as string[]))
|
||||||
|
|
||||||
|
// Benachrichtige den eingeladenen User separat
|
||||||
await sendServerSSEMessage({
|
await sendServerSSEMessage({
|
||||||
type: 'team-invite-revoked',
|
type: 'team-invite-revoked',
|
||||||
targetUserIds: [invitedUserSteamId],
|
targetUserIds: [invitedUserSteamId],
|
||||||
@ -280,7 +289,8 @@ export async function POST(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
return NextResponse.json({ message: 'Einladung gelöscht' })
|
return NextResponse.json({ message: 'Einladung widerrufen' })
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return NextResponse.json({ message: 'Ungültige Aktion' }, { status: 400 })
|
return NextResponse.json({ message: 'Ungültige Aktion' }, { status: 400 })
|
||||||
|
|||||||
@ -2,6 +2,23 @@
|
|||||||
|
|
||||||
import { Player, Team } from '@/types/team'
|
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
|
// 🔄 Team laden
|
||||||
export async function reloadTeam(teamId: string): Promise<Team | null> {
|
export async function reloadTeam(teamId: string): Promise<Team | null> {
|
||||||
try {
|
try {
|
||||||
@ -88,15 +105,16 @@ export async function revokeInvitation(invitationId: string): Promise<boolean> {
|
|||||||
// ✏️ Team umbenennen
|
// ✏️ Team umbenennen
|
||||||
export async function renameTeam(teamId: string, newName: string): Promise<boolean> {
|
export async function renameTeam(teamId: string, newName: string): Promise<boolean> {
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/team/rename', {
|
const res = await fetch(`/api/team/${encodeURIComponent(teamId)}/rename`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
credentials: 'same-origin',
|
||||||
body: JSON.stringify({ teamId, newName }),
|
body: JSON.stringify({ teamId, newName }),
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const data = await res.json()
|
const data = await readJsonSafe(res)
|
||||||
throw new Error(data.message || 'Fehler beim Umbenennen')
|
throw new Error(getErrorMessage(data, 'Fehler beim Umbenennen'))
|
||||||
}
|
}
|
||||||
|
|
||||||
return true
|
return true
|
||||||
@ -107,21 +125,30 @@ export async function renameTeam(teamId: string, newName: string): Promise<boole
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 🚪 Team verlassen
|
// 🚪 Team verlassen
|
||||||
export async function leaveTeam(steamId: string, newLeaderId?: string): Promise<boolean> {
|
// Vorher:
|
||||||
try {
|
// export async function leaveTeam(steamId: string, teamId: string, newLeaderId?: string)
|
||||||
const body = newLeaderId ? { steamId, newLeaderId } : { steamId }
|
|
||||||
|
|
||||||
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',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
credentials: 'same-origin',
|
||||||
body: JSON.stringify(body),
|
body: JSON.stringify(body),
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const data = await res.json()
|
const data = await readJsonSafe(res)
|
||||||
throw new Error(data.message || 'Fehler beim Verlassen')
|
throw new Error(getErrorMessage(data, 'Fehler beim Verlassen'))
|
||||||
}
|
}
|
||||||
|
|
||||||
return true
|
return true
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('leaveTeam:', err)
|
console.error('leaveTeam:', err)
|
||||||
@ -129,18 +156,19 @@ export async function leaveTeam(steamId: string, newLeaderId?: string): Promise<
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// 👢 Spieler kicken
|
// 👢 Spieler kicken
|
||||||
export async function kickPlayer(teamId: string, steamId: string): Promise<boolean> {
|
export async function kickPlayer(teamId: string, steamId: string): Promise<boolean> {
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/team/kick', {
|
const res = await fetch(`/api/team/${encodeURIComponent(teamId)}/kick`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ teamId, steamId }),
|
body: JSON.stringify({ teamId, steamId }),
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const data = await res.json()
|
const data = await readJsonSafe(res)
|
||||||
throw new Error(data.message || 'Fehler beim Kicken')
|
throw new Error(getErrorMessage(data, 'Fehler beim Kicken'))
|
||||||
}
|
}
|
||||||
|
|
||||||
return true
|
return true
|
||||||
@ -153,15 +181,16 @@ export async function kickPlayer(teamId: string, steamId: string): Promise<boole
|
|||||||
// 👑 Leader übertragen
|
// 👑 Leader übertragen
|
||||||
export async function transferLeader(teamId: string, newLeaderSteamId: string): Promise<boolean> {
|
export async function transferLeader(teamId: string, newLeaderSteamId: string): Promise<boolean> {
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/team/transfer-leader', {
|
const res = await fetch(`/api/team/${encodeURIComponent(teamId)}/transfer-leader`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
credentials: 'same-origin',
|
||||||
body: JSON.stringify({ teamId, newLeaderSteamId }),
|
body: JSON.stringify({ teamId, newLeaderSteamId }),
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const data = await res.json()
|
const data = await readJsonSafe(res)
|
||||||
throw new Error(data.message || 'Fehler beim Übertragen der Leader-Rolle')
|
throw new Error(getErrorMessage(data, 'Fehler beim Übertragen der Leader-Rolle'))
|
||||||
}
|
}
|
||||||
|
|
||||||
return true
|
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
|
// 🗑️ Team löschen
|
||||||
export async function deleteTeam(teamId: string): Promise<boolean> {
|
export async function deleteTeam(teamId: string): Promise<boolean> {
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/team/delete', {
|
const res = await fetch(`/api/team/${encodeURIComponent(teamId)}/delete`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
credentials: 'same-origin',
|
||||||
body: JSON.stringify({ teamId }),
|
body: JSON.stringify({ teamId }),
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const data = await res.json()
|
const data = await readJsonSafe(res)
|
||||||
throw new Error(data.message || 'Fehler beim Löschen')
|
throw new Error(getErrorMessage(data, 'Fehler beim Löschen'))
|
||||||
}
|
}
|
||||||
|
|
||||||
return true
|
return true
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user