From 3762bbf7db21e835c61cdc3989524999a346d682 Mon Sep 17 00:00:00 2001
From: Raphael Frank <04.raphael.frank@gmail.com>
Date: Sat, 27 Jun 2026 11:22:31 +0200
Subject: [PATCH 01/16] add member_roles claim to keycloak
---
.../helm/team-devoops/files/realm-config.json | 54 ++++-
infra/helm/team-devoops/values.yaml | 8 +
infra/keycloak/realm-config.json | 54 ++++-
.../service/KeycloakRoleService.java | 203 ++++++++++++++++++
.../service/MemberRoleSyncService.java | 93 ++++++++
.../service/OrganizationSportService.java | 33 ++-
.../service/OrganizationTeamService.java | 24 +++
.../src/main/resources/application.properties | 10 +
.../MemberRoleSyncServiceTest.java | 106 +++++++++
.../OrganizationControllerTest.java | 4 +
.../OrganizationServiceApplicationTests.java | 9 +-
.../OrganizationSportServiceTest.java | 4 +
.../OrganizationTeamServiceTest.java | 6 +
web-client/src/__tests__/useAuth.test.ts | 34 +++
web-client/src/features/auth/useAuth.ts | 2 +
web-client/src/types.ts | 3 +
16 files changed, 638 insertions(+), 9 deletions(-)
create mode 100644 services/spring-organization/src/main/java/tum/devoops/organizationservice/service/KeycloakRoleService.java
create mode 100644 services/spring-organization/src/main/java/tum/devoops/organizationservice/service/MemberRoleSyncService.java
create mode 100644 services/spring-organization/src/test/java/tum/devoops/organizationservice/MemberRoleSyncServiceTest.java
create mode 100644 web-client/src/__tests__/useAuth.test.ts
diff --git a/infra/helm/team-devoops/files/realm-config.json b/infra/helm/team-devoops/files/realm-config.json
index f9648eb..5a9fbe6 100644
--- a/infra/helm/team-devoops/files/realm-config.json
+++ b/infra/helm/team-devoops/files/realm-config.json
@@ -27,17 +27,39 @@
}
],
"realmRoles": ["member"]
+ },
+ {
+ "username": "service-account-org-role-sync",
+ "enabled": true,
+ "serviceAccountClientId": "org-role-sync",
+ "clientRoles": {
+ "realm-management": ["manage-users", "view-clients"]
+ }
}
],
"roles": {
"realm": [
{
- "name": "admin"
+ "name": "admin",
+ "composite": true,
+ "composites": {
+ "client": {
+ "devops-client": ["Admin"]
+ }
+ }
},
{
"name": "member"
}
- ]
+ ],
+ "client": {
+ "devops-client": [
+ { "name": "Trainee" },
+ { "name": "Coach" },
+ { "name": "Director" },
+ { "name": "Admin" }
+ ]
+ }
},
"components": {
"org.keycloak.userprofile.UserProfileProvider": [
@@ -59,7 +81,33 @@
"publicClient": true,
"redirectUris": ["*"],
"webOrigins": ["*"],
- "directAccessGrantsEnabled": true
+ "directAccessGrantsEnabled": true,
+ "protocolMappers": [
+ {
+ "name": "member-roles",
+ "protocol": "openid-connect",
+ "protocolMapper": "oidc-usermodel-client-role-mapper",
+ "consentRequired": false,
+ "config": {
+ "claim.name": "member_roles",
+ "jsonType.label": "String",
+ "multivalued": "true",
+ "usermodel.clientRoleMapping.clientId": "devops-client",
+ "access.token.claim": "true",
+ "id.token.claim": "true",
+ "userinfo.token.claim": "true"
+ }
+ }
+ ]
+ },
+ {
+ "clientId": "org-role-sync",
+ "enabled": true,
+ "publicClient": false,
+ "secret": "org-role-sync-secret",
+ "standardFlowEnabled": false,
+ "directAccessGrantsEnabled": false,
+ "serviceAccountsEnabled": true
},
{
"clientId": "traefik-forward-auth",
diff --git a/infra/helm/team-devoops/values.yaml b/infra/helm/team-devoops/values.yaml
index 9e6089e..d1a1ab8 100644
--- a/infra/helm/team-devoops/values.yaml
+++ b/infra/helm/team-devoops/values.yaml
@@ -170,6 +170,14 @@ services:
SPRING_SECURITY_OAUTH2_RESOURCESERVER_JWT_ISSUER_URI: "https://ge83mom-devops26.stud.k8s.aet.cit.tum.de/auth/realms/devops"
SPRING_SECURITY_OAUTH2_RESOURCESERVER_JWT_JWK_SET_URI: "http://keycloak:8080/auth/realms/devops/protocol/openid-connect/certs"
JAVA_TOOL_OPTIONS: "-Xmx300m -Xms64m"
+ # Keycloak Admin REST API for syncing membership client-roles (KeycloakRoleService).
+ # The org-role-sync service-account client is defined in files/realm-config.json.
+ # Override KEYCLOAK_ADMIN_CLIENT_SECRET via --set in CI/prod.
+ KEYCLOAK_BASE_URL: "http://keycloak:8080/auth"
+ KEYCLOAK_REALM: "devops"
+ KEYCLOAK_ADMIN_CLIENT_ID: "org-role-sync"
+ KEYCLOAK_ADMIN_CLIENT_SECRET: "org-role-sync-secret"
+ KEYCLOAK_ROLES_CLIENT: "devops-client"
member-service:
path: /api/v1/members
port: 8080
diff --git a/infra/keycloak/realm-config.json b/infra/keycloak/realm-config.json
index f9648eb..5a9fbe6 100644
--- a/infra/keycloak/realm-config.json
+++ b/infra/keycloak/realm-config.json
@@ -27,17 +27,39 @@
}
],
"realmRoles": ["member"]
+ },
+ {
+ "username": "service-account-org-role-sync",
+ "enabled": true,
+ "serviceAccountClientId": "org-role-sync",
+ "clientRoles": {
+ "realm-management": ["manage-users", "view-clients"]
+ }
}
],
"roles": {
"realm": [
{
- "name": "admin"
+ "name": "admin",
+ "composite": true,
+ "composites": {
+ "client": {
+ "devops-client": ["Admin"]
+ }
+ }
},
{
"name": "member"
}
- ]
+ ],
+ "client": {
+ "devops-client": [
+ { "name": "Trainee" },
+ { "name": "Coach" },
+ { "name": "Director" },
+ { "name": "Admin" }
+ ]
+ }
},
"components": {
"org.keycloak.userprofile.UserProfileProvider": [
@@ -59,7 +81,33 @@
"publicClient": true,
"redirectUris": ["*"],
"webOrigins": ["*"],
- "directAccessGrantsEnabled": true
+ "directAccessGrantsEnabled": true,
+ "protocolMappers": [
+ {
+ "name": "member-roles",
+ "protocol": "openid-connect",
+ "protocolMapper": "oidc-usermodel-client-role-mapper",
+ "consentRequired": false,
+ "config": {
+ "claim.name": "member_roles",
+ "jsonType.label": "String",
+ "multivalued": "true",
+ "usermodel.clientRoleMapping.clientId": "devops-client",
+ "access.token.claim": "true",
+ "id.token.claim": "true",
+ "userinfo.token.claim": "true"
+ }
+ }
+ ]
+ },
+ {
+ "clientId": "org-role-sync",
+ "enabled": true,
+ "publicClient": false,
+ "secret": "org-role-sync-secret",
+ "standardFlowEnabled": false,
+ "directAccessGrantsEnabled": false,
+ "serviceAccountsEnabled": true
},
{
"clientId": "traefik-forward-auth",
diff --git a/services/spring-organization/src/main/java/tum/devoops/organizationservice/service/KeycloakRoleService.java b/services/spring-organization/src/main/java/tum/devoops/organizationservice/service/KeycloakRoleService.java
new file mode 100644
index 0000000..ae00aa9
--- /dev/null
+++ b/services/spring-organization/src/main/java/tum/devoops/organizationservice/service/KeycloakRoleService.java
@@ -0,0 +1,203 @@
+package tum.devoops.organizationservice.service;
+
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Set;
+import java.util.UUID;
+import java.util.stream.Collectors;
+
+import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
+import com.fasterxml.jackson.annotation.JsonProperty;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.http.HttpMethod;
+import org.springframework.http.MediaType;
+import org.springframework.stereotype.Service;
+import org.springframework.util.LinkedMultiValueMap;
+import org.springframework.util.MultiValueMap;
+import org.springframework.web.client.RestClient;
+
+/**
+ * Thin client over the Keycloak Admin REST API that grants/revokes the
+ * membership client-roles (Coach/Director/Trainee) on the roles client.
+ *
+ *
Authenticates with its own service-account ({@code client_credentials}),
+ * so it works regardless of which user triggered the change. Deliberately has
+ * no repository dependency: it only needs the {@code keycloak.*} configuration,
+ * so the context-load smoke test exercises those placeholders and fails fast if
+ * they are unresolved.
+ */
+@Service
+public class KeycloakRoleService {
+
+ /** Client roles this service manages on the roles client. */
+ static final String ROLE_COACH = "Coach";
+ static final String ROLE_DIRECTOR = "Director";
+ static final String ROLE_TRAINEE = "Trainee";
+ static final Set MANAGED_ROLES = Set.of(ROLE_COACH, ROLE_DIRECTOR, ROLE_TRAINEE);
+
+ private static final long TOKEN_EXPIRY_LEEWAY_MS = 10_000L;
+
+ private final RestClient restClient;
+ private final String realm;
+ private final String adminClientId;
+ private final String adminClientSecret;
+ private final String rolesClientId;
+
+ private String cachedToken;
+ private long tokenExpiresAt;
+ private String cachedRolesClientUuid;
+ private Map cachedManagedRoleReps;
+
+ public KeycloakRoleService(
+ RestClient.Builder restClientBuilder,
+ @Value("${keycloak.base-url}") String baseUrl,
+ @Value("${keycloak.realm}") String realm,
+ @Value("${keycloak.admin.client-id}") String adminClientId,
+ @Value("${keycloak.admin.client-secret}") String adminClientSecret,
+ @Value("${keycloak.roles-client}") String rolesClientId) {
+ this.restClient = restClientBuilder.baseUrl(baseUrl).build();
+ this.realm = realm;
+ this.adminClientId = adminClientId;
+ this.adminClientSecret = adminClientSecret;
+ this.rolesClientId = rolesClientId;
+ }
+
+ /**
+ * Reconcile the managed client-roles of a user to match {@code desiredRoles}
+ * (a subset of {@link #MANAGED_ROLES}), adding the missing ones and removing
+ * the stale ones. Roles outside the managed set (e.g. Admin) are untouched.
+ */
+ public void reconcile(UUID userId, Set desiredRoles) {
+ String clientUuid = resolveRolesClientUuid();
+ Map roleReps = resolveManagedRoleReps(clientUuid);
+ Set current = getAssignedManagedRoles(userId, clientUuid);
+
+ List toAdd = desiredRoles.stream()
+ .filter(name -> !current.contains(name))
+ .map(roleReps::get)
+ .filter(Objects::nonNull)
+ .collect(Collectors.toList());
+ List toRemove = current.stream()
+ .filter(name -> !desiredRoles.contains(name))
+ .map(roleReps::get)
+ .filter(Objects::nonNull)
+ .collect(Collectors.toList());
+
+ if (!toAdd.isEmpty()) {
+ restClient.post()
+ .uri("/admin/realms/{realm}/users/{id}/role-mappings/clients/{client}", realm, userId, clientUuid)
+ .header("Authorization", bearer())
+ .contentType(MediaType.APPLICATION_JSON)
+ .body(toAdd)
+ .retrieve()
+ .toBodilessEntity();
+ }
+ if (!toRemove.isEmpty()) {
+ restClient.method(HttpMethod.DELETE)
+ .uri("/admin/realms/{realm}/users/{id}/role-mappings/clients/{client}", realm, userId, clientUuid)
+ .header("Authorization", bearer())
+ .contentType(MediaType.APPLICATION_JSON)
+ .body(toRemove)
+ .retrieve()
+ .toBodilessEntity();
+ }
+ }
+
+ private Set getAssignedManagedRoles(UUID userId, String clientUuid) {
+ RoleRep[] roles = restClient.get()
+ .uri("/admin/realms/{realm}/users/{id}/role-mappings/clients/{client}", realm, userId, clientUuid)
+ .header("Authorization", bearer())
+ .retrieve()
+ .body(RoleRep[].class);
+ Set result = new HashSet<>();
+ if (roles != null) {
+ for (RoleRep role : roles) {
+ if (MANAGED_ROLES.contains(role.name())) {
+ result.add(role.name());
+ }
+ }
+ }
+ return result;
+ }
+
+ private synchronized String resolveRolesClientUuid() {
+ if (cachedRolesClientUuid != null) {
+ return cachedRolesClientUuid;
+ }
+ ClientRep[] clients = restClient.get()
+ .uri("/admin/realms/{realm}/clients?clientId={clientId}", realm, rolesClientId)
+ .header("Authorization", bearer())
+ .retrieve()
+ .body(ClientRep[].class);
+ if (clients == null || clients.length == 0) {
+ throw new IllegalStateException("Keycloak client not found: " + rolesClientId);
+ }
+ cachedRolesClientUuid = clients[0].id();
+ return cachedRolesClientUuid;
+ }
+
+ private synchronized Map resolveManagedRoleReps(String clientUuid) {
+ if (cachedManagedRoleReps != null) {
+ return cachedManagedRoleReps;
+ }
+ RoleRep[] roles = restClient.get()
+ .uri("/admin/realms/{realm}/clients/{client}/roles", realm, clientUuid)
+ .header("Authorization", bearer())
+ .retrieve()
+ .body(RoleRep[].class);
+ Map reps = new HashMap<>();
+ if (roles != null) {
+ for (RoleRep role : roles) {
+ if (MANAGED_ROLES.contains(role.name())) {
+ reps.put(role.name(), role);
+ }
+ }
+ }
+ cachedManagedRoleReps = reps;
+ return reps;
+ }
+
+ private synchronized String bearer() {
+ if (cachedToken == null || System.currentTimeMillis() >= tokenExpiresAt) {
+ fetchToken();
+ }
+ return "Bearer " + cachedToken;
+ }
+
+ private void fetchToken() {
+ MultiValueMap form = new LinkedMultiValueMap<>();
+ form.add("grant_type", "client_credentials");
+ form.add("client_id", adminClientId);
+ form.add("client_secret", adminClientSecret);
+
+ TokenResponse response = restClient.post()
+ .uri("/realms/{realm}/protocol/openid-connect/token", realm)
+ .contentType(MediaType.APPLICATION_FORM_URLENCODED)
+ .body(form)
+ .retrieve()
+ .body(TokenResponse.class);
+ if (response == null || response.accessToken() == null) {
+ throw new IllegalStateException("Keycloak did not return an access token");
+ }
+ this.cachedToken = response.accessToken();
+ this.tokenExpiresAt = System.currentTimeMillis()
+ + Math.max(0L, response.expiresIn() * 1000L - TOKEN_EXPIRY_LEEWAY_MS);
+ }
+
+ @JsonIgnoreProperties(ignoreUnknown = true)
+ private record TokenResponse(
+ @JsonProperty("access_token") String accessToken,
+ @JsonProperty("expires_in") long expiresIn) {
+ }
+
+ @JsonIgnoreProperties(ignoreUnknown = true)
+ private record ClientRep(String id, String clientId) {
+ }
+
+ @JsonIgnoreProperties(ignoreUnknown = true)
+ private record RoleRep(String id, String name) {
+ }
+}
diff --git a/services/spring-organization/src/main/java/tum/devoops/organizationservice/service/MemberRoleSyncService.java b/services/spring-organization/src/main/java/tum/devoops/organizationservice/service/MemberRoleSyncService.java
new file mode 100644
index 0000000..6a41520
--- /dev/null
+++ b/services/spring-organization/src/main/java/tum/devoops/organizationservice/service/MemberRoleSyncService.java
@@ -0,0 +1,93 @@
+package tum.devoops.organizationservice.service;
+
+import java.util.Collection;
+import java.util.HashSet;
+import java.util.Set;
+import java.util.UUID;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.support.TransactionSynchronization;
+import org.springframework.transaction.support.TransactionSynchronizationManager;
+
+import tum.devoops.organizationservice.repository.DirectorRepository;
+import tum.devoops.organizationservice.repository.TraineeRepository;
+import tum.devoops.organizationservice.repository.TrainerRepository;
+
+/**
+ * Keeps each member's Keycloak membership client-roles (Coach/Director/Trainee)
+ * in sync with the organization database.
+ *
+ * Because a member can hold a role via several teams/sports, membership is
+ * recomputed from the database ("has at least one such row") rather than toggled
+ * per change. The sync runs after the surrounding transaction commits so it reads
+ * the committed state, and a failed Keycloak call is logged but never rolls back
+ * the org change.
+ */
+@Service
+public class MemberRoleSyncService {
+
+ private static final Logger LOG = LoggerFactory.getLogger(MemberRoleSyncService.class);
+
+ @Autowired
+ private TrainerRepository trainerRepository;
+ @Autowired
+ private DirectorRepository directorRepository;
+ @Autowired
+ private TraineeRepository traineeRepository;
+ @Autowired
+ private KeycloakRoleService keycloakRoleService;
+
+ /**
+ * Schedule a role re-sync for the given members once the current transaction
+ * commits (or immediately if no transaction is active). Null/duplicate ids and
+ * empty input are ignored.
+ */
+ public void scheduleSync(Collection memberIds) {
+ Set ids = new HashSet<>();
+ for (UUID id : memberIds) {
+ if (id != null) {
+ ids.add(id);
+ }
+ }
+ if (ids.isEmpty()) {
+ return;
+ }
+ if (TransactionSynchronizationManager.isSynchronizationActive()) {
+ TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
+ @Override
+ public void afterCommit() {
+ syncAll(ids);
+ }
+ });
+ } else {
+ syncAll(ids);
+ }
+ }
+
+ private void syncAll(Set ids) {
+ for (UUID id : ids) {
+ try {
+ syncMember(id);
+ } catch (RuntimeException e) {
+ LOG.error("Failed to sync Keycloak roles for member {}", id, e);
+ }
+ }
+ }
+
+ void syncMember(UUID memberId) {
+ Set desired = new HashSet<>();
+ if (!trainerRepository.findAllById_MemberId(memberId).isEmpty()) {
+ desired.add(KeycloakRoleService.ROLE_COACH);
+ }
+ if (!directorRepository.findAllById_MemberId(memberId).isEmpty()) {
+ desired.add(KeycloakRoleService.ROLE_DIRECTOR);
+ }
+ if (!traineeRepository.findAllById_MemberId(memberId).isEmpty()) {
+ desired.add(KeycloakRoleService.ROLE_TRAINEE);
+ }
+ keycloakRoleService.reconcile(memberId, desired);
+ }
+}
diff --git a/services/spring-organization/src/main/java/tum/devoops/organizationservice/service/OrganizationSportService.java b/services/spring-organization/src/main/java/tum/devoops/organizationservice/service/OrganizationSportService.java
index 0804ae8..50f03b1 100644
--- a/services/spring-organization/src/main/java/tum/devoops/organizationservice/service/OrganizationSportService.java
+++ b/services/spring-organization/src/main/java/tum/devoops/organizationservice/service/OrganizationSportService.java
@@ -1,7 +1,9 @@
package tum.devoops.organizationservice.service;
import java.time.LocalDate;
+import java.util.HashSet;
import java.util.List;
+import java.util.Set;
import java.util.UUID;
import java.util.stream.Collectors;
@@ -41,6 +43,8 @@ public class OrganizationSportService {
private TrainerRepository trainerRepository;
@Autowired
private TraineeRepository traineeRepository;
+ @Autowired
+ private MemberRoleSyncService memberRoleSyncService;
@Transactional(readOnly = true)
public List getAllSports() {
@@ -69,6 +73,8 @@ public Sport createSport(SportCreate body) {
saveDirectors(body.getName(), directorIds);
+ memberRoleSyncService.scheduleSync(new HashSet<>(directorIds));
+
return toSport(findSportOrThrow(body.getName()));
}
@@ -85,6 +91,8 @@ public Sport updateSport(String sportName, SportPartialUpdate body, UUID request
String effectiveName = (body.getName() != null) ? body.getName() : sportName;
String effectiveDescription = (body.getDescription() != null) ? body.getDescription() : sport.getDescription();
+ Set affected = new HashSet<>();
+
if (!effectiveName.equals(sportName)) {
if (sportRepository.existsById(effectiveName)) {
throw new ConflictException("Sport already exists: " + effectiveName);
@@ -105,8 +113,13 @@ public Sport updateSport(String sportName, SportPartialUpdate body, UUID request
directorRepository.deleteAllById_SportName(sportName);
if (isAdmin && !body.getDirectors().isEmpty()) {
- saveDirectors(effectiveName, resolveDirectorUuids(body.getDirectors()));
+ List newDirectorIds = resolveDirectorUuids(body.getDirectors());
+ saveDirectors(effectiveName, newDirectorIds);
+ // Directors are replaced: the removed and the added members both change.
+ oldDirectors.forEach(d -> affected.add(d.getId().getMemberId()));
+ affected.addAll(newDirectorIds);
} else {
+ // Pure rename: the same members keep a director row, so membership is unchanged.
List migratedDirectors = oldDirectors.stream()
.map(d -> new DirectorEntity(
new DirectorEntity.Id(effectiveName, d.getId().getMemberId())))
@@ -120,11 +133,17 @@ public Sport updateSport(String sportName, SportPartialUpdate body, UUID request
sportRepository.save(sport);
if (isAdmin && !body.getDirectors().isEmpty()) {
+ directorRepository.findAllById_SportName(sportName)
+ .forEach(d -> affected.add(d.getId().getMemberId()));
directorRepository.deleteAllById_SportName(sportName);
- saveDirectors(sportName, resolveDirectorUuids(body.getDirectors()));
+ List newDirectorIds = resolveDirectorUuids(body.getDirectors());
+ saveDirectors(sportName, newDirectorIds);
+ affected.addAll(newDirectorIds);
}
}
+ memberRoleSyncService.scheduleSync(affected);
+
return toSport(findSportOrThrow(effectiveName));
}
@@ -132,8 +151,16 @@ public Sport updateSport(String sportName, SportPartialUpdate body, UUID request
public void deleteSport(String sportName) {
SportEntity sport = findSportOrThrow(sportName);
+ Set affected = new HashSet<>();
+ directorRepository.findAllById_SportName(sportName)
+ .forEach(d -> affected.add(d.getId().getMemberId()));
+
List teams = teamRepository.findAllBySportName(sportName);
for (TeamEntity team : teams) {
+ trainerRepository.findAllById_TeamId(team.getId())
+ .forEach(t -> affected.add(t.getId().getMemberId()));
+ traineeRepository.findAllById_TeamId(team.getId())
+ .forEach(t -> affected.add(t.getId().getMemberId()));
traineeRepository.deleteAllById_TeamId(team.getId());
trainerRepository.deleteAllById_TeamId(team.getId());
}
@@ -141,6 +168,8 @@ public void deleteSport(String sportName) {
directorRepository.deleteAllById_SportName(sportName);
sportRepository.delete(sport);
+
+ memberRoleSyncService.scheduleSync(affected);
}
private SportEntity findSportOrThrow(String sportName) {
diff --git a/services/spring-organization/src/main/java/tum/devoops/organizationservice/service/OrganizationTeamService.java b/services/spring-organization/src/main/java/tum/devoops/organizationservice/service/OrganizationTeamService.java
index 8fc066a..87b1585 100644
--- a/services/spring-organization/src/main/java/tum/devoops/organizationservice/service/OrganizationTeamService.java
+++ b/services/spring-organization/src/main/java/tum/devoops/organizationservice/service/OrganizationTeamService.java
@@ -1,7 +1,9 @@
package tum.devoops.organizationservice.service;
import java.time.LocalDate;
+import java.util.HashSet;
import java.util.List;
+import java.util.Set;
import java.util.UUID;
import java.util.stream.Collectors;
@@ -40,6 +42,8 @@ public class OrganizationTeamService {
private TrainerRepository trainerRepository;
@Autowired
private TraineeRepository traineeRepository;
+ @Autowired
+ private MemberRoleSyncService memberRoleSyncService;
@Transactional(readOnly = true)
public List getAllTeams() {
@@ -79,6 +83,10 @@ public Team createTeam(TeamCreate body, UUID requesterId, boolean isAdmin) {
saveTrainers(team.getId(), trainerIds);
saveTrainees(team.getId(), traineeIds);
+ Set affected = new HashSet<>(trainerIds);
+ affected.addAll(traineeIds);
+ memberRoleSyncService.scheduleSync(affected);
+
return toTeam(findTeamOrThrow(team.getId()));
}
@@ -119,16 +127,24 @@ public Team updateTeam(UUID teamId, TeamPartialUpdate body, UUID requesterId, bo
}
teamRepository.save(team);
+ Set affected = new HashSet<>();
if (!body.getTrainers().isEmpty()) {
List trainerIds = resolveAndValidateMemberUuids(body.getTrainers(), "trainer");
+ trainerRepository.findAllById_TeamId(teamId)
+ .forEach(t -> affected.add(t.getId().getMemberId()));
trainerRepository.deleteAllById_TeamId(teamId);
saveTrainers(teamId, trainerIds);
+ affected.addAll(trainerIds);
}
if (!body.getTrainees().isEmpty()) {
List traineeIds = resolveAndValidateMemberUuids(body.getTrainees(), "trainee");
+ traineeRepository.findAllById_TeamId(teamId)
+ .forEach(t -> affected.add(t.getId().getMemberId()));
traineeRepository.deleteAllById_TeamId(teamId);
saveTrainees(teamId, traineeIds);
+ affected.addAll(traineeIds);
}
+ memberRoleSyncService.scheduleSync(affected);
return toTeam(findTeamOrThrow(teamId));
}
@@ -143,9 +159,17 @@ public void deleteTeam(UUID teamId, UUID requesterId, boolean isAdmin) {
throw new ForbiddenException("Access denied");
}
+ Set affected = new HashSet<>();
+ trainerRepository.findAllById_TeamId(teamId)
+ .forEach(t -> affected.add(t.getId().getMemberId()));
+ traineeRepository.findAllById_TeamId(teamId)
+ .forEach(t -> affected.add(t.getId().getMemberId()));
+
traineeRepository.deleteAllById_TeamId(teamId);
trainerRepository.deleteAllById_TeamId(teamId);
teamRepository.delete(team);
+
+ memberRoleSyncService.scheduleSync(affected);
}
private TeamEntity findTeamOrThrow(UUID teamId) {
diff --git a/services/spring-organization/src/main/resources/application.properties b/services/spring-organization/src/main/resources/application.properties
index 0f5a352..5ac8fa0 100644
--- a/services/spring-organization/src/main/resources/application.properties
+++ b/services/spring-organization/src/main/resources/application.properties
@@ -9,3 +9,13 @@ spring.jpa.properties.hibernate.default_schema=organization
spring.flyway.default-schema=organization
spring.flyway.schemas=organization
spring.flyway.create-schemas=true
+
+# Keycloak Admin REST API used to sync membership client-roles (Trainee/Coach/Director)
+# onto users. Same service name/path in docker-compose and the cluster; the service-account
+# client (org-role-sync) is defined in infra/keycloak/realm-config.json. Override per-env via
+# KEYCLOAK_BASE_URL / KEYCLOAK_REALM / KEYCLOAK_ADMIN_CLIENT_ID / KEYCLOAK_ADMIN_CLIENT_SECRET.
+keycloak.base-url=http://keycloak:8080/auth
+keycloak.realm=devops
+keycloak.admin.client-id=org-role-sync
+keycloak.admin.client-secret=org-role-sync-secret
+keycloak.roles-client=devops-client
diff --git a/services/spring-organization/src/test/java/tum/devoops/organizationservice/MemberRoleSyncServiceTest.java b/services/spring-organization/src/test/java/tum/devoops/organizationservice/MemberRoleSyncServiceTest.java
new file mode 100644
index 0000000..6e71d2e
--- /dev/null
+++ b/services/spring-organization/src/test/java/tum/devoops/organizationservice/MemberRoleSyncServiceTest.java
@@ -0,0 +1,106 @@
+package tum.devoops.organizationservice;
+
+import java.util.List;
+import java.util.Set;
+import java.util.UUID;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoInteractions;
+import static org.mockito.Mockito.when;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+import tum.devoops.organizationservice.entity.DirectorEntity;
+import tum.devoops.organizationservice.entity.TraineeEntity;
+import tum.devoops.organizationservice.entity.TrainerEntity;
+import tum.devoops.organizationservice.repository.DirectorRepository;
+import tum.devoops.organizationservice.repository.TraineeRepository;
+import tum.devoops.organizationservice.repository.TrainerRepository;
+import tum.devoops.organizationservice.service.KeycloakRoleService;
+import tum.devoops.organizationservice.service.MemberRoleSyncService;
+
+/**
+ * Recompute logic of {@link MemberRoleSyncService}. Exercised via the public
+ * {@code scheduleSync}, which runs the sync inline when no transaction is active.
+ */
+@ExtendWith(MockitoExtension.class)
+class MemberRoleSyncServiceTest {
+
+ @Mock
+ private TrainerRepository trainerRepository;
+ @Mock
+ private DirectorRepository directorRepository;
+ @Mock
+ private TraineeRepository traineeRepository;
+ @Mock
+ private KeycloakRoleService keycloakRoleService;
+
+ @InjectMocks
+ private MemberRoleSyncService service;
+
+ private static final UUID MEMBER = UUID.fromString("00000000-0000-0000-0000-000000000007");
+ private static final UUID TEAM = UUID.fromString("00000000-0000-0000-0000-000000000010");
+
+ private TrainerEntity trainer(UUID memberId) {
+ return new TrainerEntity(new TrainerEntity.Id(TEAM, memberId));
+ }
+
+ private TraineeEntity trainee(UUID memberId) {
+ return new TraineeEntity(new TraineeEntity.Id(TEAM, memberId));
+ }
+
+ private DirectorEntity director(UUID memberId) {
+ return new DirectorEntity(new DirectorEntity.Id("soccer", memberId));
+ }
+
+ @Test
+ void desiresCoach_whenTrainerRowExists() {
+ when(trainerRepository.findAllById_MemberId(MEMBER)).thenReturn(List.of(trainer(MEMBER)));
+
+ service.scheduleSync(List.of(MEMBER));
+
+ verify(keycloakRoleService).reconcile(MEMBER, Set.of("Coach"));
+ }
+
+ @Test
+ void desiresAllThree_whenMemberHoldsEveryRole() {
+ when(trainerRepository.findAllById_MemberId(MEMBER)).thenReturn(List.of(trainer(MEMBER)));
+ when(directorRepository.findAllById_MemberId(MEMBER)).thenReturn(List.of(director(MEMBER)));
+ when(traineeRepository.findAllById_MemberId(MEMBER)).thenReturn(List.of(trainee(MEMBER)));
+
+ service.scheduleSync(List.of(MEMBER));
+
+ verify(keycloakRoleService).reconcile(MEMBER, Set.of("Coach", "Director", "Trainee"));
+ }
+
+ @Test
+ void desiresEmpty_whenMemberHoldsNoRole() {
+ // All repositories return empty by default (Mockito) => no managed roles.
+ service.scheduleSync(List.of(MEMBER));
+
+ verify(keycloakRoleService).reconcile(MEMBER, Set.of());
+ }
+
+ @Test
+ void keepsCoach_whenStillTrainerOfAnotherTeam() {
+ // The member was removed from one team but still trains another, so a trainer
+ // row remains => Coach stays in the desired set.
+ when(trainerRepository.findAllById_MemberId(MEMBER))
+ .thenReturn(List.of(new TrainerEntity(
+ new TrainerEntity.Id(UUID.randomUUID(), MEMBER))));
+
+ service.scheduleSync(List.of(MEMBER));
+
+ verify(keycloakRoleService).reconcile(MEMBER, Set.of("Coach"));
+ }
+
+ @Test
+ void doesNothing_whenNoMembers() {
+ service.scheduleSync(List.of());
+
+ verifyNoInteractions(keycloakRoleService);
+ }
+}
diff --git a/services/spring-organization/src/test/java/tum/devoops/organizationservice/OrganizationControllerTest.java b/services/spring-organization/src/test/java/tum/devoops/organizationservice/OrganizationControllerTest.java
index ec75204..68d3d5d 100644
--- a/services/spring-organization/src/test/java/tum/devoops/organizationservice/OrganizationControllerTest.java
+++ b/services/spring-organization/src/test/java/tum/devoops/organizationservice/OrganizationControllerTest.java
@@ -34,6 +34,7 @@
import tum.devoops.organizationservice.exception.NotFoundException;
import tum.devoops.organizationservice.model.Sport;
import tum.devoops.organizationservice.model.Team;
+import tum.devoops.organizationservice.service.MemberRoleSyncService;
import tum.devoops.organizationservice.service.OrganizationSportService;
import tum.devoops.organizationservice.service.OrganizationTeamService;
@@ -55,6 +56,9 @@ class OrganizationControllerTest {
@MockitoBean
private OrganizationTeamService teamService;
+ @MockitoBean
+ private MemberRoleSyncService memberRoleSyncService;
+
@MockitoBean
private JwtDecoder jwtDecoder;
diff --git a/services/spring-organization/src/test/java/tum/devoops/organizationservice/OrganizationServiceApplicationTests.java b/services/spring-organization/src/test/java/tum/devoops/organizationservice/OrganizationServiceApplicationTests.java
index 0d4e67e..6704c34 100644
--- a/services/spring-organization/src/test/java/tum/devoops/organizationservice/OrganizationServiceApplicationTests.java
+++ b/services/spring-organization/src/test/java/tum/devoops/organizationservice/OrganizationServiceApplicationTests.java
@@ -6,6 +6,7 @@
import org.springframework.test.context.TestPropertySource;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
+import tum.devoops.organizationservice.service.MemberRoleSyncService;
import tum.devoops.organizationservice.service.OrganizationSportService;
import tum.devoops.organizationservice.service.OrganizationTeamService;
@@ -13,7 +14,10 @@
* Context-load smoke test.
*
* DataSource and JPA auto-configurations are excluded so the test can run
- * without a live PostgreSQL instance.
+ * without a live PostgreSQL instance. MemberRoleSyncService is mocked (it depends
+ * on the JPA repositories), but KeycloakRoleService is left real so its
+ * {@code keycloak.*} placeholders are resolved at context load — failing fast if
+ * that configuration is missing.
*/
@SpringBootTest(properties = {
"spring.autoconfigure.exclude=" +
@@ -31,6 +35,9 @@ class OrganizationServiceApplicationTests {
@MockitoBean
private OrganizationTeamService teamService;
+ @MockitoBean
+ private MemberRoleSyncService memberRoleSyncService;
+
@MockitoBean
private JwtDecoder jwtDecoder;
diff --git a/services/spring-organization/src/test/java/tum/devoops/organizationservice/OrganizationSportServiceTest.java b/services/spring-organization/src/test/java/tum/devoops/organizationservice/OrganizationSportServiceTest.java
index 16d7082..a970a08 100644
--- a/services/spring-organization/src/test/java/tum/devoops/organizationservice/OrganizationSportServiceTest.java
+++ b/services/spring-organization/src/test/java/tum/devoops/organizationservice/OrganizationSportServiceTest.java
@@ -35,6 +35,7 @@
import tum.devoops.organizationservice.repository.TeamRepository;
import tum.devoops.organizationservice.repository.TraineeRepository;
import tum.devoops.organizationservice.repository.TrainerRepository;
+import tum.devoops.organizationservice.service.MemberRoleSyncService;
import tum.devoops.organizationservice.service.OrganizationSportService;
@ExtendWith(MockitoExtension.class)
@@ -52,6 +53,8 @@ class OrganizationSportServiceTest {
private TrainerRepository trainerRepository;
@Mock
private TraineeRepository traineeRepository;
+ @Mock
+ private MemberRoleSyncService memberRoleSyncService;
@InjectMocks
private OrganizationSportService service;
@@ -175,6 +178,7 @@ void createSport_savesEntityAndDirectors_andReturnsResult() {
verify(sportRepository).save(any(SportEntity.class));
verify(directorRepository).saveAll(any());
+ verify(memberRoleSyncService).scheduleSync(argThat(ids -> ids.contains(MEMBER_ID)));
assertThat(result.getName()).isEqualTo("soccer");
assertThat(result.getDirectors()).containsExactly(MEMBER_ID.toString());
}
diff --git a/services/spring-organization/src/test/java/tum/devoops/organizationservice/OrganizationTeamServiceTest.java b/services/spring-organization/src/test/java/tum/devoops/organizationservice/OrganizationTeamServiceTest.java
index 4811123..75bc3ec 100644
--- a/services/spring-organization/src/test/java/tum/devoops/organizationservice/OrganizationTeamServiceTest.java
+++ b/services/spring-organization/src/test/java/tum/devoops/organizationservice/OrganizationTeamServiceTest.java
@@ -10,6 +10,7 @@
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.argThat;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import static org.mockito.Mockito.never;
@@ -33,6 +34,7 @@
import tum.devoops.organizationservice.repository.TeamRepository;
import tum.devoops.organizationservice.repository.TraineeRepository;
import tum.devoops.organizationservice.repository.TrainerRepository;
+import tum.devoops.organizationservice.service.MemberRoleSyncService;
import tum.devoops.organizationservice.service.OrganizationTeamService;
@ExtendWith(MockitoExtension.class)
@@ -50,6 +52,8 @@ class OrganizationTeamServiceTest {
private TrainerRepository trainerRepository;
@Mock
private TraineeRepository traineeRepository;
+ @Mock
+ private MemberRoleSyncService memberRoleSyncService;
@InjectMocks
private OrganizationTeamService service;
@@ -231,6 +235,8 @@ void createTeam_savesEntityAndTrainersAndTrainees_andReturnsResult() {
verify(teamRepository).save(any(TeamEntity.class));
verify(trainerRepository).saveAll(any());
verify(traineeRepository).saveAll(any());
+ verify(memberRoleSyncService).scheduleSync(
+ argThat(ids -> ids.contains(TRAINER_ID) && ids.contains(TRAINEE_ID)));
assertThat(result.getTrainers()).containsExactly(TRAINER_ID.toString());
assertThat(result.getTrainees()).containsExactly(TRAINEE_ID.toString());
}
diff --git a/web-client/src/__tests__/useAuth.test.ts b/web-client/src/__tests__/useAuth.test.ts
new file mode 100644
index 0000000..0c44e32
--- /dev/null
+++ b/web-client/src/__tests__/useAuth.test.ts
@@ -0,0 +1,34 @@
+import { describe, expect, it, vi } from 'vitest'
+
+const keycloakMock = {
+ tokenParsed: undefined as unknown,
+ logout: vi.fn(),
+}
+
+vi.mock('@/lib/keycloak', () => ({
+ default: keycloakMock,
+}))
+
+const { useAuth } = await import('@/features/auth/useAuth')
+
+describe('useAuth', () => {
+ it('reads the member_roles claim into user.roles', () => {
+ keycloakMock.tokenParsed = {
+ name: 'Jane Coach',
+ email: 'jane@example.com',
+ member_roles: ['Coach', 'Admin'],
+ }
+
+ const { user } = useAuth()
+
+ expect(user.roles).toEqual(['Coach', 'Admin'])
+ })
+
+ it('defaults roles to an empty array when the claim is absent', () => {
+ keycloakMock.tokenParsed = { name: 'Jane', email: 'jane@example.com' }
+
+ const { user } = useAuth()
+
+ expect(user.roles).toEqual([])
+ })
+})
diff --git a/web-client/src/features/auth/useAuth.ts b/web-client/src/features/auth/useAuth.ts
index aa115c5..6bca9a8 100644
--- a/web-client/src/features/auth/useAuth.ts
+++ b/web-client/src/features/auth/useAuth.ts
@@ -6,6 +6,7 @@ type AuthTokenSnapshot = KeycloakTokenParsed & {
email?: string
name?: string
preferred_username?: string
+ member_roles?: string[]
}
export function useAuth(): { user: AuthUser; logout: () => void } {
@@ -14,6 +15,7 @@ export function useAuth(): { user: AuthUser; logout: () => void } {
const user: AuthUser = {
name: parsed?.name ?? parsed?.preferred_username ?? parsed?.email ?? 'Unknown',
email: parsed?.email ?? '',
+ roles: parsed?.member_roles ?? [],
}
const logout = () => keycloak.logout({ redirectUri: window.location.origin })
return { user, logout }
diff --git a/web-client/src/types.ts b/web-client/src/types.ts
index 1232ce0..1ef3405 100644
--- a/web-client/src/types.ts
+++ b/web-client/src/types.ts
@@ -33,4 +33,7 @@ export type Balance = S['Balance']
export interface AuthUser {
name: string
email: string
+ // Membership roles from the Keycloak `member_roles` claim — a subset of
+ // 'Trainee' | 'Coach' | 'Director' | 'Admin'.
+ roles: string[]
}
From 7cc823a608021115ecb33f4c6df377baeb602a06 Mon Sep 17 00:00:00 2001
From: Raphael Frank <04.raphael.frank@gmail.com>
Date: Sat, 27 Jun 2026 17:06:48 +0200
Subject: [PATCH 02/16] add uuid as primary key for sports
---
api/openapi.yaml | 30 ++-
api/scripts/gen-spring.sh | 2 +-
infra/docker-compose.yml | 6 +
services/py-genai-helper/generated/models.py | 27 +-
.../devoops/eventservice/api/EventsApi.java | 6 +-
.../model/BadRequestResponse.java | 8 +-
.../tum/devoops/eventservice/model/Event.java | 32 +--
.../eventservice/model/EventCreate.java | 33 +--
.../model/EventPartialUpdate.java | 25 +-
.../converter/EventConverter.java | 2 +-
.../eventservice/entity/SportEventEntity.java | 10 +-
.../repository/SportEventRepository.java | 4 +-
.../eventservice/service/EventService.java | 9 +-
.../db/migration/V3__sport_uuid_id.sql | 19 ++
.../service/EventServiceTest.java | 8 +-
.../model/BadRequestResponse.java | 8 +-
.../model/BadRequestResponse.java | 8 +-
.../model/BadRequestResponse.java | 8 +-
.../model/BadRequestResponse.java | 8 +-
.../api/OrganizationApi.java | 40 +--
.../model/BadRequestResponse.java | 8 +-
.../organizationservice/model/Sport.java | 34 ++-
.../model/SportCreate.java | 8 +-
.../model/SportPartialUpdate.java | 8 +-
.../organizationservice/model/Team.java | 20 +-
.../organizationservice/model/TeamCreate.java | 33 +--
.../model/TeamPartialUpdate.java | 31 +--
.../controller/OrganizationController.java | 12 +-
.../entity/DirectorEntity.java | 10 +-
.../entity/SportEntity.java | 11 +-
.../entity/TeamEntity.java | 6 +-
.../repository/DirectorRepository.java | 8 +-
.../repository/SportRepository.java | 10 +-
.../repository/TeamRepository.java | 4 +-
.../service/OrganizationSportService.java | 115 +++-----
.../service/OrganizationTeamService.java | 23 +-
.../db/migration/V3__sport_uuid_id.sql | 58 ++++
.../MemberRoleSyncServiceTest.java | 3 +-
.../OrganizationControllerTest.java | 89 +++---
.../OrganizationSportServiceTest.java | 255 +++++++++---------
.../OrganizationTeamServiceTest.java | 192 +++++++------
web-client/src/api.ts | 28 +-
.../src/features/organization/api/queries.ts | 24 +-
43 files changed, 719 insertions(+), 564 deletions(-)
create mode 100644 services/spring-event/src/main/resources/db/migration/V3__sport_uuid_id.sql
create mode 100644 services/spring-organization/src/main/resources/db/migration/V3__sport_uuid_id.sql
diff --git a/api/openapi.yaml b/api/openapi.yaml
index 62d3c70..5f5da7d 100644
--- a/api/openapi.yaml
+++ b/api/openapi.yaml
@@ -70,7 +70,7 @@ paths:
application/json:
schema:
$ref: "#/components/schemas/SportCreate"
- /organization/sports/{sport_name}:
+ /organization/sports/{sport_id}:
patch:
operationId: updateSport
tags:
@@ -81,7 +81,7 @@ paths:
- Directors: can update all fields except directors.
- Admins: can update all fields.
parameters:
- - $ref: "#/components/parameters/sport_name"
+ - $ref: "#/components/parameters/sport_id"
responses:
"200":
description: The request was successful, and the server has returned the
@@ -118,7 +118,7 @@ paths:
Deletes a sport from the organization.
- Admins: can delete sports.
parameters:
- - $ref: "#/components/parameters/sport_name"
+ - $ref: "#/components/parameters/sport_id"
responses:
"204":
description: The request was successful, but there is no content to return in
@@ -142,7 +142,7 @@ paths:
Returns the details of a specific sport.
- All authenticated users: can access this endpoint.
parameters:
- - $ref: "#/components/parameters/sport_name"
+ - $ref: "#/components/parameters/sport_id"
responses:
"200":
description: The request was successful, and the server has returned the
@@ -1164,12 +1164,13 @@ components:
description: "Jwt Auth: Authenticated requests contain a valid Json Web Token
(JWT) as part of the `Authorization: Bearer ` header."
parameters:
- sport_name:
- name: sport_name
+ sport_id:
+ name: sport_id
in: path
required: true
schema:
type: string
+ format: uuid
team_id:
name: team_id
in: path
@@ -1227,6 +1228,9 @@ components:
Sport:
type: object
properties:
+ id:
+ type: string
+ format: uuid
name:
type: string
description:
@@ -1239,6 +1243,7 @@ components:
items:
type: string
required:
+ - id
- name
- description
- created_at
@@ -1288,6 +1293,8 @@ components:
type: string
sport:
type: string
+ format: uuid
+ description: ID of the sport this team belongs to.
trainers:
type: array
items:
@@ -1317,6 +1324,8 @@ components:
type: string
sport:
type: string
+ format: uuid
+ description: ID of the sport this team belongs to.
trainers:
type: array
items:
@@ -1340,6 +1349,8 @@ components:
type: string
sport:
type: string
+ format: uuid
+ description: ID of the sport this team belongs to.
trainers:
type: array
items:
@@ -1474,7 +1485,8 @@ components:
type: array
items:
type: string
- description: Names of the sports associated with this event.
+ format: uuid
+ description: IDs of the sports associated with this event.
teams_linked:
type: array
items:
@@ -1531,6 +1543,8 @@ components:
type: array
items:
type: string
+ format: uuid
+ description: IDs of the sports associated with this event.
teams_linked:
type: array
items:
@@ -1558,6 +1572,8 @@ components:
type: array
items:
type: string
+ format: uuid
+ description: IDs of the sports associated with this event.
teams_linked:
type: array
items:
diff --git a/api/scripts/gen-spring.sh b/api/scripts/gen-spring.sh
index 74b4c11..7da4900 100755
--- a/api/scripts/gen-spring.sh
+++ b/api/scripts/gen-spring.sh
@@ -29,4 +29,4 @@ docker run --rm \
-o /local/services/"$SERVICE"/src/generated/java \
--skip-validate-spec \
--global-property "apis=$TAG_CAP,models=$MODELS,supportingFiles=ApiUtil.java" \
- --additional-properties "useSpringBoot3=true,interfaceOnly=true,openApiNullable=false,useTags=true,sourceFolder=,apiPackage=tum.devoops.$PKG.api,modelPackage=tum.devoops.$PKG.model,hideGenerationTimestamp=true"
+ --additional-properties "useSpringBoot3=true,interfaceOnly=true,openApiNullable=false,containerDefaultToNull=true,useTags=true,sourceFolder=,apiPackage=tum.devoops.$PKG.api,modelPackage=tum.devoops.$PKG.model,hideGenerationTimestamp=true"
diff --git a/infra/docker-compose.yml b/infra/docker-compose.yml
index 889e33a..06e3c0d 100644
--- a/infra/docker-compose.yml
+++ b/infra/docker-compose.yml
@@ -26,6 +26,7 @@ services:
organization-service:
build: ../services/spring-organization
container_name: organization-service
+ restart: on-failure
expose:
- 8080
depends_on:
@@ -52,6 +53,7 @@ services:
member-service:
build: ../services/spring-member
container_name: member-service
+ restart: on-failure
expose:
- 8080
depends_on:
@@ -78,6 +80,7 @@ services:
event-service:
build: ../services/spring-event
container_name: event-service
+ restart: on-failure
expose:
- 8080
depends_on:
@@ -104,6 +107,7 @@ services:
feedback-service:
build: ../services/spring-feedback
container_name: feedback-service
+ restart: on-failure
expose:
- 8080
depends_on:
@@ -130,6 +134,7 @@ services:
finance-service:
build: ../services/spring-finance
container_name: finance-service
+ restart: on-failure
expose:
- 8080
depends_on:
@@ -156,6 +161,7 @@ services:
letter-service:
build: ../services/spring-letter
container_name: letter-service
+ restart: on-failure
expose:
- 8080
depends_on:
diff --git a/services/py-genai-helper/generated/models.py b/services/py-genai-helper/generated/models.py
index 65b8b3b..87ba785 100644
--- a/services/py-genai-helper/generated/models.py
+++ b/services/py-genai-helper/generated/models.py
@@ -1,11 +1,11 @@
# generated by datamodel-codegen:
# filename: openapi.yaml
-# timestamp: 2026-06-18T08:03:34+00:00
+# timestamp: 2026-06-27T15:07:04+00:00
from __future__ import annotations
from pydantic import AwareDatetime, BaseModel, Field, SecretStr
-from datetime import date
from uuid import UUID
+from datetime import date
from typing import Annotated
@@ -19,6 +19,7 @@ class BadRequestResponse(BaseModel):
class Sport(BaseModel):
+ id: UUID
name: str
description: str
created_at: date
@@ -43,7 +44,7 @@ class Team(BaseModel):
description: str
created_at: date
address: str
- sport: str
+ sport: Annotated[UUID, Field(description='ID of the sport this team belongs to.')]
trainers: list[str]
trainees: list[str]
@@ -52,7 +53,7 @@ class TeamCreate(BaseModel):
name: str
description: str | None = None
address: str | None = None
- sport: str
+ sport: Annotated[UUID, Field(description='ID of the sport this team belongs to.')]
trainers: list[str] | None = None
trainees: list[str] | None = None
@@ -61,7 +62,9 @@ class TeamPartialUpdate(BaseModel):
name: str | None = None
description: str | None = None
address: str | None = None
- sport: str | None = None
+ sport: Annotated[
+ UUID | None, Field(description='ID of the sport this team belongs to.')
+ ] = None
trainers: list[str] | None = None
trainees: list[str] | None = None
@@ -114,8 +117,8 @@ class Event(BaseModel):
end_time: AwareDatetime
attendees: list[str] | None = None
sports_linked: Annotated[
- list[str] | None,
- Field(description='Names of the sports associated with this event.'),
+ list[UUID] | None,
+ Field(description='IDs of the sports associated with this event.'),
] = None
teams_linked: Annotated[
list[str] | None,
@@ -137,7 +140,10 @@ class EventPartialUpdate(BaseModel):
start_time: AwareDatetime | None = None
end_time: AwareDatetime | None = None
attendees: list[str] | None = None
- sports_linked: list[str] | None = None
+ sports_linked: Annotated[
+ list[UUID] | None,
+ Field(description='IDs of the sports associated with this event.'),
+ ] = None
teams_linked: list[str] | None = None
@@ -147,7 +153,10 @@ class EventCreate(BaseModel):
start_time: AwareDatetime
end_time: AwareDatetime
attendees: list[str] | None = None
- sports_linked: list[str] | None = None
+ sports_linked: Annotated[
+ list[UUID] | None,
+ Field(description='IDs of the sports associated with this event.'),
+ ] = None
teams_linked: list[str] | None = None
diff --git a/services/spring-event/src/generated/java/tum/devoops/eventservice/api/EventsApi.java b/services/spring-event/src/generated/java/tum/devoops/eventservice/api/EventsApi.java
index 8567f1f..578b8ae 100644
--- a/services/spring-event/src/generated/java/tum/devoops/eventservice/api/EventsApi.java
+++ b/services/spring-event/src/generated/java/tum/devoops/eventservice/api/EventsApi.java
@@ -103,7 +103,7 @@ default ResponseEntity createEvent(
getRequest().ifPresent(request -> {
for (MediaType mediaType: MediaType.parseMediaTypes(request.getHeader("Accept"))) {
if (mediaType.isCompatibleWith(MediaType.valueOf("application/json"))) {
- String exampleString = "{ \"start_time\" : \"2000-01-23T04:56:07.000+00:00\", \"creator\" : \"creator\", \"attendees\" : [ \"attendees\", \"attendees\" ], \"name\" : \"name\", \"end_time\" : \"2000-01-23T04:56:07.000+00:00\", \"description\" : \"description\", \"id\" : \"046b6c7f-0b8a-43b9-b35d-6489e6daee91\", \"sports_linked\" : [ \"sports_linked\", \"sports_linked\" ], \"teams_linked\" : [ \"teams_linked\", \"teams_linked\" ] }";
+ String exampleString = "{ \"start_time\" : \"2000-01-23T04:56:07.000+00:00\", \"creator\" : \"creator\", \"attendees\" : [ \"attendees\", \"attendees\" ], \"name\" : \"name\", \"end_time\" : \"2000-01-23T04:56:07.000+00:00\", \"description\" : \"description\", \"id\" : \"046b6c7f-0b8a-43b9-b35d-6489e6daee91\", \"sports_linked\" : [ \"046b6c7f-0b8a-43b9-b35d-6489e6daee91\", \"046b6c7f-0b8a-43b9-b35d-6489e6daee91\" ], \"teams_linked\" : [ \"teams_linked\", \"teams_linked\" ] }";
ApiUtil.setExampleResponse(request, "application/json", exampleString);
break;
}
@@ -331,7 +331,7 @@ default ResponseEntity getEventDetails(
getRequest().ifPresent(request -> {
for (MediaType mediaType: MediaType.parseMediaTypes(request.getHeader("Accept"))) {
if (mediaType.isCompatibleWith(MediaType.valueOf("application/json"))) {
- String exampleString = "{ \"start_time\" : \"2000-01-23T04:56:07.000+00:00\", \"creator\" : \"creator\", \"attendees\" : [ \"attendees\", \"attendees\" ], \"name\" : \"name\", \"end_time\" : \"2000-01-23T04:56:07.000+00:00\", \"description\" : \"description\", \"id\" : \"046b6c7f-0b8a-43b9-b35d-6489e6daee91\", \"sports_linked\" : [ \"sports_linked\", \"sports_linked\" ], \"teams_linked\" : [ \"teams_linked\", \"teams_linked\" ] }";
+ String exampleString = "{ \"start_time\" : \"2000-01-23T04:56:07.000+00:00\", \"creator\" : \"creator\", \"attendees\" : [ \"attendees\", \"attendees\" ], \"name\" : \"name\", \"end_time\" : \"2000-01-23T04:56:07.000+00:00\", \"description\" : \"description\", \"id\" : \"046b6c7f-0b8a-43b9-b35d-6489e6daee91\", \"sports_linked\" : [ \"046b6c7f-0b8a-43b9-b35d-6489e6daee91\", \"046b6c7f-0b8a-43b9-b35d-6489e6daee91\" ], \"teams_linked\" : [ \"teams_linked\", \"teams_linked\" ] }";
ApiUtil.setExampleResponse(request, "application/json", exampleString);
break;
}
@@ -418,7 +418,7 @@ default ResponseEntity updateEventDetails(
getRequest().ifPresent(request -> {
for (MediaType mediaType: MediaType.parseMediaTypes(request.getHeader("Accept"))) {
if (mediaType.isCompatibleWith(MediaType.valueOf("application/json"))) {
- String exampleString = "{ \"start_time\" : \"2000-01-23T04:56:07.000+00:00\", \"creator\" : \"creator\", \"attendees\" : [ \"attendees\", \"attendees\" ], \"name\" : \"name\", \"end_time\" : \"2000-01-23T04:56:07.000+00:00\", \"description\" : \"description\", \"id\" : \"046b6c7f-0b8a-43b9-b35d-6489e6daee91\", \"sports_linked\" : [ \"sports_linked\", \"sports_linked\" ], \"teams_linked\" : [ \"teams_linked\", \"teams_linked\" ] }";
+ String exampleString = "{ \"start_time\" : \"2000-01-23T04:56:07.000+00:00\", \"creator\" : \"creator\", \"attendees\" : [ \"attendees\", \"attendees\" ], \"name\" : \"name\", \"end_time\" : \"2000-01-23T04:56:07.000+00:00\", \"description\" : \"description\", \"id\" : \"046b6c7f-0b8a-43b9-b35d-6489e6daee91\", \"sports_linked\" : [ \"046b6c7f-0b8a-43b9-b35d-6489e6daee91\", \"046b6c7f-0b8a-43b9-b35d-6489e6daee91\" ], \"teams_linked\" : [ \"teams_linked\", \"teams_linked\" ] }";
ApiUtil.setExampleResponse(request, "application/json", exampleString);
break;
}
diff --git a/services/spring-event/src/generated/java/tum/devoops/eventservice/model/BadRequestResponse.java b/services/spring-event/src/generated/java/tum/devoops/eventservice/model/BadRequestResponse.java
index 89a3218..7166a40 100644
--- a/services/spring-event/src/generated/java/tum/devoops/eventservice/model/BadRequestResponse.java
+++ b/services/spring-event/src/generated/java/tum/devoops/eventservice/model/BadRequestResponse.java
@@ -28,7 +28,7 @@ public class BadRequestResponse {
private String message;
@Valid
- private List<@Valid ErrorResponse> errors = new ArrayList<>();
+ private @Nullable List<@Valid ErrorResponse> errors;
public BadRequestResponse() {
super();
@@ -61,7 +61,7 @@ public void setMessage(String message) {
this.message = message;
}
- public BadRequestResponse errors(List<@Valid ErrorResponse> errors) {
+ public BadRequestResponse errors(@Nullable List<@Valid ErrorResponse> errors) {
this.errors = errors;
return this;
}
@@ -81,11 +81,11 @@ public BadRequestResponse addErrorsItem(ErrorResponse errorsItem) {
@Valid
@Schema(name = "errors", requiredMode = Schema.RequiredMode.NOT_REQUIRED)
@JsonProperty("errors")
- public List<@Valid ErrorResponse> getErrors() {
+ public @Nullable List<@Valid ErrorResponse> getErrors() {
return errors;
}
- public void setErrors(List<@Valid ErrorResponse> errors) {
+ public void setErrors(@Nullable List<@Valid ErrorResponse> errors) {
this.errors = errors;
}
diff --git a/services/spring-event/src/generated/java/tum/devoops/eventservice/model/Event.java b/services/spring-event/src/generated/java/tum/devoops/eventservice/model/Event.java
index dea140e..2bddca3 100644
--- a/services/spring-event/src/generated/java/tum/devoops/eventservice/model/Event.java
+++ b/services/spring-event/src/generated/java/tum/devoops/eventservice/model/Event.java
@@ -41,13 +41,13 @@ public class Event {
private OffsetDateTime endTime;
@Valid
- private List attendees = new ArrayList<>();
+ private @Nullable List attendees;
@Valid
- private List sportsLinked = new ArrayList<>();
+ private @Nullable List sportsLinked;
@Valid
- private List teamsLinked = new ArrayList<>();
+ private @Nullable List teamsLinked;
private String creator;
@@ -167,7 +167,7 @@ public void setEndTime(OffsetDateTime endTime) {
this.endTime = endTime;
}
- public Event attendees(List attendees) {
+ public Event attendees(@Nullable List attendees) {
this.attendees = attendees;
return this;
}
@@ -187,20 +187,20 @@ public Event addAttendeesItem(String attendeesItem) {
@Schema(name = "attendees", requiredMode = Schema.RequiredMode.NOT_REQUIRED)
@JsonProperty("attendees")
- public List getAttendees() {
+ public @Nullable List getAttendees() {
return attendees;
}
- public void setAttendees(List attendees) {
+ public void setAttendees(@Nullable List attendees) {
this.attendees = attendees;
}
- public Event sportsLinked(List sportsLinked) {
+ public Event sportsLinked(@Nullable List sportsLinked) {
this.sportsLinked = sportsLinked;
return this;
}
- public Event addSportsLinkedItem(String sportsLinkedItem) {
+ public Event addSportsLinkedItem(UUID sportsLinkedItem) {
if (this.sportsLinked == null) {
this.sportsLinked = new ArrayList<>();
}
@@ -209,21 +209,21 @@ public Event addSportsLinkedItem(String sportsLinkedItem) {
}
/**
- * Names of the sports associated with this event.
+ * IDs of the sports associated with this event.
* @return sportsLinked
*/
-
- @Schema(name = "sports_linked", description = "Names of the sports associated with this event.", requiredMode = Schema.RequiredMode.NOT_REQUIRED)
+ @Valid
+ @Schema(name = "sports_linked", description = "IDs of the sports associated with this event.", requiredMode = Schema.RequiredMode.NOT_REQUIRED)
@JsonProperty("sports_linked")
- public List getSportsLinked() {
+ public @Nullable List getSportsLinked() {
return sportsLinked;
}
- public void setSportsLinked(List sportsLinked) {
+ public void setSportsLinked(@Nullable List sportsLinked) {
this.sportsLinked = sportsLinked;
}
- public Event teamsLinked(List teamsLinked) {
+ public Event teamsLinked(@Nullable List teamsLinked) {
this.teamsLinked = teamsLinked;
return this;
}
@@ -243,11 +243,11 @@ public Event addTeamsLinkedItem(String teamsLinkedItem) {
@Schema(name = "teams_linked", description = "IDs of the teams associated with this event.", requiredMode = Schema.RequiredMode.NOT_REQUIRED)
@JsonProperty("teams_linked")
- public List getTeamsLinked() {
+ public @Nullable List getTeamsLinked() {
return teamsLinked;
}
- public void setTeamsLinked(List teamsLinked) {
+ public void setTeamsLinked(@Nullable List teamsLinked) {
this.teamsLinked = teamsLinked;
}
diff --git a/services/spring-event/src/generated/java/tum/devoops/eventservice/model/EventCreate.java b/services/spring-event/src/generated/java/tum/devoops/eventservice/model/EventCreate.java
index d15ce42..f39c403 100644
--- a/services/spring-event/src/generated/java/tum/devoops/eventservice/model/EventCreate.java
+++ b/services/spring-event/src/generated/java/tum/devoops/eventservice/model/EventCreate.java
@@ -8,6 +8,7 @@
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
+import java.util.UUID;
import org.springframework.format.annotation.DateTimeFormat;
import org.springframework.lang.Nullable;
import java.time.OffsetDateTime;
@@ -38,13 +39,13 @@ public class EventCreate {
private OffsetDateTime endTime;
@Valid
- private List attendees = new ArrayList<>();
+ private @Nullable List attendees;
@Valid
- private List sportsLinked = new ArrayList<>();
+ private @Nullable List sportsLinked;
@Valid
- private List teamsLinked = new ArrayList<>();
+ private @Nullable List teamsLinked;
public EventCreate() {
super();
@@ -139,7 +140,7 @@ public void setEndTime(OffsetDateTime endTime) {
this.endTime = endTime;
}
- public EventCreate attendees(List attendees) {
+ public EventCreate attendees(@Nullable List attendees) {
this.attendees = attendees;
return this;
}
@@ -159,20 +160,20 @@ public EventCreate addAttendeesItem(String attendeesItem) {
@Schema(name = "attendees", requiredMode = Schema.RequiredMode.NOT_REQUIRED)
@JsonProperty("attendees")
- public List getAttendees() {
+ public @Nullable List getAttendees() {
return attendees;
}
- public void setAttendees(List attendees) {
+ public void setAttendees(@Nullable List attendees) {
this.attendees = attendees;
}
- public EventCreate sportsLinked(List sportsLinked) {
+ public EventCreate sportsLinked(@Nullable List sportsLinked) {
this.sportsLinked = sportsLinked;
return this;
}
- public EventCreate addSportsLinkedItem(String sportsLinkedItem) {
+ public EventCreate addSportsLinkedItem(UUID sportsLinkedItem) {
if (this.sportsLinked == null) {
this.sportsLinked = new ArrayList<>();
}
@@ -181,21 +182,21 @@ public EventCreate addSportsLinkedItem(String sportsLinkedItem) {
}
/**
- * Get sportsLinked
+ * IDs of the sports associated with this event.
* @return sportsLinked
*/
-
- @Schema(name = "sports_linked", requiredMode = Schema.RequiredMode.NOT_REQUIRED)
+ @Valid
+ @Schema(name = "sports_linked", description = "IDs of the sports associated with this event.", requiredMode = Schema.RequiredMode.NOT_REQUIRED)
@JsonProperty("sports_linked")
- public List getSportsLinked() {
+ public @Nullable List getSportsLinked() {
return sportsLinked;
}
- public void setSportsLinked(List sportsLinked) {
+ public void setSportsLinked(@Nullable List sportsLinked) {
this.sportsLinked = sportsLinked;
}
- public EventCreate teamsLinked(List teamsLinked) {
+ public EventCreate teamsLinked(@Nullable List teamsLinked) {
this.teamsLinked = teamsLinked;
return this;
}
@@ -215,11 +216,11 @@ public EventCreate addTeamsLinkedItem(String teamsLinkedItem) {
@Schema(name = "teams_linked", requiredMode = Schema.RequiredMode.NOT_REQUIRED)
@JsonProperty("teams_linked")
- public List getTeamsLinked() {
+ public @Nullable List getTeamsLinked() {
return teamsLinked;
}
- public void setTeamsLinked(List teamsLinked) {
+ public void setTeamsLinked(@Nullable List teamsLinked) {
this.teamsLinked = teamsLinked;
}
diff --git a/services/spring-event/src/generated/java/tum/devoops/eventservice/model/EventPartialUpdate.java b/services/spring-event/src/generated/java/tum/devoops/eventservice/model/EventPartialUpdate.java
index 3f7adc5..53be3b2 100644
--- a/services/spring-event/src/generated/java/tum/devoops/eventservice/model/EventPartialUpdate.java
+++ b/services/spring-event/src/generated/java/tum/devoops/eventservice/model/EventPartialUpdate.java
@@ -8,6 +8,7 @@
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
+import java.util.UUID;
import org.springframework.format.annotation.DateTimeFormat;
import org.springframework.lang.Nullable;
import java.time.OffsetDateTime;
@@ -41,7 +42,7 @@ public class EventPartialUpdate {
private @Nullable List attendees;
@Valid
- private @Nullable List sportsLinked;
+ private @Nullable List sportsLinked;
@Valid
private @Nullable List teamsLinked;
@@ -126,7 +127,7 @@ public void setEndTime(@Nullable OffsetDateTime endTime) {
this.endTime = endTime;
}
- public EventPartialUpdate attendees(List attendees) {
+ public EventPartialUpdate attendees(@Nullable List attendees) {
this.attendees = attendees;
return this;
}
@@ -150,16 +151,16 @@ public EventPartialUpdate addAttendeesItem(String attendeesItem) {
return attendees;
}
- public void setAttendees(List attendees) {
+ public void setAttendees(@Nullable List attendees) {
this.attendees = attendees;
}
- public EventPartialUpdate sportsLinked(List sportsLinked) {
+ public EventPartialUpdate sportsLinked(@Nullable List sportsLinked) {
this.sportsLinked = sportsLinked;
return this;
}
- public EventPartialUpdate addSportsLinkedItem(String sportsLinkedItem) {
+ public EventPartialUpdate addSportsLinkedItem(UUID sportsLinkedItem) {
if (this.sportsLinked == null) {
this.sportsLinked = new ArrayList<>();
}
@@ -168,21 +169,21 @@ public EventPartialUpdate addSportsLinkedItem(String sportsLinkedItem) {
}
/**
- * Get sportsLinked
+ * IDs of the sports associated with this event.
* @return sportsLinked
*/
-
- @Schema(name = "sports_linked", requiredMode = Schema.RequiredMode.NOT_REQUIRED)
+ @Valid
+ @Schema(name = "sports_linked", description = "IDs of the sports associated with this event.", requiredMode = Schema.RequiredMode.NOT_REQUIRED)
@JsonProperty("sports_linked")
- public @Nullable List getSportsLinked() {
+ public @Nullable List getSportsLinked() {
return sportsLinked;
}
- public void setSportsLinked(List sportsLinked) {
+ public void setSportsLinked(@Nullable List sportsLinked) {
this.sportsLinked = sportsLinked;
}
- public EventPartialUpdate teamsLinked(List teamsLinked) {
+ public EventPartialUpdate teamsLinked(@Nullable List teamsLinked) {
this.teamsLinked = teamsLinked;
return this;
}
@@ -206,7 +207,7 @@ public EventPartialUpdate addTeamsLinkedItem(String teamsLinkedItem) {
return teamsLinked;
}
- public void setTeamsLinked(List teamsLinked) {
+ public void setTeamsLinked(@Nullable List teamsLinked) {
this.teamsLinked = teamsLinked;
}
diff --git a/services/spring-event/src/main/java/tum/devoops/eventservice/converter/EventConverter.java b/services/spring-event/src/main/java/tum/devoops/eventservice/converter/EventConverter.java
index 2f85925..e05b3b9 100644
--- a/services/spring-event/src/main/java/tum/devoops/eventservice/converter/EventConverter.java
+++ b/services/spring-event/src/main/java/tum/devoops/eventservice/converter/EventConverter.java
@@ -38,7 +38,7 @@ public static Event toEvent(EventEntity entity,
.map(a -> a.getId().getMemberId().toString())
.collect(Collectors.toList()));
event.setSportsLinked(sports.stream()
- .map(s -> s.getId().getSportName())
+ .map(s -> s.getId().getSportId())
.collect(Collectors.toList()));
event.setTeamsLinked(teams.stream()
.map(t -> t.getId().getTeamId().toString())
diff --git a/services/spring-event/src/main/java/tum/devoops/eventservice/entity/SportEventEntity.java b/services/spring-event/src/main/java/tum/devoops/eventservice/entity/SportEventEntity.java
index 5ade3b4..a16608e 100644
--- a/services/spring-event/src/main/java/tum/devoops/eventservice/entity/SportEventEntity.java
+++ b/services/spring-event/src/main/java/tum/devoops/eventservice/entity/SportEventEntity.java
@@ -19,9 +19,9 @@
@Getter @Setter @NoArgsConstructor @AllArgsConstructor
public class SportEventEntity {
- // Composite PK: (event_id, sport_name).
- // event_id references event.event(id).
- // sport_name references organization.sport(name) — FK added in V3 migration.
+ // Composite PK: (event_id, sport_id).
+ // event_id references event.events(id).
+ // sport_id references organization.sports(id).
@EmbeddedId
private Id id;
@@ -31,7 +31,7 @@ public static class Id implements Serializable {
@Column(name = "event_id", nullable = false)
private UUID eventId;
- @Column(name = "sport_name", nullable = false)
- private String sportName;
+ @Column(name = "sport_id", nullable = false)
+ private UUID sportId;
}
}
diff --git a/services/spring-event/src/main/java/tum/devoops/eventservice/repository/SportEventRepository.java b/services/spring-event/src/main/java/tum/devoops/eventservice/repository/SportEventRepository.java
index f0fe35f..af5577f 100644
--- a/services/spring-event/src/main/java/tum/devoops/eventservice/repository/SportEventRepository.java
+++ b/services/spring-event/src/main/java/tum/devoops/eventservice/repository/SportEventRepository.java
@@ -12,8 +12,8 @@ public interface SportEventRepository extends JpaRepository findAllById_EventId(UUID eventId);
- // SELECT * FROM event.sport_events WHERE sport_name = ?
- List findAllById_SportName(String sportName);
+ // SELECT * FROM event.sport_events WHERE sport_id = ?
+ List findAllById_SportId(UUID sportId);
// DELETE FROM event.sport_events WHERE event_id = ?
void deleteAllById_EventId(UUID eventId);
diff --git a/services/spring-event/src/main/java/tum/devoops/eventservice/service/EventService.java b/services/spring-event/src/main/java/tum/devoops/eventservice/service/EventService.java
index cfac1b7..9abd8b2 100644
--- a/services/spring-event/src/main/java/tum/devoops/eventservice/service/EventService.java
+++ b/services/spring-event/src/main/java/tum/devoops/eventservice/service/EventService.java
@@ -120,7 +120,8 @@ public Event updateEventDetails(UUID eventId, EventPartialUpdate body, UUID requ
throw new BadRequestException("Event end time must be after start time");
}
- // null means the field was omitted (no change); non-null (including empty) means replace
+ // null means the field was omitted (no change); a non-null list (including empty) replaces
+ // the existing links, so an empty list clears them.
if (body.getAttendees() != null) {
attendanceRepository.deleteAllById_EventId(eventId);
attendanceRepository.saveAll(buildAttendanceEntities(eventId, body.getAttendees()));
@@ -149,7 +150,7 @@ public void deleteEvent(UUID eventId, UUID requesterId, boolean isAdmin) {
eventRepository.delete(entity);
}
- private void persistLinks(UUID eventId, List attendees, List sports, List teams) {
+ private void persistLinks(UUID eventId, List attendees, List sports, List teams) {
if (attendees != null) {
attendanceRepository.saveAll(buildAttendanceEntities(eventId, attendees));
}
@@ -170,9 +171,9 @@ private List buildAttendanceEntities(UUID eventId, List buildSportEntities(UUID eventId, List sports) {
+ private List buildSportEntities(UUID eventId, List sports) {
List result = new ArrayList<>();
- for (String sport : sports) {
+ for (UUID sport : sports) {
if (sport == null) {
throw new BadRequestException("'sports_linked' contains a null entry");
}
diff --git a/services/spring-event/src/main/resources/db/migration/V3__sport_uuid_id.sql b/services/spring-event/src/main/resources/db/migration/V3__sport_uuid_id.sql
new file mode 100644
index 0000000..e28612a
--- /dev/null
+++ b/services/spring-event/src/main/resources/db/migration/V3__sport_uuid_id.sql
@@ -0,0 +1,19 @@
+-- Sport now has a UUID primary key (organization.sports.id). Switch sport_events from
+-- referencing the sport name to the sport id.
+--
+-- Assumes the organization schema's V3 migration has already run: it added sports.id (with a
+-- unique name column for the backfill join) and dropped this table's old name-based FK
+-- (fk_sport_events_sport), so this migration only re-adds the FK against sports(id).
+
+ALTER TABLE event.sport_events ADD COLUMN sport_id UUID;
+UPDATE event.sport_events se
+ SET sport_id = s.id
+ FROM organization.sports s
+ WHERE se.sport_name = s.name;
+
+ALTER TABLE event.sport_events DROP CONSTRAINT pk_sport_events;
+ALTER TABLE event.sport_events ALTER COLUMN sport_id SET NOT NULL;
+ALTER TABLE event.sport_events DROP COLUMN sport_name;
+ALTER TABLE event.sport_events ADD CONSTRAINT pk_sport_events PRIMARY KEY (event_id, sport_id);
+ALTER TABLE event.sport_events
+ ADD CONSTRAINT fk_sport_events_sport FOREIGN KEY (sport_id) REFERENCES organization.sports (id);
diff --git a/services/spring-event/src/test/java/tum/devoops/eventservice/service/EventServiceTest.java b/services/spring-event/src/test/java/tum/devoops/eventservice/service/EventServiceTest.java
index d86f629..de900b1 100644
--- a/services/spring-event/src/test/java/tum/devoops/eventservice/service/EventServiceTest.java
+++ b/services/spring-event/src/test/java/tum/devoops/eventservice/service/EventServiceTest.java
@@ -58,6 +58,7 @@ class EventServiceTest {
private static final UUID EVENT_ID = UUID.randomUUID();
private static final UUID MEMBER_ID = UUID.randomUUID();
private static final UUID TEAM_ID = UUID.randomUUID();
+ private static final UUID SPORT_ID = UUID.randomUUID();
private static final Instant START = Instant.parse("2026-01-01T10:00:00Z");
private static final Instant END = Instant.parse("2026-01-01T12:00:00Z");
@@ -156,7 +157,7 @@ void createEventPersistsEventAndLinks() {
when(eventRepository.save(any())).thenReturn(saved);
EventCreate body = validCreate()
.attendees(List.of(MEMBER_ID.toString()))
- .sportsLinked(List.of("football"))
+ .sportsLinked(List.of(SPORT_ID))
.teamsLinked(List.of(TEAM_ID.toString()));
Event result = service.createEvent(body, REQUESTER_ID, true);
@@ -169,7 +170,7 @@ void createEventPersistsEventAndLinks() {
ArgumentCaptor> sports = listCaptor();
verify(sportEventRepository).saveAll(sports.capture());
- assertThat(sports.getValue()).extracting(s -> s.getId().getSportName()).containsExactly("football");
+ assertThat(sports.getValue()).extracting(s -> s.getId().getSportId()).containsExactly(SPORT_ID);
ArgumentCaptor> teams = listCaptor();
verify(teamEventRepository).saveAll(teams.capture());
@@ -304,6 +305,7 @@ void updateEventDetailsWithNullListsLeavesLinksUntouched() {
@Test
void updateEventDetailsWithEmptyListClearsLinks() {
+ // An empty (non-null) list wipes the links; a null/omitted list would leave them untouched.
EventEntity entity = eventEntity(EVENT_ID, REQUESTER_ID);
when(eventRepository.findById(EVENT_ID)).thenReturn(Optional.of(entity));
when(eventRepository.save(any())).thenReturn(entity);
@@ -339,7 +341,7 @@ void updateEventDetailsWithPopulatedListReplacesLinks() {
void updateEventDetailsWithNullSportEntryThrowsBadRequest() {
EventEntity entity = eventEntity(EVENT_ID, REQUESTER_ID);
when(eventRepository.findById(EVENT_ID)).thenReturn(Optional.of(entity));
- List sports = new ArrayList<>();
+ List sports = new ArrayList<>();
sports.add(null);
assertThatThrownBy(() -> service.updateEventDetails(
diff --git a/services/spring-feedback/src/generated/java/tum/devoops/feedbackservice/model/BadRequestResponse.java b/services/spring-feedback/src/generated/java/tum/devoops/feedbackservice/model/BadRequestResponse.java
index 4f2c9e6..5281131 100644
--- a/services/spring-feedback/src/generated/java/tum/devoops/feedbackservice/model/BadRequestResponse.java
+++ b/services/spring-feedback/src/generated/java/tum/devoops/feedbackservice/model/BadRequestResponse.java
@@ -28,7 +28,7 @@ public class BadRequestResponse {
private String message;
@Valid
- private List<@Valid ErrorResponse> errors = new ArrayList<>();
+ private @Nullable List<@Valid ErrorResponse> errors;
public BadRequestResponse() {
super();
@@ -61,7 +61,7 @@ public void setMessage(String message) {
this.message = message;
}
- public BadRequestResponse errors(List<@Valid ErrorResponse> errors) {
+ public BadRequestResponse errors(@Nullable List<@Valid ErrorResponse> errors) {
this.errors = errors;
return this;
}
@@ -81,11 +81,11 @@ public BadRequestResponse addErrorsItem(ErrorResponse errorsItem) {
@Valid
@Schema(name = "errors", requiredMode = Schema.RequiredMode.NOT_REQUIRED)
@JsonProperty("errors")
- public List<@Valid ErrorResponse> getErrors() {
+ public @Nullable List<@Valid ErrorResponse> getErrors() {
return errors;
}
- public void setErrors(List<@Valid ErrorResponse> errors) {
+ public void setErrors(@Nullable List<@Valid ErrorResponse> errors) {
this.errors = errors;
}
diff --git a/services/spring-finance/src/generated/java/tum/devoops/financeservice/model/BadRequestResponse.java b/services/spring-finance/src/generated/java/tum/devoops/financeservice/model/BadRequestResponse.java
index 9906f25..d257c15 100644
--- a/services/spring-finance/src/generated/java/tum/devoops/financeservice/model/BadRequestResponse.java
+++ b/services/spring-finance/src/generated/java/tum/devoops/financeservice/model/BadRequestResponse.java
@@ -28,7 +28,7 @@ public class BadRequestResponse {
private String message;
@Valid
- private List<@Valid ErrorResponse> errors = new ArrayList<>();
+ private @Nullable List<@Valid ErrorResponse> errors;
public BadRequestResponse() {
super();
@@ -61,7 +61,7 @@ public void setMessage(String message) {
this.message = message;
}
- public BadRequestResponse errors(List<@Valid ErrorResponse> errors) {
+ public BadRequestResponse errors(@Nullable List<@Valid ErrorResponse> errors) {
this.errors = errors;
return this;
}
@@ -81,11 +81,11 @@ public BadRequestResponse addErrorsItem(ErrorResponse errorsItem) {
@Valid
@Schema(name = "errors", requiredMode = Schema.RequiredMode.NOT_REQUIRED)
@JsonProperty("errors")
- public List<@Valid ErrorResponse> getErrors() {
+ public @Nullable List<@Valid ErrorResponse> getErrors() {
return errors;
}
- public void setErrors(List<@Valid ErrorResponse> errors) {
+ public void setErrors(@Nullable List<@Valid ErrorResponse> errors) {
this.errors = errors;
}
diff --git a/services/spring-letter/src/generated/java/tum/devoops/letterservice/model/BadRequestResponse.java b/services/spring-letter/src/generated/java/tum/devoops/letterservice/model/BadRequestResponse.java
index ea3038a..d66f834 100644
--- a/services/spring-letter/src/generated/java/tum/devoops/letterservice/model/BadRequestResponse.java
+++ b/services/spring-letter/src/generated/java/tum/devoops/letterservice/model/BadRequestResponse.java
@@ -28,7 +28,7 @@ public class BadRequestResponse {
private String message;
@Valid
- private List<@Valid ErrorResponse> errors = new ArrayList<>();
+ private @Nullable List<@Valid ErrorResponse> errors;
public BadRequestResponse() {
super();
@@ -61,7 +61,7 @@ public void setMessage(String message) {
this.message = message;
}
- public BadRequestResponse errors(List<@Valid ErrorResponse> errors) {
+ public BadRequestResponse errors(@Nullable List<@Valid ErrorResponse> errors) {
this.errors = errors;
return this;
}
@@ -81,11 +81,11 @@ public BadRequestResponse addErrorsItem(ErrorResponse errorsItem) {
@Valid
@Schema(name = "errors", requiredMode = Schema.RequiredMode.NOT_REQUIRED)
@JsonProperty("errors")
- public List<@Valid ErrorResponse> getErrors() {
+ public @Nullable List<@Valid ErrorResponse> getErrors() {
return errors;
}
- public void setErrors(List<@Valid ErrorResponse> errors) {
+ public void setErrors(@Nullable List<@Valid ErrorResponse> errors) {
this.errors = errors;
}
diff --git a/services/spring-member/src/generated/java/tum/devoops/memberservice/model/BadRequestResponse.java b/services/spring-member/src/generated/java/tum/devoops/memberservice/model/BadRequestResponse.java
index 8d4f176..f5c01ea 100644
--- a/services/spring-member/src/generated/java/tum/devoops/memberservice/model/BadRequestResponse.java
+++ b/services/spring-member/src/generated/java/tum/devoops/memberservice/model/BadRequestResponse.java
@@ -28,7 +28,7 @@ public class BadRequestResponse {
private String message;
@Valid
- private List<@Valid ErrorResponse> errors = new ArrayList<>();
+ private @Nullable List<@Valid ErrorResponse> errors;
public BadRequestResponse() {
super();
@@ -61,7 +61,7 @@ public void setMessage(String message) {
this.message = message;
}
- public BadRequestResponse errors(List<@Valid ErrorResponse> errors) {
+ public BadRequestResponse errors(@Nullable List<@Valid ErrorResponse> errors) {
this.errors = errors;
return this;
}
@@ -81,11 +81,11 @@ public BadRequestResponse addErrorsItem(ErrorResponse errorsItem) {
@Valid
@Schema(name = "errors", requiredMode = Schema.RequiredMode.NOT_REQUIRED)
@JsonProperty("errors")
- public List<@Valid ErrorResponse> getErrors() {
+ public @Nullable List<@Valid ErrorResponse> getErrors() {
return errors;
}
- public void setErrors(List<@Valid ErrorResponse> errors) {
+ public void setErrors(@Nullable List<@Valid ErrorResponse> errors) {
this.errors = errors;
}
diff --git a/services/spring-organization/src/generated/java/tum/devoops/organizationservice/api/OrganizationApi.java b/services/spring-organization/src/generated/java/tum/devoops/organizationservice/api/OrganizationApi.java
index bc0c48a..b9cb998 100644
--- a/services/spring-organization/src/generated/java/tum/devoops/organizationservice/api/OrganizationApi.java
+++ b/services/spring-organization/src/generated/java/tum/devoops/organizationservice/api/OrganizationApi.java
@@ -105,7 +105,7 @@ default ResponseEntity createSport(
getRequest().ifPresent(request -> {
for (MediaType mediaType: MediaType.parseMediaTypes(request.getHeader("Accept"))) {
if (mediaType.isCompatibleWith(MediaType.valueOf("application/json"))) {
- String exampleString = "{ \"directors\" : [ \"directors\", \"directors\" ], \"name\" : \"name\", \"description\" : \"description\", \"created_at\" : \"2000-01-23\" }";
+ String exampleString = "{ \"directors\" : [ \"directors\", \"directors\" ], \"name\" : \"name\", \"description\" : \"description\", \"created_at\" : \"2000-01-23\", \"id\" : \"046b6c7f-0b8a-43b9-b35d-6489e6daee91\" }";
ApiUtil.setExampleResponse(request, "application/json", exampleString);
break;
}
@@ -195,7 +195,7 @@ default ResponseEntity createTeam(
getRequest().ifPresent(request -> {
for (MediaType mediaType: MediaType.parseMediaTypes(request.getHeader("Accept"))) {
if (mediaType.isCompatibleWith(MediaType.valueOf("application/json"))) {
- String exampleString = "{ \"trainers\" : [ \"trainers\", \"trainers\" ], \"address\" : \"address\", \"trainees\" : [ \"trainees\", \"trainees\" ], \"name\" : \"name\", \"description\" : \"description\", \"created_at\" : \"2000-01-23\", \"id\" : \"046b6c7f-0b8a-43b9-b35d-6489e6daee91\", \"sport\" : \"sport\" }";
+ String exampleString = "{ \"trainers\" : [ \"trainers\", \"trainers\" ], \"address\" : \"address\", \"trainees\" : [ \"trainees\", \"trainees\" ], \"name\" : \"name\", \"description\" : \"description\", \"created_at\" : \"2000-01-23\", \"id\" : \"046b6c7f-0b8a-43b9-b35d-6489e6daee91\", \"sport\" : \"046b6c7f-0b8a-43b9-b35d-6489e6daee91\" }";
ApiUtil.setExampleResponse(request, "application/json", exampleString);
break;
}
@@ -232,10 +232,10 @@ default ResponseEntity createTeam(
/**
- * DELETE /organization/sports/{sport_name} : Delete sport
+ * DELETE /organization/sports/{sport_id} : Delete sport
* Deletes a sport from the organization. - Admins: can delete sports.
*
- * @param sportName (required)
+ * @param sportId (required)
* @return The request was successful, but there is no content to return in the response. (status code 204)
* or Authentication is required to access the requested resource. The client must include the appropriate credentials. (status code 401)
* or The server understood the request, but refuses to authorize it. Ensure the client has appropriate permissions. (status code 403)
@@ -268,12 +268,12 @@ default ResponseEntity createTeam(
)
@RequestMapping(
method = RequestMethod.DELETE,
- value = "/organization/sports/{sport_name}",
+ value = "/organization/sports/{sport_id}",
produces = { "application/json" }
)
default ResponseEntity deleteSport(
- @Parameter(name = "sport_name", description = "", required = true, in = ParameterIn.PATH) @PathVariable("sport_name") String sportName
+ @Parameter(name = "sport_id", description = "", required = true, in = ParameterIn.PATH) @PathVariable("sport_id") UUID sportId
) {
getRequest().ifPresent(request -> {
for (MediaType mediaType: MediaType.parseMediaTypes(request.getHeader("Accept"))) {
@@ -421,7 +421,7 @@ default ResponseEntity> getAllSports(
getRequest().ifPresent(request -> {
for (MediaType mediaType: MediaType.parseMediaTypes(request.getHeader("Accept"))) {
if (mediaType.isCompatibleWith(MediaType.valueOf("application/json"))) {
- String exampleString = "[ { \"directors\" : [ \"directors\", \"directors\" ], \"name\" : \"name\", \"description\" : \"description\", \"created_at\" : \"2000-01-23\" }, { \"directors\" : [ \"directors\", \"directors\" ], \"name\" : \"name\", \"description\" : \"description\", \"created_at\" : \"2000-01-23\" } ]";
+ String exampleString = "[ { \"directors\" : [ \"directors\", \"directors\" ], \"name\" : \"name\", \"description\" : \"description\", \"created_at\" : \"2000-01-23\", \"id\" : \"046b6c7f-0b8a-43b9-b35d-6489e6daee91\" }, { \"directors\" : [ \"directors\", \"directors\" ], \"name\" : \"name\", \"description\" : \"description\", \"created_at\" : \"2000-01-23\", \"id\" : \"046b6c7f-0b8a-43b9-b35d-6489e6daee91\" } ]";
ApiUtil.setExampleResponse(request, "application/json", exampleString);
break;
}
@@ -491,7 +491,7 @@ default ResponseEntity> getAllTeams(
getRequest().ifPresent(request -> {
for (MediaType mediaType: MediaType.parseMediaTypes(request.getHeader("Accept"))) {
if (mediaType.isCompatibleWith(MediaType.valueOf("application/json"))) {
- String exampleString = "[ { \"trainers\" : [ \"trainers\", \"trainers\" ], \"address\" : \"address\", \"trainees\" : [ \"trainees\", \"trainees\" ], \"name\" : \"name\", \"description\" : \"description\", \"created_at\" : \"2000-01-23\", \"id\" : \"046b6c7f-0b8a-43b9-b35d-6489e6daee91\", \"sport\" : \"sport\" }, { \"trainers\" : [ \"trainers\", \"trainers\" ], \"address\" : \"address\", \"trainees\" : [ \"trainees\", \"trainees\" ], \"name\" : \"name\", \"description\" : \"description\", \"created_at\" : \"2000-01-23\", \"id\" : \"046b6c7f-0b8a-43b9-b35d-6489e6daee91\", \"sport\" : \"sport\" } ]";
+ String exampleString = "[ { \"trainers\" : [ \"trainers\", \"trainers\" ], \"address\" : \"address\", \"trainees\" : [ \"trainees\", \"trainees\" ], \"name\" : \"name\", \"description\" : \"description\", \"created_at\" : \"2000-01-23\", \"id\" : \"046b6c7f-0b8a-43b9-b35d-6489e6daee91\", \"sport\" : \"046b6c7f-0b8a-43b9-b35d-6489e6daee91\" }, { \"trainers\" : [ \"trainers\", \"trainers\" ], \"address\" : \"address\", \"trainees\" : [ \"trainees\", \"trainees\" ], \"name\" : \"name\", \"description\" : \"description\", \"created_at\" : \"2000-01-23\", \"id\" : \"046b6c7f-0b8a-43b9-b35d-6489e6daee91\", \"sport\" : \"046b6c7f-0b8a-43b9-b35d-6489e6daee91\" } ]";
ApiUtil.setExampleResponse(request, "application/json", exampleString);
break;
}
@@ -518,10 +518,10 @@ default ResponseEntity> getAllTeams(
/**
- * GET /organization/sports/{sport_name} : Get sport
+ * GET /organization/sports/{sport_id} : Get sport
* Returns the details of a specific sport. - All authenticated users: can access this endpoint.
*
- * @param sportName (required)
+ * @param sportId (required)
* @return The request was successful, and the server has returned the requested resource in the response body. (status code 200)
* or Authentication is required to access the requested resource. The client must include the appropriate credentials. (status code 401)
* or The server understood the request, but refuses to authorize it. Ensure the client has appropriate permissions. (status code 403)
@@ -556,17 +556,17 @@ default ResponseEntity> getAllTeams(
)
@RequestMapping(
method = RequestMethod.GET,
- value = "/organization/sports/{sport_name}",
+ value = "/organization/sports/{sport_id}",
produces = { "application/json" }
)
default ResponseEntity getSport(
- @Parameter(name = "sport_name", description = "", required = true, in = ParameterIn.PATH) @PathVariable("sport_name") String sportName
+ @Parameter(name = "sport_id", description = "", required = true, in = ParameterIn.PATH) @PathVariable("sport_id") UUID sportId
) {
getRequest().ifPresent(request -> {
for (MediaType mediaType: MediaType.parseMediaTypes(request.getHeader("Accept"))) {
if (mediaType.isCompatibleWith(MediaType.valueOf("application/json"))) {
- String exampleString = "{ \"directors\" : [ \"directors\", \"directors\" ], \"name\" : \"name\", \"description\" : \"description\", \"created_at\" : \"2000-01-23\" }";
+ String exampleString = "{ \"directors\" : [ \"directors\", \"directors\" ], \"name\" : \"name\", \"description\" : \"description\", \"created_at\" : \"2000-01-23\", \"id\" : \"046b6c7f-0b8a-43b9-b35d-6489e6daee91\" }";
ApiUtil.setExampleResponse(request, "application/json", exampleString);
break;
}
@@ -646,7 +646,7 @@ default ResponseEntity getTeam(
getRequest().ifPresent(request -> {
for (MediaType mediaType: MediaType.parseMediaTypes(request.getHeader("Accept"))) {
if (mediaType.isCompatibleWith(MediaType.valueOf("application/json"))) {
- String exampleString = "{ \"trainers\" : [ \"trainers\", \"trainers\" ], \"address\" : \"address\", \"trainees\" : [ \"trainees\", \"trainees\" ], \"name\" : \"name\", \"description\" : \"description\", \"created_at\" : \"2000-01-23\", \"id\" : \"046b6c7f-0b8a-43b9-b35d-6489e6daee91\", \"sport\" : \"sport\" }";
+ String exampleString = "{ \"trainers\" : [ \"trainers\", \"trainers\" ], \"address\" : \"address\", \"trainees\" : [ \"trainees\", \"trainees\" ], \"name\" : \"name\", \"description\" : \"description\", \"created_at\" : \"2000-01-23\", \"id\" : \"046b6c7f-0b8a-43b9-b35d-6489e6daee91\", \"sport\" : \"046b6c7f-0b8a-43b9-b35d-6489e6daee91\" }";
ApiUtil.setExampleResponse(request, "application/json", exampleString);
break;
}
@@ -678,10 +678,10 @@ default ResponseEntity getTeam(
/**
- * PATCH /organization/sports/{sport_name} : Update sport
+ * PATCH /organization/sports/{sport_id} : Update sport
* Partially updates an existing sport's details. - Directors: can update all fields except directors. - Admins: can update all fields.
*
- * @param sportName (required)
+ * @param sportId (required)
* @param sportPartialUpdate The request body for partially updating a sport. (required)
* @return The request was successful, and the server has returned the requested resource in the response body. (status code 200)
* or The server could not understand the request due to invalid syntax. The client should modify the request and try again. (status code 400)
@@ -721,19 +721,19 @@ default ResponseEntity getTeam(
)
@RequestMapping(
method = RequestMethod.PATCH,
- value = "/organization/sports/{sport_name}",
+ value = "/organization/sports/{sport_id}",
produces = { "application/json" },
consumes = { "application/json" }
)
default ResponseEntity updateSport(
- @Parameter(name = "sport_name", description = "", required = true, in = ParameterIn.PATH) @PathVariable("sport_name") String sportName,
+ @Parameter(name = "sport_id", description = "", required = true, in = ParameterIn.PATH) @PathVariable("sport_id") UUID sportId,
@Parameter(name = "SportPartialUpdate", description = "The request body for partially updating a sport.", required = true) @Valid @RequestBody SportPartialUpdate sportPartialUpdate
) {
getRequest().ifPresent(request -> {
for (MediaType mediaType: MediaType.parseMediaTypes(request.getHeader("Accept"))) {
if (mediaType.isCompatibleWith(MediaType.valueOf("application/json"))) {
- String exampleString = "{ \"directors\" : [ \"directors\", \"directors\" ], \"name\" : \"name\", \"description\" : \"description\", \"created_at\" : \"2000-01-23\" }";
+ String exampleString = "{ \"directors\" : [ \"directors\", \"directors\" ], \"name\" : \"name\", \"description\" : \"description\", \"created_at\" : \"2000-01-23\", \"id\" : \"046b6c7f-0b8a-43b9-b35d-6489e6daee91\" }";
ApiUtil.setExampleResponse(request, "application/json", exampleString);
break;
}
@@ -825,7 +825,7 @@ default ResponseEntity updateTeam(
getRequest().ifPresent(request -> {
for (MediaType mediaType: MediaType.parseMediaTypes(request.getHeader("Accept"))) {
if (mediaType.isCompatibleWith(MediaType.valueOf("application/json"))) {
- String exampleString = "{ \"trainers\" : [ \"trainers\", \"trainers\" ], \"address\" : \"address\", \"trainees\" : [ \"trainees\", \"trainees\" ], \"name\" : \"name\", \"description\" : \"description\", \"created_at\" : \"2000-01-23\", \"id\" : \"046b6c7f-0b8a-43b9-b35d-6489e6daee91\", \"sport\" : \"sport\" }";
+ String exampleString = "{ \"trainers\" : [ \"trainers\", \"trainers\" ], \"address\" : \"address\", \"trainees\" : [ \"trainees\", \"trainees\" ], \"name\" : \"name\", \"description\" : \"description\", \"created_at\" : \"2000-01-23\", \"id\" : \"046b6c7f-0b8a-43b9-b35d-6489e6daee91\", \"sport\" : \"046b6c7f-0b8a-43b9-b35d-6489e6daee91\" }";
ApiUtil.setExampleResponse(request, "application/json", exampleString);
break;
}
diff --git a/services/spring-organization/src/generated/java/tum/devoops/organizationservice/model/BadRequestResponse.java b/services/spring-organization/src/generated/java/tum/devoops/organizationservice/model/BadRequestResponse.java
index 95d7598..e52f553 100644
--- a/services/spring-organization/src/generated/java/tum/devoops/organizationservice/model/BadRequestResponse.java
+++ b/services/spring-organization/src/generated/java/tum/devoops/organizationservice/model/BadRequestResponse.java
@@ -28,7 +28,7 @@ public class BadRequestResponse {
private String message;
@Valid
- private List<@Valid ErrorResponse> errors = new ArrayList<>();
+ private @Nullable List<@Valid ErrorResponse> errors;
public BadRequestResponse() {
super();
@@ -61,7 +61,7 @@ public void setMessage(String message) {
this.message = message;
}
- public BadRequestResponse errors(List<@Valid ErrorResponse> errors) {
+ public BadRequestResponse errors(@Nullable List<@Valid ErrorResponse> errors) {
this.errors = errors;
return this;
}
@@ -81,11 +81,11 @@ public BadRequestResponse addErrorsItem(ErrorResponse errorsItem) {
@Valid
@Schema(name = "errors", requiredMode = Schema.RequiredMode.NOT_REQUIRED)
@JsonProperty("errors")
- public List<@Valid ErrorResponse> getErrors() {
+ public @Nullable List<@Valid ErrorResponse> getErrors() {
return errors;
}
- public void setErrors(List<@Valid ErrorResponse> errors) {
+ public void setErrors(@Nullable List<@Valid ErrorResponse> errors) {
this.errors = errors;
}
diff --git a/services/spring-organization/src/generated/java/tum/devoops/organizationservice/model/Sport.java b/services/spring-organization/src/generated/java/tum/devoops/organizationservice/model/Sport.java
index 248a6ef..dd36214 100644
--- a/services/spring-organization/src/generated/java/tum/devoops/organizationservice/model/Sport.java
+++ b/services/spring-organization/src/generated/java/tum/devoops/organizationservice/model/Sport.java
@@ -8,6 +8,7 @@
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
+import java.util.UUID;
import org.springframework.format.annotation.DateTimeFormat;
import org.springframework.lang.Nullable;
import java.time.OffsetDateTime;
@@ -27,6 +28,8 @@
@Generated(value = "org.openapitools.codegen.languages.SpringCodegen", comments = "Generator version: 7.14.0")
public class Sport {
+ private UUID id;
+
private String name;
private String description;
@@ -35,7 +38,7 @@ public class Sport {
private LocalDate createdAt;
@Valid
- private List directors = new ArrayList<>();
+ private List directors;
public Sport() {
super();
@@ -44,13 +47,34 @@ public Sport() {
/**
* Constructor with only required parameters
*/
- public Sport(String name, String description, LocalDate createdAt, List directors) {
+ public Sport(UUID id, String name, String description, LocalDate createdAt, List directors) {
+ this.id = id;
this.name = name;
this.description = description;
this.createdAt = createdAt;
this.directors = directors;
}
+ public Sport id(UUID id) {
+ this.id = id;
+ return this;
+ }
+
+ /**
+ * Get id
+ * @return id
+ */
+ @NotNull @Valid
+ @Schema(name = "id", requiredMode = Schema.RequiredMode.REQUIRED)
+ @JsonProperty("id")
+ public UUID getId() {
+ return id;
+ }
+
+ public void setId(UUID id) {
+ this.id = id;
+ }
+
public Sport name(String name) {
this.name = name;
return this;
@@ -148,7 +172,8 @@ public boolean equals(Object o) {
return false;
}
Sport sport = (Sport) o;
- return Objects.equals(this.name, sport.name) &&
+ return Objects.equals(this.id, sport.id) &&
+ Objects.equals(this.name, sport.name) &&
Objects.equals(this.description, sport.description) &&
Objects.equals(this.createdAt, sport.createdAt) &&
Objects.equals(this.directors, sport.directors);
@@ -156,13 +181,14 @@ public boolean equals(Object o) {
@Override
public int hashCode() {
- return Objects.hash(name, description, createdAt, directors);
+ return Objects.hash(id, name, description, createdAt, directors);
}
@Override
public String toString() {
StringBuilder sb = new StringBuilder();
sb.append("class Sport {\n");
+ sb.append(" id: ").append(toIndentedString(id)).append("\n");
sb.append(" name: ").append(toIndentedString(name)).append("\n");
sb.append(" description: ").append(toIndentedString(description)).append("\n");
sb.append(" createdAt: ").append(toIndentedString(createdAt)).append("\n");
diff --git a/services/spring-organization/src/generated/java/tum/devoops/organizationservice/model/SportCreate.java b/services/spring-organization/src/generated/java/tum/devoops/organizationservice/model/SportCreate.java
index 1e1d4c4..4cdeb22 100644
--- a/services/spring-organization/src/generated/java/tum/devoops/organizationservice/model/SportCreate.java
+++ b/services/spring-organization/src/generated/java/tum/devoops/organizationservice/model/SportCreate.java
@@ -30,7 +30,7 @@ public class SportCreate {
private @Nullable String description;
@Valid
- private List directors = new ArrayList<>();
+ private @Nullable List directors;
public SportCreate() {
super();
@@ -83,7 +83,7 @@ public void setDescription(@Nullable String description) {
this.description = description;
}
- public SportCreate directors(List directors) {
+ public SportCreate directors(@Nullable List directors) {
this.directors = directors;
return this;
}
@@ -103,11 +103,11 @@ public SportCreate addDirectorsItem(String directorsItem) {
@Schema(name = "directors", requiredMode = Schema.RequiredMode.NOT_REQUIRED)
@JsonProperty("directors")
- public List getDirectors() {
+ public @Nullable List getDirectors() {
return directors;
}
- public void setDirectors(List directors) {
+ public void setDirectors(@Nullable List directors) {
this.directors = directors;
}
diff --git a/services/spring-organization/src/generated/java/tum/devoops/organizationservice/model/SportPartialUpdate.java b/services/spring-organization/src/generated/java/tum/devoops/organizationservice/model/SportPartialUpdate.java
index 246ce3d..a494a7b 100644
--- a/services/spring-organization/src/generated/java/tum/devoops/organizationservice/model/SportPartialUpdate.java
+++ b/services/spring-organization/src/generated/java/tum/devoops/organizationservice/model/SportPartialUpdate.java
@@ -30,7 +30,7 @@ public class SportPartialUpdate {
private @Nullable String description;
@Valid
- private List directors = new ArrayList<>();
+ private @Nullable List directors;
public SportPartialUpdate name(@Nullable String name) {
this.name = name;
@@ -72,7 +72,7 @@ public void setDescription(@Nullable String description) {
this.description = description;
}
- public SportPartialUpdate directors(List directors) {
+ public SportPartialUpdate directors(@Nullable List directors) {
this.directors = directors;
return this;
}
@@ -92,11 +92,11 @@ public SportPartialUpdate addDirectorsItem(String directorsItem) {
@Schema(name = "directors", requiredMode = Schema.RequiredMode.NOT_REQUIRED)
@JsonProperty("directors")
- public List getDirectors() {
+ public @Nullable List getDirectors() {
return directors;
}
- public void setDirectors(List directors) {
+ public void setDirectors(@Nullable List directors) {
this.directors = directors;
}
diff --git a/services/spring-organization/src/generated/java/tum/devoops/organizationservice/model/Team.java b/services/spring-organization/src/generated/java/tum/devoops/organizationservice/model/Team.java
index c0c6033..5c58e51 100644
--- a/services/spring-organization/src/generated/java/tum/devoops/organizationservice/model/Team.java
+++ b/services/spring-organization/src/generated/java/tum/devoops/organizationservice/model/Team.java
@@ -39,13 +39,13 @@ public class Team {
private String address;
- private String sport;
+ private UUID sport;
@Valid
- private List trainers = new ArrayList<>();
+ private List trainers;
@Valid
- private List trainees = new ArrayList<>();
+ private List trainees;
public Team() {
super();
@@ -54,7 +54,7 @@ public Team() {
/**
* Constructor with only required parameters
*/
- public Team(UUID id, String name, String description, LocalDate createdAt, String address, String sport, List trainers, List trainees) {
+ public Team(UUID id, String name, String description, LocalDate createdAt, String address, UUID sport, List trainers, List trainees) {
this.id = id;
this.name = name;
this.description = description;
@@ -165,23 +165,23 @@ public void setAddress(String address) {
this.address = address;
}
- public Team sport(String sport) {
+ public Team sport(UUID sport) {
this.sport = sport;
return this;
}
/**
- * Get sport
+ * ID of the sport this team belongs to.
* @return sport
*/
- @NotNull
- @Schema(name = "sport", requiredMode = Schema.RequiredMode.REQUIRED)
+ @NotNull @Valid
+ @Schema(name = "sport", description = "ID of the sport this team belongs to.", requiredMode = Schema.RequiredMode.REQUIRED)
@JsonProperty("sport")
- public String getSport() {
+ public UUID getSport() {
return sport;
}
- public void setSport(String sport) {
+ public void setSport(UUID sport) {
this.sport = sport;
}
diff --git a/services/spring-organization/src/generated/java/tum/devoops/organizationservice/model/TeamCreate.java b/services/spring-organization/src/generated/java/tum/devoops/organizationservice/model/TeamCreate.java
index b517a35..82f0beb 100644
--- a/services/spring-organization/src/generated/java/tum/devoops/organizationservice/model/TeamCreate.java
+++ b/services/spring-organization/src/generated/java/tum/devoops/organizationservice/model/TeamCreate.java
@@ -7,6 +7,7 @@
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
+import java.util.UUID;
import org.springframework.lang.Nullable;
import java.time.OffsetDateTime;
import jakarta.validation.Valid;
@@ -31,13 +32,13 @@ public class TeamCreate {
private @Nullable String address;
- private String sport;
+ private UUID sport;
@Valid
- private List trainers = new ArrayList<>();
+ private @Nullable List trainers;
@Valid
- private List trainees = new ArrayList<>();
+ private @Nullable List trainees;
public TeamCreate() {
super();
@@ -46,7 +47,7 @@ public TeamCreate() {
/**
* Constructor with only required parameters
*/
- public TeamCreate(String name, String sport) {
+ public TeamCreate(String name, UUID sport) {
this.name = name;
this.sport = sport;
}
@@ -111,27 +112,27 @@ public void setAddress(@Nullable String address) {
this.address = address;
}
- public TeamCreate sport(String sport) {
+ public TeamCreate sport(UUID sport) {
this.sport = sport;
return this;
}
/**
- * Get sport
+ * ID of the sport this team belongs to.
* @return sport
*/
- @NotNull
- @Schema(name = "sport", requiredMode = Schema.RequiredMode.REQUIRED)
+ @NotNull @Valid
+ @Schema(name = "sport", description = "ID of the sport this team belongs to.", requiredMode = Schema.RequiredMode.REQUIRED)
@JsonProperty("sport")
- public String getSport() {
+ public UUID getSport() {
return sport;
}
- public void setSport(String sport) {
+ public void setSport(UUID sport) {
this.sport = sport;
}
- public TeamCreate trainers(List trainers) {
+ public TeamCreate trainers(@Nullable List trainers) {
this.trainers = trainers;
return this;
}
@@ -151,15 +152,15 @@ public TeamCreate addTrainersItem(String trainersItem) {
@Schema(name = "trainers", requiredMode = Schema.RequiredMode.NOT_REQUIRED)
@JsonProperty("trainers")
- public List getTrainers() {
+ public @Nullable List getTrainers() {
return trainers;
}
- public void setTrainers(List trainers) {
+ public void setTrainers(@Nullable List trainers) {
this.trainers = trainers;
}
- public TeamCreate trainees(List trainees) {
+ public TeamCreate trainees(@Nullable List trainees) {
this.trainees = trainees;
return this;
}
@@ -179,11 +180,11 @@ public TeamCreate addTraineesItem(String traineesItem) {
@Schema(name = "trainees", requiredMode = Schema.RequiredMode.NOT_REQUIRED)
@JsonProperty("trainees")
- public List getTrainees() {
+ public @Nullable List getTrainees() {
return trainees;
}
- public void setTrainees(List trainees) {
+ public void setTrainees(@Nullable List trainees) {
this.trainees = trainees;
}
diff --git a/services/spring-organization/src/generated/java/tum/devoops/organizationservice/model/TeamPartialUpdate.java b/services/spring-organization/src/generated/java/tum/devoops/organizationservice/model/TeamPartialUpdate.java
index fe880d3..b8d54d3 100644
--- a/services/spring-organization/src/generated/java/tum/devoops/organizationservice/model/TeamPartialUpdate.java
+++ b/services/spring-organization/src/generated/java/tum/devoops/organizationservice/model/TeamPartialUpdate.java
@@ -7,6 +7,7 @@
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
+import java.util.UUID;
import org.springframework.lang.Nullable;
import java.time.OffsetDateTime;
import jakarta.validation.Valid;
@@ -31,13 +32,13 @@ public class TeamPartialUpdate {
private @Nullable String address;
- private @Nullable String sport;
+ private @Nullable UUID sport;
@Valid
- private List trainers = new ArrayList<>();
+ private @Nullable List trainers;
@Valid
- private List trainees = new ArrayList<>();
+ private @Nullable List trainees;
public TeamPartialUpdate name(@Nullable String name) {
this.name = name;
@@ -99,27 +100,27 @@ public void setAddress(@Nullable String address) {
this.address = address;
}
- public TeamPartialUpdate sport(@Nullable String sport) {
+ public TeamPartialUpdate sport(@Nullable UUID sport) {
this.sport = sport;
return this;
}
/**
- * Get sport
+ * ID of the sport this team belongs to.
* @return sport
*/
-
- @Schema(name = "sport", requiredMode = Schema.RequiredMode.NOT_REQUIRED)
+ @Valid
+ @Schema(name = "sport", description = "ID of the sport this team belongs to.", requiredMode = Schema.RequiredMode.NOT_REQUIRED)
@JsonProperty("sport")
- public @Nullable String getSport() {
+ public @Nullable UUID getSport() {
return sport;
}
- public void setSport(@Nullable String sport) {
+ public void setSport(@Nullable UUID sport) {
this.sport = sport;
}
- public TeamPartialUpdate trainers(List trainers) {
+ public TeamPartialUpdate trainers(@Nullable List trainers) {
this.trainers = trainers;
return this;
}
@@ -139,15 +140,15 @@ public TeamPartialUpdate addTrainersItem(String trainersItem) {
@Schema(name = "trainers", requiredMode = Schema.RequiredMode.NOT_REQUIRED)
@JsonProperty("trainers")
- public List getTrainers() {
+ public @Nullable List getTrainers() {
return trainers;
}
- public void setTrainers(List trainers) {
+ public void setTrainers(@Nullable List trainers) {
this.trainers = trainers;
}
- public TeamPartialUpdate trainees(List trainees) {
+ public TeamPartialUpdate trainees(@Nullable List trainees) {
this.trainees = trainees;
return this;
}
@@ -167,11 +168,11 @@ public TeamPartialUpdate addTraineesItem(String traineesItem) {
@Schema(name = "trainees", requiredMode = Schema.RequiredMode.NOT_REQUIRED)
@JsonProperty("trainees")
- public List getTrainees() {
+ public @Nullable List getTrainees() {
return trainees;
}
- public void setTrainees(List trainees) {
+ public void setTrainees(@Nullable List trainees) {
this.trainees = trainees;
}
diff --git a/services/spring-organization/src/main/java/tum/devoops/organizationservice/controller/OrganizationController.java b/services/spring-organization/src/main/java/tum/devoops/organizationservice/controller/OrganizationController.java
index 53af3ee..121af25 100644
--- a/services/spring-organization/src/main/java/tum/devoops/organizationservice/controller/OrganizationController.java
+++ b/services/spring-organization/src/main/java/tum/devoops/organizationservice/controller/OrganizationController.java
@@ -79,22 +79,22 @@ public ResponseEntity createSport(SportCreate sportCreate) {
}
@Override
- public ResponseEntity getSport(String sportName) {
- return ResponseEntity.ok(sportService.getSport(sportName));
+ public ResponseEntity getSport(UUID sportId) {
+ return ResponseEntity.ok(sportService.getSport(sportId));
}
@Override
- public ResponseEntity updateSport(String sportName, SportPartialUpdate sportPartialUpdate) {
+ public ResponseEntity updateSport(UUID sportId, SportPartialUpdate sportPartialUpdate) {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
UUID requesterId = extractRequesterId(auth);
boolean isAdmin = extractIsAdmin(auth);
- return ResponseEntity.ok(sportService.updateSport(sportName, sportPartialUpdate, requesterId, isAdmin));
+ return ResponseEntity.ok(sportService.updateSport(sportId, sportPartialUpdate, requesterId, isAdmin));
}
@Override
@PreAuthorize("hasRole('admin')")
- public ResponseEntity deleteSport(String sportName) {
- sportService.deleteSport(sportName);
+ public ResponseEntity deleteSport(UUID sportId) {
+ sportService.deleteSport(sportId);
return ResponseEntity.noContent().build();
}
diff --git a/services/spring-organization/src/main/java/tum/devoops/organizationservice/entity/DirectorEntity.java b/services/spring-organization/src/main/java/tum/devoops/organizationservice/entity/DirectorEntity.java
index eb09ed6..d1fc767 100644
--- a/services/spring-organization/src/main/java/tum/devoops/organizationservice/entity/DirectorEntity.java
+++ b/services/spring-organization/src/main/java/tum/devoops/organizationservice/entity/DirectorEntity.java
@@ -19,17 +19,17 @@
@Getter @Setter @NoArgsConstructor @AllArgsConstructor
public class DirectorEntity {
- // Composite PK: (sport_name, member_id).
- // sport_name references organization.sport(name).
- // member_id references member.member(id) — FK added in V3 migration.
+ // Composite PK: (sport_id, member_id).
+ // sport_id references organization.sports(id).
+ // member_id references member.members(id).
@EmbeddedId
private Id id;
@Embeddable
@Data @NoArgsConstructor @AllArgsConstructor
public static class Id implements Serializable {
- @Column(name = "sport_name", nullable = false)
- private String sportName;
+ @Column(name = "sport_id", nullable = false)
+ private UUID sportId;
@Column(name = "member_id", nullable = false)
private UUID memberId;
diff --git a/services/spring-organization/src/main/java/tum/devoops/organizationservice/entity/SportEntity.java b/services/spring-organization/src/main/java/tum/devoops/organizationservice/entity/SportEntity.java
index a404469..f00086c 100644
--- a/services/spring-organization/src/main/java/tum/devoops/organizationservice/entity/SportEntity.java
+++ b/services/spring-organization/src/main/java/tum/devoops/organizationservice/entity/SportEntity.java
@@ -2,9 +2,12 @@
import java.time.LocalDate;
import java.util.List;
+import java.util.UUID;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
+import jakarta.persistence.GeneratedValue;
+import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.OneToMany;
@@ -19,7 +22,11 @@
public class SportEntity {
@Id
- @Column(name = "name", nullable = false)
+ @GeneratedValue(strategy = GenerationType.UUID)
+ @Column(name = "id", nullable = false, updatable = false)
+ private UUID id;
+
+ @Column(name = "name", nullable = false, unique = true)
private String name;
@Column(name = "description", columnDefinition = "TEXT")
@@ -30,6 +37,6 @@ public class SportEntity {
// Each Director row links this sport to a member (director role).
@OneToMany
- @JoinColumn(name = "sport_name", referencedColumnName = "name", insertable = false, updatable = false)
+ @JoinColumn(name = "sport_id", referencedColumnName = "id", insertable = false, updatable = false)
private List directors;
}
diff --git a/services/spring-organization/src/main/java/tum/devoops/organizationservice/entity/TeamEntity.java b/services/spring-organization/src/main/java/tum/devoops/organizationservice/entity/TeamEntity.java
index 52ef7a5..171725d 100644
--- a/services/spring-organization/src/main/java/tum/devoops/organizationservice/entity/TeamEntity.java
+++ b/services/spring-organization/src/main/java/tum/devoops/organizationservice/entity/TeamEntity.java
@@ -38,9 +38,9 @@ public class TeamEntity {
@Column(name = "address")
private String address;
- // FK to organization.sport(name). REFERENCES constraint added in V3 migration.
- @Column(name = "sport_name", nullable = false)
- private String sportName;
+ // FK to organization.sports(id).
+ @Column(name = "sport_id", nullable = false)
+ private UUID sportId;
@OneToMany
@JoinColumn(name = "team_id", referencedColumnName = "id", insertable = false, updatable = false)
diff --git a/services/spring-organization/src/main/java/tum/devoops/organizationservice/repository/DirectorRepository.java b/services/spring-organization/src/main/java/tum/devoops/organizationservice/repository/DirectorRepository.java
index 266c6e7..cd3a371 100644
--- a/services/spring-organization/src/main/java/tum/devoops/organizationservice/repository/DirectorRepository.java
+++ b/services/spring-organization/src/main/java/tum/devoops/organizationservice/repository/DirectorRepository.java
@@ -9,12 +9,12 @@
public interface DirectorRepository extends JpaRepository {
- // SELECT * FROM organization.directors WHERE sport_name = ?
- List findAllById_SportName(String sportName);
+ // SELECT * FROM organization.directors WHERE sport_id = ?
+ List findAllById_SportId(UUID sportId);
// SELECT * FROM organization.directors WHERE member_id = ?
List findAllById_MemberId(UUID memberId);
- // DELETE FROM organization.directors WHERE sport_name = ?
- void deleteAllById_SportName(String sportName);
+ // DELETE FROM organization.directors WHERE sport_id = ?
+ void deleteAllById_SportId(UUID sportId);
}
diff --git a/services/spring-organization/src/main/java/tum/devoops/organizationservice/repository/SportRepository.java b/services/spring-organization/src/main/java/tum/devoops/organizationservice/repository/SportRepository.java
index 0a2b63f..6a32c1d 100644
--- a/services/spring-organization/src/main/java/tum/devoops/organizationservice/repository/SportRepository.java
+++ b/services/spring-organization/src/main/java/tum/devoops/organizationservice/repository/SportRepository.java
@@ -1,15 +1,19 @@
package tum.devoops.organizationservice.repository;
import java.util.List;
+import java.util.UUID;
import org.springframework.data.jpa.repository.JpaRepository;
import tum.devoops.organizationservice.entity.SportEntity;
-public interface SportRepository extends JpaRepository {
+public interface SportRepository extends JpaRepository {
// SELECT s.* FROM organization.sports s
- // JOIN organization.directors d ON d.sport_name = s.name
+ // JOIN organization.directors d ON d.sport_id = s.id
// WHERE d.member_id = ?
- List findAllByDirectors_Id_MemberId(java.util.UUID memberId);
+ List findAllByDirectors_Id_MemberId(UUID memberId);
+
+ // SELECT EXISTS(SELECT 1 FROM organization.sports WHERE name = ?)
+ boolean existsByName(String name);
}
diff --git a/services/spring-organization/src/main/java/tum/devoops/organizationservice/repository/TeamRepository.java b/services/spring-organization/src/main/java/tum/devoops/organizationservice/repository/TeamRepository.java
index 6240d7d..91e1ec7 100644
--- a/services/spring-organization/src/main/java/tum/devoops/organizationservice/repository/TeamRepository.java
+++ b/services/spring-organization/src/main/java/tum/devoops/organizationservice/repository/TeamRepository.java
@@ -9,6 +9,6 @@
public interface TeamRepository extends JpaRepository {
- // SELECT * FROM organization.teams WHERE sport_name = ?
- List findAllBySportName(String sportName);
+ // SELECT * FROM organization.teams WHERE sport_id = ?
+ List findAllBySportId(UUID sportId);
}
diff --git a/services/spring-organization/src/main/java/tum/devoops/organizationservice/service/OrganizationSportService.java b/services/spring-organization/src/main/java/tum/devoops/organizationservice/service/OrganizationSportService.java
index 50f03b1..79e4e1f 100644
--- a/services/spring-organization/src/main/java/tum/devoops/organizationservice/service/OrganizationSportService.java
+++ b/services/spring-organization/src/main/java/tum/devoops/organizationservice/service/OrganizationSportService.java
@@ -54,13 +54,13 @@ public List getAllSports() {
}
@Transactional(readOnly = true)
- public Sport getSport(String sportName) {
- return toSport(findSportOrThrow(sportName));
+ public Sport getSport(UUID sportId) {
+ return toSport(findSportOrThrow(sportId));
}
@Transactional
public Sport createSport(SportCreate body) {
- if (sportRepository.existsById(body.getName())) {
+ if (sportRepository.existsByName(body.getName())) {
throw new ConflictException("Sport already exists: " + body.getName());
}
List directorIds = resolveDirectorUuids(body.getDirectors());
@@ -71,91 +71,60 @@ public Sport createSport(SportCreate body) {
entity.setCreatedAt(LocalDate.now());
sportRepository.save(entity);
- saveDirectors(body.getName(), directorIds);
+ saveDirectors(entity.getId(), directorIds);
memberRoleSyncService.scheduleSync(new HashSet<>(directorIds));
- return toSport(findSportOrThrow(body.getName()));
+ return toSport(findSportOrThrow(entity.getId()));
}
@Transactional
- public Sport updateSport(String sportName, SportPartialUpdate body, UUID requesterId, boolean isAdmin) {
- SportEntity sport = findSportOrThrow(sportName);
+ public Sport updateSport(UUID sportId, SportPartialUpdate body, UUID requesterId, boolean isAdmin) {
+ SportEntity sport = findSportOrThrow(sportId);
- boolean isDirector = directorRepository.findAllById_SportName(sportName).stream()
+ boolean isDirector = directorRepository.findAllById_SportId(sportId).stream()
.anyMatch(d -> d.getId().getMemberId().equals(requesterId));
if (!isAdmin && !isDirector) {
throw new ForbiddenException("Access denied");
}
- String effectiveName = (body.getName() != null) ? body.getName() : sportName;
- String effectiveDescription = (body.getDescription() != null) ? body.getDescription() : sport.getDescription();
-
- Set affected = new HashSet<>();
-
- if (!effectiveName.equals(sportName)) {
- if (sportRepository.existsById(effectiveName)) {
- throw new ConflictException("Sport already exists: " + effectiveName);
- }
- List oldDirectors = directorRepository.findAllById_SportName(sportName);
- List teams = teamRepository.findAllBySportName(sportName);
- for (TeamEntity team : teams) {
- team.setSportName(effectiveName);
- }
-
- SportEntity newSport = new SportEntity();
- newSport.setName(effectiveName);
- newSport.setDescription(effectiveDescription);
- newSport.setCreatedAt(sport.getCreatedAt());
- sportRepository.save(newSport);
-
- teamRepository.saveAll(teams);
- directorRepository.deleteAllById_SportName(sportName);
-
- if (isAdmin && !body.getDirectors().isEmpty()) {
- List newDirectorIds = resolveDirectorUuids(body.getDirectors());
- saveDirectors(effectiveName, newDirectorIds);
- // Directors are replaced: the removed and the added members both change.
- oldDirectors.forEach(d -> affected.add(d.getId().getMemberId()));
- affected.addAll(newDirectorIds);
- } else {
- // Pure rename: the same members keep a director row, so membership is unchanged.
- List migratedDirectors = oldDirectors.stream()
- .map(d -> new DirectorEntity(
- new DirectorEntity.Id(effectiveName, d.getId().getMemberId())))
- .collect(Collectors.toList());
- directorRepository.saveAll(migratedDirectors);
- }
-
- sportRepository.delete(sport);
- } else {
- sport.setDescription(effectiveDescription);
- sportRepository.save(sport);
-
- if (isAdmin && !body.getDirectors().isEmpty()) {
- directorRepository.findAllById_SportName(sportName)
- .forEach(d -> affected.add(d.getId().getMemberId()));
- directorRepository.deleteAllById_SportName(sportName);
- List newDirectorIds = resolveDirectorUuids(body.getDirectors());
- saveDirectors(sportName, newDirectorIds);
- affected.addAll(newDirectorIds);
+ // Renaming is now a plain field update — no foreign keys reference the name.
+ if (body.getName() != null && !body.getName().equals(sport.getName())) {
+ if (sportRepository.existsByName(body.getName())) {
+ throw new ConflictException("Sport already exists: " + body.getName());
}
+ sport.setName(body.getName());
+ }
+ if (body.getDescription() != null) {
+ sport.setDescription(body.getDescription());
}
+ sportRepository.save(sport);
+ // null means the directors list was omitted (no change); a non-null list (including empty)
+ // replaces the current directors, so an empty list clears them. Only admins may change it.
+ Set affected = new HashSet<>();
+ if (isAdmin && body.getDirectors() != null) {
+ directorRepository.findAllById_SportId(sportId)
+ .forEach(d -> affected.add(d.getId().getMemberId()));
+ directorRepository.deleteAllById_SportId(sportId);
+ List newDirectorIds = resolveDirectorUuids(body.getDirectors());
+ saveDirectors(sportId, newDirectorIds);
+ affected.addAll(newDirectorIds);
+ }
memberRoleSyncService.scheduleSync(affected);
- return toSport(findSportOrThrow(effectiveName));
+ return toSport(findSportOrThrow(sportId));
}
@Transactional
- public void deleteSport(String sportName) {
- SportEntity sport = findSportOrThrow(sportName);
+ public void deleteSport(UUID sportId) {
+ SportEntity sport = findSportOrThrow(sportId);
Set affected = new HashSet<>();
- directorRepository.findAllById_SportName(sportName)
+ directorRepository.findAllById_SportId(sportId)
.forEach(d -> affected.add(d.getId().getMemberId()));
- List teams = teamRepository.findAllBySportName(sportName);
+ List teams = teamRepository.findAllBySportId(sportId);
for (TeamEntity team : teams) {
trainerRepository.findAllById_TeamId(team.getId())
.forEach(t -> affected.add(t.getId().getMemberId()));
@@ -166,18 +135,21 @@ public void deleteSport(String sportName) {
}
teamRepository.deleteAll(teams);
- directorRepository.deleteAllById_SportName(sportName);
+ directorRepository.deleteAllById_SportId(sportId);
sportRepository.delete(sport);
memberRoleSyncService.scheduleSync(affected);
}
- private SportEntity findSportOrThrow(String sportName) {
- return sportRepository.findById(sportName)
- .orElseThrow(() -> new NotFoundException("Sport not found: " + sportName));
+ private SportEntity findSportOrThrow(UUID sportId) {
+ return sportRepository.findById(sportId)
+ .orElseThrow(() -> new NotFoundException("Sport not found: " + sportId));
}
private List resolveDirectorUuids(List directorStrings) {
+ if (directorStrings == null) {
+ return List.of();
+ }
return directorStrings.stream()
.map(s -> {
try {
@@ -194,9 +166,9 @@ private List resolveDirectorUuids(List directorStrings) {
.collect(Collectors.toList());
}
- private void saveDirectors(String sportName, List directorIds) {
+ private void saveDirectors(UUID sportId, List directorIds) {
List directors = directorIds.stream()
- .map(id -> new DirectorEntity(new DirectorEntity.Id(sportName, id)))
+ .map(id -> new DirectorEntity(new DirectorEntity.Id(sportId, id)))
.collect(Collectors.toList());
directorRepository.saveAll(directors);
}
@@ -205,6 +177,7 @@ private Sport toSport(SportEntity entity) {
List directors = entity.getDirectors().stream()
.map(d -> d.getId().getMemberId().toString())
.collect(Collectors.toList());
- return new Sport(entity.getName(), entity.getDescription(), entity.getCreatedAt(), directors);
+ return new Sport(entity.getId(), entity.getName(), entity.getDescription(),
+ entity.getCreatedAt(), directors);
}
}
diff --git a/services/spring-organization/src/main/java/tum/devoops/organizationservice/service/OrganizationTeamService.java b/services/spring-organization/src/main/java/tum/devoops/organizationservice/service/OrganizationTeamService.java
index 87b1585..8eb9123 100644
--- a/services/spring-organization/src/main/java/tum/devoops/organizationservice/service/OrganizationTeamService.java
+++ b/services/spring-organization/src/main/java/tum/devoops/organizationservice/service/OrganizationTeamService.java
@@ -63,7 +63,7 @@ public Team createTeam(TeamCreate body, UUID requesterId, boolean isAdmin) {
throw new BadRequestException("Sport not found: " + body.getSport());
}
- boolean isDirectorOfSport = directorRepository.findAllById_SportName(body.getSport()).stream()
+ boolean isDirectorOfSport = directorRepository.findAllById_SportId(body.getSport()).stream()
.anyMatch(d -> d.getId().getMemberId().equals(requesterId));
if (!isAdmin && !isDirectorOfSport) {
throw new ForbiddenException("Access denied");
@@ -76,7 +76,7 @@ public Team createTeam(TeamCreate body, UUID requesterId, boolean isAdmin) {
team.setName(body.getName());
team.setDescription(body.getDescription());
team.setAddress(body.getAddress());
- team.setSportName(body.getSport());
+ team.setSportId(body.getSport());
team.setCreatedAt(LocalDate.now());
teamRepository.save(team);
@@ -94,7 +94,7 @@ public Team createTeam(TeamCreate body, UUID requesterId, boolean isAdmin) {
public Team updateTeam(UUID teamId, TeamPartialUpdate body, UUID requesterId, boolean isAdmin) {
TeamEntity team = findTeamOrThrow(teamId);
- boolean isDirectorOfSport = directorRepository.findAllById_SportName(team.getSportName()).stream()
+ boolean isDirectorOfSport = directorRepository.findAllById_SportId(team.getSportId()).stream()
.anyMatch(d -> d.getId().getMemberId().equals(requesterId));
boolean isTrainerOfTeam = trainerRepository.findAllById_TeamId(teamId).stream()
.anyMatch(t -> t.getId().getMemberId().equals(requesterId));
@@ -106,7 +106,7 @@ public Team updateTeam(UUID teamId, TeamPartialUpdate body, UUID requesterId, bo
if (body.getSport() != null && !isAdmin) {
throw new ForbiddenException("Only admins can update the sport field");
}
- if (!body.getTrainers().isEmpty() && !isAdmin && !isDirectorOfSport) {
+ if (body.getTrainers() != null && !isAdmin && !isDirectorOfSport) {
throw new ForbiddenException("Only directors and admins can update the trainers list");
}
@@ -114,7 +114,7 @@ public Team updateTeam(UUID teamId, TeamPartialUpdate body, UUID requesterId, bo
if (!sportRepository.existsById(body.getSport())) {
throw new BadRequestException("Sport not found: " + body.getSport());
}
- team.setSportName(body.getSport());
+ team.setSportId(body.getSport());
}
if (body.getName() != null) {
team.setName(body.getName());
@@ -127,8 +127,10 @@ public Team updateTeam(UUID teamId, TeamPartialUpdate body, UUID requesterId, bo
}
teamRepository.save(team);
+ // null means the list was omitted (no change); a non-null list (including empty) replaces
+ // the current members, so an empty list clears them.
Set affected = new HashSet<>();
- if (!body.getTrainers().isEmpty()) {
+ if (body.getTrainers() != null) {
List trainerIds = resolveAndValidateMemberUuids(body.getTrainers(), "trainer");
trainerRepository.findAllById_TeamId(teamId)
.forEach(t -> affected.add(t.getId().getMemberId()));
@@ -136,7 +138,7 @@ public Team updateTeam(UUID teamId, TeamPartialUpdate body, UUID requesterId, bo
saveTrainers(teamId, trainerIds);
affected.addAll(trainerIds);
}
- if (!body.getTrainees().isEmpty()) {
+ if (body.getTrainees() != null) {
List traineeIds = resolveAndValidateMemberUuids(body.getTrainees(), "trainee");
traineeRepository.findAllById_TeamId(teamId)
.forEach(t -> affected.add(t.getId().getMemberId()));
@@ -153,7 +155,7 @@ public Team updateTeam(UUID teamId, TeamPartialUpdate body, UUID requesterId, bo
public void deleteTeam(UUID teamId, UUID requesterId, boolean isAdmin) {
TeamEntity team = findTeamOrThrow(teamId);
- boolean isDirectorOfSport = directorRepository.findAllById_SportName(team.getSportName()).stream()
+ boolean isDirectorOfSport = directorRepository.findAllById_SportId(team.getSportId()).stream()
.anyMatch(d -> d.getId().getMemberId().equals(requesterId));
if (!isAdmin && !isDirectorOfSport) {
throw new ForbiddenException("Access denied");
@@ -178,6 +180,9 @@ private TeamEntity findTeamOrThrow(UUID teamId) {
}
private List resolveAndValidateMemberUuids(List strings, String role) {
+ if (strings == null) {
+ return List.of();
+ }
return strings.stream()
.map(s -> {
try {
@@ -221,7 +226,7 @@ private Team toTeam(TeamEntity entity) {
entity.getDescription(),
entity.getCreatedAt(),
entity.getAddress(),
- entity.getSportName(),
+ entity.getSportId(),
trainers,
trainees
);
diff --git a/services/spring-organization/src/main/resources/db/migration/V3__sport_uuid_id.sql b/services/spring-organization/src/main/resources/db/migration/V3__sport_uuid_id.sql
new file mode 100644
index 0000000..f453c35
--- /dev/null
+++ b/services/spring-organization/src/main/resources/db/migration/V3__sport_uuid_id.sql
@@ -0,0 +1,58 @@
+-- Give Sport a UUID primary key and demote `name` to an ordinary (unique) field, so a
+-- sport can be renamed without rewriting foreign keys. Teams and directors reference the
+-- sport by its new UUID id.
+--
+-- NOTE: the cross-schema FK event.sport_events -> organization.sports(name) (added by the
+-- event service's migration) depends on the sports name primary key, so it must be dropped
+-- here before we can swap the primary key. The event service re-adds it against sports(id)
+-- in its own follow-up migration; this migration therefore assumes the organization schema
+-- migrates before the event schema (the existing ordering assumption in this codebase).
+
+-- 1. Add the new id column and backfill a stable UUID per sport.
+ALTER TABLE organization.sports
+ ADD COLUMN id UUID NOT NULL DEFAULT gen_random_uuid();
+
+-- 2. teams.sport_name -> teams.sport_id
+ALTER TABLE organization.teams ADD COLUMN sport_id UUID;
+UPDATE organization.teams t
+ SET sport_id = s.id
+ FROM organization.sports s
+ WHERE t.sport_name = s.name;
+ALTER TABLE organization.teams DROP CONSTRAINT fk_teams_sport;
+
+-- 3. directors composite PK (sport_name, member_id) -> (sport_id, member_id)
+ALTER TABLE organization.directors ADD COLUMN sport_id UUID;
+UPDATE organization.directors d
+ SET sport_id = s.id
+ FROM organization.sports s
+ WHERE d.sport_name = s.name;
+ALTER TABLE organization.directors DROP CONSTRAINT fk_directors_sport;
+ALTER TABLE organization.directors DROP CONSTRAINT pk_directors;
+
+-- 4. Drop the dependent cross-schema FK from the event service so the sports PK can change.
+-- Guarded so a fresh deploy (event schema not yet created) doesn't fail here; on an existing
+-- deploy the constraint exists and is dropped. The event service re-adds it against sports(id).
+DO $$
+BEGIN
+ IF EXISTS (SELECT 1 FROM information_schema.tables
+ WHERE table_schema = 'event' AND table_name = 'sport_events') THEN
+ ALTER TABLE event.sport_events DROP CONSTRAINT IF EXISTS fk_sport_events_sport;
+ END IF;
+END $$;
+
+-- 5. Swap the sports primary key from name to id; keep name as a unique field.
+ALTER TABLE organization.sports DROP CONSTRAINT pk_sports;
+ALTER TABLE organization.sports ADD CONSTRAINT pk_sports PRIMARY KEY (id);
+ALTER TABLE organization.sports ADD CONSTRAINT uq_sports_name UNIQUE (name);
+
+-- 6. Finalise the dependent columns and re-add the organization-owned FKs against sports(id).
+ALTER TABLE organization.teams ALTER COLUMN sport_id SET NOT NULL;
+ALTER TABLE organization.teams DROP COLUMN sport_name;
+ALTER TABLE organization.teams
+ ADD CONSTRAINT fk_teams_sport FOREIGN KEY (sport_id) REFERENCES organization.sports (id);
+
+ALTER TABLE organization.directors ALTER COLUMN sport_id SET NOT NULL;
+ALTER TABLE organization.directors DROP COLUMN sport_name;
+ALTER TABLE organization.directors ADD CONSTRAINT pk_directors PRIMARY KEY (sport_id, member_id);
+ALTER TABLE organization.directors
+ ADD CONSTRAINT fk_directors_sport FOREIGN KEY (sport_id) REFERENCES organization.sports (id);
diff --git a/services/spring-organization/src/test/java/tum/devoops/organizationservice/MemberRoleSyncServiceTest.java b/services/spring-organization/src/test/java/tum/devoops/organizationservice/MemberRoleSyncServiceTest.java
index 6e71d2e..aec9ccb 100644
--- a/services/spring-organization/src/test/java/tum/devoops/organizationservice/MemberRoleSyncServiceTest.java
+++ b/services/spring-organization/src/test/java/tum/devoops/organizationservice/MemberRoleSyncServiceTest.java
@@ -43,6 +43,7 @@ class MemberRoleSyncServiceTest {
private static final UUID MEMBER = UUID.fromString("00000000-0000-0000-0000-000000000007");
private static final UUID TEAM = UUID.fromString("00000000-0000-0000-0000-000000000010");
+ private static final UUID SPORT = UUID.fromString("00000000-0000-0000-0000-000000000020");
private TrainerEntity trainer(UUID memberId) {
return new TrainerEntity(new TrainerEntity.Id(TEAM, memberId));
@@ -53,7 +54,7 @@ private TraineeEntity trainee(UUID memberId) {
}
private DirectorEntity director(UUID memberId) {
- return new DirectorEntity(new DirectorEntity.Id("soccer", memberId));
+ return new DirectorEntity(new DirectorEntity.Id(SPORT, memberId));
}
@Test
diff --git a/services/spring-organization/src/test/java/tum/devoops/organizationservice/OrganizationControllerTest.java b/services/spring-organization/src/test/java/tum/devoops/organizationservice/OrganizationControllerTest.java
index 68d3d5d..5be990a 100644
--- a/services/spring-organization/src/test/java/tum/devoops/organizationservice/OrganizationControllerTest.java
+++ b/services/spring-organization/src/test/java/tum/devoops/organizationservice/OrganizationControllerTest.java
@@ -65,6 +65,7 @@ class OrganizationControllerTest {
private static final UUID ADMIN_ID = UUID.fromString("00000000-0000-0000-0000-000000000001");
private static final UUID MEMBER_ID = UUID.fromString("00000000-0000-0000-0000-000000000002");
private static final UUID TEAM_ID = UUID.fromString("00000000-0000-0000-0000-000000000010");
+ private static final UUID SPORT_ID = UUID.fromString("00000000-0000-0000-0000-000000000050");
private RequestPostProcessor adminJwt() {
return jwt()
@@ -79,10 +80,10 @@ private RequestPostProcessor memberJwt() {
}
private Sport sport(String name) {
- return new Sport(name, "A test sport", LocalDate.of(2024, 1, 1), List.of());
+ return new Sport(SPORT_ID, name, "A test sport", LocalDate.of(2024, 1, 1), List.of());
}
- private Team team(UUID id, String sport) {
+ private Team team(UUID id, UUID sport) {
return new Team(id, "Team Alpha", null, LocalDate.of(2024, 1, 1), null, sport, List.of(), List.of());
}
@@ -125,24 +126,24 @@ void getAllSports_returns403_whenNoOrgRole() throws Exception {
@Test
void getSport_returns200_withSport_whenFound() throws Exception {
- when(sportService.getSport("soccer")).thenReturn(sport("soccer"));
+ when(sportService.getSport(SPORT_ID)).thenReturn(sport("soccer"));
- mockMvc.perform(get("/organization/sports/soccer").with(memberJwt()))
+ mockMvc.perform(get("/organization/sports/" + SPORT_ID).with(memberJwt()))
.andExpect(status().isOk())
.andExpect(jsonPath("$.name").value("soccer"));
}
@Test
void getSport_returns404_whenNotFound() throws Exception {
- when(sportService.getSport("unknown")).thenThrow(new NotFoundException("Sport not found: unknown"));
+ when(sportService.getSport(SPORT_ID)).thenThrow(new NotFoundException("Sport not found: unknown"));
- mockMvc.perform(get("/organization/sports/unknown").with(memberJwt()))
+ mockMvc.perform(get("/organization/sports/" + SPORT_ID).with(memberJwt()))
.andExpect(status().isNotFound());
}
@Test
void getSport_returns401_whenUnauthenticated() throws Exception {
- mockMvc.perform(get("/organization/sports/soccer"))
+ mockMvc.perform(get("/organization/sports/" + SPORT_ID))
.andExpect(status().isUnauthorized());
}
@@ -201,10 +202,10 @@ void createSport_returns400_whenNameMissing() throws Exception {
@Test
void updateSport_returns200_whenAdmin() throws Exception {
- when(sportService.updateSport(eq("soccer"), any(), eq(ADMIN_ID), eq(true)))
+ when(sportService.updateSport(eq(SPORT_ID), any(), eq(ADMIN_ID), eq(true)))
.thenReturn(sport("soccer"));
- mockMvc.perform(patch("/organization/sports/soccer")
+ mockMvc.perform(patch("/organization/sports/" + SPORT_ID)
.with(adminJwt())
.contentType(MediaType.APPLICATION_JSON)
.content("{\"description\":\"updated\"}"))
@@ -214,10 +215,10 @@ void updateSport_returns200_whenAdmin() throws Exception {
@Test
void updateSport_returns200_whenMember() throws Exception {
- when(sportService.updateSport(eq("soccer"), any(), eq(MEMBER_ID), eq(false)))
+ when(sportService.updateSport(eq(SPORT_ID), any(), eq(MEMBER_ID), eq(false)))
.thenReturn(sport("soccer"));
- mockMvc.perform(patch("/organization/sports/soccer")
+ mockMvc.perform(patch("/organization/sports/" + SPORT_ID)
.with(memberJwt())
.contentType(MediaType.APPLICATION_JSON)
.content("{\"description\":\"updated\"}"))
@@ -228,34 +229,34 @@ void updateSport_returns200_whenMember() throws Exception {
void updateSport_passesRequesterIdAndIsAdminTrue_fromAdminJwt() throws Exception {
when(sportService.updateSport(any(), any(), any(), anyBoolean())).thenReturn(sport("soccer"));
- mockMvc.perform(patch("/organization/sports/soccer")
+ mockMvc.perform(patch("/organization/sports/" + SPORT_ID)
.with(adminJwt())
.contentType(MediaType.APPLICATION_JSON)
.content("{}"))
.andExpect(status().isOk());
- verify(sportService).updateSport(eq("soccer"), any(), eq(ADMIN_ID), eq(true));
+ verify(sportService).updateSport(eq(SPORT_ID), any(), eq(ADMIN_ID), eq(true));
}
@Test
void updateSport_passesRequesterIdAndIsAdminFalse_fromMemberJwt() throws Exception {
when(sportService.updateSport(any(), any(), any(), anyBoolean())).thenReturn(sport("soccer"));
- mockMvc.perform(patch("/organization/sports/soccer")
+ mockMvc.perform(patch("/organization/sports/" + SPORT_ID)
.with(memberJwt())
.contentType(MediaType.APPLICATION_JSON)
.content("{}"))
.andExpect(status().isOk());
- verify(sportService).updateSport(eq("soccer"), any(), eq(MEMBER_ID), eq(false));
+ verify(sportService).updateSport(eq(SPORT_ID), any(), eq(MEMBER_ID), eq(false));
}
@Test
void updateSport_returns404_whenNotFound() throws Exception {
- when(sportService.updateSport(eq("unknown"), any(), any(), anyBoolean()))
+ when(sportService.updateSport(eq(SPORT_ID), any(), any(), anyBoolean()))
.thenThrow(new NotFoundException("Sport not found: unknown"));
- mockMvc.perform(patch("/organization/sports/unknown")
+ mockMvc.perform(patch("/organization/sports/" + SPORT_ID)
.with(memberJwt())
.contentType(MediaType.APPLICATION_JSON)
.content("{}"))
@@ -264,10 +265,10 @@ void updateSport_returns404_whenNotFound() throws Exception {
@Test
void updateSport_returns403_whenForbidden() throws Exception {
- when(sportService.updateSport(eq("soccer"), any(), any(), anyBoolean()))
+ when(sportService.updateSport(eq(SPORT_ID), any(), any(), anyBoolean()))
.thenThrow(new ForbiddenException("Access denied"));
- mockMvc.perform(patch("/organization/sports/soccer")
+ mockMvc.perform(patch("/organization/sports/" + SPORT_ID)
.with(memberJwt())
.contentType(MediaType.APPLICATION_JSON)
.content("{}"))
@@ -276,10 +277,10 @@ void updateSport_returns403_whenForbidden() throws Exception {
@Test
void updateSport_returns409_whenRenameConflict() throws Exception {
- when(sportService.updateSport(eq("soccer"), any(), any(), anyBoolean()))
+ when(sportService.updateSport(eq(SPORT_ID), any(), any(), anyBoolean()))
.thenThrow(new ConflictException("Sport already exists: football"));
- mockMvc.perform(patch("/organization/sports/soccer")
+ mockMvc.perform(patch("/organization/sports/" + SPORT_ID)
.with(adminJwt())
.contentType(MediaType.APPLICATION_JSON)
.content("{\"name\":\"football\"}"))
@@ -288,7 +289,7 @@ void updateSport_returns409_whenRenameConflict() throws Exception {
@Test
void updateSport_returns401_whenUnauthenticated() throws Exception {
- mockMvc.perform(patch("/organization/sports/soccer")
+ mockMvc.perform(patch("/organization/sports/" + SPORT_ID)
.contentType(MediaType.APPLICATION_JSON)
.content("{}"))
.andExpect(status().isUnauthorized());
@@ -298,28 +299,28 @@ void updateSport_returns401_whenUnauthenticated() throws Exception {
@Test
void deleteSport_returns204_whenAdmin() throws Exception {
- mockMvc.perform(delete("/organization/sports/soccer").with(adminJwt()))
+ mockMvc.perform(delete("/organization/sports/" + SPORT_ID).with(adminJwt()))
.andExpect(status().isNoContent());
}
@Test
void deleteSport_returns403_whenMember() throws Exception {
- mockMvc.perform(delete("/organization/sports/soccer").with(memberJwt()))
+ mockMvc.perform(delete("/organization/sports/" + SPORT_ID).with(memberJwt()))
.andExpect(status().isForbidden());
}
@Test
void deleteSport_returns404_whenNotFound() throws Exception {
doThrow(new NotFoundException("Sport not found: unknown"))
- .when(sportService).deleteSport("unknown");
+ .when(sportService).deleteSport(SPORT_ID);
- mockMvc.perform(delete("/organization/sports/unknown").with(adminJwt()))
+ mockMvc.perform(delete("/organization/sports/" + SPORT_ID).with(adminJwt()))
.andExpect(status().isNotFound());
}
@Test
void deleteSport_returns401_whenUnauthenticated() throws Exception {
- mockMvc.perform(delete("/organization/sports/soccer"))
+ mockMvc.perform(delete("/organization/sports/" + SPORT_ID))
.andExpect(status().isUnauthorized());
}
@@ -327,7 +328,7 @@ void deleteSport_returns401_whenUnauthenticated() throws Exception {
@Test
void getAllTeams_returns200_withList_whenMemberAuthenticated() throws Exception {
- when(teamService.getAllTeams()).thenReturn(List.of(team(TEAM_ID, "soccer"), team(UUID.randomUUID(), "tennis")));
+ when(teamService.getAllTeams()).thenReturn(List.of(team(TEAM_ID, SPORT_ID), team(UUID.randomUUID(), UUID.randomUUID())));
mockMvc.perform(get("/organization/teams").with(memberJwt()))
.andExpect(status().isOk())
@@ -361,7 +362,7 @@ void getAllTeams_returns403_whenNoOrgRole() throws Exception {
@Test
void getTeam_returns200_withTeam_whenFound() throws Exception {
- when(teamService.getTeam(TEAM_ID)).thenReturn(team(TEAM_ID, "soccer"));
+ when(teamService.getTeam(TEAM_ID)).thenReturn(team(TEAM_ID, SPORT_ID));
mockMvc.perform(get("/organization/teams/" + TEAM_ID).with(memberJwt()))
.andExpect(status().isOk())
@@ -386,35 +387,35 @@ void getTeam_returns401_whenUnauthenticated() throws Exception {
@Test
void createTeam_returns201_withTeam_whenAdmin() throws Exception {
- when(teamService.createTeam(any(), any(), anyBoolean())).thenReturn(team(TEAM_ID, "soccer"));
+ when(teamService.createTeam(any(), any(), anyBoolean())).thenReturn(team(TEAM_ID, SPORT_ID));
mockMvc.perform(post("/organization/teams")
.with(adminJwt())
.contentType(MediaType.APPLICATION_JSON)
- .content("{\"name\":\"Team Alpha\",\"sport\":\"soccer\"}"))
+ .content("{\"name\":\"Team Alpha\",\"sport\":\"00000000-0000-0000-0000-000000000050\"}"))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.name").value("Team Alpha"));
}
@Test
void createTeam_returns201_withTeam_whenMember() throws Exception {
- when(teamService.createTeam(any(), any(), anyBoolean())).thenReturn(team(TEAM_ID, "soccer"));
+ when(teamService.createTeam(any(), any(), anyBoolean())).thenReturn(team(TEAM_ID, SPORT_ID));
mockMvc.perform(post("/organization/teams")
.with(memberJwt())
.contentType(MediaType.APPLICATION_JSON)
- .content("{\"name\":\"Team Alpha\",\"sport\":\"soccer\"}"))
+ .content("{\"name\":\"Team Alpha\",\"sport\":\"00000000-0000-0000-0000-000000000050\"}"))
.andExpect(status().isCreated());
}
@Test
void createTeam_passesRequesterIdAndIsAdminTrue_fromAdminJwt() throws Exception {
- when(teamService.createTeam(any(), any(), anyBoolean())).thenReturn(team(TEAM_ID, "soccer"));
+ when(teamService.createTeam(any(), any(), anyBoolean())).thenReturn(team(TEAM_ID, SPORT_ID));
mockMvc.perform(post("/organization/teams")
.with(adminJwt())
.contentType(MediaType.APPLICATION_JSON)
- .content("{\"name\":\"Team Alpha\",\"sport\":\"soccer\"}"))
+ .content("{\"name\":\"Team Alpha\",\"sport\":\"00000000-0000-0000-0000-000000000050\"}"))
.andExpect(status().isCreated());
verify(teamService).createTeam(any(), eq(ADMIN_ID), eq(true));
@@ -422,12 +423,12 @@ void createTeam_passesRequesterIdAndIsAdminTrue_fromAdminJwt() throws Exception
@Test
void createTeam_passesRequesterIdAndIsAdminFalse_fromMemberJwt() throws Exception {
- when(teamService.createTeam(any(), any(), anyBoolean())).thenReturn(team(TEAM_ID, "soccer"));
+ when(teamService.createTeam(any(), any(), anyBoolean())).thenReturn(team(TEAM_ID, SPORT_ID));
mockMvc.perform(post("/organization/teams")
.with(memberJwt())
.contentType(MediaType.APPLICATION_JSON)
- .content("{\"name\":\"Team Alpha\",\"sport\":\"soccer\"}"))
+ .content("{\"name\":\"Team Alpha\",\"sport\":\"00000000-0000-0000-0000-000000000050\"}"))
.andExpect(status().isCreated());
verify(teamService).createTeam(any(), eq(MEMBER_ID), eq(false));
@@ -438,7 +439,7 @@ void createTeam_returns400_whenNameMissing() throws Exception {
mockMvc.perform(post("/organization/teams")
.with(adminJwt())
.contentType(MediaType.APPLICATION_JSON)
- .content("{\"sport\":\"soccer\"}"))
+ .content("{\"sport\":\"00000000-0000-0000-0000-000000000050\"}"))
.andExpect(status().isBadRequest());
}
@@ -450,7 +451,7 @@ void createTeam_returns403_whenServiceThrowsForbidden() throws Exception {
mockMvc.perform(post("/organization/teams")
.with(memberJwt())
.contentType(MediaType.APPLICATION_JSON)
- .content("{\"name\":\"Team Alpha\",\"sport\":\"soccer\"}"))
+ .content("{\"name\":\"Team Alpha\",\"sport\":\"00000000-0000-0000-0000-000000000050\"}"))
.andExpect(status().isForbidden());
}
@@ -458,7 +459,7 @@ void createTeam_returns403_whenServiceThrowsForbidden() throws Exception {
void createTeam_returns401_whenUnauthenticated() throws Exception {
mockMvc.perform(post("/organization/teams")
.contentType(MediaType.APPLICATION_JSON)
- .content("{\"name\":\"Team Alpha\",\"sport\":\"soccer\"}"))
+ .content("{\"name\":\"Team Alpha\",\"sport\":\"00000000-0000-0000-0000-000000000050\"}"))
.andExpect(status().isUnauthorized());
}
@@ -467,7 +468,7 @@ void createTeam_returns401_whenUnauthenticated() throws Exception {
@Test
void updateTeam_returns200_whenAdmin() throws Exception {
when(teamService.updateTeam(eq(TEAM_ID), any(), eq(ADMIN_ID), eq(true)))
- .thenReturn(team(TEAM_ID, "soccer"));
+ .thenReturn(team(TEAM_ID, SPORT_ID));
mockMvc.perform(patch("/organization/teams/" + TEAM_ID)
.with(adminJwt())
@@ -480,7 +481,7 @@ void updateTeam_returns200_whenAdmin() throws Exception {
@Test
void updateTeam_returns200_whenMember() throws Exception {
when(teamService.updateTeam(eq(TEAM_ID), any(), eq(MEMBER_ID), eq(false)))
- .thenReturn(team(TEAM_ID, "soccer"));
+ .thenReturn(team(TEAM_ID, SPORT_ID));
mockMvc.perform(patch("/organization/teams/" + TEAM_ID)
.with(memberJwt())
@@ -491,7 +492,7 @@ void updateTeam_returns200_whenMember() throws Exception {
@Test
void updateTeam_passesRequesterIdAndIsAdminTrue_fromAdminJwt() throws Exception {
- when(teamService.updateTeam(any(), any(), any(), anyBoolean())).thenReturn(team(TEAM_ID, "soccer"));
+ when(teamService.updateTeam(any(), any(), any(), anyBoolean())).thenReturn(team(TEAM_ID, SPORT_ID));
mockMvc.perform(patch("/organization/teams/" + TEAM_ID)
.with(adminJwt())
@@ -504,7 +505,7 @@ void updateTeam_passesRequesterIdAndIsAdminTrue_fromAdminJwt() throws Exception
@Test
void updateTeam_passesRequesterIdAndIsAdminFalse_fromMemberJwt() throws Exception {
- when(teamService.updateTeam(any(), any(), any(), anyBoolean())).thenReturn(team(TEAM_ID, "soccer"));
+ when(teamService.updateTeam(any(), any(), any(), anyBoolean())).thenReturn(team(TEAM_ID, SPORT_ID));
mockMvc.perform(patch("/organization/teams/" + TEAM_ID)
.with(memberJwt())
diff --git a/services/spring-organization/src/test/java/tum/devoops/organizationservice/OrganizationSportServiceTest.java b/services/spring-organization/src/test/java/tum/devoops/organizationservice/OrganizationSportServiceTest.java
index a970a08..c24411f 100644
--- a/services/spring-organization/src/test/java/tum/devoops/organizationservice/OrganizationSportServiceTest.java
+++ b/services/spring-organization/src/test/java/tum/devoops/organizationservice/OrganizationSportServiceTest.java
@@ -1,7 +1,6 @@
package tum.devoops.organizationservice;
import java.time.LocalDate;
-import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
@@ -59,11 +58,13 @@ class OrganizationSportServiceTest {
@InjectMocks
private OrganizationSportService service;
+ private static final UUID SPORT_ID = UUID.fromString("00000000-0000-0000-0000-000000000050");
private static final UUID ADMIN_ID = UUID.fromString("00000000-0000-0000-0000-000000000001");
private static final UUID MEMBER_ID = UUID.fromString("00000000-0000-0000-0000-000000000002");
- private SportEntity sportEntity(String name, List directors) {
+ private SportEntity sportEntity(UUID id, String name, List directors) {
SportEntity entity = new SportEntity();
+ entity.setId(id);
entity.setName(name);
entity.setDescription("A test sport");
entity.setCreatedAt(LocalDate.of(2024, 1, 1));
@@ -71,14 +72,14 @@ private SportEntity sportEntity(String name, List directors) {
return entity;
}
- private DirectorEntity directorEntity(String sportName, UUID memberId) {
- return new DirectorEntity(new DirectorEntity.Id(sportName, memberId));
+ private DirectorEntity directorEntity(UUID sportId, UUID memberId) {
+ return new DirectorEntity(new DirectorEntity.Id(sportId, memberId));
}
- private TeamEntity teamEntity(UUID id, String sportName) {
+ private TeamEntity teamEntity(UUID id, UUID sportId) {
TeamEntity team = new TeamEntity();
team.setId(id);
- team.setSportName(sportName);
+ team.setSportId(sportId);
return team;
}
@@ -93,46 +94,45 @@ void getAllSports_returnsEmptyList_whenNoSports() {
@Test
void getAllSports_returnsMappedList_whenSportsExist() {
- UUID dirId = UUID.randomUUID();
- SportEntity entity = sportEntity("soccer", List.of(directorEntity("soccer", dirId)));
+ SportEntity entity = sportEntity(SPORT_ID, "soccer", List.of(directorEntity(SPORT_ID, MEMBER_ID)));
when(sportRepository.findAll()).thenReturn(List.of(entity));
List result = service.getAllSports();
assertThat(result).hasSize(1);
+ assertThat(result.get(0).getId()).isEqualTo(SPORT_ID);
assertThat(result.get(0).getName()).isEqualTo("soccer");
- assertThat(result.get(0).getDescription()).isEqualTo("A test sport");
- assertThat(result.get(0).getCreatedAt()).isEqualTo(LocalDate.of(2024, 1, 1));
- assertThat(result.get(0).getDirectors()).containsExactly(dirId.toString());
+ assertThat(result.get(0).getDirectors()).containsExactly(MEMBER_ID.toString());
}
// --- getSport ---
@Test
void getSport_returnsMappedSport_whenFound() {
- when(sportRepository.findById("soccer"))
- .thenReturn(Optional.of(sportEntity("soccer", List.of())));
+ when(sportRepository.findById(SPORT_ID))
+ .thenReturn(Optional.of(sportEntity(SPORT_ID, "soccer", List.of())));
- Sport result = service.getSport("soccer");
+ Sport result = service.getSport(SPORT_ID);
+ assertThat(result.getId()).isEqualTo(SPORT_ID);
assertThat(result.getName()).isEqualTo("soccer");
assertThat(result.getDirectors()).isEmpty();
}
@Test
void getSport_throwsNotFoundException_whenAbsent() {
- when(sportRepository.findById("unknown")).thenReturn(Optional.empty());
+ when(sportRepository.findById(SPORT_ID)).thenReturn(Optional.empty());
- assertThatThrownBy(() -> service.getSport("unknown"))
+ assertThatThrownBy(() -> service.getSport(SPORT_ID))
.isInstanceOf(NotFoundException.class)
- .hasMessageContaining("unknown");
+ .hasMessageContaining(SPORT_ID.toString());
}
// --- createSport ---
@Test
void createSport_throwsConflict_whenNameAlreadyExists() {
- when(sportRepository.existsById("soccer")).thenReturn(true);
+ when(sportRepository.existsByName("soccer")).thenReturn(true);
assertThatThrownBy(() -> service.createSport(new SportCreate("soccer")))
.isInstanceOf(ConflictException.class)
@@ -141,7 +141,7 @@ void createSport_throwsConflict_whenNameAlreadyExists() {
@Test
void createSport_throwsBadRequest_whenDirectorUuidMalformed() {
- when(sportRepository.existsById("soccer")).thenReturn(false);
+ when(sportRepository.existsByName("soccer")).thenReturn(false);
SportCreate body = new SportCreate("soccer");
body.setDirectors(List.of("not-a-uuid"));
@@ -153,7 +153,7 @@ void createSport_throwsBadRequest_whenDirectorUuidMalformed() {
@Test
void createSport_throwsBadRequest_whenDirectorMemberNotFound() {
- when(sportRepository.existsById("soccer")).thenReturn(false);
+ when(sportRepository.existsByName("soccer")).thenReturn(false);
when(memberRepository.existsById(MEMBER_ID)).thenReturn(false);
SportCreate body = new SportCreate("soccer");
@@ -166,11 +166,16 @@ void createSport_throwsBadRequest_whenDirectorMemberNotFound() {
@Test
void createSport_savesEntityAndDirectors_andReturnsResult() {
- when(sportRepository.existsById("soccer")).thenReturn(false);
+ when(sportRepository.existsByName("soccer")).thenReturn(false);
when(memberRepository.existsById(MEMBER_ID)).thenReturn(true);
- when(sportRepository.findById("soccer"))
+ when(sportRepository.save(any(SportEntity.class))).thenAnswer(inv -> {
+ SportEntity s = inv.getArgument(0);
+ s.setId(SPORT_ID);
+ return s;
+ });
+ when(sportRepository.findById(SPORT_ID))
.thenReturn(Optional.of(
- sportEntity("soccer", List.of(directorEntity("soccer", MEMBER_ID)))));
+ sportEntity(SPORT_ID, "soccer", List.of(directorEntity(SPORT_ID, MEMBER_ID)))));
SportCreate body = new SportCreate("soccer");
body.setDirectors(List.of(MEMBER_ID.toString()));
@@ -179,15 +184,21 @@ void createSport_savesEntityAndDirectors_andReturnsResult() {
verify(sportRepository).save(any(SportEntity.class));
verify(directorRepository).saveAll(any());
verify(memberRoleSyncService).scheduleSync(argThat(ids -> ids.contains(MEMBER_ID)));
+ assertThat(result.getId()).isEqualTo(SPORT_ID);
assertThat(result.getName()).isEqualTo("soccer");
assertThat(result.getDirectors()).containsExactly(MEMBER_ID.toString());
}
@Test
void createSport_savesEntityWithNoDirectors_whenEmptyList() {
- when(sportRepository.existsById("soccer")).thenReturn(false);
- when(sportRepository.findById("soccer"))
- .thenReturn(Optional.of(sportEntity("soccer", List.of())));
+ when(sportRepository.existsByName("soccer")).thenReturn(false);
+ when(sportRepository.save(any(SportEntity.class))).thenAnswer(inv -> {
+ SportEntity s = inv.getArgument(0);
+ s.setId(SPORT_ID);
+ return s;
+ });
+ when(sportRepository.findById(SPORT_ID))
+ .thenReturn(Optional.of(sportEntity(SPORT_ID, "soccer", List.of())));
Sport result = service.createSport(new SportCreate("soccer"));
@@ -199,191 +210,167 @@ void createSport_savesEntityWithNoDirectors_whenEmptyList() {
@Test
void updateSport_throwsNotFoundException_whenSportAbsent() {
- when(sportRepository.findById("unknown")).thenReturn(Optional.empty());
+ when(sportRepository.findById(SPORT_ID)).thenReturn(Optional.empty());
- assertThatThrownBy(() -> service.updateSport("unknown", new SportPartialUpdate(), MEMBER_ID, false))
+ assertThatThrownBy(() -> service.updateSport(SPORT_ID, new SportPartialUpdate(), MEMBER_ID, false))
.isInstanceOf(NotFoundException.class);
}
@Test
void updateSport_throwsForbidden_whenNotAdminAndNotDirector() {
- when(sportRepository.findById("soccer"))
- .thenReturn(Optional.of(sportEntity("soccer", List.of())));
- when(directorRepository.findAllById_SportName("soccer")).thenReturn(List.of());
+ when(sportRepository.findById(SPORT_ID))
+ .thenReturn(Optional.of(sportEntity(SPORT_ID, "soccer", List.of())));
+ when(directorRepository.findAllById_SportId(SPORT_ID)).thenReturn(List.of());
- assertThatThrownBy(() -> service.updateSport("soccer", new SportPartialUpdate(), MEMBER_ID, false))
+ assertThatThrownBy(() -> service.updateSport(SPORT_ID, new SportPartialUpdate(), MEMBER_ID, false))
.isInstanceOf(ForbiddenException.class);
}
@Test
void updateSport_allowsUpdate_whenMemberIsDirector() {
- DirectorEntity director = directorEntity("soccer", MEMBER_ID);
- when(sportRepository.findById("soccer"))
- .thenReturn(Optional.of(sportEntity("soccer", List.of(director))))
- .thenReturn(Optional.of(sportEntity("soccer", List.of(director))));
- when(directorRepository.findAllById_SportName("soccer"))
- .thenReturn(List.of(director));
+ DirectorEntity director = directorEntity(SPORT_ID, MEMBER_ID);
+ when(sportRepository.findById(SPORT_ID))
+ .thenReturn(Optional.of(sportEntity(SPORT_ID, "soccer", List.of(director))))
+ .thenReturn(Optional.of(sportEntity(SPORT_ID, "soccer", List.of(director))));
+ when(directorRepository.findAllById_SportId(SPORT_ID)).thenReturn(List.of(director));
- Sport result = service.updateSport("soccer", new SportPartialUpdate(), MEMBER_ID, false);
+ Sport result = service.updateSport(SPORT_ID, new SportPartialUpdate(), MEMBER_ID, false);
assertThat(result.getName()).isEqualTo("soccer");
}
@Test
- void updateSport_updatesDescriptionOnly_whenNoNameChange_asAdmin() {
- SportEntity entity = sportEntity("soccer", List.of());
- when(sportRepository.findById("soccer"))
+ void updateSport_renamesSport_asPlainFieldUpdate() {
+ SportEntity entity = sportEntity(SPORT_ID, "soccer", List.of());
+ when(sportRepository.findById(SPORT_ID))
.thenReturn(Optional.of(entity))
- .thenReturn(Optional.of(sportEntity("soccer", List.of())));
+ .thenReturn(Optional.of(sportEntity(SPORT_ID, "football", List.of())));
+ when(sportRepository.existsByName("football")).thenReturn(false);
SportPartialUpdate body = new SportPartialUpdate();
- body.setDescription("new description");
- service.updateSport("soccer", body, ADMIN_ID, true);
+ body.setName("football");
+ Sport result = service.updateSport(SPORT_ID, body, ADMIN_ID, true);
+ assertThat(entity.getName()).isEqualTo("football");
verify(sportRepository).save(entity);
- verify(directorRepository, never()).deleteAllById_SportName(any());
+ verify(directorRepository, never()).deleteAllById_SportId(any());
+ assertThat(result.getName()).isEqualTo("football");
}
@Test
- void updateSport_doesNotReplaceDirectors_whenAdminAndEmptyList() {
- SportEntity entity = sportEntity("soccer", List.of());
- when(sportRepository.findById("soccer"))
- .thenReturn(Optional.of(entity))
- .thenReturn(Optional.of(sportEntity("soccer", List.of())));
+ void updateSport_throwsConflict_whenRenamingToExistingName() {
+ when(sportRepository.findById(SPORT_ID))
+ .thenReturn(Optional.of(sportEntity(SPORT_ID, "soccer", List.of())));
+ when(sportRepository.existsByName("football")).thenReturn(true);
- service.updateSport("soccer", new SportPartialUpdate(), ADMIN_ID, true);
+ SportPartialUpdate body = new SportPartialUpdate();
+ body.setName("football");
- verify(directorRepository, never()).deleteAllById_SportName("soccer");
- verify(directorRepository, never()).saveAll(any());
+ assertThatThrownBy(() -> service.updateSport(SPORT_ID, body, ADMIN_ID, true))
+ .isInstanceOf(ConflictException.class)
+ .hasMessageContaining("football");
}
@Test
- void updateSport_replacesDirectors_whenAdminAndNonEmptyList() {
- SportEntity entity = sportEntity("soccer", List.of());
- when(sportRepository.findById("soccer"))
+ void updateSport_updatesDescriptionOnly_whenNoDirectorsChange_asAdmin() {
+ SportEntity entity = sportEntity(SPORT_ID, "soccer", List.of());
+ when(sportRepository.findById(SPORT_ID))
.thenReturn(Optional.of(entity))
- .thenReturn(Optional.of(sportEntity("soccer",
- List.of(directorEntity("soccer", MEMBER_ID)))));
- when(memberRepository.existsById(MEMBER_ID)).thenReturn(true);
+ .thenReturn(Optional.of(sportEntity(SPORT_ID, "soccer", List.of())));
SportPartialUpdate body = new SportPartialUpdate();
- body.setDirectors(List.of(MEMBER_ID.toString()));
- service.updateSport("soccer", body, ADMIN_ID, true);
+ body.setDescription("new description");
+ service.updateSport(SPORT_ID, body, ADMIN_ID, true);
- verify(directorRepository).deleteAllById_SportName("soccer");
- verify(directorRepository).saveAll(any());
+ assertThat(entity.getDescription()).isEqualTo("new description");
+ verify(sportRepository).save(entity);
+ verify(directorRepository, never()).deleteAllById_SportId(any());
}
@Test
- void updateSport_throwsConflict_whenRenamingToExistingName() {
- when(sportRepository.findById("soccer"))
- .thenReturn(Optional.of(sportEntity("soccer", List.of())));
- when(directorRepository.findAllById_SportName("soccer")).thenReturn(List.of());
- when(sportRepository.existsById("football")).thenReturn(true);
+ void updateSport_doesNotReplaceDirectors_whenAdminAndNullList() {
+ SportEntity entity = sportEntity(SPORT_ID, "soccer", List.of());
+ when(sportRepository.findById(SPORT_ID))
+ .thenReturn(Optional.of(entity))
+ .thenReturn(Optional.of(sportEntity(SPORT_ID, "soccer", List.of())));
- SportPartialUpdate body = new SportPartialUpdate();
- body.setName("football");
+ service.updateSport(SPORT_ID, new SportPartialUpdate(), ADMIN_ID, true);
- assertThatThrownBy(() -> service.updateSport("soccer", body, ADMIN_ID, true))
- .isInstanceOf(ConflictException.class)
- .hasMessageContaining("football");
+ verify(directorRepository, never()).deleteAllById_SportId(SPORT_ID);
+ verify(directorRepository, never()).saveAll(any());
}
@Test
- void updateSport_renamesSport_migratesTeamsAndDirectors() {
- UUID dirId = UUID.randomUUID();
- UUID teamId = UUID.randomUUID();
- DirectorEntity oldDirector = directorEntity("soccer", dirId);
- TeamEntity team = teamEntity(teamId, "soccer");
-
- when(sportRepository.findById("soccer"))
- .thenReturn(Optional.of(sportEntity("soccer", List.of(oldDirector))));
- when(directorRepository.findAllById_SportName("soccer")).thenReturn(List.of(oldDirector));
- when(teamRepository.findAllBySportName("soccer")).thenReturn(List.of(team));
- when(sportRepository.existsById("football")).thenReturn(false);
- when(sportRepository.findById("football"))
- .thenReturn(Optional.of(
- sportEntity("football", List.of(directorEntity("football", dirId)))));
+ void updateSport_clearsDirectors_whenAdminAndEmptyList() {
+ SportEntity entity = sportEntity(SPORT_ID, "soccer", List.of());
+ when(sportRepository.findById(SPORT_ID))
+ .thenReturn(Optional.of(entity))
+ .thenReturn(Optional.of(sportEntity(SPORT_ID, "soccer", List.of())));
SportPartialUpdate body = new SportPartialUpdate();
- body.setName("football");
- Sport result = service.updateSport("soccer", body, ADMIN_ID, true);
-
- verify(sportRepository).save(argThat(e -> "football".equals(e.getName())));
- verify(teamRepository).saveAll(any());
- verify(directorRepository).deleteAllById_SportName("soccer");
- verify(directorRepository).saveAll(argThat(it -> {
- List saved = new ArrayList<>();
- it.forEach(saved::add);
- return !saved.isEmpty() && "football".equals(saved.get(0).getId().getSportName());
- }));
- verify(sportRepository).delete(any(SportEntity.class));
- assertThat(result.getName()).isEqualTo("football");
+ body.setDirectors(List.of());
+ service.updateSport(SPORT_ID, body, ADMIN_ID, true);
+
+ verify(directorRepository).deleteAllById_SportId(SPORT_ID);
+ verify(directorRepository).saveAll(argThat(it -> !it.iterator().hasNext()));
}
@Test
- void updateSport_replacesDirectors_whenRenaming_andAdminProvidesNewList() {
- UUID newDirId = UUID.randomUUID();
- when(sportRepository.findById("soccer"))
- .thenReturn(Optional.of(sportEntity("soccer", List.of())));
- when(directorRepository.findAllById_SportName("soccer")).thenReturn(List.of());
- when(sportRepository.existsById("football")).thenReturn(false);
- when(memberRepository.existsById(newDirId)).thenReturn(true);
- when(sportRepository.findById("football"))
- .thenReturn(Optional.of(
- sportEntity("football", List.of(directorEntity("football", newDirId)))));
+ void updateSport_replacesDirectors_whenAdminAndNonEmptyList() {
+ SportEntity entity = sportEntity(SPORT_ID, "soccer", List.of());
+ when(sportRepository.findById(SPORT_ID))
+ .thenReturn(Optional.of(entity))
+ .thenReturn(Optional.of(sportEntity(SPORT_ID, "soccer",
+ List.of(directorEntity(SPORT_ID, MEMBER_ID)))));
+ when(memberRepository.existsById(MEMBER_ID)).thenReturn(true);
SportPartialUpdate body = new SportPartialUpdate();
- body.setName("football");
- body.setDirectors(List.of(newDirId.toString()));
- Sport result = service.updateSport("soccer", body, ADMIN_ID, true);
-
- verify(directorRepository).saveAll(argThat(it -> {
- List saved = new ArrayList<>();
- it.forEach(saved::add);
- return saved.stream().anyMatch(d -> newDirId.equals(d.getId().getMemberId()));
- }));
- assertThat(result.getDirectors()).containsExactly(newDirId.toString());
+ body.setDirectors(List.of(MEMBER_ID.toString()));
+ service.updateSport(SPORT_ID, body, ADMIN_ID, true);
+
+ verify(directorRepository).deleteAllById_SportId(SPORT_ID);
+ verify(directorRepository).saveAll(any());
+ verify(memberRoleSyncService).scheduleSync(argThat(ids -> ids.contains(MEMBER_ID)));
}
// --- deleteSport ---
@Test
void deleteSport_throwsNotFoundException_whenAbsent() {
- when(sportRepository.findById("unknown")).thenReturn(Optional.empty());
+ when(sportRepository.findById(SPORT_ID)).thenReturn(Optional.empty());
- assertThatThrownBy(() -> service.deleteSport("unknown"))
+ assertThatThrownBy(() -> service.deleteSport(SPORT_ID))
.isInstanceOf(NotFoundException.class);
}
@Test
void deleteSport_deletesTrainersAndTrainees_perTeam_thenTeamsDirectorsSport() {
UUID teamId = UUID.randomUUID();
- TeamEntity team = teamEntity(teamId, "soccer");
- SportEntity entity = sportEntity("soccer", List.of());
- when(sportRepository.findById("soccer")).thenReturn(Optional.of(entity));
- when(teamRepository.findAllBySportName("soccer")).thenReturn(List.of(team));
+ TeamEntity team = teamEntity(teamId, SPORT_ID);
+ SportEntity entity = sportEntity(SPORT_ID, "soccer", List.of());
+ when(sportRepository.findById(SPORT_ID)).thenReturn(Optional.of(entity));
+ when(teamRepository.findAllBySportId(SPORT_ID)).thenReturn(List.of(team));
- service.deleteSport("soccer");
+ service.deleteSport(SPORT_ID);
verify(traineeRepository).deleteAllById_TeamId(teamId);
verify(trainerRepository).deleteAllById_TeamId(teamId);
verify(teamRepository).deleteAll(List.of(team));
- verify(directorRepository).deleteAllById_SportName("soccer");
+ verify(directorRepository).deleteAllById_SportId(SPORT_ID);
verify(sportRepository).delete(entity);
}
@Test
void deleteSport_deletesDirectorsAndSport_whenNoTeams() {
- SportEntity entity = sportEntity("soccer", List.of());
- when(sportRepository.findById("soccer")).thenReturn(Optional.of(entity));
- when(teamRepository.findAllBySportName("soccer")).thenReturn(List.of());
+ SportEntity entity = sportEntity(SPORT_ID, "soccer", List.of());
+ when(sportRepository.findById(SPORT_ID)).thenReturn(Optional.of(entity));
+ when(teamRepository.findAllBySportId(SPORT_ID)).thenReturn(List.of());
- service.deleteSport("soccer");
+ service.deleteSport(SPORT_ID);
verify(traineeRepository, never()).deleteAllById_TeamId(any());
verify(trainerRepository, never()).deleteAllById_TeamId(any());
- verify(directorRepository).deleteAllById_SportName("soccer");
+ verify(directorRepository).deleteAllById_SportId(SPORT_ID);
verify(sportRepository).delete(entity);
}
}
diff --git a/services/spring-organization/src/test/java/tum/devoops/organizationservice/OrganizationTeamServiceTest.java b/services/spring-organization/src/test/java/tum/devoops/organizationservice/OrganizationTeamServiceTest.java
index 75bc3ec..d7535ce 100644
--- a/services/spring-organization/src/test/java/tum/devoops/organizationservice/OrganizationTeamServiceTest.java
+++ b/services/spring-organization/src/test/java/tum/devoops/organizationservice/OrganizationTeamServiceTest.java
@@ -63,8 +63,10 @@ class OrganizationTeamServiceTest {
private static final UUID DIRECTOR_ID = UUID.fromString("00000000-0000-0000-0000-000000000002");
private static final UUID TRAINER_ID = UUID.fromString("00000000-0000-0000-0000-000000000003");
private static final UUID TRAINEE_ID = UUID.fromString("00000000-0000-0000-0000-000000000004");
+ private static final UUID SPORT_ID = UUID.fromString("00000000-0000-0000-0000-000000000050");
+ private static final UUID OTHER_SPORT_ID = UUID.fromString("00000000-0000-0000-0000-000000000051");
- private TeamEntity teamEntity(UUID id, String sportName,
+ private TeamEntity teamEntity(UUID id, UUID sportId,
List trainers, List trainees) {
TeamEntity entity = new TeamEntity();
entity.setId(id);
@@ -72,7 +74,7 @@ private TeamEntity teamEntity(UUID id, String sportName,
entity.setDescription("A test team");
entity.setAddress("123 Main St");
entity.setCreatedAt(LocalDate.of(2024, 1, 1));
- entity.setSportName(sportName);
+ entity.setSportId(sportId);
entity.setTrainers(trainers);
entity.setTrainees(trainees);
return entity;
@@ -86,8 +88,8 @@ private TraineeEntity traineeEntity(UUID teamId, UUID memberId) {
return new TraineeEntity(new TraineeEntity.Id(teamId, memberId));
}
- private DirectorEntity directorEntity(String sportName, UUID memberId) {
- return new DirectorEntity(new DirectorEntity.Id(sportName, memberId));
+ private DirectorEntity directorEntity(UUID sportId, UUID memberId) {
+ return new DirectorEntity(new DirectorEntity.Id(sportId, memberId));
}
// --- getAllTeams ---
@@ -101,7 +103,7 @@ void getAllTeams_returnsEmptyList_whenNoTeams() {
@Test
void getAllTeams_returnsMappedList_whenTeamsExist() {
- TeamEntity entity = teamEntity(TEAM_ID, "soccer",
+ TeamEntity entity = teamEntity(TEAM_ID, SPORT_ID,
List.of(trainerEntity(TEAM_ID, TRAINER_ID)),
List.of(traineeEntity(TEAM_ID, TRAINEE_ID)));
when(teamRepository.findAll()).thenReturn(List.of(entity));
@@ -111,6 +113,7 @@ void getAllTeams_returnsMappedList_whenTeamsExist() {
assertThat(result).hasSize(1);
assertThat(result.get(0).getId()).isEqualTo(TEAM_ID);
assertThat(result.get(0).getName()).isEqualTo("Team Alpha");
+ assertThat(result.get(0).getSport()).isEqualTo(SPORT_ID);
assertThat(result.get(0).getTrainers()).containsExactly(TRAINER_ID.toString());
assertThat(result.get(0).getTrainees()).containsExactly(TRAINEE_ID.toString());
}
@@ -119,7 +122,7 @@ void getAllTeams_returnsMappedList_whenTeamsExist() {
@Test
void getTeam_returnsMappedTeam_whenFound() {
- TeamEntity entity = teamEntity(TEAM_ID, "soccer", List.of(), List.of());
+ TeamEntity entity = teamEntity(TEAM_ID, SPORT_ID, List.of(), List.of());
when(teamRepository.findById(TEAM_ID)).thenReturn(Optional.of(entity));
Team result = service.getTeam(TEAM_ID);
@@ -128,7 +131,7 @@ void getTeam_returnsMappedTeam_whenFound() {
assertThat(result.getName()).isEqualTo("Team Alpha");
assertThat(result.getDescription()).isEqualTo("A test team");
assertThat(result.getAddress()).isEqualTo("123 Main St");
- assertThat(result.getSport()).isEqualTo("soccer");
+ assertThat(result.getSport()).isEqualTo(SPORT_ID);
assertThat(result.getCreatedAt()).isEqualTo(LocalDate.of(2024, 1, 1));
}
@@ -145,37 +148,37 @@ void getTeam_throwsNotFoundException_whenAbsent() {
@Test
void createTeam_throwsBadRequest_whenSportNotFound() {
- when(sportRepository.existsById("soccer")).thenReturn(false);
+ when(sportRepository.existsById(SPORT_ID)).thenReturn(false);
- assertThatThrownBy(() -> service.createTeam(new TeamCreate("Team Alpha", "soccer"), ADMIN_ID, true))
+ assertThatThrownBy(() -> service.createTeam(new TeamCreate("Team Alpha", SPORT_ID), ADMIN_ID, true))
.isInstanceOf(BadRequestException.class)
- .hasMessageContaining("soccer");
+ .hasMessageContaining(SPORT_ID.toString());
}
@Test
void createTeam_throwsForbidden_whenNotAdminAndNotDirector() {
UUID callerId = UUID.randomUUID();
- when(sportRepository.existsById("soccer")).thenReturn(true);
- when(directorRepository.findAllById_SportName("soccer")).thenReturn(List.of());
+ when(sportRepository.existsById(SPORT_ID)).thenReturn(true);
+ when(directorRepository.findAllById_SportId(SPORT_ID)).thenReturn(List.of());
- assertThatThrownBy(() -> service.createTeam(new TeamCreate("Team Alpha", "soccer"), callerId, false))
+ assertThatThrownBy(() -> service.createTeam(new TeamCreate("Team Alpha", SPORT_ID), callerId, false))
.isInstanceOf(ForbiddenException.class);
}
@Test
void createTeam_allowsCreate_whenDirectorOfSport() {
- DirectorEntity director = directorEntity("soccer", DIRECTOR_ID);
- when(sportRepository.existsById("soccer")).thenReturn(true);
- when(directorRepository.findAllById_SportName("soccer")).thenReturn(List.of(director));
+ DirectorEntity director = directorEntity(SPORT_ID, DIRECTOR_ID);
+ when(sportRepository.existsById(SPORT_ID)).thenReturn(true);
+ when(directorRepository.findAllById_SportId(SPORT_ID)).thenReturn(List.of(director));
when(teamRepository.save(any(TeamEntity.class))).thenAnswer(inv -> {
TeamEntity t = inv.getArgument(0);
t.setId(TEAM_ID);
return t;
});
- TeamEntity saved = teamEntity(TEAM_ID, "soccer", List.of(), List.of());
+ TeamEntity saved = teamEntity(TEAM_ID, SPORT_ID, List.of(), List.of());
when(teamRepository.findById(TEAM_ID)).thenReturn(Optional.of(saved));
- Team result = service.createTeam(new TeamCreate("Team Alpha", "soccer"), DIRECTOR_ID, false);
+ Team result = service.createTeam(new TeamCreate("Team Alpha", SPORT_ID), DIRECTOR_ID, false);
verify(teamRepository).save(any(TeamEntity.class));
assertThat(result.getId()).isEqualTo(TEAM_ID);
@@ -183,11 +186,11 @@ void createTeam_allowsCreate_whenDirectorOfSport() {
@Test
void createTeam_throwsBadRequest_whenTrainerUuidMalformed() {
- when(sportRepository.existsById("soccer")).thenReturn(true);
- when(directorRepository.findAllById_SportName("soccer"))
- .thenReturn(List.of(directorEntity("soccer", DIRECTOR_ID)));
+ when(sportRepository.existsById(SPORT_ID)).thenReturn(true);
+ when(directorRepository.findAllById_SportId(SPORT_ID))
+ .thenReturn(List.of(directorEntity(SPORT_ID, DIRECTOR_ID)));
- TeamCreate body = new TeamCreate("Team Alpha", "soccer");
+ TeamCreate body = new TeamCreate("Team Alpha", SPORT_ID);
body.setTrainers(List.of("not-a-uuid"));
assertThatThrownBy(() -> service.createTeam(body, DIRECTOR_ID, false))
@@ -197,12 +200,12 @@ void createTeam_throwsBadRequest_whenTrainerUuidMalformed() {
@Test
void createTeam_throwsBadRequest_whenTraineeMemberNotFound() {
- when(sportRepository.existsById("soccer")).thenReturn(true);
- when(directorRepository.findAllById_SportName("soccer"))
- .thenReturn(List.of(directorEntity("soccer", DIRECTOR_ID)));
+ when(sportRepository.existsById(SPORT_ID)).thenReturn(true);
+ when(directorRepository.findAllById_SportId(SPORT_ID))
+ .thenReturn(List.of(directorEntity(SPORT_ID, DIRECTOR_ID)));
when(memberRepository.existsById(TRAINEE_ID)).thenReturn(false);
- TeamCreate body = new TeamCreate("Team Alpha", "soccer");
+ TeamCreate body = new TeamCreate("Team Alpha", SPORT_ID);
body.setTrainees(List.of(TRAINEE_ID.toString()));
assertThatThrownBy(() -> service.createTeam(body, DIRECTOR_ID, false))
@@ -212,9 +215,9 @@ void createTeam_throwsBadRequest_whenTraineeMemberNotFound() {
@Test
void createTeam_savesEntityAndTrainersAndTrainees_andReturnsResult() {
- when(sportRepository.existsById("soccer")).thenReturn(true);
- when(directorRepository.findAllById_SportName("soccer"))
- .thenReturn(List.of(directorEntity("soccer", DIRECTOR_ID)));
+ when(sportRepository.existsById(SPORT_ID)).thenReturn(true);
+ when(directorRepository.findAllById_SportId(SPORT_ID))
+ .thenReturn(List.of(directorEntity(SPORT_ID, DIRECTOR_ID)));
when(memberRepository.existsById(TRAINER_ID)).thenReturn(true);
when(memberRepository.existsById(TRAINEE_ID)).thenReturn(true);
when(teamRepository.save(any(TeamEntity.class))).thenAnswer(inv -> {
@@ -222,12 +225,12 @@ void createTeam_savesEntityAndTrainersAndTrainees_andReturnsResult() {
t.setId(TEAM_ID);
return t;
});
- TeamEntity saved = teamEntity(TEAM_ID, "soccer",
+ TeamEntity saved = teamEntity(TEAM_ID, SPORT_ID,
List.of(trainerEntity(TEAM_ID, TRAINER_ID)),
List.of(traineeEntity(TEAM_ID, TRAINEE_ID)));
when(teamRepository.findById(TEAM_ID)).thenReturn(Optional.of(saved));
- TeamCreate body = new TeamCreate("Team Alpha", "soccer");
+ TeamCreate body = new TeamCreate("Team Alpha", SPORT_ID);
body.setTrainers(List.of(TRAINER_ID.toString()));
body.setTrainees(List.of(TRAINEE_ID.toString()));
Team result = service.createTeam(body, DIRECTOR_ID, false);
@@ -243,16 +246,16 @@ void createTeam_savesEntityAndTrainersAndTrainees_andReturnsResult() {
@Test
void createTeam_savesEntityWithNoMembers_whenEmptyLists() {
- when(sportRepository.existsById("soccer")).thenReturn(true);
+ when(sportRepository.existsById(SPORT_ID)).thenReturn(true);
when(teamRepository.save(any(TeamEntity.class))).thenAnswer(inv -> {
TeamEntity t = inv.getArgument(0);
t.setId(TEAM_ID);
return t;
});
- TeamEntity saved = teamEntity(TEAM_ID, "soccer", List.of(), List.of());
+ TeamEntity saved = teamEntity(TEAM_ID, SPORT_ID, List.of(), List.of());
when(teamRepository.findById(TEAM_ID)).thenReturn(Optional.of(saved));
- Team result = service.createTeam(new TeamCreate("Team Alpha", "soccer"), ADMIN_ID, true);
+ Team result = service.createTeam(new TeamCreate("Team Alpha", SPORT_ID), ADMIN_ID, true);
verify(teamRepository).save(any(TeamEntity.class));
assertThat(result.getTrainers()).isEmpty();
@@ -272,9 +275,9 @@ void updateTeam_throwsNotFoundException_whenTeamAbsent() {
@Test
void updateTeam_throwsForbidden_whenNotAdminNotDirectorNotTrainer() {
UUID outsiderId = UUID.randomUUID();
- TeamEntity team = teamEntity(TEAM_ID, "soccer", List.of(), List.of());
+ TeamEntity team = teamEntity(TEAM_ID, SPORT_ID, List.of(), List.of());
when(teamRepository.findById(TEAM_ID)).thenReturn(Optional.of(team));
- when(directorRepository.findAllById_SportName("soccer")).thenReturn(List.of());
+ when(directorRepository.findAllById_SportId(SPORT_ID)).thenReturn(List.of());
when(trainerRepository.findAllById_TeamId(TEAM_ID)).thenReturn(List.of());
assertThatThrownBy(() -> service.updateTeam(TEAM_ID, new TeamPartialUpdate(), outsiderId, false))
@@ -283,11 +286,11 @@ void updateTeam_throwsForbidden_whenNotAdminNotDirectorNotTrainer() {
@Test
void updateTeam_allowsUpdate_whenMemberIsTrainer() {
- TeamEntity team = teamEntity(TEAM_ID, "soccer", List.of(), List.of());
+ TeamEntity team = teamEntity(TEAM_ID, SPORT_ID, List.of(), List.of());
when(teamRepository.findById(TEAM_ID))
.thenReturn(Optional.of(team))
- .thenReturn(Optional.of(teamEntity(TEAM_ID, "soccer", List.of(), List.of())));
- when(directorRepository.findAllById_SportName("soccer")).thenReturn(List.of());
+ .thenReturn(Optional.of(teamEntity(TEAM_ID, SPORT_ID, List.of(), List.of())));
+ when(directorRepository.findAllById_SportId(SPORT_ID)).thenReturn(List.of());
when(trainerRepository.findAllById_TeamId(TEAM_ID))
.thenReturn(List.of(trainerEntity(TEAM_ID, TRAINER_ID)));
@@ -298,12 +301,12 @@ void updateTeam_allowsUpdate_whenMemberIsTrainer() {
@Test
void updateTeam_allowsUpdate_whenMemberIsDirector() {
- DirectorEntity director = directorEntity("soccer", DIRECTOR_ID);
- TeamEntity team = teamEntity(TEAM_ID, "soccer", List.of(), List.of());
+ DirectorEntity director = directorEntity(SPORT_ID, DIRECTOR_ID);
+ TeamEntity team = teamEntity(TEAM_ID, SPORT_ID, List.of(), List.of());
when(teamRepository.findById(TEAM_ID))
.thenReturn(Optional.of(team))
- .thenReturn(Optional.of(teamEntity(TEAM_ID, "soccer", List.of(), List.of())));
- when(directorRepository.findAllById_SportName("soccer")).thenReturn(List.of(director));
+ .thenReturn(Optional.of(teamEntity(TEAM_ID, SPORT_ID, List.of(), List.of())));
+ when(directorRepository.findAllById_SportId(SPORT_ID)).thenReturn(List.of(director));
Team result = service.updateTeam(TEAM_ID, new TeamPartialUpdate(), DIRECTOR_ID, false);
@@ -312,14 +315,14 @@ void updateTeam_allowsUpdate_whenMemberIsDirector() {
@Test
void updateTeam_throwsForbidden_whenTrainerTriesToUpdateSport() {
- TeamEntity team = teamEntity(TEAM_ID, "soccer", List.of(), List.of());
+ TeamEntity team = teamEntity(TEAM_ID, SPORT_ID, List.of(), List.of());
when(teamRepository.findById(TEAM_ID)).thenReturn(Optional.of(team));
- when(directorRepository.findAllById_SportName("soccer")).thenReturn(List.of());
+ when(directorRepository.findAllById_SportId(SPORT_ID)).thenReturn(List.of());
when(trainerRepository.findAllById_TeamId(TEAM_ID))
.thenReturn(List.of(trainerEntity(TEAM_ID, TRAINER_ID)));
TeamPartialUpdate body = new TeamPartialUpdate();
- body.setSport("football");
+ body.setSport(OTHER_SPORT_ID);
assertThatThrownBy(() -> service.updateTeam(TEAM_ID, body, TRAINER_ID, false))
.isInstanceOf(ForbiddenException.class);
@@ -327,9 +330,9 @@ void updateTeam_throwsForbidden_whenTrainerTriesToUpdateSport() {
@Test
void updateTeam_throwsForbidden_whenTrainerTriesToUpdateTrainers() {
- TeamEntity team = teamEntity(TEAM_ID, "soccer", List.of(), List.of());
+ TeamEntity team = teamEntity(TEAM_ID, SPORT_ID, List.of(), List.of());
when(teamRepository.findById(TEAM_ID)).thenReturn(Optional.of(team));
- when(directorRepository.findAllById_SportName("soccer")).thenReturn(List.of());
+ when(directorRepository.findAllById_SportId(SPORT_ID)).thenReturn(List.of());
when(trainerRepository.findAllById_TeamId(TEAM_ID))
.thenReturn(List.of(trainerEntity(TEAM_ID, TRAINER_ID)));
@@ -342,25 +345,25 @@ void updateTeam_throwsForbidden_whenTrainerTriesToUpdateTrainers() {
@Test
void updateTeam_throwsBadRequest_whenNewSportNotFound() {
- TeamEntity team = teamEntity(TEAM_ID, "soccer", List.of(), List.of());
+ TeamEntity team = teamEntity(TEAM_ID, SPORT_ID, List.of(), List.of());
when(teamRepository.findById(TEAM_ID)).thenReturn(Optional.of(team));
- when(sportRepository.existsById("unknown")).thenReturn(false);
+ when(sportRepository.existsById(OTHER_SPORT_ID)).thenReturn(false);
TeamPartialUpdate body = new TeamPartialUpdate();
- body.setSport("unknown");
+ body.setSport(OTHER_SPORT_ID);
assertThatThrownBy(() -> service.updateTeam(TEAM_ID, body, ADMIN_ID, true))
.isInstanceOf(BadRequestException.class)
- .hasMessageContaining("unknown");
+ .hasMessageContaining(OTHER_SPORT_ID.toString());
}
@Test
void updateTeam_updatesScalarFields_whenNonNull() {
- TeamEntity team = teamEntity(TEAM_ID, "soccer", List.of(), List.of());
+ TeamEntity team = teamEntity(TEAM_ID, SPORT_ID, List.of(), List.of());
when(teamRepository.findById(TEAM_ID))
.thenReturn(Optional.of(team))
- .thenReturn(Optional.of(teamEntity(TEAM_ID, "soccer", List.of(), List.of())));
- when(directorRepository.findAllById_SportName("soccer")).thenReturn(List.of());
+ .thenReturn(Optional.of(teamEntity(TEAM_ID, SPORT_ID, List.of(), List.of())));
+ when(directorRepository.findAllById_SportId(SPORT_ID)).thenReturn(List.of());
when(trainerRepository.findAllById_TeamId(TEAM_ID))
.thenReturn(List.of(trainerEntity(TEAM_ID, TRAINER_ID)));
@@ -378,11 +381,11 @@ void updateTeam_updatesScalarFields_whenNonNull() {
@Test
void updateTeam_doesNotUpdateScalarField_whenNull() {
- TeamEntity team = teamEntity(TEAM_ID, "soccer", List.of(), List.of());
+ TeamEntity team = teamEntity(TEAM_ID, SPORT_ID, List.of(), List.of());
when(teamRepository.findById(TEAM_ID))
.thenReturn(Optional.of(team))
- .thenReturn(Optional.of(teamEntity(TEAM_ID, "soccer", List.of(), List.of())));
- when(directorRepository.findAllById_SportName("soccer")).thenReturn(List.of());
+ .thenReturn(Optional.of(teamEntity(TEAM_ID, SPORT_ID, List.of(), List.of())));
+ when(directorRepository.findAllById_SportId(SPORT_ID)).thenReturn(List.of());
when(trainerRepository.findAllById_TeamId(TEAM_ID))
.thenReturn(List.of(trainerEntity(TEAM_ID, TRAINER_ID)));
@@ -395,29 +398,29 @@ void updateTeam_doesNotUpdateScalarField_whenNull() {
@Test
void updateTeam_updatesSport_whenAdminSetsNewSport() {
- TeamEntity team = teamEntity(TEAM_ID, "soccer", List.of(), List.of());
+ TeamEntity team = teamEntity(TEAM_ID, SPORT_ID, List.of(), List.of());
when(teamRepository.findById(TEAM_ID))
.thenReturn(Optional.of(team))
- .thenReturn(Optional.of(teamEntity(TEAM_ID, "football", List.of(), List.of())));
- when(directorRepository.findAllById_SportName("soccer")).thenReturn(List.of());
- when(sportRepository.existsById("football")).thenReturn(true);
+ .thenReturn(Optional.of(teamEntity(TEAM_ID, OTHER_SPORT_ID, List.of(), List.of())));
+ when(directorRepository.findAllById_SportId(SPORT_ID)).thenReturn(List.of());
+ when(sportRepository.existsById(OTHER_SPORT_ID)).thenReturn(true);
TeamPartialUpdate body = new TeamPartialUpdate();
- body.setSport("football");
+ body.setSport(OTHER_SPORT_ID);
Team result = service.updateTeam(TEAM_ID, body, ADMIN_ID, true);
- assertThat(team.getSportName()).isEqualTo("football");
+ assertThat(team.getSportId()).isEqualTo(OTHER_SPORT_ID);
verify(teamRepository).save(team);
- assertThat(result.getSport()).isEqualTo("football");
+ assertThat(result.getSport()).isEqualTo(OTHER_SPORT_ID);
}
@Test
- void updateTeam_doesNotReplaceTrainers_whenEmptyList() {
- TeamEntity team = teamEntity(TEAM_ID, "soccer", List.of(), List.of());
+ void updateTeam_doesNotReplaceTrainers_whenNullList() {
+ TeamEntity team = teamEntity(TEAM_ID, SPORT_ID, List.of(), List.of());
when(teamRepository.findById(TEAM_ID))
.thenReturn(Optional.of(team))
- .thenReturn(Optional.of(teamEntity(TEAM_ID, "soccer", List.of(), List.of())));
- when(directorRepository.findAllById_SportName("soccer")).thenReturn(List.of());
+ .thenReturn(Optional.of(teamEntity(TEAM_ID, SPORT_ID, List.of(), List.of())));
+ when(directorRepository.findAllById_SportId(SPORT_ID)).thenReturn(List.of());
when(trainerRepository.findAllById_TeamId(TEAM_ID))
.thenReturn(List.of(trainerEntity(TEAM_ID, TRAINER_ID)));
@@ -427,15 +430,32 @@ void updateTeam_doesNotReplaceTrainers_whenEmptyList() {
verify(trainerRepository, never()).saveAll(any());
}
+ @Test
+ void updateTeam_clearsTrainers_whenEmptyList() {
+ DirectorEntity director = directorEntity(SPORT_ID, DIRECTOR_ID);
+ TeamEntity team = teamEntity(TEAM_ID, SPORT_ID, List.of(), List.of());
+ when(teamRepository.findById(TEAM_ID))
+ .thenReturn(Optional.of(team))
+ .thenReturn(Optional.of(teamEntity(TEAM_ID, SPORT_ID, List.of(), List.of())));
+ when(directorRepository.findAllById_SportId(SPORT_ID)).thenReturn(List.of(director));
+
+ TeamPartialUpdate body = new TeamPartialUpdate();
+ body.setTrainers(List.of());
+ service.updateTeam(TEAM_ID, body, DIRECTOR_ID, false);
+
+ verify(trainerRepository).deleteAllById_TeamId(TEAM_ID);
+ verify(trainerRepository).saveAll(argThat(it -> !it.iterator().hasNext()));
+ }
+
@Test
void updateTeam_replacesTrainers_whenNonEmptyList() {
- DirectorEntity director = directorEntity("soccer", DIRECTOR_ID);
- TeamEntity team = teamEntity(TEAM_ID, "soccer", List.of(), List.of());
+ DirectorEntity director = directorEntity(SPORT_ID, DIRECTOR_ID);
+ TeamEntity team = teamEntity(TEAM_ID, SPORT_ID, List.of(), List.of());
when(teamRepository.findById(TEAM_ID))
.thenReturn(Optional.of(team))
- .thenReturn(Optional.of(teamEntity(TEAM_ID, "soccer",
+ .thenReturn(Optional.of(teamEntity(TEAM_ID, SPORT_ID,
List.of(trainerEntity(TEAM_ID, TRAINER_ID)), List.of())));
- when(directorRepository.findAllById_SportName("soccer")).thenReturn(List.of(director));
+ when(directorRepository.findAllById_SportId(SPORT_ID)).thenReturn(List.of(director));
when(memberRepository.existsById(TRAINER_ID)).thenReturn(true);
TeamPartialUpdate body = new TeamPartialUpdate();
@@ -448,12 +468,12 @@ void updateTeam_replacesTrainers_whenNonEmptyList() {
@Test
void updateTeam_replacesTrainees_whenNonEmptyList() {
- TeamEntity team = teamEntity(TEAM_ID, "soccer", List.of(), List.of());
+ TeamEntity team = teamEntity(TEAM_ID, SPORT_ID, List.of(), List.of());
when(teamRepository.findById(TEAM_ID))
.thenReturn(Optional.of(team))
- .thenReturn(Optional.of(teamEntity(TEAM_ID, "soccer",
+ .thenReturn(Optional.of(teamEntity(TEAM_ID, SPORT_ID,
List.of(), List.of(traineeEntity(TEAM_ID, TRAINEE_ID)))));
- when(directorRepository.findAllById_SportName("soccer")).thenReturn(List.of());
+ when(directorRepository.findAllById_SportId(SPORT_ID)).thenReturn(List.of());
when(trainerRepository.findAllById_TeamId(TEAM_ID))
.thenReturn(List.of(trainerEntity(TEAM_ID, TRAINER_ID)));
when(memberRepository.existsById(TRAINEE_ID)).thenReturn(true);
@@ -468,9 +488,9 @@ void updateTeam_replacesTrainees_whenNonEmptyList() {
@Test
void updateTeam_throwsBadRequest_whenTraineeUuidMalformed() {
- TeamEntity team = teamEntity(TEAM_ID, "soccer", List.of(), List.of());
+ TeamEntity team = teamEntity(TEAM_ID, SPORT_ID, List.of(), List.of());
when(teamRepository.findById(TEAM_ID)).thenReturn(Optional.of(team));
- when(directorRepository.findAllById_SportName("soccer")).thenReturn(List.of());
+ when(directorRepository.findAllById_SportId(SPORT_ID)).thenReturn(List.of());
when(trainerRepository.findAllById_TeamId(TEAM_ID))
.thenReturn(List.of(trainerEntity(TEAM_ID, TRAINER_ID)));
@@ -494,9 +514,9 @@ void deleteTeam_throwsNotFoundException_whenAbsent() {
@Test
void deleteTeam_throwsForbidden_whenTrainerTriesToDelete() {
- TeamEntity team = teamEntity(TEAM_ID, "soccer", List.of(), List.of());
+ TeamEntity team = teamEntity(TEAM_ID, SPORT_ID, List.of(), List.of());
when(teamRepository.findById(TEAM_ID)).thenReturn(Optional.of(team));
- when(directorRepository.findAllById_SportName("soccer")).thenReturn(List.of());
+ when(directorRepository.findAllById_SportId(SPORT_ID)).thenReturn(List.of());
assertThatThrownBy(() -> service.deleteTeam(TEAM_ID, TRAINER_ID, false))
.isInstanceOf(ForbiddenException.class);
@@ -504,10 +524,10 @@ void deleteTeam_throwsForbidden_whenTrainerTriesToDelete() {
@Test
void deleteTeam_allowsDelete_whenDirectorOfSport() {
- DirectorEntity director = directorEntity("soccer", DIRECTOR_ID);
- TeamEntity team = teamEntity(TEAM_ID, "soccer", List.of(), List.of());
+ DirectorEntity director = directorEntity(SPORT_ID, DIRECTOR_ID);
+ TeamEntity team = teamEntity(TEAM_ID, SPORT_ID, List.of(), List.of());
when(teamRepository.findById(TEAM_ID)).thenReturn(Optional.of(team));
- when(directorRepository.findAllById_SportName("soccer")).thenReturn(List.of(director));
+ when(directorRepository.findAllById_SportId(SPORT_ID)).thenReturn(List.of(director));
service.deleteTeam(TEAM_ID, DIRECTOR_ID, false);
@@ -516,10 +536,10 @@ void deleteTeam_allowsDelete_whenDirectorOfSport() {
@Test
void deleteTeam_deletesTraineesTrainersAndTeam() {
- TeamEntity team = teamEntity(TEAM_ID, "soccer", List.of(), List.of());
+ TeamEntity team = teamEntity(TEAM_ID, SPORT_ID, List.of(), List.of());
when(teamRepository.findById(TEAM_ID)).thenReturn(Optional.of(team));
- when(directorRepository.findAllById_SportName("soccer"))
- .thenReturn(List.of(directorEntity("soccer", DIRECTOR_ID)));
+ when(directorRepository.findAllById_SportId(SPORT_ID))
+ .thenReturn(List.of(directorEntity(SPORT_ID, DIRECTOR_ID)));
service.deleteTeam(TEAM_ID, DIRECTOR_ID, false);
diff --git a/web-client/src/api.ts b/web-client/src/api.ts
index 1e23e85..1cd185d 100644
--- a/web-client/src/api.ts
+++ b/web-client/src/api.ts
@@ -30,7 +30,7 @@ export interface paths {
patch?: never;
trace?: never;
};
- "/organization/sports/{sport_name}": {
+ "/organization/sports/{sport_id}": {
parameters: {
query?: never;
header?: never;
@@ -509,6 +509,8 @@ export interface components {
};
/** @description The object representation of a Sport within the organization. */
Sport: {
+ /** Format: uuid */
+ id: string;
name: string;
description: string;
/** Format: date */
@@ -536,6 +538,10 @@ export interface components {
/** Format: date */
created_at: string;
address: string;
+ /**
+ * Format: uuid
+ * @description ID of the sport this team belongs to.
+ */
sport: string;
trainers: string[];
trainees: string[];
@@ -545,6 +551,10 @@ export interface components {
name: string;
description?: string;
address?: string;
+ /**
+ * Format: uuid
+ * @description ID of the sport this team belongs to.
+ */
sport: string;
trainers?: string[];
trainees?: string[];
@@ -554,6 +564,10 @@ export interface components {
name?: string;
description?: string;
address?: string;
+ /**
+ * Format: uuid
+ * @description ID of the sport this team belongs to.
+ */
sport?: string;
trainers?: string[];
trainees?: string[];
@@ -616,7 +630,7 @@ export interface components {
/** Format: date-time */
end_time: string;
attendees?: string[];
- /** @description Names of the sports associated with this event. */
+ /** @description IDs of the sports associated with this event. */
sports_linked?: string[];
/** @description IDs of the teams associated with this event. */
teams_linked?: string[];
@@ -641,6 +655,7 @@ export interface components {
/** Format: date-time */
end_time?: string;
attendees?: string[];
+ /** @description IDs of the sports associated with this event. */
sports_linked?: string[];
teams_linked?: string[];
};
@@ -653,6 +668,7 @@ export interface components {
/** Format: date-time */
end_time: string;
attendees?: string[];
+ /** @description IDs of the sports associated with this event. */
sports_linked?: string[];
teams_linked?: string[];
};
@@ -785,7 +801,7 @@ export interface components {
};
};
parameters: {
- sport_name: string;
+ sport_id: string;
team_id: string;
member_id: string;
event_id: string;
@@ -856,7 +872,7 @@ export interface operations {
query?: never;
header?: never;
path: {
- sport_name: components["parameters"]["sport_name"];
+ sport_id: components["parameters"]["sport_id"];
};
cookie?: never;
};
@@ -882,7 +898,7 @@ export interface operations {
query?: never;
header?: never;
path: {
- sport_name: components["parameters"]["sport_name"];
+ sport_id: components["parameters"]["sport_id"];
};
cookie?: never;
};
@@ -906,7 +922,7 @@ export interface operations {
query?: never;
header?: never;
path: {
- sport_name: components["parameters"]["sport_name"];
+ sport_id: components["parameters"]["sport_id"];
};
cookie?: never;
};
diff --git a/web-client/src/features/organization/api/queries.ts b/web-client/src/features/organization/api/queries.ts
index cbb4378..f26ee9c 100644
--- a/web-client/src/features/organization/api/queries.ts
+++ b/web-client/src/features/organization/api/queries.ts
@@ -13,7 +13,7 @@ import type {
export const organizationKeys = {
hello: ['organization', 'hello'] as const,
sports: ['organization', 'sports'] as const,
- sport: (name: string) => ['organization', 'sports', name] as const,
+ sport: (id: string) => ['organization', 'sports', id] as const,
teams: ['organization', 'teams'] as const,
team: (id: string) => ['organization', 'teams', id] as const,
}
@@ -32,11 +32,11 @@ export function useSports() {
})
}
-export function useSport(name: string) {
+export function useSport(id: string) {
return useQuery({
- queryKey: organizationKeys.sport(name),
- queryFn: () => organizationClient.get(`/sports/${name}`).then(r => r.data),
- enabled: !!name,
+ queryKey: organizationKeys.sport(id),
+ queryFn: () => organizationClient.get(`/sports/${id}`).then(r => r.data),
+ enabled: !!id,
})
}
@@ -52,11 +52,11 @@ export function useCreateSport() {
export function useUpdateSport() {
const qc = useQueryClient()
- return useMutation({
- mutationFn: ({ name, ...data }) => organizationClient.patch(`/sports/${name}`, data).then(r => r.data),
- onSuccess: (_, { name }) => {
+ return useMutation