Implement cross-club friendly match concept with invitations and shared matches
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 49s

- Added controllers for handling friendly match invitations and shared matches.
- Created migration scripts for `friendly_match_invitation` and `friendly_match_shared` tables.
- Developed models for `FriendlyMatchInvitation` and `FriendlyMatchShared`.
- Established routes for managing invitations and shared matches.
- Implemented services for business logic related to invitations and shared matches.
- Documented the concept plan for the new feature including API endpoints and data models.
This commit is contained in:
Torsten Schulz (local)
2026-05-30 17:50:35 +02:00
parent 359527eb5b
commit 0ff67dae80
21 changed files with 1795 additions and 17 deletions

View File

@@ -4,6 +4,8 @@ import de.tsschulz.tt_tagebuch.shared.api.http.AuthedHttpClient
import de.tsschulz.tt_tagebuch.shared.api.models.LeaguePlayerStatDto
import de.tsschulz.tt_tagebuch.shared.api.models.LeagueTableRowDto
import de.tsschulz.tt_tagebuch.shared.api.models.FriendlyMatchSaveBody
import de.tsschulz.tt_tagebuch.shared.api.models.FriendlyMatchInvitationCreateBody
import de.tsschulz.tt_tagebuch.shared.api.models.FriendlyMatchInvitationDto
import de.tsschulz.tt_tagebuch.shared.api.models.ScheduleMatchDto
import de.tsschulz.tt_tagebuch.shared.api.models.UpdateMatchPlayersBody
import io.ktor.client.call.body
@@ -50,6 +52,10 @@ class MatchesApi(
return client.http.get("/api/friendly-matches/$clubId").body()
}
suspend fun listSharedFriendlyMatches(clubId: Int): List<ScheduleMatchDto> {
return client.http.get("/api/friendly-matches/shared/$clubId").body()
}
suspend fun createFriendlyMatch(clubId: Int, body: FriendlyMatchSaveBody): ScheduleMatchDto {
return client.http.post("/api/friendly-matches/$clubId") {
setBody(body)
@@ -62,7 +68,45 @@ class MatchesApi(
}.body()
}
suspend fun updateSharedFriendlyMatch(clubId: Int, matchId: Int, body: FriendlyMatchSaveBody): ScheduleMatchDto {
return client.http.put("/api/friendly-matches/shared/$clubId/$matchId") {
setBody(body)
}.body()
}
suspend fun updateSharedFriendlyMatchPlayers(clubId: Int, matchId: Int, body: UpdateMatchPlayersBody) {
client.http.patch("/api/friendly-matches/shared/$clubId/$matchId/players") {
setBody(body)
}
}
suspend fun deleteFriendlyMatch(clubId: Int, matchId: Int) {
client.http.delete("/api/friendly-matches/$clubId/$matchId")
}
suspend fun deleteSharedFriendlyMatch(clubId: Int, matchId: Int) {
client.http.delete("/api/friendly-matches/shared/$clubId/$matchId")
}
suspend fun createFriendlyInvitation(clubId: Int, body: FriendlyMatchInvitationCreateBody): FriendlyMatchInvitationDto {
return client.http.post("/api/friendly-match-invitations/$clubId") {
setBody(body)
}.body()
}
suspend fun listIncomingFriendlyInvitations(clubId: Int): List<FriendlyMatchInvitationDto> {
return client.http.get("/api/friendly-match-invitations/$clubId/incoming").body()
}
suspend fun listOutgoingFriendlyInvitations(clubId: Int): List<FriendlyMatchInvitationDto> {
return client.http.get("/api/friendly-match-invitations/$clubId/outgoing").body()
}
suspend fun acceptFriendlyInvitation(clubId: Int, invitationId: Int): ScheduleMatchDto {
return client.http.post("/api/friendly-match-invitations/$clubId/$invitationId/accept").body()
}
suspend fun declineFriendlyInvitation(clubId: Int, invitationId: Int): FriendlyMatchInvitationDto {
return client.http.post("/api/friendly-match-invitations/$clubId/$invitationId/decline").body()
}
}

View File

@@ -92,6 +92,11 @@ class SocketService(private val socketUrl: String) {
"tournament:changed",
"schedule:match:updated",
"schedule:match-report:submitted",
"friendly:invitation:created",
"friendly:invitation:accepted",
"friendly:invitation:declined",
"friendly:shared:match:updated",
"friendly:shared:match:deleted",
)
}
}

View File

@@ -83,9 +83,13 @@ data class ScheduleLeagueDetailsDto(
data class ScheduleMatchDto(
val id: Int,
val friendlyMatchId: Int? = null,
val sharedMatchId: Int? = null,
val isFriendly: Boolean = false,
val isSharedFriendly: Boolean = false,
val date: String? = null,
val time: String? = null,
val homeClubId: Int? = null,
val guestClubId: Int? = null,
val homeTeamId: Int? = null,
val guestTeamId: Int? = null,
val locationId: Int? = null,
@@ -150,6 +154,30 @@ data class FriendlyMatchSaveBody(
val resultDetails: List<FriendlyResultRowDto> = emptyList(),
)
@Serializable
data class FriendlyMatchInvitationDto(
val id: Int,
val fromClubId: Int,
val toClubId: Int,
val proposedDate: String,
val proposedStartTime: String? = null,
val proposedMatchName: String,
val message: String? = null,
val status: String = "pending",
val createdByUserId: Int? = null,
val acceptedByUserId: Int? = null,
val acceptedAt: String? = null,
)
@Serializable
data class FriendlyMatchInvitationCreateBody(
val toClubId: Int,
val date: String,
val startTime: String? = null,
val matchName: String,
val message: String? = null,
)
@Serializable
data class LeagueTableRowDto(
val teamId: Int,

View File

@@ -4,6 +4,8 @@ import de.tsschulz.tt_tagebuch.shared.api.ClubTeamsApi
import de.tsschulz.tt_tagebuch.shared.api.MatchesApi
import de.tsschulz.tt_tagebuch.shared.api.ScheduleLogic
import de.tsschulz.tt_tagebuch.shared.api.models.ClubTeamDto
import de.tsschulz.tt_tagebuch.shared.api.models.FriendlyMatchInvitationCreateBody
import de.tsschulz.tt_tagebuch.shared.api.models.FriendlyMatchInvitationDto
import de.tsschulz.tt_tagebuch.shared.api.models.FriendlyMatchSaveBody
import de.tsschulz.tt_tagebuch.shared.api.models.LeagueTableRowDto
import de.tsschulz.tt_tagebuch.shared.api.models.ScheduleMatchDto
@@ -23,6 +25,8 @@ data class ScheduleState(
val allMatches: List<ScheduleMatchDto> = emptyList(),
val overallMatches: List<ScheduleMatchDto> = emptyList(),
val friendlyMatches: List<ScheduleMatchDto> = emptyList(),
val incomingFriendlyInvitations: List<FriendlyMatchInvitationDto> = emptyList(),
val outgoingFriendlyInvitations: List<FriendlyMatchInvitationDto> = emptyList(),
val leagueTable: List<LeagueTableRowDto> = emptyList(),
val matchScope: ScheduleMatchScope = ScheduleMatchScope.Own,
val otherTeamName: String = "",
@@ -78,7 +82,10 @@ class ScheduleManager(
}
ScheduleViewMode.Overall -> loadOverallSchedule(clubId)
ScheduleViewMode.Adult -> loadAdultSchedule(clubId)
ScheduleViewMode.Friendly -> loadFriendlyMatches(clubId)
ScheduleViewMode.Friendly -> {
loadFriendlyMatches(clubId)
loadFriendlyInvitations(clubId)
}
}
}
@@ -248,8 +255,11 @@ class ScheduleManager(
)
}
try {
val matches = matchesApi.listFriendlyMatches(clubId)
_state.update { it.copy(friendlyMatches = matches, isLoading = false, error = null) }
val localMatches = matchesApi.listFriendlyMatches(clubId)
val sharedMatches = matchesApi.listSharedFriendlyMatches(clubId)
val combined = ScheduleLogic.sortMatches(localMatches + sharedMatches)
_state.update { it.copy(friendlyMatches = combined, isLoading = false, error = null) }
loadFriendlyInvitations(clubId)
} catch (t: Throwable) {
_state.update {
it.copy(
@@ -294,8 +304,44 @@ class ScheduleManager(
_state.update { it.copy(friendlyMatches = ScheduleLogic.sortMatches(it.friendlyMatches + saved)) }
}
suspend fun loadFriendlyInvitations(clubId: Int) {
try {
val incoming = matchesApi.listIncomingFriendlyInvitations(clubId)
val outgoing = matchesApi.listOutgoingFriendlyInvitations(clubId)
_state.update {
it.copy(
incomingFriendlyInvitations = incoming,
outgoingFriendlyInvitations = outgoing,
)
}
} catch (_: Throwable) {
// Invitations sind ein Zusatzbereich; Fehler sollen den Hauptscreen nicht blockieren.
}
}
suspend fun createFriendlyInvitation(clubId: Int, body: FriendlyMatchInvitationCreateBody) {
matchesApi.createFriendlyInvitation(clubId, body)
loadFriendlyInvitations(clubId)
}
suspend fun acceptFriendlyInvitation(clubId: Int, invitationId: Int) {
val sharedMatch = matchesApi.acceptFriendlyInvitation(clubId, invitationId)
_state.update { it.copy(friendlyMatches = ScheduleLogic.sortMatches(it.friendlyMatches + sharedMatch)) }
loadFriendlyInvitations(clubId)
}
suspend fun declineFriendlyInvitation(clubId: Int, invitationId: Int) {
matchesApi.declineFriendlyInvitation(clubId, invitationId)
loadFriendlyInvitations(clubId)
}
suspend fun updateFriendlyMatch(clubId: Int, matchId: Int, body: FriendlyMatchSaveBody) {
val saved = matchesApi.updateFriendlyMatch(clubId, matchId, body)
val existing = _state.value.friendlyMatches.find { it.id == matchId }
val saved = if (existing?.isSharedFriendly == true) {
matchesApi.updateSharedFriendlyMatch(clubId, matchId, body)
} else {
matchesApi.updateFriendlyMatch(clubId, matchId, body)
}
_state.update {
it.copy(
friendlyMatches = ScheduleLogic.sortMatches(
@@ -306,7 +352,12 @@ class ScheduleManager(
}
suspend fun deleteFriendlyMatch(clubId: Int, matchId: Int) {
matchesApi.deleteFriendlyMatch(clubId, matchId)
val existing = _state.value.friendlyMatches.find { it.id == matchId }
if (existing?.isSharedFriendly == true) {
matchesApi.deleteSharedFriendlyMatch(clubId, matchId)
} else {
matchesApi.deleteFriendlyMatch(clubId, matchId)
}
_state.update { it.copy(friendlyMatches = it.friendlyMatches.filterNot { match -> match.id == matchId }) }
}
}