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 }) {
|
||||
try {
|
||||
// evtl. laufende Anfrage abbrechen
|
||||
abortRef.current?.abort();
|
||||
const ctrl = new AbortController();
|
||||
abortRef.current = ctrl;
|
||||
|
||||
// aktuelle Grid-Höhe merken (Anti-Jump)
|
||||
if (gridRef.current) setGridHoldHeight(gridRef.current.clientHeight);
|
||||
|
||||
setIsFetching(true);
|
||||
|
||||
// Spinner: verzögert einblenden + Mindestdauer
|
||||
if (spinnerShowTimer.current) window.clearTimeout(spinnerShowTimer.current);
|
||||
spinnerShowTimer.current = window.setTimeout(() => {
|
||||
setSpinnerVisible(true);
|
||||
spinnerShownAt.current = Date.now();
|
||||
}, SPINNER_DELAY_MS);
|
||||
|
||||
const qs = new URLSearchParams({ teamId: team.id });
|
||||
// 🔁 NEU: teamId im Pfad, nicht als Query-Param
|
||||
const qs = new URLSearchParams();
|
||||
if (onlyFree) qs.set('onlyFree', 'true');
|
||||
|
||||
const res = await fetch(`/api/team/available-users?${qs.toString()}`, {
|
||||
const url = `/api/team/${encodeURIComponent(team.id)}/available-users${
|
||||
qs.toString() ? `?${qs.toString()}` : ''
|
||||
}`;
|
||||
|
||||
const res = await fetch(url, {
|
||||
signal: ctrl.signal,
|
||||
cache: 'no-store',
|
||||
});
|
||||
if (!res.ok) throw new Error('load failed');
|
||||
|
||||
const dataUnknown: unknown = await res.json();
|
||||
const users = (isRecord(dataUnknown) && Array.isArray(dataUnknown.users))
|
||||
? (dataUnknown.users as Player[])
|
||||
: [];
|
||||
let users: Player[] = [];
|
||||
if (isRecord(dataUnknown)) {
|
||||
const maybeUsers = (dataUnknown as UnknownRec).users;
|
||||
if (Array.isArray(maybeUsers)) {
|
||||
users = maybeUsers as Player[];
|
||||
}
|
||||
}
|
||||
|
||||
startTransition(() => {
|
||||
setAllUsers(users);
|
||||
@ -186,41 +198,43 @@ export default function InvitePlayersModal({ show, onClose, onSuccess, team, dir
|
||||
}
|
||||
});
|
||||
} catch (e: unknown) {
|
||||
if (isRecord(e) && e.name === 'AbortError') return;
|
||||
console.error('Fehler beim Laden der Benutzer:', e);
|
||||
} finally {
|
||||
setIsFetching(false)
|
||||
abortRef.current = null
|
||||
|
||||
// 🔽 Spinner Mindestdauer respektieren
|
||||
const hide = () => {
|
||||
setSpinnerVisible(false)
|
||||
spinnerShownAt.current = null
|
||||
setGridHoldHeight(0)
|
||||
// sauberer Abort-Check ohne "any"
|
||||
if (
|
||||
(e instanceof DOMException && e.name === 'AbortError') ||
|
||||
(typeof (e as { name?: unknown })?.name === 'string' && (e as { name: string }).name === 'AbortError')
|
||||
) {
|
||||
return;
|
||||
}
|
||||
} finally {
|
||||
setIsFetching(false);
|
||||
abortRef.current = null;
|
||||
|
||||
// Spinner-Mindestdauer respektieren & aufräumen
|
||||
const hide = () => {
|
||||
setSpinnerVisible(false);
|
||||
spinnerShownAt.current = null;
|
||||
setGridHoldHeight(0);
|
||||
};
|
||||
|
||||
if (spinnerShowTimer.current) {
|
||||
// Wenn der Delay-Timer noch nicht gefeuert hat: einfach abbrechen
|
||||
window.clearTimeout(spinnerShowTimer.current)
|
||||
spinnerShowTimer.current = null
|
||||
// Der Spinner wurde evtl. nie sichtbar -> direkt aufräumen
|
||||
window.clearTimeout(spinnerShowTimer.current);
|
||||
spinnerShowTimer.current = null;
|
||||
if (!spinnerShownAt.current) {
|
||||
hide()
|
||||
return
|
||||
hide();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (spinnerShownAt.current) {
|
||||
const elapsed = Date.now() - spinnerShownAt.current
|
||||
const remain = Math.max(0, SPINNER_MIN_MS - elapsed)
|
||||
const elapsed = Date.now() - spinnerShownAt.current;
|
||||
const remain = Math.max(0, SPINNER_MIN_MS - elapsed);
|
||||
if (remain > 0) {
|
||||
window.setTimeout(hide, remain)
|
||||
window.setTimeout(hide, remain);
|
||||
} else {
|
||||
hide()
|
||||
hide();
|
||||
}
|
||||
} else {
|
||||
// Falls er nie sichtbar war
|
||||
hide()
|
||||
hide();
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -246,7 +260,9 @@ export default function InvitePlayersModal({ show, onClose, onSuccess, team, dir
|
||||
|
||||
try {
|
||||
setIsInviting(true);
|
||||
const url = directAdd ? '/api/team/add-players' : '/api/team/invite';
|
||||
const url = directAdd
|
||||
? `/api/team/${encodeURIComponent(team.id)}/add-players`
|
||||
: `/api/team/${encodeURIComponent(team.id)}/invite`
|
||||
const body = directAdd
|
||||
? { teamId: team.id, steamIds: ids }
|
||||
: { teamId: team.id, userIds: ids, invitedBy: steamId };
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useState, useEffect, useMemo } from 'react'
|
||||
import Modal from './Modal'
|
||||
import MiniCard from './MiniCard'
|
||||
import { useSession } from 'next-auth/react'
|
||||
@ -21,25 +21,59 @@ export default function LeaveTeamModal({ show, onClose, onSuccess, team }: Props
|
||||
const [newLeaderId, setNewLeaderId] = useState<string>('')
|
||||
const [, setIsSubmitting] = useState(false)
|
||||
|
||||
// Leader-IDs anderer Teams laden (aktuelles Team per ?exclude= ausnehmen)
|
||||
const [leaderIds, setLeaderIds] = useState<Set<string>>(new Set())
|
||||
useEffect(() => {
|
||||
if (show && team.leader?.steamId) {
|
||||
// ⬅︎ Player -> steamId
|
||||
setNewLeaderId(team.leader.steamId)
|
||||
}
|
||||
}, [show, team.leader?.steamId])
|
||||
if (!show) return
|
||||
let alive = true
|
||||
;(async () => {
|
||||
try {
|
||||
const res = await fetch(`/api/team/leader-ids?exclude=${encodeURIComponent(team.id)}`, {
|
||||
cache: 'no-store',
|
||||
credentials: 'same-origin',
|
||||
})
|
||||
if (!res.ok) throw new Error('leader-ids failed')
|
||||
const j = await res.json().catch(() => ({}))
|
||||
const ids: string[] = Array.isArray(j.leaderIds) ? j.leaderIds : []
|
||||
if (alive) setLeaderIds(new Set(ids))
|
||||
} catch (e) {
|
||||
console.warn('[LeaveTeamModal] leader-ids:', e)
|
||||
if (alive) setLeaderIds(new Set())
|
||||
}
|
||||
})()
|
||||
return () => { alive = false }
|
||||
}, [show, team.id])
|
||||
|
||||
// Kandidaten: alle Mitglieder außer ich + nicht bereits Leader woanders
|
||||
const candidates: Player[] = useMemo(() => {
|
||||
const all = [...(team.activePlayers ?? []), ...(team.inactivePlayers ?? [])]
|
||||
return all.filter(p => p.steamId !== steamId && !leaderIds.has(p.steamId))
|
||||
}, [team.activePlayers, team.inactivePlayers, steamId, leaderIds])
|
||||
|
||||
// Beim Öffnen ersten gültigen Kandidaten vorwählen
|
||||
useEffect(() => {
|
||||
if (!show) return
|
||||
setNewLeaderId(candidates[0]?.steamId ?? '')
|
||||
}, [show, candidates])
|
||||
|
||||
const handleLeave = async () => {
|
||||
if (!steamId) return
|
||||
setIsSubmitting(true)
|
||||
try {
|
||||
const iAmLeader = team.leader?.steamId === steamId // ⬅︎ Player vergleichen über steamId
|
||||
const success = await leaveTeam(steamId, iAmLeader ? newLeaderId : undefined)
|
||||
if (success) {
|
||||
const iAmLeader = team.leader?.steamId === steamId
|
||||
if (iAmLeader && !newLeaderId) {
|
||||
alert('Bitte wähle eine:n neue:n Teamleader:in aus.')
|
||||
return
|
||||
}
|
||||
// WICHTIG: team.id mitgeben
|
||||
const ok = await leaveTeam({ steamId, teamId: team.id, newLeaderId: iAmLeader ? newLeaderId : undefined })
|
||||
if (ok) {
|
||||
onSuccess()
|
||||
onClose()
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Fehler beim Verlassen:', err)
|
||||
alert('Aktion fehlgeschlagen.')
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
@ -54,18 +88,25 @@ export default function LeaveTeamModal({ show, onClose, onSuccess, team }: Props
|
||||
onSave={handleLeave}
|
||||
closeButtonColor="red"
|
||||
closeButtonTitle="Team verlassen"
|
||||
disableSave={(team.leader?.steamId === steamId) && candidates.length === 0}
|
||||
>
|
||||
{team.leader?.steamId === steamId ? (
|
||||
<p className="text-sm text-gray-700 dark:text-neutral-300">
|
||||
Du bist der Teamleader. Bitte wähle ein anderes Mitglied aus, das die Rolle des Leaders übernehmen soll:
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-sm text-gray-700 dark:text-neutral-300">
|
||||
Willst du das Team wirklich verlassen?
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-4 mt-4">
|
||||
{[
|
||||
...(team.activePlayers ?? []),
|
||||
...(team.inactivePlayers ?? []),
|
||||
]
|
||||
.filter((player) => player.steamId !== steamId)
|
||||
.map((player: Player) => (
|
||||
{candidates.length === 0 ? (
|
||||
<div className="mt-4 text-sm text-amber-600 dark:text-amber-400">
|
||||
Es gibt aktuell kein Mitglied, das nicht bereits Leader eines anderen Teams ist.
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-4 mt-4">
|
||||
{candidates.map((player: Player) => (
|
||||
<MiniCard
|
||||
key={player.steamId}
|
||||
steamId={player.steamId}
|
||||
@ -82,7 +123,8 @@ export default function LeaveTeamModal({ show, onClose, onSuccess, team }: Props
|
||||
hideActions={true}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
@ -17,6 +17,7 @@ type Notification = {
|
||||
actionType?: string
|
||||
actionData?: string
|
||||
createdAt?: string
|
||||
teamId?: string
|
||||
}
|
||||
|
||||
type ActionData =
|
||||
@ -80,14 +81,22 @@ export default function NotificationBell() {
|
||||
const res = await fetch('/api/notifications')
|
||||
if (!res.ok) throw new Error('Fehler beim Laden')
|
||||
const data: NotificationsResponse = await res.json()
|
||||
const loaded: Notification[] = data.notifications.map((n: ApiNotification) => ({
|
||||
id: n.id,
|
||||
text: n.message,
|
||||
read: n.read,
|
||||
actionType: n.actionType,
|
||||
actionData: n.actionData,
|
||||
createdAt: n.createdAt,
|
||||
}))
|
||||
const loaded: Notification[] = data.notifications.map((n: ApiNotification) => {
|
||||
let teamId: string | undefined
|
||||
try {
|
||||
const a = n.actionData ? JSON.parse(n.actionData) : null
|
||||
if (a && typeof a === 'object' && typeof a.teamId === 'string') teamId = a.teamId
|
||||
} catch {}
|
||||
return {
|
||||
id: n.id,
|
||||
text: n.message,
|
||||
read: n.read,
|
||||
actionType: n.actionType,
|
||||
actionData: n.actionData,
|
||||
createdAt: n.createdAt,
|
||||
teamId,
|
||||
}
|
||||
})
|
||||
setNotifications(loaded)
|
||||
} catch (err) {
|
||||
console.error('[NotificationBell] Fehler beim Laden:', err)
|
||||
@ -115,6 +124,7 @@ export default function NotificationBell() {
|
||||
actionType: data?.actionType,
|
||||
actionData: data?.actionData,
|
||||
createdAt: data?.createdAt ?? new Date().toISOString(),
|
||||
teamId: data?.teamId,
|
||||
};
|
||||
|
||||
setNotifications(prev => [newNotification, ...prev]);
|
||||
@ -171,7 +181,7 @@ export default function NotificationBell() {
|
||||
let kind: ActionData['kind'] | undefined
|
||||
let invitationId: string | undefined
|
||||
let requestId: string | undefined
|
||||
let teamId: string | undefined
|
||||
let teamId: string | undefined = n.teamId
|
||||
|
||||
try {
|
||||
const data = JSON.parse(n.actionData) as ActionData | string
|
||||
@ -180,7 +190,7 @@ export default function NotificationBell() {
|
||||
kind = data.kind
|
||||
if (data.kind === 'invite') invitationId = data.inviteId
|
||||
if (data.kind === 'join-request') requestId = data.requestId
|
||||
teamId = data.teamId
|
||||
teamId = teamId ?? data.teamId
|
||||
} else if (typeof data === 'string') {
|
||||
// nackte ID: sowohl als invitationId als auch requestId nutzbar
|
||||
invitationId = data
|
||||
@ -205,6 +215,10 @@ export default function NotificationBell() {
|
||||
console.warn('[NotificationBell] requestId fehlt')
|
||||
return
|
||||
}
|
||||
if (kind === 'join-request' && !teamId) {
|
||||
console.warn('[NotificationBell] teamId fehlt')
|
||||
return
|
||||
}
|
||||
|
||||
// Optimistic Update (Buttons ausblenden)
|
||||
const snapshot = notifications
|
||||
@ -220,9 +234,9 @@ export default function NotificationBell() {
|
||||
|
||||
if (kind === 'join-request') {
|
||||
if (action === 'accept') {
|
||||
await apiJSON('/api/team/request-join/accept', { requestId, teamId })
|
||||
await apiJSON(`/api/team/${encodeURIComponent(teamId!)}/request-join/accept`, { requestId })
|
||||
} else {
|
||||
await apiJSON('/api/team/request-join/reject', { requestId })
|
||||
await apiJSON(`/api/team/${encodeURIComponent(teamId!)}/request-join/reject`, { requestId })
|
||||
}
|
||||
setNotifications(prev => prev.filter(x => x.id !== n.id))
|
||||
if (action === 'accept') router.refresh()
|
||||
|
||||
@ -137,10 +137,10 @@ export default function TeamCard({
|
||||
onUpdateInvitation(team.id, null)
|
||||
} else {
|
||||
if (isInviteOnly) return
|
||||
await fetch('/api/team/request-join', {
|
||||
await fetch(`/api/team/${encodeURIComponent(team.id)}/request-join`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ teamId: team.id }),
|
||||
body: JSON.stringify({}),
|
||||
})
|
||||
onUpdateInvitation(team.id, 'pending')
|
||||
}
|
||||
|
||||
@ -26,6 +26,7 @@ import TeamPremierRankBadge from './TeamPremierRankBadge'
|
||||
import Link from 'next/link'
|
||||
import { useTeamStore } from '@/lib/stores'
|
||||
import { useSSEStore } from '@/lib/useSSEStore'
|
||||
import TransferLeaderModal from './TransferLeaderModal'
|
||||
import {
|
||||
TEAM_EVENTS,
|
||||
SELF_EVENTS,
|
||||
@ -334,11 +335,10 @@ function TeamMemberViewBody({
|
||||
|
||||
const updateTeamMembers = async (tId: string, active: Player[], inactive: Player[]) => {
|
||||
try {
|
||||
const res = await fetch('/api/team/update-players', {
|
||||
const res = await fetch(`/api/team/${tId}/update-players`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
teamId: tId,
|
||||
activePlayers: active.map(p => p.steamId),
|
||||
inactivePlayers: inactive.map(p => p.steamId),
|
||||
}),
|
||||
@ -451,33 +451,15 @@ function TeamMemberViewBody({
|
||||
setActivePlayers(newActive)
|
||||
setInactivePlayers(newInactive)
|
||||
|
||||
await fetch('/api/team/kick', {
|
||||
await fetch(`/api/team/${teamId}/kick`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ steamId: kickCandidate.steamId, teamId }),
|
||||
body: JSON.stringify({ steamId: kickCandidate.steamId }),
|
||||
})
|
||||
await updateTeamMembers(team.id, newActive, newInactive)
|
||||
setKickCandidate(null)
|
||||
}
|
||||
|
||||
const promoteToLeader = async (newLeaderId: string) => {
|
||||
try {
|
||||
const res = await fetch('/api/team/transfer-leader', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ teamId, newLeaderSteamId: newLeaderId }),
|
||||
})
|
||||
if (!res.ok) {
|
||||
const data: unknown = await res.json().catch(() => ({}))
|
||||
console.error('Fehler bei Leader-Übertragung:', (data as { message?: string }).message)
|
||||
return
|
||||
}
|
||||
await handleReload()
|
||||
} catch (err) {
|
||||
console.error('Fehler bei Leader-Übertragung:', err)
|
||||
}
|
||||
}
|
||||
|
||||
type DownscaleOpts = {
|
||||
size?: number
|
||||
quality?: number
|
||||
@ -490,12 +472,12 @@ function TeamMemberViewBody({
|
||||
try {
|
||||
setSavingPolicy(true)
|
||||
|
||||
const res = await fetch('/api/team/update-join-policy', {
|
||||
const res = await fetch(`/api/team/${teamId}/update-join-policy`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'same-origin',
|
||||
cache: 'no-store',
|
||||
body: JSON.stringify({ teamId, joinPolicy: next }),
|
||||
body: JSON.stringify({ joinPolicy: next }),
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
@ -641,10 +623,9 @@ function TeamMemberViewBody({
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
const formData = new FormData()
|
||||
formData.append('logo', file)
|
||||
formData.append('teamId', teamId)
|
||||
|
||||
const xhr = new XMLHttpRequest()
|
||||
xhr.open('POST', '/api/team/upload-logo')
|
||||
xhr.open('POST', `/api/team/${teamId}/upload-logo`)
|
||||
xhr.upload.onprogress = (e) => {
|
||||
if (e.lengthComputable) setUploadPct(Math.round((e.loaded / e.total) * 100))
|
||||
else setUploadPct(p => (p < 90 ? p + 1 : p))
|
||||
@ -952,7 +933,7 @@ function TeamMemberViewBody({
|
||||
onClick={async () => {
|
||||
if (isLeader) setShowLeaveModal(true)
|
||||
else {
|
||||
try { await leaveTeam(currentUserSteamId) }
|
||||
try { await leaveTeam({ steamId: currentUserSteamId, teamId: team.id }) }
|
||||
catch (err) { console.error('Fehler beim Verlassen:', err) }
|
||||
}
|
||||
}}
|
||||
@ -1096,39 +1077,17 @@ function TeamMemberViewBody({
|
||||
/>
|
||||
)}
|
||||
|
||||
{canManage && promoteCandidate && (
|
||||
<Modal
|
||||
id={`modal-promote-player-${promoteCandidate.steamId}`}
|
||||
title="Leader übertragen"
|
||||
show={true}
|
||||
{canManage && (
|
||||
<TransferLeaderModal
|
||||
show={!!promoteCandidate}
|
||||
team={team}
|
||||
preselectId={promoteCandidate?.steamId}
|
||||
onClose={() => setPromoteCandidate(null)}
|
||||
onSave={async () => {
|
||||
await promoteToLeader(promoteCandidate.steamId)
|
||||
onSuccess={async () => {
|
||||
await handleReload()
|
||||
setPromoteCandidate(null)
|
||||
}}
|
||||
closeButtonTitle="Übertragen"
|
||||
closeButtonColor="blue"
|
||||
>
|
||||
<div className="flex justify-center mb-4">
|
||||
<MiniCard
|
||||
steamId={promoteCandidate.steamId}
|
||||
title={promoteCandidate.name}
|
||||
avatar={promoteCandidate.avatar}
|
||||
location={promoteCandidate.location}
|
||||
selected={false}
|
||||
onSelect={() => {}}
|
||||
draggable={false}
|
||||
rank={promoteCandidate.premierRank}
|
||||
currentUserSteamId={currentUserSteamId}
|
||||
teamLeaderSteamId={team.leader?.steamId}
|
||||
hideActions
|
||||
isSelectable={false}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-sm text-gray-700 dark:text-neutral-300">
|
||||
Möchtest du <strong>{promoteCandidate.name}</strong> wirklich zum Team-Leader machen?
|
||||
</p>
|
||||
</Modal>
|
||||
/>
|
||||
)}
|
||||
|
||||
{canManage && kickCandidate && (
|
||||
@ -1170,13 +1129,12 @@ function TeamMemberViewBody({
|
||||
show={showDeleteModal}
|
||||
onClose={() => setShowDeleteModal(false)}
|
||||
onSave={async () => {
|
||||
await fetch('/api/team/delete', {
|
||||
await fetch(`/api/team/${team.id}/delete`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ teamId: team.id }),
|
||||
})
|
||||
setShowDeleteModal(false)
|
||||
window.location.href = '/team'
|
||||
window.location.href = '/teams'
|
||||
}}
|
||||
closeButtonTitle="Team löschen"
|
||||
closeButtonColor="red"
|
||||
|
||||
@ -65,6 +65,7 @@ export default function TeamPlayerCard({
|
||||
alt={name}
|
||||
size={48}
|
||||
showStatus
|
||||
isLeader={isLeader}
|
||||
/>
|
||||
|
||||
<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 { MatchProvider } from './MatchContext'
|
||||
import type { Match } from '../../../../types/match'
|
||||
import { Agent } from 'undici'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
export const revalidate = 0
|
||||
|
||||
async function loadMatch(matchId: string): Promise<Match | null> {
|
||||
async function buildOrigin(): Promise<string> {
|
||||
const h = await headers()
|
||||
const proto = (h.get('x-forwarded-proto') ?? 'http').split(',')[0].trim()
|
||||
const host = (h.get('x-forwarded-host') ?? h.get('host') ?? '').split(',')[0].trim()
|
||||
const base = host ? `${proto}://${host}` : (process.env.NEXTAUTH_URL ?? 'http://localhost:3000')
|
||||
if (host) return `${proto}://${host}`
|
||||
|
||||
const insecure = new Agent({ connect: { rejectUnauthorized: false } })
|
||||
const init: RequestInit & { dispatcher?: Agent } = { cache: 'no-store' }
|
||||
if (base.startsWith('https://') && process.env.NODE_ENV !== 'production') {
|
||||
init.dispatcher = insecure
|
||||
// Fallbacks (lokale Entwicklung / SSR-Tools)
|
||||
return (
|
||||
process.env.NEXT_PUBLIC_SITE_URL ||
|
||||
process.env.NEXTAUTH_URL ||
|
||||
'http://localhost:3000'
|
||||
)
|
||||
}
|
||||
|
||||
async function loadMatch(matchId: string): Promise<Match | null> {
|
||||
const origin = await buildOrigin()
|
||||
|
||||
// ⚠️ Dev-Only: Selbstsignierte Zertifikate erlauben
|
||||
const allowInsecure =
|
||||
process.env.NODE_ENV !== 'production' ||
|
||||
process.env.ALLOW_INSECURE_FETCH === '1'
|
||||
|
||||
if (origin.startsWith('https://') && allowInsecure) {
|
||||
// global für den Node-Prozess – bitte NICHT in Production setzen
|
||||
process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0'
|
||||
}
|
||||
|
||||
const res = await fetch(`${base}/api/matches/${matchId}`, init)
|
||||
const res = await fetch(`${origin}/api/matches/${encodeURIComponent(matchId)}`, {
|
||||
cache: 'no-store',
|
||||
})
|
||||
if (!res.ok) return null
|
||||
return res.json()
|
||||
}
|
||||
|
||||
@ -26,9 +26,20 @@ function uniqBySteamId<T extends { steamId: string }>(list: T[]): T[] {
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Sichere Fehlermeldung aus unknown extrahieren (vermeidet "any")
|
||||
function getErrorMessage(data: unknown, fallback: string): string {
|
||||
if (typeof data === 'object' && data !== null && 'message' in data) {
|
||||
const m = (data as Record<string, unknown>).message
|
||||
if (typeof m === 'string') return m
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
function byName<T extends { name: string }>(a: T, b: T) {
|
||||
return a.name.localeCompare(b.name, 'de', { sensitivity: 'base' })
|
||||
}
|
||||
|
||||
function classNames(...xs: Array<string | false | null | undefined>) {
|
||||
return xs.filter(Boolean).join(' ')
|
||||
}
|
||||
@ -37,7 +48,9 @@ type TeamDetailClientProps = {
|
||||
teamId: string
|
||||
/** Darf fehlen – wird dann via /api/user ermittelt */
|
||||
currentUserSteamId?: string | null
|
||||
/** Einladung des Users für dieses Team: string = echte Einladung, 'pending' = offene Anfrage, null = nichts */
|
||||
invitationId?: string | 'pending' | null
|
||||
/** Falls false, kann der User generell keine Join-Requests stellen (z. B. schon in einem Team) */
|
||||
canRequestJoin?: boolean
|
||||
}
|
||||
|
||||
@ -100,6 +113,41 @@ export default function TeamDetailClient({
|
||||
return () => ac.abort()
|
||||
}, [teamId, router])
|
||||
|
||||
// ⬇️ Pending/Invite-Status nach Page-Reload wiederherstellen
|
||||
useEffect(() => {
|
||||
if (!team?.id) return
|
||||
let cancelled = false
|
||||
;(async () => {
|
||||
try {
|
||||
const res = await fetch('/api/user/invitations', {
|
||||
cache: 'no-store',
|
||||
credentials: 'same-origin',
|
||||
})
|
||||
if (!res.ok) return
|
||||
|
||||
const data = (await res.json()) as {
|
||||
invitations: Array<{ id: string; teamId: string; type: 'team-invite' | 'team-join-request' }>
|
||||
}
|
||||
|
||||
const match = data.invitations.find(inv => inv.teamId === team.id)
|
||||
if (cancelled) return
|
||||
|
||||
if (!match) {
|
||||
setInvitationId(null)
|
||||
} else if (match.type === 'team-invite') {
|
||||
// echte Einladung → Button zeigt "Einladung ablehnen"
|
||||
setInvitationId(match.id)
|
||||
} else if (match.type === 'team-join-request') {
|
||||
// eigene Beitrittsanfrage → Button zeigt "Angefragt (zurückziehen)"
|
||||
setInvitationId('pending')
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
})()
|
||||
return () => { cancelled = true }
|
||||
}, [team?.id])
|
||||
|
||||
/* ---------- Ableitungen ---------- */
|
||||
|
||||
// Counts (ohne Eingeladene)
|
||||
@ -154,6 +202,7 @@ export default function TeamDetailClient({
|
||||
const isInviteOnly = team?.joinPolicy === 'INVITE_ONLY'
|
||||
const hasRealInvitation = Boolean(invitationId && invitationId !== 'pending')
|
||||
const hasPendingRequest = invitationId === 'pending'
|
||||
const isRequested = hasRealInvitation || hasPendingRequest
|
||||
|
||||
/* ---------- Aktionen ---------- */
|
||||
|
||||
@ -165,7 +214,8 @@ export default function TeamDetailClient({
|
||||
|
||||
setJoining(true)
|
||||
try {
|
||||
await leaveTeam(mySteamId)
|
||||
// team.id mitgeben – damit findet die Route garantiert das richtige Team
|
||||
await leaveTeam({ steamId: mySteamId, teamId: team.id })
|
||||
const updated = await reloadTeam(team.id)
|
||||
if (updated) setTeam(updated)
|
||||
} catch (err) {
|
||||
@ -176,7 +226,7 @@ export default function TeamDetailClient({
|
||||
}
|
||||
}
|
||||
|
||||
// Falls du später wieder Join zeigen willst: Handler bleibt nutzbar
|
||||
// Join / Einladung / Zurückziehen (wie TeamCard)
|
||||
const handleJoinBranches = async () => {
|
||||
if (!team || joining) return
|
||||
setJoining(true)
|
||||
@ -185,6 +235,7 @@ export default function TeamDetailClient({
|
||||
await fetch('/api/user/invitations/reject', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'same-origin',
|
||||
body: JSON.stringify({ invitationId }),
|
||||
})
|
||||
setInvitationId(null)
|
||||
@ -192,17 +243,24 @@ export default function TeamDetailClient({
|
||||
await fetch('/api/user/invitations/revoke', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'same-origin',
|
||||
body: JSON.stringify({ teamId: team.id, type: 'team-join-request' }),
|
||||
})
|
||||
setInvitationId(null)
|
||||
} else {
|
||||
if (isInviteOnly) return
|
||||
await fetch('/api/team/request-join', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ teamId: team.id }),
|
||||
})
|
||||
setInvitationId('pending')
|
||||
if (!isInviteOnly) {
|
||||
const res = await fetch(`/api/team/${encodeURIComponent(team.id)}/request-join`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'same-origin',
|
||||
body: JSON.stringify({}),
|
||||
})
|
||||
if (!res.ok) {
|
||||
const data: unknown = await res.json().catch(() => ({}))
|
||||
throw new Error(getErrorMessage(data, 'Anfrage fehlgeschlagen'))
|
||||
}
|
||||
setInvitationId('pending')
|
||||
}
|
||||
}
|
||||
const updated = await reloadTeam(team.id)
|
||||
if (updated) setTeam(updated)
|
||||
@ -234,7 +292,30 @@ export default function TeamDetailClient({
|
||||
}
|
||||
if (!team) return null
|
||||
|
||||
const goToProfile = (steamId: string) => router.push(`/profile/${steamId}`)
|
||||
// ====== Join-Button-Zustände (analog TeamCard) ======
|
||||
const isJoinDisabled =
|
||||
joining ||
|
||||
isMemberOfThisTeam ||
|
||||
!canRequestJoin ||
|
||||
(isInviteOnly && !hasRealInvitation && !hasPendingRequest)
|
||||
|
||||
const joinButtonLabel = joining ? (
|
||||
<>
|
||||
<span className="animate-spin inline-block size-4 border-[3px] border-current border-t-transparent rounded-full mr-1" />
|
||||
Lädt
|
||||
</>
|
||||
) : hasRealInvitation ? (
|
||||
'Einladung ablehnen'
|
||||
) : hasPendingRequest ? (
|
||||
'Angefragt (zurückziehen)'
|
||||
) : isInviteOnly ? (
|
||||
'Nur Einladungen'
|
||||
) : (
|
||||
'Beitritt anfragen'
|
||||
)
|
||||
|
||||
const joinButtonColor: 'blue' | 'red' | 'gray' =
|
||||
hasRealInvitation ? 'red' : isJoinDisabled ? 'gray' : isRequested ? 'gray' : 'blue'
|
||||
|
||||
return (
|
||||
<Card maxWidth="auto">
|
||||
@ -260,9 +341,9 @@ export default function TeamDetailClient({
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Button nur anzeigen, wenn Mitglied */}
|
||||
{isMemberOfThisTeam && (
|
||||
<div className="ml-auto">
|
||||
{/* Rechts: Action-Button */}
|
||||
<div className="ml-auto">
|
||||
{isMemberOfThisTeam ? (
|
||||
<Button
|
||||
size="sm"
|
||||
color="red"
|
||||
@ -279,8 +360,19 @@ export default function TeamDetailClient({
|
||||
'Team verlassen'
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
) : (
|
||||
<Button
|
||||
size="sm"
|
||||
color={joinButtonColor}
|
||||
disabled={isJoinDisabled}
|
||||
onClick={(e) => { e.preventDefault(); handleJoinBranches() }}
|
||||
title={typeof joinButtonLabel === 'string' ? joinButtonLabel : undefined}
|
||||
aria-disabled={isJoinDisabled ? 'true' : undefined}
|
||||
>
|
||||
{joinButtonLabel}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Toolbar */}
|
||||
@ -304,17 +396,17 @@ export default function TeamDetailClient({
|
||||
|
||||
{/* Segment */}
|
||||
<div className="inline-flex overflow-hidden rounded-lg border border-gray-200 dark:border-neutral-700">
|
||||
{[
|
||||
{([
|
||||
{ key: 'all', label: `Alle (${counts.all})` },
|
||||
{ key: 'active', label: `Aktiv (${counts.active})` },
|
||||
{ key: 'inactive', label: `Inaktiv (${counts.inactive})` },
|
||||
].map((opt) => (
|
||||
] as const).map((opt) => (
|
||||
<button
|
||||
key={opt.key}
|
||||
onClick={() => setSeg(opt.key as 'all' | 'active' | 'inactive')}
|
||||
onClick={() => setSeg(opt.key)}
|
||||
className={classNames(
|
||||
'px-3 py-1.5 text-sm transition',
|
||||
seg === (opt.key as 'all' | 'active' | 'inactive')
|
||||
seg === opt.key
|
||||
? 'bg-gray-100 text-gray-900 dark:bg-neutral-700 dark:text-white'
|
||||
: 'text-gray-600 hover:bg-gray-50 dark:text-neutral-300 dark:hover:bg-neutral-800'
|
||||
)}
|
||||
@ -359,7 +451,7 @@ export default function TeamDetailClient({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Leader-Leave-Modal (wie in TeamMemberView) */}
|
||||
{/* Leader-Leave-Modal */}
|
||||
{isLeader && (
|
||||
<LeaveTeamModal
|
||||
show={showLeaveModal}
|
||||
|
||||
@ -71,17 +71,25 @@ export default function TeamsPage() {
|
||||
|
||||
const [tJson, iJson, uJson]: [TeamsJson, InvitesJson, UserJson] = await Promise.all([
|
||||
tRes.json(),
|
||||
iRes.json().catch<unknown>(() => ({})),
|
||||
iRes.ok ? iRes.json().catch<unknown>(() => ({})) : Promise.resolve({} as unknown),
|
||||
uRes.ok ? uRes.json() : Promise.resolve({} as unknown),
|
||||
])
|
||||
|
||||
const nextTeams = parseTeams(tJson)
|
||||
const invites = parseInvites(iJson)
|
||||
|
||||
// 🔧 WICHTIG: 'team-join-request' → 'pending', 'team-invite' → ID
|
||||
const nextMap: Record<string, string> = {}
|
||||
for (const inv of invites) {
|
||||
if (inv?.type === 'team-join-request' && inv.teamId && inv.id) {
|
||||
nextMap[inv.teamId] = inv.id
|
||||
const teamId = inv?.teamId
|
||||
const type = inv?.type
|
||||
const id = inv?.id
|
||||
if (!teamId) continue
|
||||
|
||||
if (type === 'team-join-request') {
|
||||
nextMap[teamId] = 'pending'
|
||||
} else if (type === 'team-invite' && id) {
|
||||
nextMap[teamId] = id
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
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 { prisma } from '@/lib/prisma'
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
export async function GET(
|
||||
req: NextRequest,
|
||||
ctx: { params: Promise<{ teamId: string }> }
|
||||
) {
|
||||
try {
|
||||
const { searchParams } = new URL(req.url)
|
||||
const teamId = searchParams.get('teamId')
|
||||
const onlyFree = (searchParams.get('onlyFree') ?? '').toLowerCase() === 'true'
|
||||
|
||||
if (!teamId) {
|
||||
// 🔁 teamId kommt jetzt aus den Route-Parametern
|
||||
const { teamId } = await ctx.params
|
||||
const tid = teamId?.trim()
|
||||
if (!tid) {
|
||||
return NextResponse.json({ message: 'teamId fehlt' }, { status: 400 })
|
||||
}
|
||||
|
||||
// 1) Team laden (für den Standard-Fall weiterhin nötig)
|
||||
const team = await prisma.team.findUnique({
|
||||
where: { id: teamId },
|
||||
select: { activePlayers: true, inactivePlayers: true }
|
||||
where: { id: tid },
|
||||
select: { activePlayers: true, inactivePlayers: true },
|
||||
})
|
||||
if (!team) {
|
||||
return NextResponse.json({ message: 'Team nicht gefunden' }, { status: 404 })
|
||||
@ -23,9 +31,9 @@ export async function GET(req: NextRequest) {
|
||||
let excludeIds: string[] = []
|
||||
|
||||
if (onlyFree) {
|
||||
// 2a) Nur Spieler ohne Team => ALLE Teammitglieder (aus allen Teams) ausschließen
|
||||
// 2a) Nur Spieler ohne Team: alle, die irgendwo Mitglied sind, ausschließen
|
||||
const allTeams = await prisma.team.findMany({
|
||||
select: { activePlayers: true, inactivePlayers: true }
|
||||
select: { activePlayers: true, inactivePlayers: true },
|
||||
})
|
||||
const occupied = new Set<string>()
|
||||
for (const t of allTeams) {
|
||||
@ -33,40 +41,43 @@ export async function GET(req: NextRequest) {
|
||||
for (const id of (t.inactivePlayers ?? [])) occupied.add(id)
|
||||
}
|
||||
excludeIds = Array.from(occupied)
|
||||
// Hinweis: Pending-Invites zählen nicht als "hat Team" – werden hier NICHT ausgeschlossen.
|
||||
// Hinweis: Pending-Invites zählen nicht als „hat Team“ – werden hier NICHT ausgeschlossen.
|
||||
} else {
|
||||
// 2b) Standard: Mitglieder + bereits eingeladene dieses Teams ausschließen
|
||||
// 2b) Standard: Mitglieder + bereits (für dieses Team) eingeladene ausschließen
|
||||
const members = new Set<string>([
|
||||
...(team.activePlayers ?? []),
|
||||
...(team.inactivePlayers ?? [])
|
||||
...(team.inactivePlayers ?? []),
|
||||
])
|
||||
|
||||
const pendingInvites = await prisma.teamInvite.findMany({
|
||||
where: { teamId },
|
||||
select: { steamId: true }
|
||||
where: { teamId: tid },
|
||||
select: { steamId: true },
|
||||
})
|
||||
const invited = new Set<string>(pendingInvites.map(i => i.steamId))
|
||||
|
||||
excludeIds = Array.from(new Set([...members, ...invited]))
|
||||
}
|
||||
|
||||
// 3) Kandidaten: nur einladbare, nicht in excludeIds
|
||||
// 3) Kandidaten: einladbare Nutzer, die nicht ausgeschlossen sind
|
||||
const availableUsers = await prisma.user.findMany({
|
||||
where: {
|
||||
canBeInvited: true,
|
||||
steamId: { notIn: excludeIds }
|
||||
steamId: { notIn: excludeIds },
|
||||
},
|
||||
select: {
|
||||
steamId: true,
|
||||
name: true,
|
||||
avatar: true,
|
||||
location: true,
|
||||
premierRank: true
|
||||
premierRank: true,
|
||||
},
|
||||
orderBy: { name: 'asc' }
|
||||
orderBy: { name: 'asc' },
|
||||
})
|
||||
|
||||
return NextResponse.json({ users: availableUsers })
|
||||
return NextResponse.json(
|
||||
{ users: availableUsers },
|
||||
{ headers: { 'Cache-Control': 'no-store' } }
|
||||
)
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Laden der verfügbaren Benutzer:', error)
|
||||
return NextResponse.json({ message: 'Fehler beim Laden' }, { status: 500 })
|
||||
@ -1,4 +1,4 @@
|
||||
// /src/app/api/team/delete/route.ts
|
||||
// /src/app/api/team/[teamId]/delete/route.ts
|
||||
import { NextResponse, type NextRequest } from 'next/server'
|
||||
import { getServerSession } from 'next-auth'
|
||||
import { sessionAuthOptions } from '@/lib/auth'
|
||||
@ -7,7 +7,10 @@ import { sendServerSSEMessage } from '@/lib/sse-server-client'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
export async function POST(
|
||||
req: NextRequest,
|
||||
ctx: { params: Promise<{ teamId: string }> }
|
||||
) {
|
||||
try {
|
||||
const session = await getServerSession(sessionAuthOptions)
|
||||
const steamId = session?.user?.steamId
|
||||
@ -16,7 +19,7 @@ export async function POST(req: NextRequest) {
|
||||
return NextResponse.json({ error: 'Nicht eingeloggt' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { teamId } = await req.json().catch(() => ({}))
|
||||
const { teamId } = await ctx.params
|
||||
if (!teamId) {
|
||||
return NextResponse.json({ error: 'Team-ID fehlt' }, { status: 400 })
|
||||
}
|
||||
@ -1,4 +1,4 @@
|
||||
// /api/team/invite/route.ts
|
||||
// /src/app/api/team/[teamId]/invite/route.ts
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { sendServerSSEMessage } from '@/lib/sse-server-client'
|
||||
@ -11,9 +11,13 @@ type ResultReason =
|
||||
| 'duplicate'
|
||||
| 'ok'
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
export async function POST(
|
||||
req: NextRequest,
|
||||
ctx: { params: Promise<{ teamId: string }> }
|
||||
) {
|
||||
try {
|
||||
const { teamId, userIds: rawUserIds, invitedBy } = await req.json()
|
||||
const { teamId } = await ctx.params
|
||||
const { userIds: rawUserIds, invitedBy } = await req.json()
|
||||
|
||||
if (!teamId || !Array.isArray(rawUserIds) || rawUserIds.length === 0) {
|
||||
return NextResponse.json({ message: 'Fehlende Daten' }, { status: 400 })
|
||||
@ -97,11 +101,10 @@ export async function POST(req: NextRequest) {
|
||||
})
|
||||
}
|
||||
|
||||
// Optional: Team-Update an Leader
|
||||
await sendServerSSEMessage({
|
||||
type: 'team-updated',
|
||||
teamId,
|
||||
targetUserIds: team.leader?.steamId
|
||||
targetUserIds: team.leader?.steamId ? [team.leader.steamId] : []
|
||||
})
|
||||
|
||||
const okCount = targetIds.length
|
||||
@ -1,4 +1,4 @@
|
||||
// /src/app/api/team/kick/route.ts
|
||||
// /src/app/api/team/[teamId]/kick/route.ts
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { sendServerSSEMessage } from '@/lib/sse-server-client'
|
||||
@ -6,12 +6,16 @@ import { removePlayerFromMatches } from '@/lib/removePlayerFromMatches'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
export async function POST(
|
||||
req: NextRequest,
|
||||
ctx: { params: Promise<{ teamId: string }> }
|
||||
) {
|
||||
try {
|
||||
/* 1) Payload prüfen */
|
||||
const { teamId, steamId } = await req.json()
|
||||
if (!teamId || !steamId) {
|
||||
return NextResponse.json({ message: 'Fehlende Daten' }, { status: 400 })
|
||||
const { teamId } = await ctx.params
|
||||
const { steamId } = await req.json()
|
||||
if (!steamId) {
|
||||
return NextResponse.json({ message: 'Fehlende SteamID' }, { status: 400 })
|
||||
}
|
||||
|
||||
/* 2) Team + User laden */
|
||||
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 { prisma } from '@/lib/prisma'
|
||||
import { sendServerSSEMessage } from '@/lib/sse-server-client'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
// kleiner Body-Parser für sichere Typen
|
||||
function parseBody(v: unknown): { teamId?: string; newName?: string } {
|
||||
if (!v || typeof v !== 'object') return {}
|
||||
const r = v as Record<string, unknown>
|
||||
return {
|
||||
teamId : typeof r.teamId === 'string' ? r.teamId : undefined,
|
||||
newName: typeof r.newName === 'string' ? r.newName : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
// Prisma-Fehler-Shape (ohne Import von Prisma-Typen)
|
||||
type KnownPrismaError = { code: string; meta?: Record<string, unknown> }
|
||||
function isKnownPrismaError(e: unknown): e is KnownPrismaError {
|
||||
return !!e && typeof e === 'object' && 'code' in e && typeof (e as { code: unknown }).code === 'string'
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
export async function POST(
|
||||
req: NextRequest,
|
||||
ctx: { params: Promise<{ teamId: string }> }
|
||||
) {
|
||||
try {
|
||||
const raw = await req.json().catch(() => null)
|
||||
const { teamId, newName } = parseBody(raw)
|
||||
const { teamId } = await ctx.params
|
||||
const { newName } = (await req.json().catch(() => ({}))) as { newName?: string }
|
||||
const name = (newName ?? '').trim()
|
||||
|
||||
if (!teamId || !name) {
|
||||
if (!name) {
|
||||
return NextResponse.json({ error: 'Fehlende Parameter' }, { status: 400 })
|
||||
}
|
||||
|
||||
@ -1,26 +1,25 @@
|
||||
// /src/app/api/team/request-join/[action]/route.ts
|
||||
// /src/app/api/team/[teamId]/request-join/[action]/route.ts
|
||||
import { NextResponse, type NextRequest } from 'next/server'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { getServerSession } from 'next-auth'
|
||||
import { sessionAuthOptions } from '@/lib/auth'
|
||||
import { sendServerSSEMessage } from '@/lib/sse-server-client'
|
||||
import type { AsyncParams } from '@/types/next'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
export async function POST(
|
||||
req: NextRequest,
|
||||
ctx: AsyncParams<{ action: 'accept' | 'reject' }>
|
||||
ctx: { params: Promise<{ teamId: string; action: 'accept' | 'reject' }> }
|
||||
) {
|
||||
try {
|
||||
const { teamId, action } = await ctx.params
|
||||
const session = await getServerSession(sessionAuthOptions)
|
||||
const leaderSteamId = session?.user?.steamId
|
||||
if (!leaderSteamId) {
|
||||
return NextResponse.json({ message: 'Nicht eingeloggt' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { action } = await ctx.params
|
||||
const { requestId, teamId: teamIdFromBody } = await req.json().catch(() => ({}))
|
||||
const { requestId } = await req.json().catch(() => ({}))
|
||||
if (!requestId) {
|
||||
return NextResponse.json({ message: 'requestId fehlt' }, { status: 400 })
|
||||
}
|
||||
@ -31,8 +30,9 @@ export async function POST(
|
||||
if (joinInv.type !== 'team-join-request') {
|
||||
return NextResponse.json({ message: 'Invitation ist keine Beitrittsanfrage' }, { status: 400 })
|
||||
}
|
||||
|
||||
const teamId = joinInv.teamId ?? teamIdFromBody
|
||||
if (joinInv.teamId && joinInv.teamId !== teamId) {
|
||||
return NextResponse.json({ message: 'Falsches Team' }, { status: 400 })
|
||||
}
|
||||
if (!teamId) return NextResponse.json({ message: 'teamId fehlt/ungültig' }, { status: 400 })
|
||||
|
||||
// Team + Leader prüfen
|
||||
@ -146,7 +146,7 @@ export async function POST(
|
||||
|
||||
return NextResponse.json({ message: 'Beitrittsanfrage angenommen' }, { status: 200 })
|
||||
} catch (err) {
|
||||
console.error('[POST] /api/team/request-join/[action]', err)
|
||||
console.error('[POST] /api/team/[teamId]/request-join/[action]', err)
|
||||
return NextResponse.json({ message: 'Serverfehler' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@ -1,12 +1,16 @@
|
||||
// /src/app/api/team/request-join/route.ts
|
||||
// /src/app/api/team/[teamId]/request-join/route.ts
|
||||
import { NextResponse, type NextRequest } from 'next/server'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { getServerSession } from 'next-auth'
|
||||
import { sessionAuthOptions } from '@/lib/auth'
|
||||
import { sendServerSSEMessage } from '@/lib/sse-server-client'
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
export async function POST(
|
||||
req: NextRequest,
|
||||
ctx: { params: Promise<{ teamId: string }> }
|
||||
) {
|
||||
try {
|
||||
const { teamId } = await ctx.params
|
||||
// ── Session ──────────────────────────────────────────────
|
||||
const session = await getServerSession(sessionAuthOptions)
|
||||
if (!session?.user?.steamId) {
|
||||
@ -15,13 +19,12 @@ export async function POST(req: NextRequest) {
|
||||
const requesterSteamId = session.user.steamId
|
||||
|
||||
// ── Body ────────────────────────────────────────────────
|
||||
const { teamId } = await req.json()
|
||||
if (!teamId) {
|
||||
return NextResponse.json({ message: 'teamId fehlt' }, { status: 400 })
|
||||
}
|
||||
|
||||
// ── Daten holen ─────────────────────────────────────────
|
||||
const [team, requester] = await Promise.all([
|
||||
const [team] = await Promise.all([
|
||||
prisma.team.findUnique({
|
||||
where: { id: teamId },
|
||||
select: {
|
||||
@ -43,11 +46,6 @@ export async function POST(req: NextRequest) {
|
||||
return NextResponse.json({ message: 'Team nicht gefunden' }, { status: 404 })
|
||||
}
|
||||
|
||||
// ── Bereits in irgendeinem Team? ─────────────────────────
|
||||
if (requester?.teamId && requester.teamId !== team.id) {
|
||||
return NextResponse.json({ message: 'Du bist bereits in einem anderen Team' }, { status: 400 })
|
||||
}
|
||||
|
||||
// ── Schon (irgendwie) Mitglied dieses Teams? ────────────
|
||||
if (
|
||||
requesterSteamId === team.leaderId ||
|
||||
@ -102,7 +100,7 @@ export async function POST(req: NextRequest) {
|
||||
|
||||
return NextResponse.json({ message: 'Anfrage gesendet' }, { status: 200 })
|
||||
} catch (err) {
|
||||
console.error('POST /api/team/request-join', err)
|
||||
console.error('POST /api/team/[teamId]/request-join', err)
|
||||
return NextResponse.json({ message: 'Interner Serverfehler' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@ -1,4 +1,4 @@
|
||||
// /src/app/api/team/transfer-leader/route.ts
|
||||
// /src/app/api/team/[teamId]/transfer-leader/route.ts
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { NextResponse, type NextRequest } from 'next/server'
|
||||
import { sendServerSSEMessage } from '@/lib/sse-server-client'
|
||||
@ -7,16 +7,6 @@ export const dynamic = 'force-dynamic'
|
||||
|
||||
type KnownPrismaErrorShape = { code: string; meta?: Record<string, unknown> };
|
||||
|
||||
// kleines Body-Parser-Helper
|
||||
function parseBody(v: unknown): { teamId?: string; newLeaderSteamId?: string } {
|
||||
if (!v || typeof v !== 'object') return {}
|
||||
const r = v as Record<string, unknown>
|
||||
return {
|
||||
teamId: typeof r.teamId === 'string' ? r.teamId : undefined,
|
||||
newLeaderSteamId: typeof r.newLeaderSteamId === 'string' ? r.newLeaderSteamId : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
// Type Guard für PrismaClientKnownRequestError
|
||||
function isKnownPrismaError(e: unknown): e is KnownPrismaErrorShape {
|
||||
return !!e
|
||||
@ -25,14 +15,14 @@ function isKnownPrismaError(e: unknown): e is KnownPrismaErrorShape {
|
||||
&& typeof (e as { code: unknown }).code === 'string';
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
export async function POST(
|
||||
req: NextRequest,
|
||||
ctx: { params: Promise<{ teamId: string }> }
|
||||
) {
|
||||
try {
|
||||
const raw = await req.json().catch(() => null)
|
||||
const { teamId, newLeaderSteamId } = parseBody(raw)
|
||||
|
||||
if (!teamId || !newLeaderSteamId) {
|
||||
return NextResponse.json({ message: 'Fehlende Parameter' }, { status: 400 })
|
||||
}
|
||||
const { teamId } = await ctx.params
|
||||
const { newLeaderSteamId } = (await req.json().catch(() => ({}))) as { newLeaderSteamId?: string }
|
||||
if (!newLeaderSteamId) return NextResponse.json({ message: 'Fehlende Parameter' }, { status: 400 })
|
||||
|
||||
const team = await prisma.team.findUnique({
|
||||
where: { id: teamId },
|
||||
@ -1,4 +1,4 @@
|
||||
// /src/app/api/team/update-join-policy/route.ts
|
||||
// /src/app/api/team/[teamId]/update-join-policy/route.ts
|
||||
import { NextResponse, type NextRequest } from 'next/server'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { getServerSession } from 'next-auth'
|
||||
@ -12,18 +12,10 @@ export const dynamic = 'force-dynamic'
|
||||
// Einmal zentral definieren und später benutzen
|
||||
const ALLOWED: readonly TeamJoinPolicy[] = ['REQUEST', 'INVITE_ONLY'] as const
|
||||
|
||||
type Body = { teamId?: string; joinPolicy?: TeamJoinPolicy }
|
||||
|
||||
function parseBody(v: unknown): Body {
|
||||
if (!v || typeof v !== 'object') return {}
|
||||
const r = v as Record<string, unknown>
|
||||
return {
|
||||
teamId: typeof r.teamId === 'string' ? r.teamId : undefined,
|
||||
joinPolicy: typeof r.joinPolicy === 'string' ? (r.joinPolicy as TeamJoinPolicy) : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
export async function POST(
|
||||
req: NextRequest,
|
||||
ctx: { params: Promise<{ teamId: string }> }
|
||||
) {
|
||||
try {
|
||||
const session = await getServerSession(sessionAuthOptions)
|
||||
|
||||
@ -32,8 +24,8 @@ export async function POST(req: NextRequest) {
|
||||
return NextResponse.json({ message: 'Nicht eingeloggt' }, { status: 401 })
|
||||
}
|
||||
|
||||
const raw: unknown = await req.json().catch(() => null)
|
||||
const { teamId, joinPolicy } = parseBody(raw)
|
||||
const { teamId } = await ctx.params
|
||||
const { joinPolicy } = (await req.json().catch(() => ({}))) as { joinPolicy?: TeamJoinPolicy }
|
||||
|
||||
if (!teamId) {
|
||||
return NextResponse.json({ message: 'teamId fehlt' }, { status: 400 })
|
||||
@ -1,12 +1,18 @@
|
||||
// ✅ /api/team/update-players/route.ts
|
||||
// /src/app/api/team/[teamId]/update-players/route.ts
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { sendServerSSEMessage } from '@/lib/sse-server-client'
|
||||
import { NextResponse, type NextRequest } from 'next/server'
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
export async function POST(
|
||||
req: NextRequest,
|
||||
ctx: { params: Promise<{ teamId: string }> }
|
||||
) {
|
||||
try {
|
||||
const { teamId, activePlayers, inactivePlayers, invitedPlayers } = await req.json()
|
||||
if (!teamId || !Array.isArray(activePlayers) || !Array.isArray(inactivePlayers)) {
|
||||
const { teamId } = await ctx.params
|
||||
const { activePlayers, inactivePlayers, invitedPlayers } =
|
||||
(await req.json().catch(() => ({}))) as { activePlayers?: string[]; inactivePlayers?: string[]; invitedPlayers?: string[] }
|
||||
|
||||
if (!Array.isArray(activePlayers) || !Array.isArray(inactivePlayers)) {
|
||||
return NextResponse.json({ error: 'Ungültige Eingabedaten' }, { status: 400 })
|
||||
}
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
// /api/team/upload-logo/route.ts
|
||||
// /src/app/api/team/[teamId]/upload-logo/route.ts
|
||||
import { NextResponse, type NextRequest } from 'next/server'
|
||||
import { writeFile, mkdir, unlink } from 'fs/promises'
|
||||
import { join, dirname } from 'path'
|
||||
@ -8,14 +8,15 @@ import { prisma } from '@/lib/prisma'
|
||||
|
||||
export const runtime = 'nodejs' // wichtig, falls du irgendwo Edge aktiviert hast
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
export async function POST(
|
||||
req: NextRequest,
|
||||
ctx: { params: Promise<{ teamId: string }> }
|
||||
) {
|
||||
const { teamId } = await ctx.params
|
||||
const formData = await req.formData()
|
||||
const file = formData.get('logo') as File | null
|
||||
const teamId = formData.get('teamId') as string | null
|
||||
const file = formData.get('logo') as File | null
|
||||
|
||||
if (!file || !teamId) {
|
||||
return NextResponse.json({ message: 'Ungültige Daten' }, { status: 400 })
|
||||
}
|
||||
if (!file) return NextResponse.json({ message: 'Ungültige Daten' }, { status: 400 })
|
||||
|
||||
// Bytes lesen
|
||||
const inputBuffer = Buffer.from(await file.arrayBuffer())
|
||||
@ -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 { sendServerSSEMessage } from '@/lib/sse-server-client'
|
||||
import type { AsyncParams } from '@/types/next'
|
||||
import { getServerSession } from 'next-auth'
|
||||
import { sessionAuthOptions } from '@/lib/auth'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
@ -13,47 +15,63 @@ export async function POST(
|
||||
const { action } = await ctx.params
|
||||
|
||||
try {
|
||||
const session = await getServerSession(sessionAuthOptions)
|
||||
const sessionSteamId = session?.user?.steamId
|
||||
|
||||
const body = (await req.json().catch(() => ({}))) as Partial<{
|
||||
invitationId: string
|
||||
teamId: string
|
||||
steamId: string
|
||||
type: 'team-invite' | 'team-join-request'
|
||||
}>
|
||||
|
||||
const incomingInvitationId = body.invitationId?.trim() || undefined
|
||||
const fallbackTeamId = body.teamId?.trim() || undefined
|
||||
const fallbackSteamId = body.steamId?.trim() || undefined
|
||||
const incomingInvitationId = body.invitationId?.trim()
|
||||
const fallbackTeamId = body.teamId?.trim()
|
||||
const fallbackSteamId = body.steamId?.trim() || sessionSteamId
|
||||
const requestedType = body.type
|
||||
|
||||
// Einladung auflösen (bevorzugt per ID, sonst per teamId+steamId)
|
||||
const invitation =
|
||||
incomingInvitationId
|
||||
? await prisma.teamInvite.findUnique({ where: { id: incomingInvitationId } })
|
||||
: (fallbackTeamId && fallbackSteamId
|
||||
? await prisma.teamInvite.findFirst({
|
||||
where: {
|
||||
type: 'team-invite',
|
||||
teamId: fallbackTeamId,
|
||||
steamId: fallbackSteamId,
|
||||
},
|
||||
})
|
||||
: null)
|
||||
// ---- Invitation auflösen -----------------------------------------
|
||||
let invitation = null as (Awaited<ReturnType<typeof prisma.teamInvite.findUnique>> | null)
|
||||
|
||||
if (!invitation) {
|
||||
return NextResponse.json({ message: 'Einladung nicht gefunden' }, { status: 404 })
|
||||
if (incomingInvitationId) {
|
||||
invitation = await prisma.teamInvite.findUnique({ where: { id: incomingInvitationId } })
|
||||
}
|
||||
|
||||
if (invitation.type !== 'team-invite') {
|
||||
return NextResponse.json({ message: 'Ungültiger Einladungstyp' }, { status: 400 })
|
||||
if (!invitation && fallbackTeamId && fallbackSteamId) {
|
||||
// Wenn ein Typ angegeben ist, suche strikt danach …
|
||||
if (requestedType === 'team-join-request' || requestedType === 'team-invite') {
|
||||
invitation = await prisma.teamInvite.findFirst({
|
||||
where: { teamId: fallbackTeamId, steamId: fallbackSteamId, type: requestedType },
|
||||
})
|
||||
} else {
|
||||
// … sonst versuche zuerst die Join-Request, dann die „echte“ Einladung
|
||||
invitation =
|
||||
(await prisma.teamInvite.findFirst({
|
||||
where: { teamId: fallbackTeamId, steamId: fallbackSteamId, type: 'team-join-request' },
|
||||
})) ||
|
||||
(await prisma.teamInvite.findFirst({
|
||||
where: { teamId: fallbackTeamId, steamId: fallbackSteamId, type: 'team-invite' },
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
if (!invitation) {
|
||||
return NextResponse.json({ message: 'Einladung/Anfrage nicht gefunden' }, { status: 404 })
|
||||
}
|
||||
|
||||
const invitationId = invitation.id
|
||||
const invitedUserSteamId = invitation.steamId
|
||||
const teamId = invitation.teamId
|
||||
const invitedUserSteamId = invitation.steamId
|
||||
if (!teamId) {
|
||||
return NextResponse.json({ message: 'Einladung ohne Team' }, { status: 400 })
|
||||
return NextResponse.json({ message: 'Eintrag ohne Team' }, { status: 400 })
|
||||
}
|
||||
|
||||
/* ───────────────────────────── ACCEPT ───────────────────────────── */
|
||||
if (action === 'accept') {
|
||||
if (invitation.type !== 'team-invite') {
|
||||
return NextResponse.json({ message: 'Nur Team-Einladungen können angenommen werden' }, { status: 400 })
|
||||
}
|
||||
|
||||
const result = await prisma.$transaction(async (tx) => {
|
||||
const teamBefore = await tx.team.findUnique({
|
||||
where: { id: teamId },
|
||||
@ -61,14 +79,16 @@ export async function POST(
|
||||
})
|
||||
if (!teamBefore) throw new Error('Team nicht gefunden')
|
||||
|
||||
// 1) User ins Team hängen
|
||||
// 1) (Optional) user.teamId pflegen – falls du Multi-Teams erlaubst, entferne das Feld oder diese Zeile
|
||||
await tx.user.update({
|
||||
where: { steamId: invitedUserSteamId },
|
||||
data: { teamId },
|
||||
data: { teamId }, // <- bei Multi-Teams evtl. löschen
|
||||
})
|
||||
|
||||
// 2) Inaktive Liste updaten (ohne Duplikate)
|
||||
const nextInactive = Array.from(new Set([...(teamBefore.inactivePlayers ?? []), invitedUserSteamId]))
|
||||
const nextInactive = Array.from(
|
||||
new Set([...(teamBefore.inactivePlayers ?? []), invitedUserSteamId])
|
||||
)
|
||||
await tx.team.update({
|
||||
where: { id: teamId },
|
||||
data: { inactivePlayers: nextInactive },
|
||||
@ -83,14 +103,9 @@ export async function POST(
|
||||
|
||||
// 4) Andere offenen Team-Einladungen für diesen User entfernen
|
||||
const otherInvites = await tx.teamInvite.findMany({
|
||||
where: {
|
||||
steamId: invitedUserSteamId,
|
||||
type: 'team-invite',
|
||||
NOT: { id: invitationId },
|
||||
},
|
||||
where: { steamId: invitedUserSteamId, type: 'team-invite', NOT: { id: invitationId } },
|
||||
select: { id: true, teamId: true },
|
||||
})
|
||||
|
||||
if (otherInvites.length) {
|
||||
await tx.teamInvite.deleteMany({ where: { id: { in: otherInvites.map(o => o.id) } } })
|
||||
await tx.notification.updateMany({
|
||||
@ -102,13 +117,15 @@ export async function POST(
|
||||
return { teamBefore, nextInactive, otherInvites }
|
||||
})
|
||||
|
||||
const allMembers = Array.from(new Set(
|
||||
[
|
||||
result.teamBefore.leaderId,
|
||||
...(result.teamBefore.activePlayers ?? []),
|
||||
...result.nextInactive,
|
||||
].filter(Boolean) as string[]
|
||||
))
|
||||
const allMembers = Array.from(
|
||||
new Set(
|
||||
[
|
||||
result.teamBefore.leaderId,
|
||||
...(result.teamBefore.activePlayers ?? []),
|
||||
...result.nextInactive,
|
||||
].filter(Boolean) as string[]
|
||||
)
|
||||
)
|
||||
|
||||
const joinedNotif = await prisma.notification.create({
|
||||
data: {
|
||||
@ -169,48 +186,17 @@ export async function POST(
|
||||
targetUserIds: allMembers,
|
||||
})
|
||||
|
||||
if (result.otherInvites.length) {
|
||||
const byTeam = new Map<string, string[]>()
|
||||
for (const o of result.otherInvites) {
|
||||
if (!o.teamId) continue
|
||||
byTeam.set(o.teamId, [...(byTeam.get(o.teamId) ?? []), o.id])
|
||||
}
|
||||
|
||||
const otherTeams = await prisma.team.findMany({
|
||||
where: { id: { in: Array.from(byTeam.keys()) } },
|
||||
select: { id: true, leaderId: true, activePlayers: true, inactivePlayers: true },
|
||||
})
|
||||
|
||||
for (const t of otherTeams) {
|
||||
const recipients = Array.from(new Set(
|
||||
[t.leaderId, ...(t.activePlayers ?? []), ...(t.inactivePlayers ?? [])].filter(Boolean) as string[]
|
||||
))
|
||||
const inviteIds = byTeam.get(t.id) ?? []
|
||||
|
||||
await Promise.all(inviteIds.map(invId =>
|
||||
sendServerSSEMessage({
|
||||
type: 'team-invite-revoked',
|
||||
targetUserIds: recipients,
|
||||
invitationId: invId,
|
||||
teamId: t.id,
|
||||
})
|
||||
))
|
||||
|
||||
if (recipients.length) {
|
||||
await sendServerSSEMessage({
|
||||
type: 'team-updated',
|
||||
teamId: t.id,
|
||||
targetUserIds: recipients,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
// In anderen Teams eingegangene EINLADUNGEN gelöscht → optional: SSE wie gehabt
|
||||
|
||||
return NextResponse.json({ message: 'Einladung angenommen' })
|
||||
}
|
||||
|
||||
/* ───────────────────────────── REJECT ──────────────────────────── */
|
||||
if (action === 'reject') {
|
||||
if (invitation.type !== 'team-invite') {
|
||||
return NextResponse.json({ message: 'Nur Team-Einladungen können abgelehnt werden' }, { status: 400 })
|
||||
}
|
||||
|
||||
await prisma.teamInvite.delete({ where: { id: invitationId } })
|
||||
await prisma.notification.updateMany({
|
||||
where: { actionData: invitationId },
|
||||
@ -243,6 +229,7 @@ export async function POST(
|
||||
|
||||
/* ───────────────────────────── REVOKE ──────────────────────────── */
|
||||
if (action === 'revoke') {
|
||||
// Zwei Fälle: join-request (Spieler zieht seine Anfrage zurück) ODER team-invite (Leader widerruft Einladung)
|
||||
await prisma.teamInvite.delete({ where: { id: invitationId } })
|
||||
await prisma.notification.updateMany({
|
||||
where: { actionData: invitationId },
|
||||
@ -253,34 +240,57 @@ export async function POST(
|
||||
where: { id: teamId },
|
||||
select: { leaderId: true, activePlayers: true, inactivePlayers: true },
|
||||
})
|
||||
const admins = await prisma.user.findMany({
|
||||
where: { isAdmin: true },
|
||||
select: { steamId: true },
|
||||
})
|
||||
|
||||
const targetUserIds = Array.from(new Set([
|
||||
team?.leaderId,
|
||||
...(team?.activePlayers ?? []),
|
||||
...(team?.inactivePlayers ?? []),
|
||||
...admins.map(a => a.steamId),
|
||||
].filter(Boolean) as string[]))
|
||||
if (invitation.type === 'team-join-request') {
|
||||
// Notify Leader (und optional Team), dass die Anfrage zurückgezogen wurde
|
||||
const recipients = Array.from(new Set(
|
||||
[team?.leaderId /*, ...(team?.activePlayers ?? []), ...(team?.inactivePlayers ?? [])*/]
|
||||
.filter(Boolean) as string[]
|
||||
))
|
||||
|
||||
await sendServerSSEMessage({
|
||||
type: 'team-invite-revoked',
|
||||
targetUserIds: [invitedUserSteamId],
|
||||
invitationId,
|
||||
teamId,
|
||||
})
|
||||
if (recipients.length) {
|
||||
await sendServerSSEMessage({
|
||||
type: 'team-join-request-revoked',
|
||||
targetUserIds: recipients,
|
||||
invitationId,
|
||||
teamId,
|
||||
})
|
||||
await sendServerSSEMessage({
|
||||
type: 'team-updated',
|
||||
teamId,
|
||||
targetUserIds: recipients,
|
||||
})
|
||||
}
|
||||
|
||||
if (targetUserIds.length) {
|
||||
return NextResponse.json({ message: 'Anfrage zurückgezogen' })
|
||||
} else {
|
||||
// invitation.type === 'team-invite' → Einladungswiderruf
|
||||
const admins = await prisma.user.findMany({ where: { isAdmin: true }, select: { steamId: true } })
|
||||
const targetUserIds = Array.from(new Set([
|
||||
team?.leaderId,
|
||||
...(team?.activePlayers ?? []),
|
||||
...(team?.inactivePlayers ?? []),
|
||||
...admins.map(a => a.steamId),
|
||||
].filter(Boolean) as string[]))
|
||||
|
||||
// Benachrichtige den eingeladenen User separat
|
||||
await sendServerSSEMessage({
|
||||
type: 'team-updated',
|
||||
type: 'team-invite-revoked',
|
||||
targetUserIds: [invitedUserSteamId],
|
||||
invitationId,
|
||||
teamId,
|
||||
targetUserIds,
|
||||
})
|
||||
}
|
||||
|
||||
return NextResponse.json({ message: 'Einladung gelöscht' })
|
||||
if (targetUserIds.length) {
|
||||
await sendServerSSEMessage({
|
||||
type: 'team-updated',
|
||||
teamId,
|
||||
targetUserIds,
|
||||
})
|
||||
}
|
||||
|
||||
return NextResponse.json({ message: 'Einladung widerrufen' })
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({ message: 'Ungültige Aktion' }, { status: 400 })
|
||||
|
||||
@ -2,6 +2,23 @@
|
||||
|
||||
import { Player, Team } from '@/types/team'
|
||||
|
||||
// ── Helpers, um ohne "any" zu arbeiten ───────────────────────────────────────
|
||||
|
||||
async function readJsonSafe(res: Response): Promise<unknown> {
|
||||
// Gibt {} zurück, wenn JSON parse fehlschlägt
|
||||
return res.json().catch(() => ({}))
|
||||
}
|
||||
|
||||
function getErrorMessage(data: unknown, fallback: string): string {
|
||||
if (typeof data === 'object' && data !== null && 'message' in data) {
|
||||
const m = (data as Record<string, unknown>).message
|
||||
if (typeof m === 'string') return m
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
// 🔄 Team laden
|
||||
export async function reloadTeam(teamId: string): Promise<Team | null> {
|
||||
try {
|
||||
@ -88,15 +105,16 @@ export async function revokeInvitation(invitationId: string): Promise<boolean> {
|
||||
// ✏️ Team umbenennen
|
||||
export async function renameTeam(teamId: string, newName: string): Promise<boolean> {
|
||||
try {
|
||||
const res = await fetch('/api/team/rename', {
|
||||
const res = await fetch(`/api/team/${encodeURIComponent(teamId)}/rename`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'same-origin',
|
||||
body: JSON.stringify({ teamId, newName }),
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
const data = await res.json()
|
||||
throw new Error(data.message || 'Fehler beim Umbenennen')
|
||||
const data = await readJsonSafe(res)
|
||||
throw new Error(getErrorMessage(data, 'Fehler beim Umbenennen'))
|
||||
}
|
||||
|
||||
return true
|
||||
@ -107,21 +125,30 @@ export async function renameTeam(teamId: string, newName: string): Promise<boole
|
||||
}
|
||||
|
||||
// 🚪 Team verlassen
|
||||
export async function leaveTeam(steamId: string, newLeaderId?: string): Promise<boolean> {
|
||||
try {
|
||||
const body = newLeaderId ? { steamId, newLeaderId } : { steamId }
|
||||
// Vorher:
|
||||
// export async function leaveTeam(steamId: string, teamId: string, newLeaderId?: string)
|
||||
|
||||
const res = await fetch('/api/team/leave', {
|
||||
export async function leaveTeam(args: {
|
||||
steamId: string
|
||||
teamId: string
|
||||
newLeaderId?: string
|
||||
}): Promise<boolean> {
|
||||
const { steamId, teamId, newLeaderId } = args
|
||||
try {
|
||||
const body: Record<string, unknown> = { steamId, teamId }
|
||||
if (newLeaderId) body.newLeaderId = newLeaderId
|
||||
|
||||
const res = await fetch(`/api/team/${encodeURIComponent(teamId)}/leave`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'same-origin',
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
const data = await res.json()
|
||||
throw new Error(data.message || 'Fehler beim Verlassen')
|
||||
const data = await readJsonSafe(res)
|
||||
throw new Error(getErrorMessage(data, 'Fehler beim Verlassen'))
|
||||
}
|
||||
|
||||
return true
|
||||
} catch (err) {
|
||||
console.error('leaveTeam:', err)
|
||||
@ -129,18 +156,19 @@ export async function leaveTeam(steamId: string, newLeaderId?: string): Promise<
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 👢 Spieler kicken
|
||||
export async function kickPlayer(teamId: string, steamId: string): Promise<boolean> {
|
||||
try {
|
||||
const res = await fetch('/api/team/kick', {
|
||||
const res = await fetch(`/api/team/${encodeURIComponent(teamId)}/kick`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ teamId, steamId }),
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
const data = await res.json()
|
||||
throw new Error(data.message || 'Fehler beim Kicken')
|
||||
const data = await readJsonSafe(res)
|
||||
throw new Error(getErrorMessage(data, 'Fehler beim Kicken'))
|
||||
}
|
||||
|
||||
return true
|
||||
@ -153,15 +181,16 @@ export async function kickPlayer(teamId: string, steamId: string): Promise<boole
|
||||
// 👑 Leader übertragen
|
||||
export async function transferLeader(teamId: string, newLeaderSteamId: string): Promise<boolean> {
|
||||
try {
|
||||
const res = await fetch('/api/team/transfer-leader', {
|
||||
const res = await fetch(`/api/team/${encodeURIComponent(teamId)}/transfer-leader`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'same-origin',
|
||||
body: JSON.stringify({ teamId, newLeaderSteamId }),
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
const data = await res.json()
|
||||
throw new Error(data.message || 'Fehler beim Übertragen der Leader-Rolle')
|
||||
const data = await readJsonSafe(res)
|
||||
throw new Error(getErrorMessage(data, 'Fehler beim Übertragen der Leader-Rolle'))
|
||||
}
|
||||
|
||||
return true
|
||||
@ -171,18 +200,44 @@ export async function transferLeader(teamId: string, newLeaderSteamId: string):
|
||||
}
|
||||
}
|
||||
|
||||
// Lädt die SteamIDs aller Leader anderer Teams (aktuelles Team via exclude ausnehmen)
|
||||
export async function getOtherTeamLeaderIds(excludeTeamId?: string): Promise<Set<string>> {
|
||||
try {
|
||||
const qs = excludeTeamId ? `?exclude=${encodeURIComponent(excludeTeamId)}` : ''
|
||||
const res = await fetch(`/api/team/leader-ids${qs}`, {
|
||||
cache: 'no-store',
|
||||
credentials: 'same-origin',
|
||||
})
|
||||
if (!res.ok) throw new Error('leader-ids fetch failed')
|
||||
const j: unknown = await readJsonSafe(res)
|
||||
let ids: string[] = []
|
||||
|
||||
if (typeof j === 'object' && j !== null) {
|
||||
const v = (j as Record<string, unknown>).leaderIds
|
||||
if (Array.isArray(v) && v.every((x) => typeof x === 'string')) {
|
||||
ids = v
|
||||
}
|
||||
}
|
||||
return new Set(ids)
|
||||
} catch (err) {
|
||||
console.warn('[sse-actions] getOtherTeamLeaderIds:', err)
|
||||
return new Set()
|
||||
}
|
||||
}
|
||||
|
||||
// 🗑️ Team löschen
|
||||
export async function deleteTeam(teamId: string): Promise<boolean> {
|
||||
try {
|
||||
const res = await fetch('/api/team/delete', {
|
||||
const res = await fetch(`/api/team/${encodeURIComponent(teamId)}/delete`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'same-origin',
|
||||
body: JSON.stringify({ teamId }),
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
const data = await res.json()
|
||||
throw new Error(data.message || 'Fehler beim Löschen')
|
||||
const data = await readJsonSafe(res)
|
||||
throw new Error(getErrorMessage(data, 'Fehler beim Löschen'))
|
||||
}
|
||||
|
||||
return true
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user