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({ + mutationFn: ({ id, ...data }) => organizationClient.patch(`/sports/${id}`, data).then(r => r.data), + onSuccess: (_, { id }) => { qc.invalidateQueries({ queryKey: organizationKeys.sports }) - qc.invalidateQueries({ queryKey: organizationKeys.sport(name) }) + qc.invalidateQueries({ queryKey: organizationKeys.sport(id) }) }, }) } @@ -65,10 +65,10 @@ export function useDeleteSport() { const qc = useQueryClient() return useMutation({ - mutationFn: name => organizationClient.delete(`/sports/${name}`).then(() => undefined), - onSuccess: (_, name) => { + mutationFn: id => organizationClient.delete(`/sports/${id}`).then(() => undefined), + onSuccess: (_, id) => { qc.invalidateQueries({ queryKey: organizationKeys.sports }) - qc.removeQueries({ queryKey: organizationKeys.sport(name) }) + qc.removeQueries({ queryKey: organizationKeys.sport(id) }) }, }) } From c461ab0ca3e9194839f58cc36075931688ac3895 Mon Sep 17 00:00:00 2001 From: Raphael Frank <04.raphael.frank@gmail.com> Date: Sat, 27 Jun 2026 17:54:24 +0200 Subject: [PATCH 03/16] add reference object in api --- api/openapi.yaml | 53 ++++---- api/scripts/gen-all.sh | 8 +- services/py-genai-helper/generated/models.py | 43 ++++--- .../generated/java/.openapi-generator/FILES | 1 + .../devoops/eventservice/api/EventsApi.java | 6 +- .../tum/devoops/eventservice/model/Event.java | 55 ++++---- .../devoops/eventservice/model/Reference.java | 121 ++++++++++++++++++ .../converter/EventConverter.java | 20 ++- .../eventservice/entity/MemberEntity.java | 31 +++++ .../eventservice/entity/SportEntity.java | 28 ++++ .../eventservice/entity/TeamEntity.java | 28 ++++ .../repository/MemberRepository.java | 10 ++ .../repository/SportRepository.java | 10 ++ .../repository/TeamRepository.java | 10 ++ .../eventservice/service/EventService.java | 40 +++++- .../controller/EventControllerTest.java | 3 +- .../service/EventServiceTest.java | 11 +- .../generated/java/.openapi-generator/FILES | 1 + .../feedbackservice/api/FeedbackApi.java | 8 +- .../feedbackservice/model/Feedback.java | 33 ++--- .../model/FeedbackSummary.java | 33 ++--- .../feedbackservice/model/Reference.java | 121 ++++++++++++++++++ .../feedbackservice/entity/EventEntity.java | 3 + .../feedbackservice/entity/MemberEntity.java | 6 + .../service/FeedbackService.java | 25 +++- .../controller/FeedbackControllerTest.java | 11 +- .../service/FeedbackServiceTest.java | 12 +- .../generated/java/.openapi-generator/FILES | 1 + .../financeservice/api/FinanceApi.java | 12 +- .../devoops/financeservice/model/Balance.java | 13 +- .../financeservice/model/Reference.java | 121 ++++++++++++++++++ .../financeservice/model/Transaction.java | 23 ++-- .../generated/java/.openapi-generator/FILES | 1 + .../api/OrganizationApi.java | 16 +-- .../organizationservice/model/Reference.java | 121 ++++++++++++++++++ .../organizationservice/model/Sport.java | 15 ++- .../organizationservice/model/Team.java | 39 +++--- .../entity/MemberEntity.java | 10 ++ .../service/OrganizationSportService.java | 22 +++- .../service/OrganizationTeamService.java | 36 +++++- .../OrganizationControllerTest.java | 4 +- .../OrganizationSportServiceTest.java | 18 ++- .../OrganizationTeamServiceTest.java | 41 +++++- web-client/src/api.ts | 48 +++---- 44 files changed, 1031 insertions(+), 241 deletions(-) create mode 100644 services/spring-event/src/generated/java/tum/devoops/eventservice/model/Reference.java create mode 100644 services/spring-event/src/main/java/tum/devoops/eventservice/entity/MemberEntity.java create mode 100644 services/spring-event/src/main/java/tum/devoops/eventservice/entity/SportEntity.java create mode 100644 services/spring-event/src/main/java/tum/devoops/eventservice/entity/TeamEntity.java create mode 100644 services/spring-event/src/main/java/tum/devoops/eventservice/repository/MemberRepository.java create mode 100644 services/spring-event/src/main/java/tum/devoops/eventservice/repository/SportRepository.java create mode 100644 services/spring-event/src/main/java/tum/devoops/eventservice/repository/TeamRepository.java create mode 100644 services/spring-feedback/src/generated/java/tum/devoops/feedbackservice/model/Reference.java create mode 100644 services/spring-finance/src/generated/java/tum/devoops/financeservice/model/Reference.java create mode 100644 services/spring-organization/src/generated/java/tum/devoops/organizationservice/model/Reference.java diff --git a/api/openapi.yaml b/api/openapi.yaml index 5f5da7d..f845b6d 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -1225,6 +1225,18 @@ components: $ref: "#/components/schemas/ErrorResponse" required: - message + Reference: + type: object + properties: + id: + type: string + format: uuid + name: + type: string + required: + - id + - name + description: A lightweight reference to another entity — its id plus a display name. Sport: type: object properties: @@ -1241,7 +1253,7 @@ components: directors: type: array items: - type: string + $ref: "#/components/schemas/Reference" required: - id - name @@ -1292,17 +1304,15 @@ components: address: type: string sport: - type: string - format: uuid - description: ID of the sport this team belongs to. + $ref: "#/components/schemas/Reference" trainers: type: array items: - type: string + $ref: "#/components/schemas/Reference" trainees: type: array items: - type: string + $ref: "#/components/schemas/Reference" required: - id - name @@ -1480,20 +1490,19 @@ components: attendees: type: array items: - type: string + $ref: "#/components/schemas/Reference" sports_linked: type: array items: - type: string - format: uuid - description: IDs of the sports associated with this event. + $ref: "#/components/schemas/Reference" + description: Sports associated with this event. teams_linked: type: array items: - type: string - description: IDs of the teams associated with this event. + $ref: "#/components/schemas/Reference" + description: Teams associated with this event. creator: - type: string + $ref: "#/components/schemas/Reference" required: - id - name @@ -1590,11 +1599,11 @@ components: type: string format: uuid event: - type: string + $ref: "#/components/schemas/Reference" member: - type: string + $ref: "#/components/schemas/Reference" creator: - type: string + $ref: "#/components/schemas/Reference" created_at: type: string format: date-time @@ -1615,11 +1624,11 @@ components: type: string format: uuid event: - type: string + $ref: "#/components/schemas/Reference" member: - type: string + $ref: "#/components/schemas/Reference" creator: - type: string + $ref: "#/components/schemas/Reference" created_at: type: string format: date-time @@ -1662,9 +1671,9 @@ components: type: string format: uuid member: - type: string + $ref: "#/components/schemas/Reference" creator: - type: string + $ref: "#/components/schemas/Reference" amount_cents: type: integer created_at: @@ -1716,7 +1725,7 @@ components: type: object properties: member: - type: string + $ref: "#/components/schemas/Reference" balance_cents: type: integer required: diff --git a/api/scripts/gen-all.sh b/api/scripts/gen-all.sh index bde5a12..b45195f 100755 --- a/api/scripts/gen-all.sh +++ b/api/scripts/gen-all.sh @@ -13,11 +13,11 @@ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" echo "Running OpenAPI code generation..." # Spring services — each receives only its own tag's API interface + relevant models -"$SCRIPT_DIR/gen-spring.sh" spring-organization organization organizationservice "Sport:SportCreate:SportPartialUpdate:Team:TeamCreate:TeamPartialUpdate:ErrorResponse:BadRequestResponse" +"$SCRIPT_DIR/gen-spring.sh" spring-organization organization organizationservice "Reference:Sport:SportCreate:SportPartialUpdate:Team:TeamCreate:TeamPartialUpdate:ErrorResponse:BadRequestResponse" "$SCRIPT_DIR/gen-spring.sh" spring-member members memberservice "Member:MemberSummary:MemberCreate:MemberPartialUpdate:ErrorResponse:BadRequestResponse" -"$SCRIPT_DIR/gen-spring.sh" spring-event events eventservice "Event:EventSummary:EventCreate:EventPartialUpdate:ErrorResponse:BadRequestResponse" -"$SCRIPT_DIR/gen-spring.sh" spring-feedback feedback feedbackservice "Feedback:FeedbackSummary:FeedbackCreate:FeedbackPartialUpdate:ErrorResponse:BadRequestResponse" -"$SCRIPT_DIR/gen-spring.sh" spring-finance finance financeservice "Balance:Transaction:TransactionCreate:TransactionPartialUpdate:ErrorResponse:BadRequestResponse" +"$SCRIPT_DIR/gen-spring.sh" spring-event events eventservice "Reference:Event:EventSummary:EventCreate:EventPartialUpdate:ErrorResponse:BadRequestResponse" +"$SCRIPT_DIR/gen-spring.sh" spring-feedback feedback feedbackservice "Reference:Feedback:FeedbackSummary:FeedbackCreate:FeedbackPartialUpdate:ErrorResponse:BadRequestResponse" +"$SCRIPT_DIR/gen-spring.sh" spring-finance finance financeservice "Reference:Balance:Transaction:TransactionCreate:TransactionPartialUpdate:ErrorResponse:BadRequestResponse" "$SCRIPT_DIR/gen-spring.sh" spring-letter letters letterservice "ErrorResponse:BadRequestResponse" # Pydantic models for py-genai-helper diff --git a/services/py-genai-helper/generated/models.py b/services/py-genai-helper/generated/models.py index 87ba785..d7eb54f 100644 --- a/services/py-genai-helper/generated/models.py +++ b/services/py-genai-helper/generated/models.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: openapi.yaml -# timestamp: 2026-06-27T15:07:04+00:00 +# timestamp: 2026-06-27T15:54:38+00:00 from __future__ import annotations from pydantic import AwareDatetime, BaseModel, Field, SecretStr @@ -18,12 +18,17 @@ class BadRequestResponse(BaseModel): errors: list[ErrorResponse] | None = None +class Reference(BaseModel): + id: UUID + name: str + + class Sport(BaseModel): id: UUID name: str description: str created_at: date - directors: list[str] + directors: list[Reference] class SportCreate(BaseModel): @@ -44,9 +49,9 @@ class Team(BaseModel): description: str created_at: date address: str - sport: Annotated[UUID, Field(description='ID of the sport this team belongs to.')] - trainers: list[str] - trainees: list[str] + sport: Reference + trainers: list[Reference] + trainees: list[Reference] class TeamCreate(BaseModel): @@ -115,16 +120,14 @@ class Event(BaseModel): description: str start_time: AwareDatetime end_time: AwareDatetime - attendees: list[str] | None = None + attendees: list[Reference] | None = None sports_linked: Annotated[ - list[UUID] | None, - Field(description='IDs of the sports associated with this event.'), + list[Reference] | None, Field(description='Sports associated with this event.') ] = None teams_linked: Annotated[ - list[str] | None, - Field(description='IDs of the teams associated with this event.'), + list[Reference] | None, Field(description='Teams associated with this event.') ] = None - creator: str + creator: Reference class EventSummary(BaseModel): @@ -162,18 +165,18 @@ class EventCreate(BaseModel): class Feedback(BaseModel): id: UUID - event: str - member: str - creator: str + event: Reference + member: Reference + creator: Reference created_at: AwareDatetime feedback: str class FeedbackSummary(BaseModel): id: UUID - event: str - member: str - creator: str + event: Reference + member: Reference + creator: Reference created_at: AwareDatetime @@ -191,8 +194,8 @@ class FeedbackCreate(BaseModel): class Transaction(BaseModel): id: UUID - member: str - creator: str + member: Reference + creator: Reference amount_cents: int created_at: AwareDatetime title: str @@ -214,5 +217,5 @@ class TransactionCreate(BaseModel): class Balance(BaseModel): - member: str + member: Reference balance_cents: int diff --git a/services/spring-event/src/generated/java/.openapi-generator/FILES b/services/spring-event/src/generated/java/.openapi-generator/FILES index 799d838..931a01c 100644 --- a/services/spring-event/src/generated/java/.openapi-generator/FILES +++ b/services/spring-event/src/generated/java/.openapi-generator/FILES @@ -6,3 +6,4 @@ tum/devoops/eventservice/model/Event.java tum/devoops/eventservice/model/EventCreate.java tum/devoops/eventservice/model/EventPartialUpdate.java tum/devoops/eventservice/model/EventSummary.java +tum/devoops/eventservice/model/Reference.java 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 578b8ae..544163b 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\" : [ \"046b6c7f-0b8a-43b9-b35d-6489e6daee91\", \"046b6c7f-0b8a-43b9-b35d-6489e6daee91\" ], \"teams_linked\" : [ \"teams_linked\", \"teams_linked\" ] }"; + String exampleString = "{ \"start_time\" : \"2000-01-23T04:56:07.000+00:00\", \"creator\" : { \"name\" : \"name\", \"id\" : \"046b6c7f-0b8a-43b9-b35d-6489e6daee91\" }, \"attendees\" : [ { \"name\" : \"name\", \"id\" : \"046b6c7f-0b8a-43b9-b35d-6489e6daee91\" }, { \"name\" : \"name\", \"id\" : \"046b6c7f-0b8a-43b9-b35d-6489e6daee91\" } ], \"name\" : \"name\", \"end_time\" : \"2000-01-23T04:56:07.000+00:00\", \"description\" : \"description\", \"id\" : \"046b6c7f-0b8a-43b9-b35d-6489e6daee91\", \"sports_linked\" : [ { \"name\" : \"name\", \"id\" : \"046b6c7f-0b8a-43b9-b35d-6489e6daee91\" }, { \"name\" : \"name\", \"id\" : \"046b6c7f-0b8a-43b9-b35d-6489e6daee91\" } ], \"teams_linked\" : [ { \"name\" : \"name\", \"id\" : \"046b6c7f-0b8a-43b9-b35d-6489e6daee91\" }, { \"name\" : \"name\", \"id\" : \"046b6c7f-0b8a-43b9-b35d-6489e6daee91\" } ] }"; 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\" : [ \"046b6c7f-0b8a-43b9-b35d-6489e6daee91\", \"046b6c7f-0b8a-43b9-b35d-6489e6daee91\" ], \"teams_linked\" : [ \"teams_linked\", \"teams_linked\" ] }"; + String exampleString = "{ \"start_time\" : \"2000-01-23T04:56:07.000+00:00\", \"creator\" : { \"name\" : \"name\", \"id\" : \"046b6c7f-0b8a-43b9-b35d-6489e6daee91\" }, \"attendees\" : [ { \"name\" : \"name\", \"id\" : \"046b6c7f-0b8a-43b9-b35d-6489e6daee91\" }, { \"name\" : \"name\", \"id\" : \"046b6c7f-0b8a-43b9-b35d-6489e6daee91\" } ], \"name\" : \"name\", \"end_time\" : \"2000-01-23T04:56:07.000+00:00\", \"description\" : \"description\", \"id\" : \"046b6c7f-0b8a-43b9-b35d-6489e6daee91\", \"sports_linked\" : [ { \"name\" : \"name\", \"id\" : \"046b6c7f-0b8a-43b9-b35d-6489e6daee91\" }, { \"name\" : \"name\", \"id\" : \"046b6c7f-0b8a-43b9-b35d-6489e6daee91\" } ], \"teams_linked\" : [ { \"name\" : \"name\", \"id\" : \"046b6c7f-0b8a-43b9-b35d-6489e6daee91\" }, { \"name\" : \"name\", \"id\" : \"046b6c7f-0b8a-43b9-b35d-6489e6daee91\" } ] }"; 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\" : [ \"046b6c7f-0b8a-43b9-b35d-6489e6daee91\", \"046b6c7f-0b8a-43b9-b35d-6489e6daee91\" ], \"teams_linked\" : [ \"teams_linked\", \"teams_linked\" ] }"; + String exampleString = "{ \"start_time\" : \"2000-01-23T04:56:07.000+00:00\", \"creator\" : { \"name\" : \"name\", \"id\" : \"046b6c7f-0b8a-43b9-b35d-6489e6daee91\" }, \"attendees\" : [ { \"name\" : \"name\", \"id\" : \"046b6c7f-0b8a-43b9-b35d-6489e6daee91\" }, { \"name\" : \"name\", \"id\" : \"046b6c7f-0b8a-43b9-b35d-6489e6daee91\" } ], \"name\" : \"name\", \"end_time\" : \"2000-01-23T04:56:07.000+00:00\", \"description\" : \"description\", \"id\" : \"046b6c7f-0b8a-43b9-b35d-6489e6daee91\", \"sports_linked\" : [ { \"name\" : \"name\", \"id\" : \"046b6c7f-0b8a-43b9-b35d-6489e6daee91\" }, { \"name\" : \"name\", \"id\" : \"046b6c7f-0b8a-43b9-b35d-6489e6daee91\" } ], \"teams_linked\" : [ { \"name\" : \"name\", \"id\" : \"046b6c7f-0b8a-43b9-b35d-6489e6daee91\" }, { \"name\" : \"name\", \"id\" : \"046b6c7f-0b8a-43b9-b35d-6489e6daee91\" } ] }"; ApiUtil.setExampleResponse(request, "application/json", exampleString); break; } 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 2bddca3..23b2181 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 @@ -11,6 +11,7 @@ import java.util.UUID; import org.springframework.format.annotation.DateTimeFormat; import org.springframework.lang.Nullable; +import tum.devoops.eventservice.model.Reference; import java.time.OffsetDateTime; import jakarta.validation.Valid; import jakarta.validation.constraints.*; @@ -41,15 +42,15 @@ public class Event { private OffsetDateTime endTime; @Valid - private @Nullable List attendees; + private @Nullable List<@Valid Reference> attendees; @Valid - private @Nullable List sportsLinked; + private @Nullable List<@Valid Reference> sportsLinked; @Valid - private @Nullable List teamsLinked; + private @Nullable List<@Valid Reference> teamsLinked; - private String creator; + private Reference creator; public Event() { super(); @@ -58,7 +59,7 @@ public Event() { /** * Constructor with only required parameters */ - public Event(UUID id, String name, String description, OffsetDateTime startTime, OffsetDateTime endTime, String creator) { + public Event(UUID id, String name, String description, OffsetDateTime startTime, OffsetDateTime endTime, Reference creator) { this.id = id; this.name = name; this.description = description; @@ -167,12 +168,12 @@ public void setEndTime(OffsetDateTime endTime) { this.endTime = endTime; } - public Event attendees(@Nullable List attendees) { + public Event attendees(@Nullable List<@Valid Reference> attendees) { this.attendees = attendees; return this; } - public Event addAttendeesItem(String attendeesItem) { + public Event addAttendeesItem(Reference attendeesItem) { if (this.attendees == null) { this.attendees = new ArrayList<>(); } @@ -184,23 +185,23 @@ public Event addAttendeesItem(String attendeesItem) { * Get attendees * @return attendees */ - + @Valid @Schema(name = "attendees", requiredMode = Schema.RequiredMode.NOT_REQUIRED) @JsonProperty("attendees") - public @Nullable List getAttendees() { + public @Nullable List<@Valid Reference> getAttendees() { return attendees; } - public void setAttendees(@Nullable List attendees) { + public void setAttendees(@Nullable List<@Valid Reference> attendees) { this.attendees = attendees; } - public Event sportsLinked(@Nullable List sportsLinked) { + public Event sportsLinked(@Nullable List<@Valid Reference> sportsLinked) { this.sportsLinked = sportsLinked; return this; } - public Event addSportsLinkedItem(UUID sportsLinkedItem) { + public Event addSportsLinkedItem(Reference sportsLinkedItem) { if (this.sportsLinked == null) { this.sportsLinked = new ArrayList<>(); } @@ -209,26 +210,26 @@ public Event addSportsLinkedItem(UUID sportsLinkedItem) { } /** - * IDs of the sports associated with this event. + * Sports associated with this event. * @return sportsLinked */ @Valid - @Schema(name = "sports_linked", description = "IDs of the sports associated with this event.", requiredMode = Schema.RequiredMode.NOT_REQUIRED) + @Schema(name = "sports_linked", description = "Sports associated with this event.", requiredMode = Schema.RequiredMode.NOT_REQUIRED) @JsonProperty("sports_linked") - public @Nullable List getSportsLinked() { + public @Nullable List<@Valid Reference> getSportsLinked() { return sportsLinked; } - public void setSportsLinked(@Nullable List sportsLinked) { + public void setSportsLinked(@Nullable List<@Valid Reference> sportsLinked) { this.sportsLinked = sportsLinked; } - public Event teamsLinked(@Nullable List teamsLinked) { + public Event teamsLinked(@Nullable List<@Valid Reference> teamsLinked) { this.teamsLinked = teamsLinked; return this; } - public Event addTeamsLinkedItem(String teamsLinkedItem) { + public Event addTeamsLinkedItem(Reference teamsLinkedItem) { if (this.teamsLinked == null) { this.teamsLinked = new ArrayList<>(); } @@ -237,21 +238,21 @@ public Event addTeamsLinkedItem(String teamsLinkedItem) { } /** - * IDs of the teams associated with this event. + * Teams associated with this event. * @return teamsLinked */ - - @Schema(name = "teams_linked", description = "IDs of the teams associated with this event.", requiredMode = Schema.RequiredMode.NOT_REQUIRED) + @Valid + @Schema(name = "teams_linked", description = "Teams associated with this event.", requiredMode = Schema.RequiredMode.NOT_REQUIRED) @JsonProperty("teams_linked") - public @Nullable List getTeamsLinked() { + public @Nullable List<@Valid Reference> getTeamsLinked() { return teamsLinked; } - public void setTeamsLinked(@Nullable List teamsLinked) { + public void setTeamsLinked(@Nullable List<@Valid Reference> teamsLinked) { this.teamsLinked = teamsLinked; } - public Event creator(String creator) { + public Event creator(Reference creator) { this.creator = creator; return this; } @@ -260,14 +261,14 @@ public Event creator(String creator) { * Get creator * @return creator */ - @NotNull + @NotNull @Valid @Schema(name = "creator", requiredMode = Schema.RequiredMode.REQUIRED) @JsonProperty("creator") - public String getCreator() { + public Reference getCreator() { return creator; } - public void setCreator(String creator) { + public void setCreator(Reference creator) { this.creator = creator; } diff --git a/services/spring-event/src/generated/java/tum/devoops/eventservice/model/Reference.java b/services/spring-event/src/generated/java/tum/devoops/eventservice/model/Reference.java new file mode 100644 index 0000000..74e80f3 --- /dev/null +++ b/services/spring-event/src/generated/java/tum/devoops/eventservice/model/Reference.java @@ -0,0 +1,121 @@ +package tum.devoops.eventservice.model; + +import java.net.URI; +import java.util.Objects; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.JsonCreator; +import java.util.UUID; +import org.springframework.lang.Nullable; +import java.time.OffsetDateTime; +import jakarta.validation.Valid; +import jakarta.validation.constraints.*; +import io.swagger.v3.oas.annotations.media.Schema; + + +import java.util.*; +import jakarta.annotation.Generated; + +/** + * A lightweight reference to another entity — its id plus a display name. + */ + +@Schema(name = "Reference", description = "A lightweight reference to another entity — its id plus a display name.") +@Generated(value = "org.openapitools.codegen.languages.SpringCodegen", comments = "Generator version: 7.14.0") +public class Reference { + + private UUID id; + + private String name; + + public Reference() { + super(); + } + + /** + * Constructor with only required parameters + */ + public Reference(UUID id, String name) { + this.id = id; + this.name = name; + } + + public Reference 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 Reference name(String name) { + this.name = name; + return this; + } + + /** + * Get name + * @return name + */ + @NotNull + @Schema(name = "name", requiredMode = Schema.RequiredMode.REQUIRED) + @JsonProperty("name") + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + Reference reference = (Reference) o; + return Objects.equals(this.id, reference.id) && + Objects.equals(this.name, reference.name); + } + + @Override + public int hashCode() { + return Objects.hash(id, name); + } + + @Override + public String toString() { + StringBuilder sb = new StringBuilder(); + sb.append("class Reference {\n"); + sb.append(" id: ").append(toIndentedString(id)).append("\n"); + sb.append(" name: ").append(toIndentedString(name)).append("\n"); + sb.append("}"); + return sb.toString(); + } + + /** + * Convert the given object to string with each line indented by 4 spaces + * (except the first line). + */ + private String toIndentedString(Object o) { + if (o == null) { + return "null"; + } + return o.toString().replace("\n", "\n "); + } +} + 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 e05b3b9..8a769ca 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 @@ -2,6 +2,8 @@ import java.time.ZoneOffset; import java.util.List; +import java.util.Map; +import java.util.UUID; import java.util.stream.Collectors; import tum.devoops.eventservice.entity.AttendanceEntity; @@ -10,6 +12,7 @@ import tum.devoops.eventservice.entity.TeamEventEntity; import tum.devoops.eventservice.model.Event; import tum.devoops.eventservice.model.EventSummary; +import tum.devoops.eventservice.model.Reference; /** * Maps {@link EventEntity} (and its link entities) to the API models. @@ -25,27 +28,34 @@ private EventConverter() { public static Event toEvent(EventEntity entity, List attendances, List sports, - List teams) { + List teams, + Map memberNames, + Map sportNames, + Map teamNames) { Event event = new Event( entity.getId(), entity.getName(), entity.getDescription(), entity.getStartTime().atOffset(ZoneOffset.UTC), entity.getEndTime().atOffset(ZoneOffset.UTC), - entity.getCreatorId().toString() + reference(entity.getCreatorId(), memberNames) ); event.setAttendees(attendances.stream() - .map(a -> a.getId().getMemberId().toString()) + .map(a -> reference(a.getId().getMemberId(), memberNames)) .collect(Collectors.toList())); event.setSportsLinked(sports.stream() - .map(s -> s.getId().getSportId()) + .map(s -> reference(s.getId().getSportId(), sportNames)) .collect(Collectors.toList())); event.setTeamsLinked(teams.stream() - .map(t -> t.getId().getTeamId().toString()) + .map(t -> reference(t.getId().getTeamId(), teamNames)) .collect(Collectors.toList())); return event; } + private static Reference reference(UUID id, Map names) { + return new Reference(id, names.get(id)); + } + public static EventSummary toSummary(EventEntity entity) { return new EventSummary( entity.getId(), diff --git a/services/spring-event/src/main/java/tum/devoops/eventservice/entity/MemberEntity.java b/services/spring-event/src/main/java/tum/devoops/eventservice/entity/MemberEntity.java new file mode 100644 index 0000000..01d47d6 --- /dev/null +++ b/services/spring-event/src/main/java/tum/devoops/eventservice/entity/MemberEntity.java @@ -0,0 +1,31 @@ +package tum.devoops.eventservice.entity; + +import java.util.UUID; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +/** + * Read-only shadow of {@code member.members} (owned by the member service), used to resolve member + * display names for reference objects in responses. This service never writes to it. + */ +@Entity +@Table(schema = "member", name = "members") +@Getter @Setter @NoArgsConstructor +public class MemberEntity { + + @Id + @Column(name = "id") + private UUID id; + + @Column(name = "first_name", insertable = false, updatable = false) + private String firstName; + + @Column(name = "last_name", insertable = false, updatable = false) + private String lastName; +} diff --git a/services/spring-event/src/main/java/tum/devoops/eventservice/entity/SportEntity.java b/services/spring-event/src/main/java/tum/devoops/eventservice/entity/SportEntity.java new file mode 100644 index 0000000..6abfa46 --- /dev/null +++ b/services/spring-event/src/main/java/tum/devoops/eventservice/entity/SportEntity.java @@ -0,0 +1,28 @@ +package tum.devoops.eventservice.entity; + +import java.util.UUID; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +/** + * Read-only shadow of {@code organization.sports} (owned by the organization service), used to + * resolve sport display names for reference objects in responses. + */ +@Entity +@Table(schema = "organization", name = "sports") +@Getter @Setter @NoArgsConstructor +public class SportEntity { + + @Id + @Column(name = "id") + private UUID id; + + @Column(name = "name", insertable = false, updatable = false) + private String name; +} diff --git a/services/spring-event/src/main/java/tum/devoops/eventservice/entity/TeamEntity.java b/services/spring-event/src/main/java/tum/devoops/eventservice/entity/TeamEntity.java new file mode 100644 index 0000000..8af6bac --- /dev/null +++ b/services/spring-event/src/main/java/tum/devoops/eventservice/entity/TeamEntity.java @@ -0,0 +1,28 @@ +package tum.devoops.eventservice.entity; + +import java.util.UUID; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +/** + * Read-only shadow of {@code organization.teams} (owned by the organization service), used to + * resolve team display names for reference objects in responses. + */ +@Entity +@Table(schema = "organization", name = "teams") +@Getter @Setter @NoArgsConstructor +public class TeamEntity { + + @Id + @Column(name = "id") + private UUID id; + + @Column(name = "name", insertable = false, updatable = false) + private String name; +} diff --git a/services/spring-event/src/main/java/tum/devoops/eventservice/repository/MemberRepository.java b/services/spring-event/src/main/java/tum/devoops/eventservice/repository/MemberRepository.java new file mode 100644 index 0000000..ad7257c --- /dev/null +++ b/services/spring-event/src/main/java/tum/devoops/eventservice/repository/MemberRepository.java @@ -0,0 +1,10 @@ +package tum.devoops.eventservice.repository; + +import java.util.UUID; + +import org.springframework.data.jpa.repository.JpaRepository; + +import tum.devoops.eventservice.entity.MemberEntity; + +public interface MemberRepository extends JpaRepository { +} diff --git a/services/spring-event/src/main/java/tum/devoops/eventservice/repository/SportRepository.java b/services/spring-event/src/main/java/tum/devoops/eventservice/repository/SportRepository.java new file mode 100644 index 0000000..1d9b79b --- /dev/null +++ b/services/spring-event/src/main/java/tum/devoops/eventservice/repository/SportRepository.java @@ -0,0 +1,10 @@ +package tum.devoops.eventservice.repository; + +import java.util.UUID; + +import org.springframework.data.jpa.repository.JpaRepository; + +import tum.devoops.eventservice.entity.SportEntity; + +public interface SportRepository extends JpaRepository { +} diff --git a/services/spring-event/src/main/java/tum/devoops/eventservice/repository/TeamRepository.java b/services/spring-event/src/main/java/tum/devoops/eventservice/repository/TeamRepository.java new file mode 100644 index 0000000..cdcac2a --- /dev/null +++ b/services/spring-event/src/main/java/tum/devoops/eventservice/repository/TeamRepository.java @@ -0,0 +1,10 @@ +package tum.devoops.eventservice.repository; + +import java.util.UUID; + +import org.springframework.data.jpa.repository.JpaRepository; + +import tum.devoops.eventservice.entity.TeamEntity; + +public interface TeamRepository extends JpaRepository { +} 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 9abd8b2..931d334 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 @@ -1,7 +1,10 @@ package tum.devoops.eventservice.service; import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; import java.util.List; +import java.util.Map; import java.util.Set; import java.util.UUID; import java.util.stream.Collectors; @@ -24,8 +27,11 @@ import tum.devoops.eventservice.model.EventSummary; import tum.devoops.eventservice.repository.AttendanceRepository; import tum.devoops.eventservice.repository.EventRepository; +import tum.devoops.eventservice.repository.MemberRepository; import tum.devoops.eventservice.repository.SportEventRepository; +import tum.devoops.eventservice.repository.SportRepository; import tum.devoops.eventservice.repository.TeamEventRepository; +import tum.devoops.eventservice.repository.TeamRepository; @Service public class EventService { @@ -38,6 +44,12 @@ public class EventService { private SportEventRepository sportEventRepository; @Autowired private TeamEventRepository teamEventRepository; + @Autowired + private MemberRepository memberRepository; + @Autowired + private SportRepository sportRepository; + @Autowired + private TeamRepository teamRepository; @Transactional(readOnly = true) public List getAllEvents(UUID requesterId, boolean isAdmin) { @@ -213,11 +225,27 @@ private UUID parseUuid(String value, String fieldName) { private Event toEvent(EventEntity entity) { UUID eventId = entity.getId(); - return EventConverter.toEvent( - entity, - attendanceRepository.findAllById_EventId(eventId), - sportEventRepository.findAllById_EventId(eventId), - teamEventRepository.findAllById_EventId(eventId) - ); + List attendances = attendanceRepository.findAllById_EventId(eventId); + List sports = sportEventRepository.findAllById_EventId(eventId); + List teams = teamEventRepository.findAllById_EventId(eventId); + + Set memberIds = new HashSet<>(); + memberIds.add(entity.getCreatorId()); + attendances.forEach(a -> memberIds.add(a.getId().getMemberId())); + List sportIds = sports.stream() + .map(s -> s.getId().getSportId()).collect(Collectors.toList()); + List teamIds = teams.stream() + .map(t -> t.getId().getTeamId()).collect(Collectors.toList()); + + Map memberNames = new HashMap<>(); + memberRepository.findAllById(memberIds) + .forEach(m -> memberNames.put(m.getId(), m.getFirstName() + " " + m.getLastName())); + Map sportNames = new HashMap<>(); + sportRepository.findAllById(sportIds).forEach(s -> sportNames.put(s.getId(), s.getName())); + Map teamNames = new HashMap<>(); + teamRepository.findAllById(teamIds).forEach(t -> teamNames.put(t.getId(), t.getName())); + + return EventConverter.toEvent(entity, attendances, sports, teams, + memberNames, sportNames, teamNames); } } diff --git a/services/spring-event/src/test/java/tum/devoops/eventservice/controller/EventControllerTest.java b/services/spring-event/src/test/java/tum/devoops/eventservice/controller/EventControllerTest.java index a23b445..8749ccf 100644 --- a/services/spring-event/src/test/java/tum/devoops/eventservice/controller/EventControllerTest.java +++ b/services/spring-event/src/test/java/tum/devoops/eventservice/controller/EventControllerTest.java @@ -33,6 +33,7 @@ import tum.devoops.eventservice.exception.NotFoundException; import tum.devoops.eventservice.model.Event; import tum.devoops.eventservice.model.EventSummary; +import tum.devoops.eventservice.model.Reference; import tum.devoops.eventservice.service.EventService; @WebMvcTest(EventController.class) @@ -52,7 +53,7 @@ private Event sampleEvent() { return new Event(EVENT_ID, "Training session", "Weekly practice", OffsetDateTime.parse("2026-07-01T10:00:00Z"), OffsetDateTime.parse("2026-07-01T12:00:00Z"), - REQUESTER_ID.toString()); + new Reference(REQUESTER_ID, "Casey Creator")); } private EventSummary sampleSummary() { 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 de900b1..c59cb94 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 @@ -35,8 +35,11 @@ import tum.devoops.eventservice.model.EventSummary; import tum.devoops.eventservice.repository.AttendanceRepository; import tum.devoops.eventservice.repository.EventRepository; +import tum.devoops.eventservice.repository.MemberRepository; import tum.devoops.eventservice.repository.SportEventRepository; +import tum.devoops.eventservice.repository.SportRepository; import tum.devoops.eventservice.repository.TeamEventRepository; +import tum.devoops.eventservice.repository.TeamRepository; @ExtendWith(MockitoExtension.class) class EventServiceTest { @@ -49,6 +52,12 @@ class EventServiceTest { private SportEventRepository sportEventRepository; @Mock private TeamEventRepository teamEventRepository; + @Mock + private MemberRepository memberRepository; + @Mock + private SportRepository sportRepository; + @Mock + private TeamRepository teamRepository; @InjectMocks private EventService service; @@ -162,7 +171,7 @@ void createEventPersistsEventAndLinks() { Event result = service.createEvent(body, REQUESTER_ID, true); - assertThat(result.getCreator()).isEqualTo(REQUESTER_ID.toString()); + assertThat(result.getCreator().getId()).isEqualTo(REQUESTER_ID); ArgumentCaptor> attendees = listCaptor(); verify(attendanceRepository).saveAll(attendees.capture()); diff --git a/services/spring-feedback/src/generated/java/.openapi-generator/FILES b/services/spring-feedback/src/generated/java/.openapi-generator/FILES index 7dd59c2..639cc64 100644 --- a/services/spring-feedback/src/generated/java/.openapi-generator/FILES +++ b/services/spring-feedback/src/generated/java/.openapi-generator/FILES @@ -6,3 +6,4 @@ tum/devoops/feedbackservice/model/Feedback.java tum/devoops/feedbackservice/model/FeedbackCreate.java tum/devoops/feedbackservice/model/FeedbackPartialUpdate.java tum/devoops/feedbackservice/model/FeedbackSummary.java +tum/devoops/feedbackservice/model/Reference.java diff --git a/services/spring-feedback/src/generated/java/tum/devoops/feedbackservice/api/FeedbackApi.java b/services/spring-feedback/src/generated/java/tum/devoops/feedbackservice/api/FeedbackApi.java index 03ecfe3..b57d1d5 100644 --- a/services/spring-feedback/src/generated/java/tum/devoops/feedbackservice/api/FeedbackApi.java +++ b/services/spring-feedback/src/generated/java/tum/devoops/feedbackservice/api/FeedbackApi.java @@ -103,7 +103,7 @@ default ResponseEntity createFeedback( getRequest().ifPresent(request -> { for (MediaType mediaType: MediaType.parseMediaTypes(request.getHeader("Accept"))) { if (mediaType.isCompatibleWith(MediaType.valueOf("application/json"))) { - String exampleString = "{ \"feedback\" : \"feedback\", \"creator\" : \"creator\", \"member\" : \"member\", \"created_at\" : \"2000-01-23T04:56:07.000+00:00\", \"id\" : \"046b6c7f-0b8a-43b9-b35d-6489e6daee91\", \"event\" : \"event\" }"; + String exampleString = "{ \"feedback\" : \"feedback\", \"creator\" : { \"name\" : \"name\", \"id\" : \"046b6c7f-0b8a-43b9-b35d-6489e6daee91\" }, \"member\" : { \"name\" : \"name\", \"id\" : \"046b6c7f-0b8a-43b9-b35d-6489e6daee91\" }, \"created_at\" : \"2000-01-23T04:56:07.000+00:00\", \"id\" : \"046b6c7f-0b8a-43b9-b35d-6489e6daee91\", \"event\" : { \"name\" : \"name\", \"id\" : \"046b6c7f-0b8a-43b9-b35d-6489e6daee91\" } }"; ApiUtil.setExampleResponse(request, "application/json", exampleString); break; } @@ -256,7 +256,7 @@ default ResponseEntity> getAllFeedback( getRequest().ifPresent(request -> { for (MediaType mediaType: MediaType.parseMediaTypes(request.getHeader("Accept"))) { if (mediaType.isCompatibleWith(MediaType.valueOf("application/json"))) { - String exampleString = "[ { \"creator\" : \"creator\", \"member\" : \"member\", \"created_at\" : \"2000-01-23T04:56:07.000+00:00\", \"id\" : \"046b6c7f-0b8a-43b9-b35d-6489e6daee91\", \"event\" : \"event\" }, { \"creator\" : \"creator\", \"member\" : \"member\", \"created_at\" : \"2000-01-23T04:56:07.000+00:00\", \"id\" : \"046b6c7f-0b8a-43b9-b35d-6489e6daee91\", \"event\" : \"event\" } ]"; + String exampleString = "[ { \"creator\" : { \"name\" : \"name\", \"id\" : \"046b6c7f-0b8a-43b9-b35d-6489e6daee91\" }, \"member\" : { \"name\" : \"name\", \"id\" : \"046b6c7f-0b8a-43b9-b35d-6489e6daee91\" }, \"created_at\" : \"2000-01-23T04:56:07.000+00:00\", \"id\" : \"046b6c7f-0b8a-43b9-b35d-6489e6daee91\", \"event\" : { \"name\" : \"name\", \"id\" : \"046b6c7f-0b8a-43b9-b35d-6489e6daee91\" } }, { \"creator\" : { \"name\" : \"name\", \"id\" : \"046b6c7f-0b8a-43b9-b35d-6489e6daee91\" }, \"member\" : { \"name\" : \"name\", \"id\" : \"046b6c7f-0b8a-43b9-b35d-6489e6daee91\" }, \"created_at\" : \"2000-01-23T04:56:07.000+00:00\", \"id\" : \"046b6c7f-0b8a-43b9-b35d-6489e6daee91\", \"event\" : { \"name\" : \"name\", \"id\" : \"046b6c7f-0b8a-43b9-b35d-6489e6daee91\" } } ]"; ApiUtil.setExampleResponse(request, "application/json", exampleString); break; } @@ -331,7 +331,7 @@ default ResponseEntity getFeedbackDetails( getRequest().ifPresent(request -> { for (MediaType mediaType: MediaType.parseMediaTypes(request.getHeader("Accept"))) { if (mediaType.isCompatibleWith(MediaType.valueOf("application/json"))) { - String exampleString = "{ \"feedback\" : \"feedback\", \"creator\" : \"creator\", \"member\" : \"member\", \"created_at\" : \"2000-01-23T04:56:07.000+00:00\", \"id\" : \"046b6c7f-0b8a-43b9-b35d-6489e6daee91\", \"event\" : \"event\" }"; + String exampleString = "{ \"feedback\" : \"feedback\", \"creator\" : { \"name\" : \"name\", \"id\" : \"046b6c7f-0b8a-43b9-b35d-6489e6daee91\" }, \"member\" : { \"name\" : \"name\", \"id\" : \"046b6c7f-0b8a-43b9-b35d-6489e6daee91\" }, \"created_at\" : \"2000-01-23T04:56:07.000+00:00\", \"id\" : \"046b6c7f-0b8a-43b9-b35d-6489e6daee91\", \"event\" : { \"name\" : \"name\", \"id\" : \"046b6c7f-0b8a-43b9-b35d-6489e6daee91\" } }"; ApiUtil.setExampleResponse(request, "application/json", exampleString); break; } @@ -418,7 +418,7 @@ default ResponseEntity updateFeedbackDetails( getRequest().ifPresent(request -> { for (MediaType mediaType: MediaType.parseMediaTypes(request.getHeader("Accept"))) { if (mediaType.isCompatibleWith(MediaType.valueOf("application/json"))) { - String exampleString = "{ \"feedback\" : \"feedback\", \"creator\" : \"creator\", \"member\" : \"member\", \"created_at\" : \"2000-01-23T04:56:07.000+00:00\", \"id\" : \"046b6c7f-0b8a-43b9-b35d-6489e6daee91\", \"event\" : \"event\" }"; + String exampleString = "{ \"feedback\" : \"feedback\", \"creator\" : { \"name\" : \"name\", \"id\" : \"046b6c7f-0b8a-43b9-b35d-6489e6daee91\" }, \"member\" : { \"name\" : \"name\", \"id\" : \"046b6c7f-0b8a-43b9-b35d-6489e6daee91\" }, \"created_at\" : \"2000-01-23T04:56:07.000+00:00\", \"id\" : \"046b6c7f-0b8a-43b9-b35d-6489e6daee91\", \"event\" : { \"name\" : \"name\", \"id\" : \"046b6c7f-0b8a-43b9-b35d-6489e6daee91\" } }"; ApiUtil.setExampleResponse(request, "application/json", exampleString); break; } diff --git a/services/spring-feedback/src/generated/java/tum/devoops/feedbackservice/model/Feedback.java b/services/spring-feedback/src/generated/java/tum/devoops/feedbackservice/model/Feedback.java index 455b7a1..9a19a6d 100644 --- a/services/spring-feedback/src/generated/java/tum/devoops/feedbackservice/model/Feedback.java +++ b/services/spring-feedback/src/generated/java/tum/devoops/feedbackservice/model/Feedback.java @@ -8,6 +8,7 @@ import java.util.UUID; import org.springframework.format.annotation.DateTimeFormat; import org.springframework.lang.Nullable; +import tum.devoops.feedbackservice.model.Reference; import java.time.OffsetDateTime; import jakarta.validation.Valid; import jakarta.validation.constraints.*; @@ -27,11 +28,11 @@ public class Feedback { private UUID id; - private String event; + private Reference event; - private String member; + private Reference member; - private String creator; + private Reference creator; @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) private OffsetDateTime createdAt; @@ -45,7 +46,7 @@ public Feedback() { /** * Constructor with only required parameters */ - public Feedback(UUID id, String event, String member, String creator, OffsetDateTime createdAt, String feedback) { + public Feedback(UUID id, Reference event, Reference member, Reference creator, OffsetDateTime createdAt, String feedback) { this.id = id; this.event = event; this.member = member; @@ -74,7 +75,7 @@ public void setId(UUID id) { this.id = id; } - public Feedback event(String event) { + public Feedback event(Reference event) { this.event = event; return this; } @@ -83,18 +84,18 @@ public Feedback event(String event) { * Get event * @return event */ - @NotNull + @NotNull @Valid @Schema(name = "event", requiredMode = Schema.RequiredMode.REQUIRED) @JsonProperty("event") - public String getEvent() { + public Reference getEvent() { return event; } - public void setEvent(String event) { + public void setEvent(Reference event) { this.event = event; } - public Feedback member(String member) { + public Feedback member(Reference member) { this.member = member; return this; } @@ -103,18 +104,18 @@ public Feedback member(String member) { * Get member * @return member */ - @NotNull + @NotNull @Valid @Schema(name = "member", requiredMode = Schema.RequiredMode.REQUIRED) @JsonProperty("member") - public String getMember() { + public Reference getMember() { return member; } - public void setMember(String member) { + public void setMember(Reference member) { this.member = member; } - public Feedback creator(String creator) { + public Feedback creator(Reference creator) { this.creator = creator; return this; } @@ -123,14 +124,14 @@ public Feedback creator(String creator) { * Get creator * @return creator */ - @NotNull + @NotNull @Valid @Schema(name = "creator", requiredMode = Schema.RequiredMode.REQUIRED) @JsonProperty("creator") - public String getCreator() { + public Reference getCreator() { return creator; } - public void setCreator(String creator) { + public void setCreator(Reference creator) { this.creator = creator; } diff --git a/services/spring-feedback/src/generated/java/tum/devoops/feedbackservice/model/FeedbackSummary.java b/services/spring-feedback/src/generated/java/tum/devoops/feedbackservice/model/FeedbackSummary.java index ab658d4..53e6e74 100644 --- a/services/spring-feedback/src/generated/java/tum/devoops/feedbackservice/model/FeedbackSummary.java +++ b/services/spring-feedback/src/generated/java/tum/devoops/feedbackservice/model/FeedbackSummary.java @@ -8,6 +8,7 @@ import java.util.UUID; import org.springframework.format.annotation.DateTimeFormat; import org.springframework.lang.Nullable; +import tum.devoops.feedbackservice.model.Reference; import java.time.OffsetDateTime; import jakarta.validation.Valid; import jakarta.validation.constraints.*; @@ -27,11 +28,11 @@ public class FeedbackSummary { private UUID id; - private String event; + private Reference event; - private String member; + private Reference member; - private String creator; + private Reference creator; @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) private OffsetDateTime createdAt; @@ -43,7 +44,7 @@ public FeedbackSummary() { /** * Constructor with only required parameters */ - public FeedbackSummary(UUID id, String event, String member, String creator, OffsetDateTime createdAt) { + public FeedbackSummary(UUID id, Reference event, Reference member, Reference creator, OffsetDateTime createdAt) { this.id = id; this.event = event; this.member = member; @@ -71,7 +72,7 @@ public void setId(UUID id) { this.id = id; } - public FeedbackSummary event(String event) { + public FeedbackSummary event(Reference event) { this.event = event; return this; } @@ -80,18 +81,18 @@ public FeedbackSummary event(String event) { * Get event * @return event */ - @NotNull + @NotNull @Valid @Schema(name = "event", requiredMode = Schema.RequiredMode.REQUIRED) @JsonProperty("event") - public String getEvent() { + public Reference getEvent() { return event; } - public void setEvent(String event) { + public void setEvent(Reference event) { this.event = event; } - public FeedbackSummary member(String member) { + public FeedbackSummary member(Reference member) { this.member = member; return this; } @@ -100,18 +101,18 @@ public FeedbackSummary member(String member) { * Get member * @return member */ - @NotNull + @NotNull @Valid @Schema(name = "member", requiredMode = Schema.RequiredMode.REQUIRED) @JsonProperty("member") - public String getMember() { + public Reference getMember() { return member; } - public void setMember(String member) { + public void setMember(Reference member) { this.member = member; } - public FeedbackSummary creator(String creator) { + public FeedbackSummary creator(Reference creator) { this.creator = creator; return this; } @@ -120,14 +121,14 @@ public FeedbackSummary creator(String creator) { * Get creator * @return creator */ - @NotNull + @NotNull @Valid @Schema(name = "creator", requiredMode = Schema.RequiredMode.REQUIRED) @JsonProperty("creator") - public String getCreator() { + public Reference getCreator() { return creator; } - public void setCreator(String creator) { + public void setCreator(Reference creator) { this.creator = creator; } diff --git a/services/spring-feedback/src/generated/java/tum/devoops/feedbackservice/model/Reference.java b/services/spring-feedback/src/generated/java/tum/devoops/feedbackservice/model/Reference.java new file mode 100644 index 0000000..aef0052 --- /dev/null +++ b/services/spring-feedback/src/generated/java/tum/devoops/feedbackservice/model/Reference.java @@ -0,0 +1,121 @@ +package tum.devoops.feedbackservice.model; + +import java.net.URI; +import java.util.Objects; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.JsonCreator; +import java.util.UUID; +import org.springframework.lang.Nullable; +import java.time.OffsetDateTime; +import jakarta.validation.Valid; +import jakarta.validation.constraints.*; +import io.swagger.v3.oas.annotations.media.Schema; + + +import java.util.*; +import jakarta.annotation.Generated; + +/** + * A lightweight reference to another entity — its id plus a display name. + */ + +@Schema(name = "Reference", description = "A lightweight reference to another entity — its id plus a display name.") +@Generated(value = "org.openapitools.codegen.languages.SpringCodegen", comments = "Generator version: 7.14.0") +public class Reference { + + private UUID id; + + private String name; + + public Reference() { + super(); + } + + /** + * Constructor with only required parameters + */ + public Reference(UUID id, String name) { + this.id = id; + this.name = name; + } + + public Reference 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 Reference name(String name) { + this.name = name; + return this; + } + + /** + * Get name + * @return name + */ + @NotNull + @Schema(name = "name", requiredMode = Schema.RequiredMode.REQUIRED) + @JsonProperty("name") + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + Reference reference = (Reference) o; + return Objects.equals(this.id, reference.id) && + Objects.equals(this.name, reference.name); + } + + @Override + public int hashCode() { + return Objects.hash(id, name); + } + + @Override + public String toString() { + StringBuilder sb = new StringBuilder(); + sb.append("class Reference {\n"); + sb.append(" id: ").append(toIndentedString(id)).append("\n"); + sb.append(" name: ").append(toIndentedString(name)).append("\n"); + sb.append("}"); + return sb.toString(); + } + + /** + * Convert the given object to string with each line indented by 4 spaces + * (except the first line). + */ + private String toIndentedString(Object o) { + if (o == null) { + return "null"; + } + return o.toString().replace("\n", "\n "); + } +} + diff --git a/services/spring-feedback/src/main/java/tum/devoops/feedbackservice/entity/EventEntity.java b/services/spring-feedback/src/main/java/tum/devoops/feedbackservice/entity/EventEntity.java index af846e0..40159fd 100644 --- a/services/spring-feedback/src/main/java/tum/devoops/feedbackservice/entity/EventEntity.java +++ b/services/spring-feedback/src/main/java/tum/devoops/feedbackservice/entity/EventEntity.java @@ -18,4 +18,7 @@ public class EventEntity { @Id @Column(name = "id", nullable = false, updatable = false) private UUID id; + + @Column(name = "name", insertable = false, updatable = false) + private String name; } diff --git a/services/spring-feedback/src/main/java/tum/devoops/feedbackservice/entity/MemberEntity.java b/services/spring-feedback/src/main/java/tum/devoops/feedbackservice/entity/MemberEntity.java index 8665e66..bac885f 100644 --- a/services/spring-feedback/src/main/java/tum/devoops/feedbackservice/entity/MemberEntity.java +++ b/services/spring-feedback/src/main/java/tum/devoops/feedbackservice/entity/MemberEntity.java @@ -18,4 +18,10 @@ public class MemberEntity { @Id @Column(name = "id", nullable = false, updatable = false) private UUID id; + + @Column(name = "first_name", insertable = false, updatable = false) + private String firstName; + + @Column(name = "last_name", insertable = false, updatable = false) + private String lastName; } diff --git a/services/spring-feedback/src/main/java/tum/devoops/feedbackservice/service/FeedbackService.java b/services/spring-feedback/src/main/java/tum/devoops/feedbackservice/service/FeedbackService.java index 1405e2a..9f9dd5b 100644 --- a/services/spring-feedback/src/main/java/tum/devoops/feedbackservice/service/FeedbackService.java +++ b/services/spring-feedback/src/main/java/tum/devoops/feedbackservice/service/FeedbackService.java @@ -13,6 +13,7 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import tum.devoops.feedbackservice.entity.EventEntity; import tum.devoops.feedbackservice.entity.FeedbackEntity; import tum.devoops.feedbackservice.exception.BadRequestException; import tum.devoops.feedbackservice.exception.ForbiddenException; @@ -21,6 +22,7 @@ import tum.devoops.feedbackservice.model.FeedbackCreate; import tum.devoops.feedbackservice.model.FeedbackPartialUpdate; import tum.devoops.feedbackservice.model.FeedbackSummary; +import tum.devoops.feedbackservice.model.Reference; import tum.devoops.feedbackservice.repository.EventRepository; import tum.devoops.feedbackservice.repository.FeedbackRepository; import tum.devoops.feedbackservice.repository.MemberRepository; @@ -169,9 +171,9 @@ private UUID parseUuid(String value, String fieldName) { private Feedback toFeedback(FeedbackEntity entity) { return new Feedback( entity.getId(), - entity.getEventId().toString(), - entity.getMemberId().toString(), - entity.getCreatorId().toString(), + eventReference(entity.getEventId()), + memberReference(entity.getMemberId()), + memberReference(entity.getCreatorId()), entity.getCreatedAt().atOffset(ZoneOffset.UTC), entity.getFeedback() ); @@ -180,10 +182,21 @@ private Feedback toFeedback(FeedbackEntity entity) { private FeedbackSummary toFeedbackSummary(FeedbackEntity entity) { return new FeedbackSummary( entity.getId(), - entity.getEventId().toString(), - entity.getMemberId().toString(), - entity.getCreatorId().toString(), + eventReference(entity.getEventId()), + memberReference(entity.getMemberId()), + memberReference(entity.getCreatorId()), entity.getCreatedAt().atOffset(ZoneOffset.UTC) ); } + + private Reference eventReference(UUID eventId) { + String name = eventRepository.findById(eventId).map(EventEntity::getName).orElse(null); + return new Reference(eventId, name); + } + + private Reference memberReference(UUID memberId) { + String name = memberRepository.findById(memberId) + .map(m -> m.getFirstName() + " " + m.getLastName()).orElse(null); + return new Reference(memberId, name); + } } diff --git a/services/spring-feedback/src/test/java/tum/devoops/feedbackservice/controller/FeedbackControllerTest.java b/services/spring-feedback/src/test/java/tum/devoops/feedbackservice/controller/FeedbackControllerTest.java index 1f5cf36..7953fa1 100644 --- a/services/spring-feedback/src/test/java/tum/devoops/feedbackservice/controller/FeedbackControllerTest.java +++ b/services/spring-feedback/src/test/java/tum/devoops/feedbackservice/controller/FeedbackControllerTest.java @@ -33,6 +33,7 @@ import tum.devoops.feedbackservice.exception.NotFoundException; import tum.devoops.feedbackservice.model.Feedback; import tum.devoops.feedbackservice.model.FeedbackSummary; +import tum.devoops.feedbackservice.model.Reference; import tum.devoops.feedbackservice.service.FeedbackService; @WebMvcTest(FeedbackController.class) @@ -51,13 +52,15 @@ class FeedbackControllerTest { private static final UUID MEMBER_ID = UUID.randomUUID(); private Feedback sampleFeedback() { - return new Feedback(FEEDBACK_ID, EVENT_ID.toString(), MEMBER_ID.toString(), - REQUESTER_ID.toString(), OffsetDateTime.now(), "Great work!"); + return new Feedback(FEEDBACK_ID, new Reference(EVENT_ID, "Training"), + new Reference(MEMBER_ID, "Mary Member"), + new Reference(REQUESTER_ID, "Casey Creator"), OffsetDateTime.now(), "Great work!"); } private FeedbackSummary sampleSummary() { - return new FeedbackSummary(FEEDBACK_ID, EVENT_ID.toString(), MEMBER_ID.toString(), - REQUESTER_ID.toString(), OffsetDateTime.now()); + return new FeedbackSummary(FEEDBACK_ID, new Reference(EVENT_ID, "Training"), + new Reference(MEMBER_ID, "Mary Member"), + new Reference(REQUESTER_ID, "Casey Creator"), OffsetDateTime.now()); } private String feedbackCreateJson(UUID eventId, UUID memberId, String text) { diff --git a/services/spring-feedback/src/test/java/tum/devoops/feedbackservice/service/FeedbackServiceTest.java b/services/spring-feedback/src/test/java/tum/devoops/feedbackservice/service/FeedbackServiceTest.java index 1d8850e..2e03bf8 100644 --- a/services/spring-feedback/src/test/java/tum/devoops/feedbackservice/service/FeedbackServiceTest.java +++ b/services/spring-feedback/src/test/java/tum/devoops/feedbackservice/service/FeedbackServiceTest.java @@ -103,8 +103,8 @@ void getAllFeedbackAsNonAdminReturnsOwnFeedback() { List result = service.getAllFeedback(REQUESTER_ID, false); assertThat(result).hasSize(2); - assertThat(result).extracting(FeedbackSummary::getCreator) - .contains(REQUESTER_ID.toString(), ANOTHER_ID.toString()); + assertThat(result).extracting(s -> s.getCreator().getId()) + .contains(REQUESTER_ID, ANOTHER_ID); } @Test @@ -140,7 +140,7 @@ void createFeedbackAsAdminSkipsTrainerCheck() { Feedback result = service.createFeedback(body, REQUESTER_ID, true); assertThat(result.getId()).isEqualTo(FEEDBACK_ID); - assertThat(result.getCreator()).isEqualTo(REQUESTER_ID.toString()); + assertThat(result.getCreator().getId()).isEqualTo(REQUESTER_ID); } @Test @@ -153,7 +153,7 @@ void createFeedbackAsTrainerWithSharedTeamSucceeds() { Feedback result = service.createFeedback(body, REQUESTER_ID, false); - assertThat(result.getMember()).isEqualTo(MEMBER_ID.toString()); + assertThat(result.getMember().getId()).isEqualTo(MEMBER_ID); } @Test @@ -237,7 +237,7 @@ void getFeedbackDetailsAsCreatorSucceeds() { Feedback result = service.getFeedbackDetails(FEEDBACK_ID, REQUESTER_ID, false); - assertThat(result.getCreator()).isEqualTo(REQUESTER_ID.toString()); + assertThat(result.getCreator().getId()).isEqualTo(REQUESTER_ID); } @Test @@ -247,7 +247,7 @@ void getFeedbackDetailsAsMemberSubjectSucceeds() { Feedback result = service.getFeedbackDetails(FEEDBACK_ID, REQUESTER_ID, false); - assertThat(result.getMember()).isEqualTo(REQUESTER_ID.toString()); + assertThat(result.getMember().getId()).isEqualTo(REQUESTER_ID); } @Test diff --git a/services/spring-finance/src/generated/java/.openapi-generator/FILES b/services/spring-finance/src/generated/java/.openapi-generator/FILES index 5e5593a..20ddda5 100644 --- a/services/spring-finance/src/generated/java/.openapi-generator/FILES +++ b/services/spring-finance/src/generated/java/.openapi-generator/FILES @@ -3,6 +3,7 @@ tum/devoops/financeservice/api/FinanceApi.java tum/devoops/financeservice/model/BadRequestResponse.java tum/devoops/financeservice/model/Balance.java tum/devoops/financeservice/model/ErrorResponse.java +tum/devoops/financeservice/model/Reference.java tum/devoops/financeservice/model/Transaction.java tum/devoops/financeservice/model/TransactionCreate.java tum/devoops/financeservice/model/TransactionPartialUpdate.java diff --git a/services/spring-finance/src/generated/java/tum/devoops/financeservice/api/FinanceApi.java b/services/spring-finance/src/generated/java/tum/devoops/financeservice/api/FinanceApi.java index 26dd235..0a32e69 100644 --- a/services/spring-finance/src/generated/java/tum/devoops/financeservice/api/FinanceApi.java +++ b/services/spring-finance/src/generated/java/tum/devoops/financeservice/api/FinanceApi.java @@ -103,7 +103,7 @@ default ResponseEntity createTransaction( getRequest().ifPresent(request -> { for (MediaType mediaType: MediaType.parseMediaTypes(request.getHeader("Accept"))) { if (mediaType.isCompatibleWith(MediaType.valueOf("application/json"))) { - String exampleString = "{ \"creator\" : \"creator\", \"amount_cents\" : 0, \"member\" : \"member\", \"created_at\" : \"2000-01-23T04:56:07.000+00:00\", \"description\" : \"description\", \"id\" : \"046b6c7f-0b8a-43b9-b35d-6489e6daee91\", \"title\" : \"title\" }"; + String exampleString = "{ \"creator\" : { \"name\" : \"name\", \"id\" : \"046b6c7f-0b8a-43b9-b35d-6489e6daee91\" }, \"amount_cents\" : 0, \"member\" : { \"name\" : \"name\", \"id\" : \"046b6c7f-0b8a-43b9-b35d-6489e6daee91\" }, \"created_at\" : \"2000-01-23T04:56:07.000+00:00\", \"description\" : \"description\", \"id\" : \"046b6c7f-0b8a-43b9-b35d-6489e6daee91\", \"title\" : \"title\" }"; ApiUtil.setExampleResponse(request, "application/json", exampleString); break; } @@ -256,7 +256,7 @@ default ResponseEntity> getAllBalances( getRequest().ifPresent(request -> { for (MediaType mediaType: MediaType.parseMediaTypes(request.getHeader("Accept"))) { if (mediaType.isCompatibleWith(MediaType.valueOf("application/json"))) { - String exampleString = "[ { \"member\" : \"member\", \"balance_cents\" : 0 }, { \"member\" : \"member\", \"balance_cents\" : 0 } ]"; + String exampleString = "[ { \"member\" : { \"name\" : \"name\", \"id\" : \"046b6c7f-0b8a-43b9-b35d-6489e6daee91\" }, \"balance_cents\" : 0 }, { \"member\" : { \"name\" : \"name\", \"id\" : \"046b6c7f-0b8a-43b9-b35d-6489e6daee91\" }, \"balance_cents\" : 0 } ]"; ApiUtil.setExampleResponse(request, "application/json", exampleString); break; } @@ -326,7 +326,7 @@ default ResponseEntity> getAllTransactions( getRequest().ifPresent(request -> { for (MediaType mediaType: MediaType.parseMediaTypes(request.getHeader("Accept"))) { if (mediaType.isCompatibleWith(MediaType.valueOf("application/json"))) { - String exampleString = "[ { \"creator\" : \"creator\", \"amount_cents\" : 0, \"member\" : \"member\", \"created_at\" : \"2000-01-23T04:56:07.000+00:00\", \"description\" : \"description\", \"id\" : \"046b6c7f-0b8a-43b9-b35d-6489e6daee91\", \"title\" : \"title\" }, { \"creator\" : \"creator\", \"amount_cents\" : 0, \"member\" : \"member\", \"created_at\" : \"2000-01-23T04:56:07.000+00:00\", \"description\" : \"description\", \"id\" : \"046b6c7f-0b8a-43b9-b35d-6489e6daee91\", \"title\" : \"title\" } ]"; + String exampleString = "[ { \"creator\" : { \"name\" : \"name\", \"id\" : \"046b6c7f-0b8a-43b9-b35d-6489e6daee91\" }, \"amount_cents\" : 0, \"member\" : { \"name\" : \"name\", \"id\" : \"046b6c7f-0b8a-43b9-b35d-6489e6daee91\" }, \"created_at\" : \"2000-01-23T04:56:07.000+00:00\", \"description\" : \"description\", \"id\" : \"046b6c7f-0b8a-43b9-b35d-6489e6daee91\", \"title\" : \"title\" }, { \"creator\" : { \"name\" : \"name\", \"id\" : \"046b6c7f-0b8a-43b9-b35d-6489e6daee91\" }, \"amount_cents\" : 0, \"member\" : { \"name\" : \"name\", \"id\" : \"046b6c7f-0b8a-43b9-b35d-6489e6daee91\" }, \"created_at\" : \"2000-01-23T04:56:07.000+00:00\", \"description\" : \"description\", \"id\" : \"046b6c7f-0b8a-43b9-b35d-6489e6daee91\", \"title\" : \"title\" } ]"; ApiUtil.setExampleResponse(request, "application/json", exampleString); break; } @@ -401,7 +401,7 @@ default ResponseEntity getMemberBalance( getRequest().ifPresent(request -> { for (MediaType mediaType: MediaType.parseMediaTypes(request.getHeader("Accept"))) { if (mediaType.isCompatibleWith(MediaType.valueOf("application/json"))) { - String exampleString = "{ \"member\" : \"member\", \"balance_cents\" : 0 }"; + String exampleString = "{ \"member\" : { \"name\" : \"name\", \"id\" : \"046b6c7f-0b8a-43b9-b35d-6489e6daee91\" }, \"balance_cents\" : 0 }"; ApiUtil.setExampleResponse(request, "application/json", exampleString); break; } @@ -481,7 +481,7 @@ default ResponseEntity getTransaction( getRequest().ifPresent(request -> { for (MediaType mediaType: MediaType.parseMediaTypes(request.getHeader("Accept"))) { if (mediaType.isCompatibleWith(MediaType.valueOf("application/json"))) { - String exampleString = "{ \"creator\" : \"creator\", \"amount_cents\" : 0, \"member\" : \"member\", \"created_at\" : \"2000-01-23T04:56:07.000+00:00\", \"description\" : \"description\", \"id\" : \"046b6c7f-0b8a-43b9-b35d-6489e6daee91\", \"title\" : \"title\" }"; + String exampleString = "{ \"creator\" : { \"name\" : \"name\", \"id\" : \"046b6c7f-0b8a-43b9-b35d-6489e6daee91\" }, \"amount_cents\" : 0, \"member\" : { \"name\" : \"name\", \"id\" : \"046b6c7f-0b8a-43b9-b35d-6489e6daee91\" }, \"created_at\" : \"2000-01-23T04:56:07.000+00:00\", \"description\" : \"description\", \"id\" : \"046b6c7f-0b8a-43b9-b35d-6489e6daee91\", \"title\" : \"title\" }"; ApiUtil.setExampleResponse(request, "application/json", exampleString); break; } @@ -568,7 +568,7 @@ default ResponseEntity updateTransaction( getRequest().ifPresent(request -> { for (MediaType mediaType: MediaType.parseMediaTypes(request.getHeader("Accept"))) { if (mediaType.isCompatibleWith(MediaType.valueOf("application/json"))) { - String exampleString = "{ \"creator\" : \"creator\", \"amount_cents\" : 0, \"member\" : \"member\", \"created_at\" : \"2000-01-23T04:56:07.000+00:00\", \"description\" : \"description\", \"id\" : \"046b6c7f-0b8a-43b9-b35d-6489e6daee91\", \"title\" : \"title\" }"; + String exampleString = "{ \"creator\" : { \"name\" : \"name\", \"id\" : \"046b6c7f-0b8a-43b9-b35d-6489e6daee91\" }, \"amount_cents\" : 0, \"member\" : { \"name\" : \"name\", \"id\" : \"046b6c7f-0b8a-43b9-b35d-6489e6daee91\" }, \"created_at\" : \"2000-01-23T04:56:07.000+00:00\", \"description\" : \"description\", \"id\" : \"046b6c7f-0b8a-43b9-b35d-6489e6daee91\", \"title\" : \"title\" }"; ApiUtil.setExampleResponse(request, "application/json", exampleString); break; } diff --git a/services/spring-finance/src/generated/java/tum/devoops/financeservice/model/Balance.java b/services/spring-finance/src/generated/java/tum/devoops/financeservice/model/Balance.java index 453416d..50fbc39 100644 --- a/services/spring-finance/src/generated/java/tum/devoops/financeservice/model/Balance.java +++ b/services/spring-finance/src/generated/java/tum/devoops/financeservice/model/Balance.java @@ -5,6 +5,7 @@ import com.fasterxml.jackson.annotation.JsonProperty; import com.fasterxml.jackson.annotation.JsonCreator; import org.springframework.lang.Nullable; +import tum.devoops.financeservice.model.Reference; import java.time.OffsetDateTime; import jakarta.validation.Valid; import jakarta.validation.constraints.*; @@ -22,7 +23,7 @@ @Generated(value = "org.openapitools.codegen.languages.SpringCodegen", comments = "Generator version: 7.14.0") public class Balance { - private String member; + private Reference member; private Integer balanceCents; @@ -33,12 +34,12 @@ public Balance() { /** * Constructor with only required parameters */ - public Balance(String member, Integer balanceCents) { + public Balance(Reference member, Integer balanceCents) { this.member = member; this.balanceCents = balanceCents; } - public Balance member(String member) { + public Balance member(Reference member) { this.member = member; return this; } @@ -47,14 +48,14 @@ public Balance member(String member) { * Get member * @return member */ - @NotNull + @NotNull @Valid @Schema(name = "member", requiredMode = Schema.RequiredMode.REQUIRED) @JsonProperty("member") - public String getMember() { + public Reference getMember() { return member; } - public void setMember(String member) { + public void setMember(Reference member) { this.member = member; } diff --git a/services/spring-finance/src/generated/java/tum/devoops/financeservice/model/Reference.java b/services/spring-finance/src/generated/java/tum/devoops/financeservice/model/Reference.java new file mode 100644 index 0000000..a5e25b1 --- /dev/null +++ b/services/spring-finance/src/generated/java/tum/devoops/financeservice/model/Reference.java @@ -0,0 +1,121 @@ +package tum.devoops.financeservice.model; + +import java.net.URI; +import java.util.Objects; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.JsonCreator; +import java.util.UUID; +import org.springframework.lang.Nullable; +import java.time.OffsetDateTime; +import jakarta.validation.Valid; +import jakarta.validation.constraints.*; +import io.swagger.v3.oas.annotations.media.Schema; + + +import java.util.*; +import jakarta.annotation.Generated; + +/** + * A lightweight reference to another entity — its id plus a display name. + */ + +@Schema(name = "Reference", description = "A lightweight reference to another entity — its id plus a display name.") +@Generated(value = "org.openapitools.codegen.languages.SpringCodegen", comments = "Generator version: 7.14.0") +public class Reference { + + private UUID id; + + private String name; + + public Reference() { + super(); + } + + /** + * Constructor with only required parameters + */ + public Reference(UUID id, String name) { + this.id = id; + this.name = name; + } + + public Reference 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 Reference name(String name) { + this.name = name; + return this; + } + + /** + * Get name + * @return name + */ + @NotNull + @Schema(name = "name", requiredMode = Schema.RequiredMode.REQUIRED) + @JsonProperty("name") + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + Reference reference = (Reference) o; + return Objects.equals(this.id, reference.id) && + Objects.equals(this.name, reference.name); + } + + @Override + public int hashCode() { + return Objects.hash(id, name); + } + + @Override + public String toString() { + StringBuilder sb = new StringBuilder(); + sb.append("class Reference {\n"); + sb.append(" id: ").append(toIndentedString(id)).append("\n"); + sb.append(" name: ").append(toIndentedString(name)).append("\n"); + sb.append("}"); + return sb.toString(); + } + + /** + * Convert the given object to string with each line indented by 4 spaces + * (except the first line). + */ + private String toIndentedString(Object o) { + if (o == null) { + return "null"; + } + return o.toString().replace("\n", "\n "); + } +} + diff --git a/services/spring-finance/src/generated/java/tum/devoops/financeservice/model/Transaction.java b/services/spring-finance/src/generated/java/tum/devoops/financeservice/model/Transaction.java index 23af174..87afea9 100644 --- a/services/spring-finance/src/generated/java/tum/devoops/financeservice/model/Transaction.java +++ b/services/spring-finance/src/generated/java/tum/devoops/financeservice/model/Transaction.java @@ -8,6 +8,7 @@ import java.util.UUID; import org.springframework.format.annotation.DateTimeFormat; import org.springframework.lang.Nullable; +import tum.devoops.financeservice.model.Reference; import java.time.OffsetDateTime; import jakarta.validation.Valid; import jakarta.validation.constraints.*; @@ -27,9 +28,9 @@ public class Transaction { private UUID id; - private String member; + private Reference member; - private String creator; + private Reference creator; private Integer amountCents; @@ -47,7 +48,7 @@ public Transaction() { /** * Constructor with only required parameters */ - public Transaction(UUID id, String member, String creator, Integer amountCents, OffsetDateTime createdAt, String title, String description) { + public Transaction(UUID id, Reference member, Reference creator, Integer amountCents, OffsetDateTime createdAt, String title, String description) { this.id = id; this.member = member; this.creator = creator; @@ -77,7 +78,7 @@ public void setId(UUID id) { this.id = id; } - public Transaction member(String member) { + public Transaction member(Reference member) { this.member = member; return this; } @@ -86,18 +87,18 @@ public Transaction member(String member) { * Get member * @return member */ - @NotNull + @NotNull @Valid @Schema(name = "member", requiredMode = Schema.RequiredMode.REQUIRED) @JsonProperty("member") - public String getMember() { + public Reference getMember() { return member; } - public void setMember(String member) { + public void setMember(Reference member) { this.member = member; } - public Transaction creator(String creator) { + public Transaction creator(Reference creator) { this.creator = creator; return this; } @@ -106,14 +107,14 @@ public Transaction creator(String creator) { * Get creator * @return creator */ - @NotNull + @NotNull @Valid @Schema(name = "creator", requiredMode = Schema.RequiredMode.REQUIRED) @JsonProperty("creator") - public String getCreator() { + public Reference getCreator() { return creator; } - public void setCreator(String creator) { + public void setCreator(Reference creator) { this.creator = creator; } diff --git a/services/spring-organization/src/generated/java/.openapi-generator/FILES b/services/spring-organization/src/generated/java/.openapi-generator/FILES index 25bf6c3..3fb8793 100644 --- a/services/spring-organization/src/generated/java/.openapi-generator/FILES +++ b/services/spring-organization/src/generated/java/.openapi-generator/FILES @@ -2,6 +2,7 @@ tum/devoops/organizationservice/api/ApiUtil.java tum/devoops/organizationservice/api/OrganizationApi.java tum/devoops/organizationservice/model/BadRequestResponse.java tum/devoops/organizationservice/model/ErrorResponse.java +tum/devoops/organizationservice/model/Reference.java tum/devoops/organizationservice/model/Sport.java tum/devoops/organizationservice/model/SportCreate.java tum/devoops/organizationservice/model/SportPartialUpdate.java 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 b9cb998..4b8cee2 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\", \"id\" : \"046b6c7f-0b8a-43b9-b35d-6489e6daee91\" }"; + String exampleString = "{ \"directors\" : [ { \"name\" : \"name\", \"id\" : \"046b6c7f-0b8a-43b9-b35d-6489e6daee91\" }, { \"name\" : \"name\", \"id\" : \"046b6c7f-0b8a-43b9-b35d-6489e6daee91\" } ], \"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\" : \"046b6c7f-0b8a-43b9-b35d-6489e6daee91\" }"; + String exampleString = "{ \"trainers\" : [ { \"name\" : \"name\", \"id\" : \"046b6c7f-0b8a-43b9-b35d-6489e6daee91\" }, { \"name\" : \"name\", \"id\" : \"046b6c7f-0b8a-43b9-b35d-6489e6daee91\" } ], \"address\" : \"address\", \"trainees\" : [ { \"name\" : \"name\", \"id\" : \"046b6c7f-0b8a-43b9-b35d-6489e6daee91\" }, { \"name\" : \"name\", \"id\" : \"046b6c7f-0b8a-43b9-b35d-6489e6daee91\" } ], \"name\" : \"name\", \"description\" : \"description\", \"created_at\" : \"2000-01-23\", \"id\" : \"046b6c7f-0b8a-43b9-b35d-6489e6daee91\", \"sport\" : { \"name\" : \"name\", \"id\" : \"046b6c7f-0b8a-43b9-b35d-6489e6daee91\" } }"; ApiUtil.setExampleResponse(request, "application/json", exampleString); break; } @@ -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\", \"id\" : \"046b6c7f-0b8a-43b9-b35d-6489e6daee91\" }, { \"directors\" : [ \"directors\", \"directors\" ], \"name\" : \"name\", \"description\" : \"description\", \"created_at\" : \"2000-01-23\", \"id\" : \"046b6c7f-0b8a-43b9-b35d-6489e6daee91\" } ]"; + String exampleString = "[ { \"directors\" : [ { \"name\" : \"name\", \"id\" : \"046b6c7f-0b8a-43b9-b35d-6489e6daee91\" }, { \"name\" : \"name\", \"id\" : \"046b6c7f-0b8a-43b9-b35d-6489e6daee91\" } ], \"name\" : \"name\", \"description\" : \"description\", \"created_at\" : \"2000-01-23\", \"id\" : \"046b6c7f-0b8a-43b9-b35d-6489e6daee91\" }, { \"directors\" : [ { \"name\" : \"name\", \"id\" : \"046b6c7f-0b8a-43b9-b35d-6489e6daee91\" }, { \"name\" : \"name\", \"id\" : \"046b6c7f-0b8a-43b9-b35d-6489e6daee91\" } ], \"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\" : \"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\" } ]"; + String exampleString = "[ { \"trainers\" : [ { \"name\" : \"name\", \"id\" : \"046b6c7f-0b8a-43b9-b35d-6489e6daee91\" }, { \"name\" : \"name\", \"id\" : \"046b6c7f-0b8a-43b9-b35d-6489e6daee91\" } ], \"address\" : \"address\", \"trainees\" : [ { \"name\" : \"name\", \"id\" : \"046b6c7f-0b8a-43b9-b35d-6489e6daee91\" }, { \"name\" : \"name\", \"id\" : \"046b6c7f-0b8a-43b9-b35d-6489e6daee91\" } ], \"name\" : \"name\", \"description\" : \"description\", \"created_at\" : \"2000-01-23\", \"id\" : \"046b6c7f-0b8a-43b9-b35d-6489e6daee91\", \"sport\" : { \"name\" : \"name\", \"id\" : \"046b6c7f-0b8a-43b9-b35d-6489e6daee91\" } }, { \"trainers\" : [ { \"name\" : \"name\", \"id\" : \"046b6c7f-0b8a-43b9-b35d-6489e6daee91\" }, { \"name\" : \"name\", \"id\" : \"046b6c7f-0b8a-43b9-b35d-6489e6daee91\" } ], \"address\" : \"address\", \"trainees\" : [ { \"name\" : \"name\", \"id\" : \"046b6c7f-0b8a-43b9-b35d-6489e6daee91\" }, { \"name\" : \"name\", \"id\" : \"046b6c7f-0b8a-43b9-b35d-6489e6daee91\" } ], \"name\" : \"name\", \"description\" : \"description\", \"created_at\" : \"2000-01-23\", \"id\" : \"046b6c7f-0b8a-43b9-b35d-6489e6daee91\", \"sport\" : { \"name\" : \"name\", \"id\" : \"046b6c7f-0b8a-43b9-b35d-6489e6daee91\" } } ]"; ApiUtil.setExampleResponse(request, "application/json", exampleString); break; } @@ -566,7 +566,7 @@ default ResponseEntity getSport( 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\", \"id\" : \"046b6c7f-0b8a-43b9-b35d-6489e6daee91\" }"; + String exampleString = "{ \"directors\" : [ { \"name\" : \"name\", \"id\" : \"046b6c7f-0b8a-43b9-b35d-6489e6daee91\" }, { \"name\" : \"name\", \"id\" : \"046b6c7f-0b8a-43b9-b35d-6489e6daee91\" } ], \"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\" : \"046b6c7f-0b8a-43b9-b35d-6489e6daee91\" }"; + String exampleString = "{ \"trainers\" : [ { \"name\" : \"name\", \"id\" : \"046b6c7f-0b8a-43b9-b35d-6489e6daee91\" }, { \"name\" : \"name\", \"id\" : \"046b6c7f-0b8a-43b9-b35d-6489e6daee91\" } ], \"address\" : \"address\", \"trainees\" : [ { \"name\" : \"name\", \"id\" : \"046b6c7f-0b8a-43b9-b35d-6489e6daee91\" }, { \"name\" : \"name\", \"id\" : \"046b6c7f-0b8a-43b9-b35d-6489e6daee91\" } ], \"name\" : \"name\", \"description\" : \"description\", \"created_at\" : \"2000-01-23\", \"id\" : \"046b6c7f-0b8a-43b9-b35d-6489e6daee91\", \"sport\" : { \"name\" : \"name\", \"id\" : \"046b6c7f-0b8a-43b9-b35d-6489e6daee91\" } }"; ApiUtil.setExampleResponse(request, "application/json", exampleString); break; } @@ -733,7 +733,7 @@ default ResponseEntity updateSport( 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\", \"id\" : \"046b6c7f-0b8a-43b9-b35d-6489e6daee91\" }"; + String exampleString = "{ \"directors\" : [ { \"name\" : \"name\", \"id\" : \"046b6c7f-0b8a-43b9-b35d-6489e6daee91\" }, { \"name\" : \"name\", \"id\" : \"046b6c7f-0b8a-43b9-b35d-6489e6daee91\" } ], \"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\" : \"046b6c7f-0b8a-43b9-b35d-6489e6daee91\" }"; + String exampleString = "{ \"trainers\" : [ { \"name\" : \"name\", \"id\" : \"046b6c7f-0b8a-43b9-b35d-6489e6daee91\" }, { \"name\" : \"name\", \"id\" : \"046b6c7f-0b8a-43b9-b35d-6489e6daee91\" } ], \"address\" : \"address\", \"trainees\" : [ { \"name\" : \"name\", \"id\" : \"046b6c7f-0b8a-43b9-b35d-6489e6daee91\" }, { \"name\" : \"name\", \"id\" : \"046b6c7f-0b8a-43b9-b35d-6489e6daee91\" } ], \"name\" : \"name\", \"description\" : \"description\", \"created_at\" : \"2000-01-23\", \"id\" : \"046b6c7f-0b8a-43b9-b35d-6489e6daee91\", \"sport\" : { \"name\" : \"name\", \"id\" : \"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/Reference.java b/services/spring-organization/src/generated/java/tum/devoops/organizationservice/model/Reference.java new file mode 100644 index 0000000..97be670 --- /dev/null +++ b/services/spring-organization/src/generated/java/tum/devoops/organizationservice/model/Reference.java @@ -0,0 +1,121 @@ +package tum.devoops.organizationservice.model; + +import java.net.URI; +import java.util.Objects; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.JsonCreator; +import java.util.UUID; +import org.springframework.lang.Nullable; +import java.time.OffsetDateTime; +import jakarta.validation.Valid; +import jakarta.validation.constraints.*; +import io.swagger.v3.oas.annotations.media.Schema; + + +import java.util.*; +import jakarta.annotation.Generated; + +/** + * A lightweight reference to another entity — its id plus a display name. + */ + +@Schema(name = "Reference", description = "A lightweight reference to another entity — its id plus a display name.") +@Generated(value = "org.openapitools.codegen.languages.SpringCodegen", comments = "Generator version: 7.14.0") +public class Reference { + + private UUID id; + + private String name; + + public Reference() { + super(); + } + + /** + * Constructor with only required parameters + */ + public Reference(UUID id, String name) { + this.id = id; + this.name = name; + } + + public Reference 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 Reference name(String name) { + this.name = name; + return this; + } + + /** + * Get name + * @return name + */ + @NotNull + @Schema(name = "name", requiredMode = Schema.RequiredMode.REQUIRED) + @JsonProperty("name") + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + Reference reference = (Reference) o; + return Objects.equals(this.id, reference.id) && + Objects.equals(this.name, reference.name); + } + + @Override + public int hashCode() { + return Objects.hash(id, name); + } + + @Override + public String toString() { + StringBuilder sb = new StringBuilder(); + sb.append("class Reference {\n"); + sb.append(" id: ").append(toIndentedString(id)).append("\n"); + sb.append(" name: ").append(toIndentedString(name)).append("\n"); + sb.append("}"); + return sb.toString(); + } + + /** + * Convert the given object to string with each line indented by 4 spaces + * (except the first line). + */ + private String toIndentedString(Object o) { + if (o == null) { + return "null"; + } + return o.toString().replace("\n", "\n "); + } +} + 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 dd36214..b4b4545 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 @@ -11,6 +11,7 @@ import java.util.UUID; import org.springframework.format.annotation.DateTimeFormat; import org.springframework.lang.Nullable; +import tum.devoops.organizationservice.model.Reference; import java.time.OffsetDateTime; import jakarta.validation.Valid; import jakarta.validation.constraints.*; @@ -38,7 +39,7 @@ public class Sport { private LocalDate createdAt; @Valid - private List directors; + private List<@Valid Reference> directors; public Sport() { super(); @@ -47,7 +48,7 @@ public Sport() { /** * Constructor with only required parameters */ - public Sport(UUID id, String name, String description, LocalDate createdAt, List directors) { + public Sport(UUID id, String name, String description, LocalDate createdAt, List<@Valid Reference> directors) { this.id = id; this.name = name; this.description = description; @@ -135,12 +136,12 @@ public void setCreatedAt(LocalDate createdAt) { this.createdAt = createdAt; } - public Sport directors(List directors) { + public Sport directors(List<@Valid Reference> directors) { this.directors = directors; return this; } - public Sport addDirectorsItem(String directorsItem) { + public Sport addDirectorsItem(Reference directorsItem) { if (this.directors == null) { this.directors = new ArrayList<>(); } @@ -152,14 +153,14 @@ public Sport addDirectorsItem(String directorsItem) { * Get directors * @return directors */ - @NotNull + @NotNull @Valid @Schema(name = "directors", requiredMode = Schema.RequiredMode.REQUIRED) @JsonProperty("directors") - public List getDirectors() { + public List<@Valid Reference> getDirectors() { return directors; } - public void setDirectors(List directors) { + public void setDirectors(List<@Valid Reference> 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 5c58e51..1db0482 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 @@ -11,6 +11,7 @@ import java.util.UUID; import org.springframework.format.annotation.DateTimeFormat; import org.springframework.lang.Nullable; +import tum.devoops.organizationservice.model.Reference; import java.time.OffsetDateTime; import jakarta.validation.Valid; import jakarta.validation.constraints.*; @@ -39,13 +40,13 @@ public class Team { private String address; - private UUID sport; + private Reference sport; @Valid - private List trainers; + private List<@Valid Reference> trainers; @Valid - private List trainees; + private List<@Valid Reference> trainees; public Team() { super(); @@ -54,7 +55,7 @@ public Team() { /** * Constructor with only required parameters */ - public Team(UUID id, String name, String description, LocalDate createdAt, String address, UUID sport, List trainers, List trainees) { + public Team(UUID id, String name, String description, LocalDate createdAt, String address, Reference sport, List<@Valid Reference> trainers, List<@Valid Reference> trainees) { this.id = id; this.name = name; this.description = description; @@ -165,32 +166,32 @@ public void setAddress(String address) { this.address = address; } - public Team sport(UUID sport) { + public Team sport(Reference sport) { this.sport = sport; return this; } /** - * ID of the sport this team belongs to. + * Get sport * @return sport */ @NotNull @Valid - @Schema(name = "sport", description = "ID of the sport this team belongs to.", requiredMode = Schema.RequiredMode.REQUIRED) + @Schema(name = "sport", requiredMode = Schema.RequiredMode.REQUIRED) @JsonProperty("sport") - public UUID getSport() { + public Reference getSport() { return sport; } - public void setSport(UUID sport) { + public void setSport(Reference sport) { this.sport = sport; } - public Team trainers(List trainers) { + public Team trainers(List<@Valid Reference> trainers) { this.trainers = trainers; return this; } - public Team addTrainersItem(String trainersItem) { + public Team addTrainersItem(Reference trainersItem) { if (this.trainers == null) { this.trainers = new ArrayList<>(); } @@ -202,23 +203,23 @@ public Team addTrainersItem(String trainersItem) { * Get trainers * @return trainers */ - @NotNull + @NotNull @Valid @Schema(name = "trainers", requiredMode = Schema.RequiredMode.REQUIRED) @JsonProperty("trainers") - public List getTrainers() { + public List<@Valid Reference> getTrainers() { return trainers; } - public void setTrainers(List trainers) { + public void setTrainers(List<@Valid Reference> trainers) { this.trainers = trainers; } - public Team trainees(List trainees) { + public Team trainees(List<@Valid Reference> trainees) { this.trainees = trainees; return this; } - public Team addTraineesItem(String traineesItem) { + public Team addTraineesItem(Reference traineesItem) { if (this.trainees == null) { this.trainees = new ArrayList<>(); } @@ -230,14 +231,14 @@ public Team addTraineesItem(String traineesItem) { * Get trainees * @return trainees */ - @NotNull + @NotNull @Valid @Schema(name = "trainees", requiredMode = Schema.RequiredMode.REQUIRED) @JsonProperty("trainees") - public List getTrainees() { + public List<@Valid Reference> getTrainees() { return trainees; } - public void setTrainees(List trainees) { + public void setTrainees(List<@Valid Reference> trainees) { this.trainees = trainees; } diff --git a/services/spring-organization/src/main/java/tum/devoops/organizationservice/entity/MemberEntity.java b/services/spring-organization/src/main/java/tum/devoops/organizationservice/entity/MemberEntity.java index 3a2231c..dd564e3 100644 --- a/services/spring-organization/src/main/java/tum/devoops/organizationservice/entity/MemberEntity.java +++ b/services/spring-organization/src/main/java/tum/devoops/organizationservice/entity/MemberEntity.java @@ -10,6 +10,10 @@ import lombok.NoArgsConstructor; import lombok.Setter; +/** + * Read-only shadow of {@code member.members}, owned by the member service. Used to resolve member + * display names for reference objects in responses; this service never writes to it. + */ @Entity @Table(schema = "member", name = "members") @Getter @Setter @NoArgsConstructor @@ -18,4 +22,10 @@ public class MemberEntity { @Id @Column(name = "id") private UUID id; + + @Column(name = "first_name", insertable = false, updatable = false) + private String firstName; + + @Column(name = "last_name", insertable = false, updatable = false) + private String lastName; } 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 79e4e1f..1598bd1 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,8 +1,11 @@ package tum.devoops.organizationservice.service; import java.time.LocalDate; +import java.util.ArrayList; +import java.util.HashMap; import java.util.HashSet; import java.util.List; +import java.util.Map; import java.util.Set; import java.util.UUID; import java.util.stream.Collectors; @@ -18,6 +21,7 @@ import tum.devoops.organizationservice.exception.ConflictException; import tum.devoops.organizationservice.exception.ForbiddenException; import tum.devoops.organizationservice.exception.NotFoundException; +import tum.devoops.organizationservice.model.Reference; import tum.devoops.organizationservice.model.Sport; import tum.devoops.organizationservice.model.SportCreate; import tum.devoops.organizationservice.model.SportPartialUpdate; @@ -174,10 +178,22 @@ private void saveDirectors(UUID sportId, List directorIds) { } private Sport toSport(SportEntity entity) { - List directors = entity.getDirectors().stream() - .map(d -> d.getId().getMemberId().toString()) + List directorIds = entity.getDirectors().stream() + .map(d -> d.getId().getMemberId()) .collect(Collectors.toList()); return new Sport(entity.getId(), entity.getName(), entity.getDescription(), - entity.getCreatedAt(), directors); + entity.getCreatedAt(), memberReferences(directorIds)); + } + + private List memberReferences(List memberIds) { + if (memberIds.isEmpty()) { + return new ArrayList<>(); + } + Map names = new HashMap<>(); + memberRepository.findAllById(memberIds) + .forEach(m -> names.put(m.getId(), m.getFirstName() + " " + m.getLastName())); + return memberIds.stream() + .map(id -> new Reference(id, names.get(id))) + .collect(Collectors.toList()); } } 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 8eb9123..19de0c6 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,8 +1,11 @@ package tum.devoops.organizationservice.service; import java.time.LocalDate; +import java.util.ArrayList; +import java.util.HashMap; import java.util.HashSet; import java.util.List; +import java.util.Map; import java.util.Set; import java.util.UUID; import java.util.stream.Collectors; @@ -11,12 +14,14 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import tum.devoops.organizationservice.entity.SportEntity; import tum.devoops.organizationservice.entity.TeamEntity; import tum.devoops.organizationservice.entity.TraineeEntity; import tum.devoops.organizationservice.entity.TrainerEntity; import tum.devoops.organizationservice.exception.BadRequestException; import tum.devoops.organizationservice.exception.ForbiddenException; import tum.devoops.organizationservice.exception.NotFoundException; +import tum.devoops.organizationservice.model.Reference; import tum.devoops.organizationservice.model.Team; import tum.devoops.organizationservice.model.TeamCreate; import tum.devoops.organizationservice.model.TeamPartialUpdate; @@ -214,11 +219,11 @@ private void saveTrainees(UUID teamId, List memberIds) { } private Team toTeam(TeamEntity entity) { - List trainers = entity.getTrainers().stream() - .map(t -> t.getId().getMemberId().toString()) + List trainerIds = entity.getTrainers().stream() + .map(t -> t.getId().getMemberId()) .collect(Collectors.toList()); - List trainees = entity.getTrainees().stream() - .map(t -> t.getId().getMemberId().toString()) + List traineeIds = entity.getTrainees().stream() + .map(t -> t.getId().getMemberId()) .collect(Collectors.toList()); return new Team( entity.getId(), @@ -226,9 +231,26 @@ private Team toTeam(TeamEntity entity) { entity.getDescription(), entity.getCreatedAt(), entity.getAddress(), - entity.getSportId(), - trainers, - trainees + sportReference(entity.getSportId()), + memberReferences(trainerIds), + memberReferences(traineeIds) ); } + + private Reference sportReference(UUID sportId) { + String name = sportRepository.findById(sportId).map(SportEntity::getName).orElse(null); + return new Reference(sportId, name); + } + + private List memberReferences(List memberIds) { + if (memberIds.isEmpty()) { + return new ArrayList<>(); + } + Map names = new HashMap<>(); + memberRepository.findAllById(memberIds) + .forEach(m -> names.put(m.getId(), m.getFirstName() + " " + m.getLastName())); + return memberIds.stream() + .map(id -> new Reference(id, names.get(id))) + .collect(Collectors.toList()); + } } 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 5be990a..ebd2b45 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 @@ -32,6 +32,7 @@ import tum.devoops.organizationservice.exception.ConflictException; import tum.devoops.organizationservice.exception.ForbiddenException; import tum.devoops.organizationservice.exception.NotFoundException; +import tum.devoops.organizationservice.model.Reference; import tum.devoops.organizationservice.model.Sport; import tum.devoops.organizationservice.model.Team; import tum.devoops.organizationservice.service.MemberRoleSyncService; @@ -84,7 +85,8 @@ private Sport sport(String name) { } 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()); + return new Team(id, "Team Alpha", null, LocalDate.of(2024, 1, 1), null, + new Reference(sport, "Soccer"), List.of(), List.of()); } // --- getAllSports --- 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 c24411f..aa7b33d 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 @@ -19,12 +19,14 @@ import org.mockito.junit.jupiter.MockitoExtension; import tum.devoops.organizationservice.entity.DirectorEntity; +import tum.devoops.organizationservice.entity.MemberEntity; import tum.devoops.organizationservice.entity.SportEntity; import tum.devoops.organizationservice.entity.TeamEntity; import tum.devoops.organizationservice.exception.BadRequestException; import tum.devoops.organizationservice.exception.ConflictException; import tum.devoops.organizationservice.exception.ForbiddenException; import tum.devoops.organizationservice.exception.NotFoundException; +import tum.devoops.organizationservice.model.Reference; import tum.devoops.organizationservice.model.Sport; import tum.devoops.organizationservice.model.SportCreate; import tum.devoops.organizationservice.model.SportPartialUpdate; @@ -76,6 +78,14 @@ private DirectorEntity directorEntity(UUID sportId, UUID memberId) { return new DirectorEntity(new DirectorEntity.Id(sportId, memberId)); } + private MemberEntity memberNamed(UUID id, String first, String last) { + MemberEntity m = new MemberEntity(); + m.setId(id); + m.setFirstName(first); + m.setLastName(last); + return m; + } + private TeamEntity teamEntity(UUID id, UUID sportId) { TeamEntity team = new TeamEntity(); team.setId(id); @@ -96,13 +106,17 @@ void getAllSports_returnsEmptyList_whenNoSports() { void getAllSports_returnsMappedList_whenSportsExist() { SportEntity entity = sportEntity(SPORT_ID, "soccer", List.of(directorEntity(SPORT_ID, MEMBER_ID))); when(sportRepository.findAll()).thenReturn(List.of(entity)); + when(memberRepository.findAllById(any())) + .thenReturn(List.of(memberNamed(MEMBER_ID, "Dana", "Director"))); 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).getDirectors()).containsExactly(MEMBER_ID.toString()); + assertThat(result.get(0).getDirectors()).extracting(Reference::getId).containsExactly(MEMBER_ID); + assertThat(result.get(0).getDirectors()) + .extracting(Reference::getName).containsExactly("Dana Director"); } // --- getSport --- @@ -186,7 +200,7 @@ void createSport_savesEntityAndDirectors_andReturnsResult() { 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()); + assertThat(result.getDirectors()).extracting(Reference::getId).containsExactly(MEMBER_ID); } @Test 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 d7535ce..0858574 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 @@ -19,12 +19,15 @@ import org.mockito.junit.jupiter.MockitoExtension; import tum.devoops.organizationservice.entity.DirectorEntity; +import tum.devoops.organizationservice.entity.MemberEntity; +import tum.devoops.organizationservice.entity.SportEntity; import tum.devoops.organizationservice.entity.TeamEntity; import tum.devoops.organizationservice.entity.TraineeEntity; import tum.devoops.organizationservice.entity.TrainerEntity; import tum.devoops.organizationservice.exception.BadRequestException; import tum.devoops.organizationservice.exception.ForbiddenException; import tum.devoops.organizationservice.exception.NotFoundException; +import tum.devoops.organizationservice.model.Reference; import tum.devoops.organizationservice.model.Team; import tum.devoops.organizationservice.model.TeamCreate; import tum.devoops.organizationservice.model.TeamPartialUpdate; @@ -92,6 +95,21 @@ private DirectorEntity directorEntity(UUID sportId, UUID memberId) { return new DirectorEntity(new DirectorEntity.Id(sportId, memberId)); } + private MemberEntity memberNamed(UUID id, String first, String last) { + MemberEntity m = new MemberEntity(); + m.setId(id); + m.setFirstName(first); + m.setLastName(last); + return m; + } + + private SportEntity sportNamed(UUID id, String name) { + SportEntity s = new SportEntity(); + s.setId(id); + s.setName(name); + return s; + } + // --- getAllTeams --- @Test @@ -107,15 +125,24 @@ void getAllTeams_returnsMappedList_whenTeamsExist() { List.of(trainerEntity(TEAM_ID, TRAINER_ID)), List.of(traineeEntity(TEAM_ID, TRAINEE_ID))); when(teamRepository.findAll()).thenReturn(List.of(entity)); + when(sportRepository.findById(SPORT_ID)).thenReturn(Optional.of(sportNamed(SPORT_ID, "Soccer"))); + when(memberRepository.findAllById(any())).thenReturn(List.of( + memberNamed(TRAINER_ID, "Tina", "Trainer"), + memberNamed(TRAINEE_ID, "Tom", "Trainee"))); List result = service.getAllTeams(); 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()); + assertThat(result.get(0).getSport().getId()).isEqualTo(SPORT_ID); + assertThat(result.get(0).getSport().getName()).isEqualTo("Soccer"); + assertThat(result.get(0).getTrainers()) + .extracting(Reference::getId).containsExactly(TRAINER_ID); + assertThat(result.get(0).getTrainers()) + .extracting(Reference::getName).containsExactly("Tina Trainer"); + assertThat(result.get(0).getTrainees()) + .extracting(Reference::getId).containsExactly(TRAINEE_ID); } // --- getTeam --- @@ -131,7 +158,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(SPORT_ID); + assertThat(result.getSport().getId()).isEqualTo(SPORT_ID); assertThat(result.getCreatedAt()).isEqualTo(LocalDate.of(2024, 1, 1)); } @@ -240,8 +267,8 @@ void createTeam_savesEntityAndTrainersAndTrainees_andReturnsResult() { 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()); + assertThat(result.getTrainers()).extracting(Reference::getId).containsExactly(TRAINER_ID); + assertThat(result.getTrainees()).extracting(Reference::getId).containsExactly(TRAINEE_ID); } @Test @@ -411,7 +438,7 @@ void updateTeam_updatesSport_whenAdminSetsNewSport() { assertThat(team.getSportId()).isEqualTo(OTHER_SPORT_ID); verify(teamRepository).save(team); - assertThat(result.getSport()).isEqualTo(OTHER_SPORT_ID); + assertThat(result.getSport().getId()).isEqualTo(OTHER_SPORT_ID); } @Test diff --git a/web-client/src/api.ts b/web-client/src/api.ts index 1cd185d..c5f16f4 100644 --- a/web-client/src/api.ts +++ b/web-client/src/api.ts @@ -507,6 +507,12 @@ export interface components { message: string; errors?: components["schemas"]["ErrorResponse"][]; }; + /** @description A lightweight reference to another entity — its id plus a display name. */ + Reference: { + /** Format: uuid */ + id: string; + name: string; + }; /** @description The object representation of a Sport within the organization. */ Sport: { /** Format: uuid */ @@ -515,7 +521,7 @@ export interface components { description: string; /** Format: date */ created_at: string; - directors: string[]; + directors: components["schemas"]["Reference"][]; }; /** @description Data transfer object for creating a new Sport. */ SportCreate: { @@ -538,13 +544,9 @@ 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[]; + sport: components["schemas"]["Reference"]; + trainers: components["schemas"]["Reference"][]; + trainees: components["schemas"]["Reference"][]; }; /** @description Data transfer object for creating a new Team. */ TeamCreate: { @@ -629,12 +631,12 @@ export interface components { start_time: string; /** Format: date-time */ end_time: string; - attendees?: string[]; - /** @description IDs of the sports associated with this event. */ - sports_linked?: string[]; - /** @description IDs of the teams associated with this event. */ - teams_linked?: string[]; - creator: string; + attendees?: components["schemas"]["Reference"][]; + /** @description Sports associated with this event. */ + sports_linked?: components["schemas"]["Reference"][]; + /** @description Teams associated with this event. */ + teams_linked?: components["schemas"]["Reference"][]; + creator: components["schemas"]["Reference"]; }; /** @description A simplified representation of a Event, typically used in list views. */ EventSummary: { @@ -676,9 +678,9 @@ export interface components { Feedback: { /** Format: uuid */ id: string; - event: string; - member: string; - creator: string; + event: components["schemas"]["Reference"]; + member: components["schemas"]["Reference"]; + creator: components["schemas"]["Reference"]; /** Format: date-time */ created_at: string; feedback: string; @@ -687,9 +689,9 @@ export interface components { FeedbackSummary: { /** Format: uuid */ id: string; - event: string; - member: string; - creator: string; + event: components["schemas"]["Reference"]; + member: components["schemas"]["Reference"]; + creator: components["schemas"]["Reference"]; /** Format: date-time */ created_at: string; }; @@ -709,8 +711,8 @@ export interface components { Transaction: { /** Format: uuid */ id: string; - member: string; - creator: string; + member: components["schemas"]["Reference"]; + creator: components["schemas"]["Reference"]; amount_cents: number; /** Format: date-time */ created_at: string; @@ -733,7 +735,7 @@ export interface components { }; /** @description The object representation of a Member's Balance, which includes the total balance in cents. */ Balance: { - member: string; + member: components["schemas"]["Reference"]; balance_cents: number; }; }; From cc76871e2b7a735086986c0ca69beac1b0d77ec9 Mon Sep 17 00:00:00 2001 From: Raphael Frank <04.raphael.frank@gmail.com> Date: Sat, 27 Jun 2026 19:27:32 +0200 Subject: [PATCH 04/16] add attendees list to event summary --- api/openapi.yaml | 4 ++ services/py-genai-helper/generated/models.py | 3 +- .../devoops/eventservice/api/EventsApi.java | 2 +- .../eventservice/model/EventSummary.java | 41 ++++++++++++++++++- .../converter/EventConverter.java | 10 ++++- .../repository/AttendanceRepository.java | 4 ++ .../eventservice/service/EventService.java | 17 +++++++- .../service/EventServiceTest.java | 29 +++++++++++-- web-client/src/api.ts | 1 + 9 files changed, 101 insertions(+), 10 deletions(-) diff --git a/api/openapi.yaml b/api/openapi.yaml index f845b6d..df79c96 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -1525,6 +1525,10 @@ components: end_time: type: string format: date-time + attendees: + type: array + items: + $ref: "#/components/schemas/Reference" required: - id - name diff --git a/services/py-genai-helper/generated/models.py b/services/py-genai-helper/generated/models.py index d7eb54f..7070e06 100644 --- a/services/py-genai-helper/generated/models.py +++ b/services/py-genai-helper/generated/models.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: openapi.yaml -# timestamp: 2026-06-27T15:54:38+00:00 +# timestamp: 2026-06-27T17:27:48+00:00 from __future__ import annotations from pydantic import AwareDatetime, BaseModel, Field, SecretStr @@ -135,6 +135,7 @@ class EventSummary(BaseModel): name: str start_time: AwareDatetime end_time: AwareDatetime + attendees: list[Reference] | None = None class EventPartialUpdate(BaseModel): 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 544163b..568b1d3 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 @@ -256,7 +256,7 @@ default ResponseEntity> getAllEvents( 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\", \"name\" : \"name\", \"end_time\" : \"2000-01-23T04:56:07.000+00:00\", \"id\" : \"046b6c7f-0b8a-43b9-b35d-6489e6daee91\" }, { \"start_time\" : \"2000-01-23T04:56:07.000+00:00\", \"name\" : \"name\", \"end_time\" : \"2000-01-23T04:56:07.000+00:00\", \"id\" : \"046b6c7f-0b8a-43b9-b35d-6489e6daee91\" } ]"; + String exampleString = "[ { \"start_time\" : \"2000-01-23T04:56:07.000+00:00\", \"attendees\" : [ { \"name\" : \"name\", \"id\" : \"046b6c7f-0b8a-43b9-b35d-6489e6daee91\" }, { \"name\" : \"name\", \"id\" : \"046b6c7f-0b8a-43b9-b35d-6489e6daee91\" } ], \"name\" : \"name\", \"end_time\" : \"2000-01-23T04:56:07.000+00:00\", \"id\" : \"046b6c7f-0b8a-43b9-b35d-6489e6daee91\" }, { \"start_time\" : \"2000-01-23T04:56:07.000+00:00\", \"attendees\" : [ { \"name\" : \"name\", \"id\" : \"046b6c7f-0b8a-43b9-b35d-6489e6daee91\" }, { \"name\" : \"name\", \"id\" : \"046b6c7f-0b8a-43b9-b35d-6489e6daee91\" } ], \"name\" : \"name\", \"end_time\" : \"2000-01-23T04:56:07.000+00:00\", \"id\" : \"046b6c7f-0b8a-43b9-b35d-6489e6daee91\" } ]"; ApiUtil.setExampleResponse(request, "application/json", exampleString); break; } diff --git a/services/spring-event/src/generated/java/tum/devoops/eventservice/model/EventSummary.java b/services/spring-event/src/generated/java/tum/devoops/eventservice/model/EventSummary.java index e8c205b..1438593 100644 --- a/services/spring-event/src/generated/java/tum/devoops/eventservice/model/EventSummary.java +++ b/services/spring-event/src/generated/java/tum/devoops/eventservice/model/EventSummary.java @@ -5,9 +5,13 @@ import com.fasterxml.jackson.annotation.JsonProperty; import com.fasterxml.jackson.annotation.JsonCreator; import java.time.OffsetDateTime; +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 tum.devoops.eventservice.model.Reference; import java.time.OffsetDateTime; import jakarta.validation.Valid; import jakarta.validation.constraints.*; @@ -35,6 +39,9 @@ public class EventSummary { @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) private OffsetDateTime endTime; + @Valid + private @Nullable List<@Valid Reference> attendees; + public EventSummary() { super(); } @@ -129,6 +136,34 @@ public void setEndTime(OffsetDateTime endTime) { this.endTime = endTime; } + public EventSummary attendees(@Nullable List<@Valid Reference> attendees) { + this.attendees = attendees; + return this; + } + + public EventSummary addAttendeesItem(Reference attendeesItem) { + if (this.attendees == null) { + this.attendees = new ArrayList<>(); + } + this.attendees.add(attendeesItem); + return this; + } + + /** + * Get attendees + * @return attendees + */ + @Valid + @Schema(name = "attendees", requiredMode = Schema.RequiredMode.NOT_REQUIRED) + @JsonProperty("attendees") + public @Nullable List<@Valid Reference> getAttendees() { + return attendees; + } + + public void setAttendees(@Nullable List<@Valid Reference> attendees) { + this.attendees = attendees; + } + @Override public boolean equals(Object o) { if (this == o) { @@ -141,12 +176,13 @@ public boolean equals(Object o) { return Objects.equals(this.id, eventSummary.id) && Objects.equals(this.name, eventSummary.name) && Objects.equals(this.startTime, eventSummary.startTime) && - Objects.equals(this.endTime, eventSummary.endTime); + Objects.equals(this.endTime, eventSummary.endTime) && + Objects.equals(this.attendees, eventSummary.attendees); } @Override public int hashCode() { - return Objects.hash(id, name, startTime, endTime); + return Objects.hash(id, name, startTime, endTime, attendees); } @Override @@ -157,6 +193,7 @@ public String toString() { sb.append(" name: ").append(toIndentedString(name)).append("\n"); sb.append(" startTime: ").append(toIndentedString(startTime)).append("\n"); sb.append(" endTime: ").append(toIndentedString(endTime)).append("\n"); + sb.append(" attendees: ").append(toIndentedString(attendees)).append("\n"); sb.append("}"); return sb.toString(); } 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 8a769ca..fa13699 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 @@ -56,12 +56,18 @@ private static Reference reference(UUID id, Map names) { return new Reference(id, names.get(id)); } - public static EventSummary toSummary(EventEntity entity) { - return new EventSummary( + public static EventSummary toSummary(EventEntity entity, + List attendances, + Map memberNames) { + EventSummary summary = new EventSummary( entity.getId(), entity.getName(), entity.getStartTime().atOffset(ZoneOffset.UTC), entity.getEndTime().atOffset(ZoneOffset.UTC) ); + summary.setAttendees(attendances.stream() + .map(a -> reference(a.getId().getMemberId(), memberNames)) + .collect(Collectors.toList())); + return summary; } } diff --git a/services/spring-event/src/main/java/tum/devoops/eventservice/repository/AttendanceRepository.java b/services/spring-event/src/main/java/tum/devoops/eventservice/repository/AttendanceRepository.java index db2337c..bf52bf4 100644 --- a/services/spring-event/src/main/java/tum/devoops/eventservice/repository/AttendanceRepository.java +++ b/services/spring-event/src/main/java/tum/devoops/eventservice/repository/AttendanceRepository.java @@ -1,5 +1,6 @@ package tum.devoops.eventservice.repository; +import java.util.Collection; import java.util.List; import java.util.UUID; @@ -12,6 +13,9 @@ public interface AttendanceRepository extends JpaRepository findAllById_EventId(UUID eventId); + // SELECT * FROM event.attendances WHERE event_id IN (?) + List findAllById_EventIdIn(Collection eventIds); + // SELECT * FROM event.attendances WHERE member_id = ? List findAllById_MemberId(UUID memberId); 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 931d334..ce02b1a 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 @@ -74,7 +74,22 @@ public List getAllEvents(UUID requesterId, boolean isAdmin) { entities = new ArrayList<>(created); entities.addAll(attended); } - return entities.stream().map(EventConverter::toSummary).collect(Collectors.toList()); + + List eventIds = entities.stream().map(EventEntity::getId).collect(Collectors.toList()); + Map> attendancesByEvent = new HashMap<>(); + Set attendeeMemberIds = new HashSet<>(); + for (AttendanceEntity a : attendanceRepository.findAllById_EventIdIn(eventIds)) { + attendancesByEvent.computeIfAbsent(a.getId().getEventId(), k -> new ArrayList<>()).add(a); + attendeeMemberIds.add(a.getId().getMemberId()); + } + Map memberNames = new HashMap<>(); + memberRepository.findAllById(attendeeMemberIds) + .forEach(m -> memberNames.put(m.getId(), m.getFirstName() + " " + m.getLastName())); + + return entities.stream() + .map(e -> EventConverter.toSummary(e, + attendancesByEvent.getOrDefault(e.getId(), List.of()), memberNames)) + .collect(Collectors.toList()); } @Transactional 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 c59cb94..b201344 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 @@ -18,11 +18,11 @@ import org.mockito.Mock; import static org.mockito.Mockito.never; 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.eventservice.entity.AttendanceEntity; +import tum.devoops.eventservice.entity.MemberEntity; import tum.devoops.eventservice.entity.EventEntity; import tum.devoops.eventservice.entity.SportEventEntity; import tum.devoops.eventservice.entity.TeamEventEntity; @@ -33,6 +33,7 @@ import tum.devoops.eventservice.model.EventCreate; import tum.devoops.eventservice.model.EventPartialUpdate; import tum.devoops.eventservice.model.EventSummary; +import tum.devoops.eventservice.model.Reference; import tum.devoops.eventservice.repository.AttendanceRepository; import tum.devoops.eventservice.repository.EventRepository; import tum.devoops.eventservice.repository.MemberRepository; @@ -99,7 +100,7 @@ private static ArgumentCaptor> listCaptor() { // ─── getAllEvents ────────────────────────────────────────────────────────── @Test - void getAllEventsAsAdminReturnsAllAndIgnoresAttendance() { + void getAllEventsAsAdminReturnsAll() { EventEntity a = eventEntity(UUID.randomUUID(), REQUESTER_ID); EventEntity b = eventEntity(UUID.randomUUID(), OTHER_ID); when(eventRepository.findAll()).thenReturn(List.of(a, b)); @@ -107,7 +108,29 @@ void getAllEventsAsAdminReturnsAllAndIgnoresAttendance() { List result = service.getAllEvents(REQUESTER_ID, true); assertThat(result).hasSize(2); - verifyNoInteractions(attendanceRepository); + // Admin doesn't consult attendance for the membership decision, only to populate attendees. + verify(attendanceRepository, never()).findAllById_MemberId(any()); + } + + @Test + void getAllEventsPopulatesAttendeesOnSummaries() { + EventEntity event = eventEntity(EVENT_ID, REQUESTER_ID); + when(eventRepository.findAll()).thenReturn(List.of(event)); + when(attendanceRepository.findAllById_EventIdIn(any())) + .thenReturn(List.of(new AttendanceEntity(new AttendanceEntity.Id(EVENT_ID, MEMBER_ID)))); + MemberEntity member = new MemberEntity(); + member.setId(MEMBER_ID); + member.setFirstName("Mary"); + member.setLastName("Member"); + when(memberRepository.findAllById(any())).thenReturn(List.of(member)); + + List result = service.getAllEvents(REQUESTER_ID, true); + + assertThat(result).hasSize(1); + assertThat(result.get(0).getAttendees()) + .extracting(Reference::getId).containsExactly(MEMBER_ID); + assertThat(result.get(0).getAttendees()) + .extracting(Reference::getName).containsExactly("Mary Member"); } @Test diff --git a/web-client/src/api.ts b/web-client/src/api.ts index c5f16f4..a920f7a 100644 --- a/web-client/src/api.ts +++ b/web-client/src/api.ts @@ -647,6 +647,7 @@ export interface components { start_time: string; /** Format: date-time */ end_time: string; + attendees?: components["schemas"]["Reference"][]; }; /** @description Data transfer object for partially updating an existing Event (PATCH operation). */ EventPartialUpdate: { From 3a4fb363ff2c80fd0e3345829d9db00f51b2fdef Mon Sep 17 00:00:00 2001 From: Raphael Frank <04.raphael.frank@gmail.com> Date: Sun, 28 Jun 2026 00:44:24 +0200 Subject: [PATCH 05/16] add rating field to feedback --- api/openapi.yaml | 23 +++++++ services/py-genai-helper/generated/models.py | 6 +- .../feedbackservice/api/FeedbackApi.java | 8 +-- .../feedbackservice/model/Feedback.java | 33 +++++++++- .../feedbackservice/model/FeedbackCreate.java | 33 +++++++++- .../model/FeedbackPartialUpdate.java | 30 ++++++++- .../model/FeedbackSummary.java | 33 +++++++++- .../entity/FeedbackEntity.java | 3 + .../service/FeedbackService.java | 19 +++++- .../resources/db/migration/V3__add_rating.sql | 4 ++ .../controller/FeedbackControllerTest.java | 21 +++++-- .../service/FeedbackServiceTest.java | 63 ++++++++++++++++--- web-client/src/api.ts | 8 +++ 13 files changed, 254 insertions(+), 30 deletions(-) create mode 100644 services/spring-feedback/src/main/resources/db/migration/V3__add_rating.sql diff --git a/api/openapi.yaml b/api/openapi.yaml index df79c96..f725cbb 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -1613,6 +1613,11 @@ components: format: date-time feedback: type: string + rating: + type: integer + format: int32 + minimum: 0 + maximum: 10 required: - id - event @@ -1620,6 +1625,7 @@ components: - creator - created_at - feedback + - rating description: The object representation of Feedback, which is associated with a specific Event and Member. FeedbackSummary: type: object @@ -1636,12 +1642,18 @@ components: created_at: type: string format: date-time + rating: + type: integer + format: int32 + minimum: 0 + maximum: 10 required: - id - event - member - creator - created_at + - rating description: A simplified representation of a Feedback, typically used in list views. FeedbackPartialUpdate: type: object @@ -1652,6 +1664,11 @@ components: type: string feedback: type: string + rating: + type: integer + format: int32 + minimum: 0 + maximum: 10 description: Data transfer object for partially updating an existing Feedback (PATCH operation). FeedbackCreate: @@ -1663,10 +1680,16 @@ components: type: string feedback: type: string + rating: + type: integer + format: int32 + minimum: 0 + maximum: 10 required: - event - member - feedback + - rating description: Data transfer object for creating a new Feedback. Transaction: type: object diff --git a/services/py-genai-helper/generated/models.py b/services/py-genai-helper/generated/models.py index 7070e06..494e593 100644 --- a/services/py-genai-helper/generated/models.py +++ b/services/py-genai-helper/generated/models.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: openapi.yaml -# timestamp: 2026-06-27T17:27:48+00:00 +# timestamp: 2026-06-27T22:44:38+00:00 from __future__ import annotations from pydantic import AwareDatetime, BaseModel, Field, SecretStr @@ -171,6 +171,7 @@ class Feedback(BaseModel): creator: Reference created_at: AwareDatetime feedback: str + rating: Annotated[int, Field(ge=0, le=10)] class FeedbackSummary(BaseModel): @@ -179,18 +180,21 @@ class FeedbackSummary(BaseModel): member: Reference creator: Reference created_at: AwareDatetime + rating: Annotated[int, Field(ge=0, le=10)] class FeedbackPartialUpdate(BaseModel): event: str | None = None member: str | None = None feedback: str | None = None + rating: Annotated[int | None, Field(ge=0, le=10)] = None class FeedbackCreate(BaseModel): event: str member: str feedback: str + rating: Annotated[int, Field(ge=0, le=10)] class Transaction(BaseModel): diff --git a/services/spring-feedback/src/generated/java/tum/devoops/feedbackservice/api/FeedbackApi.java b/services/spring-feedback/src/generated/java/tum/devoops/feedbackservice/api/FeedbackApi.java index b57d1d5..2c3dfd0 100644 --- a/services/spring-feedback/src/generated/java/tum/devoops/feedbackservice/api/FeedbackApi.java +++ b/services/spring-feedback/src/generated/java/tum/devoops/feedbackservice/api/FeedbackApi.java @@ -103,7 +103,7 @@ default ResponseEntity createFeedback( getRequest().ifPresent(request -> { for (MediaType mediaType: MediaType.parseMediaTypes(request.getHeader("Accept"))) { if (mediaType.isCompatibleWith(MediaType.valueOf("application/json"))) { - String exampleString = "{ \"feedback\" : \"feedback\", \"creator\" : { \"name\" : \"name\", \"id\" : \"046b6c7f-0b8a-43b9-b35d-6489e6daee91\" }, \"member\" : { \"name\" : \"name\", \"id\" : \"046b6c7f-0b8a-43b9-b35d-6489e6daee91\" }, \"created_at\" : \"2000-01-23T04:56:07.000+00:00\", \"id\" : \"046b6c7f-0b8a-43b9-b35d-6489e6daee91\", \"event\" : { \"name\" : \"name\", \"id\" : \"046b6c7f-0b8a-43b9-b35d-6489e6daee91\" } }"; + String exampleString = "{ \"feedback\" : \"feedback\", \"creator\" : { \"name\" : \"name\", \"id\" : \"046b6c7f-0b8a-43b9-b35d-6489e6daee91\" }, \"member\" : { \"name\" : \"name\", \"id\" : \"046b6c7f-0b8a-43b9-b35d-6489e6daee91\" }, \"rating\" : 0, \"created_at\" : \"2000-01-23T04:56:07.000+00:00\", \"id\" : \"046b6c7f-0b8a-43b9-b35d-6489e6daee91\", \"event\" : { \"name\" : \"name\", \"id\" : \"046b6c7f-0b8a-43b9-b35d-6489e6daee91\" } }"; ApiUtil.setExampleResponse(request, "application/json", exampleString); break; } @@ -256,7 +256,7 @@ default ResponseEntity> getAllFeedback( getRequest().ifPresent(request -> { for (MediaType mediaType: MediaType.parseMediaTypes(request.getHeader("Accept"))) { if (mediaType.isCompatibleWith(MediaType.valueOf("application/json"))) { - String exampleString = "[ { \"creator\" : { \"name\" : \"name\", \"id\" : \"046b6c7f-0b8a-43b9-b35d-6489e6daee91\" }, \"member\" : { \"name\" : \"name\", \"id\" : \"046b6c7f-0b8a-43b9-b35d-6489e6daee91\" }, \"created_at\" : \"2000-01-23T04:56:07.000+00:00\", \"id\" : \"046b6c7f-0b8a-43b9-b35d-6489e6daee91\", \"event\" : { \"name\" : \"name\", \"id\" : \"046b6c7f-0b8a-43b9-b35d-6489e6daee91\" } }, { \"creator\" : { \"name\" : \"name\", \"id\" : \"046b6c7f-0b8a-43b9-b35d-6489e6daee91\" }, \"member\" : { \"name\" : \"name\", \"id\" : \"046b6c7f-0b8a-43b9-b35d-6489e6daee91\" }, \"created_at\" : \"2000-01-23T04:56:07.000+00:00\", \"id\" : \"046b6c7f-0b8a-43b9-b35d-6489e6daee91\", \"event\" : { \"name\" : \"name\", \"id\" : \"046b6c7f-0b8a-43b9-b35d-6489e6daee91\" } } ]"; + String exampleString = "[ { \"creator\" : { \"name\" : \"name\", \"id\" : \"046b6c7f-0b8a-43b9-b35d-6489e6daee91\" }, \"member\" : { \"name\" : \"name\", \"id\" : \"046b6c7f-0b8a-43b9-b35d-6489e6daee91\" }, \"rating\" : 0, \"created_at\" : \"2000-01-23T04:56:07.000+00:00\", \"id\" : \"046b6c7f-0b8a-43b9-b35d-6489e6daee91\", \"event\" : { \"name\" : \"name\", \"id\" : \"046b6c7f-0b8a-43b9-b35d-6489e6daee91\" } }, { \"creator\" : { \"name\" : \"name\", \"id\" : \"046b6c7f-0b8a-43b9-b35d-6489e6daee91\" }, \"member\" : { \"name\" : \"name\", \"id\" : \"046b6c7f-0b8a-43b9-b35d-6489e6daee91\" }, \"rating\" : 0, \"created_at\" : \"2000-01-23T04:56:07.000+00:00\", \"id\" : \"046b6c7f-0b8a-43b9-b35d-6489e6daee91\", \"event\" : { \"name\" : \"name\", \"id\" : \"046b6c7f-0b8a-43b9-b35d-6489e6daee91\" } } ]"; ApiUtil.setExampleResponse(request, "application/json", exampleString); break; } @@ -331,7 +331,7 @@ default ResponseEntity getFeedbackDetails( getRequest().ifPresent(request -> { for (MediaType mediaType: MediaType.parseMediaTypes(request.getHeader("Accept"))) { if (mediaType.isCompatibleWith(MediaType.valueOf("application/json"))) { - String exampleString = "{ \"feedback\" : \"feedback\", \"creator\" : { \"name\" : \"name\", \"id\" : \"046b6c7f-0b8a-43b9-b35d-6489e6daee91\" }, \"member\" : { \"name\" : \"name\", \"id\" : \"046b6c7f-0b8a-43b9-b35d-6489e6daee91\" }, \"created_at\" : \"2000-01-23T04:56:07.000+00:00\", \"id\" : \"046b6c7f-0b8a-43b9-b35d-6489e6daee91\", \"event\" : { \"name\" : \"name\", \"id\" : \"046b6c7f-0b8a-43b9-b35d-6489e6daee91\" } }"; + String exampleString = "{ \"feedback\" : \"feedback\", \"creator\" : { \"name\" : \"name\", \"id\" : \"046b6c7f-0b8a-43b9-b35d-6489e6daee91\" }, \"member\" : { \"name\" : \"name\", \"id\" : \"046b6c7f-0b8a-43b9-b35d-6489e6daee91\" }, \"rating\" : 0, \"created_at\" : \"2000-01-23T04:56:07.000+00:00\", \"id\" : \"046b6c7f-0b8a-43b9-b35d-6489e6daee91\", \"event\" : { \"name\" : \"name\", \"id\" : \"046b6c7f-0b8a-43b9-b35d-6489e6daee91\" } }"; ApiUtil.setExampleResponse(request, "application/json", exampleString); break; } @@ -418,7 +418,7 @@ default ResponseEntity updateFeedbackDetails( getRequest().ifPresent(request -> { for (MediaType mediaType: MediaType.parseMediaTypes(request.getHeader("Accept"))) { if (mediaType.isCompatibleWith(MediaType.valueOf("application/json"))) { - String exampleString = "{ \"feedback\" : \"feedback\", \"creator\" : { \"name\" : \"name\", \"id\" : \"046b6c7f-0b8a-43b9-b35d-6489e6daee91\" }, \"member\" : { \"name\" : \"name\", \"id\" : \"046b6c7f-0b8a-43b9-b35d-6489e6daee91\" }, \"created_at\" : \"2000-01-23T04:56:07.000+00:00\", \"id\" : \"046b6c7f-0b8a-43b9-b35d-6489e6daee91\", \"event\" : { \"name\" : \"name\", \"id\" : \"046b6c7f-0b8a-43b9-b35d-6489e6daee91\" } }"; + String exampleString = "{ \"feedback\" : \"feedback\", \"creator\" : { \"name\" : \"name\", \"id\" : \"046b6c7f-0b8a-43b9-b35d-6489e6daee91\" }, \"member\" : { \"name\" : \"name\", \"id\" : \"046b6c7f-0b8a-43b9-b35d-6489e6daee91\" }, \"rating\" : 0, \"created_at\" : \"2000-01-23T04:56:07.000+00:00\", \"id\" : \"046b6c7f-0b8a-43b9-b35d-6489e6daee91\", \"event\" : { \"name\" : \"name\", \"id\" : \"046b6c7f-0b8a-43b9-b35d-6489e6daee91\" } }"; ApiUtil.setExampleResponse(request, "application/json", exampleString); break; } diff --git a/services/spring-feedback/src/generated/java/tum/devoops/feedbackservice/model/Feedback.java b/services/spring-feedback/src/generated/java/tum/devoops/feedbackservice/model/Feedback.java index 9a19a6d..eb2fa47 100644 --- a/services/spring-feedback/src/generated/java/tum/devoops/feedbackservice/model/Feedback.java +++ b/services/spring-feedback/src/generated/java/tum/devoops/feedbackservice/model/Feedback.java @@ -39,6 +39,8 @@ public class Feedback { private String feedback; + private Integer rating; + public Feedback() { super(); } @@ -46,13 +48,14 @@ public Feedback() { /** * Constructor with only required parameters */ - public Feedback(UUID id, Reference event, Reference member, Reference creator, OffsetDateTime createdAt, String feedback) { + public Feedback(UUID id, Reference event, Reference member, Reference creator, OffsetDateTime createdAt, String feedback, Integer rating) { this.id = id; this.event = event; this.member = member; this.creator = creator; this.createdAt = createdAt; this.feedback = feedback; + this.rating = rating; } public Feedback id(UUID id) { @@ -175,6 +178,28 @@ public void setFeedback(String feedback) { this.feedback = feedback; } + public Feedback rating(Integer rating) { + this.rating = rating; + return this; + } + + /** + * Get rating + * minimum: 0 + * maximum: 10 + * @return rating + */ + @NotNull @Min(0) @Max(10) + @Schema(name = "rating", requiredMode = Schema.RequiredMode.REQUIRED) + @JsonProperty("rating") + public Integer getRating() { + return rating; + } + + public void setRating(Integer rating) { + this.rating = rating; + } + @Override public boolean equals(Object o) { if (this == o) { @@ -189,12 +214,13 @@ public boolean equals(Object o) { Objects.equals(this.member, feedback.member) && Objects.equals(this.creator, feedback.creator) && Objects.equals(this.createdAt, feedback.createdAt) && - Objects.equals(this.feedback, feedback.feedback); + Objects.equals(this.feedback, feedback.feedback) && + Objects.equals(this.rating, feedback.rating); } @Override public int hashCode() { - return Objects.hash(id, event, member, creator, createdAt, feedback); + return Objects.hash(id, event, member, creator, createdAt, feedback, rating); } @Override @@ -207,6 +233,7 @@ public String toString() { sb.append(" creator: ").append(toIndentedString(creator)).append("\n"); sb.append(" createdAt: ").append(toIndentedString(createdAt)).append("\n"); sb.append(" feedback: ").append(toIndentedString(feedback)).append("\n"); + sb.append(" rating: ").append(toIndentedString(rating)).append("\n"); sb.append("}"); return sb.toString(); } diff --git a/services/spring-feedback/src/generated/java/tum/devoops/feedbackservice/model/FeedbackCreate.java b/services/spring-feedback/src/generated/java/tum/devoops/feedbackservice/model/FeedbackCreate.java index 62118cd..0d0f8d5 100644 --- a/services/spring-feedback/src/generated/java/tum/devoops/feedbackservice/model/FeedbackCreate.java +++ b/services/spring-feedback/src/generated/java/tum/devoops/feedbackservice/model/FeedbackCreate.java @@ -28,6 +28,8 @@ public class FeedbackCreate { private String feedback; + private Integer rating; + public FeedbackCreate() { super(); } @@ -35,10 +37,11 @@ public FeedbackCreate() { /** * Constructor with only required parameters */ - public FeedbackCreate(String event, String member, String feedback) { + public FeedbackCreate(String event, String member, String feedback, Integer rating) { this.event = event; this.member = member; this.feedback = feedback; + this.rating = rating; } public FeedbackCreate event(String event) { @@ -101,6 +104,28 @@ public void setFeedback(String feedback) { this.feedback = feedback; } + public FeedbackCreate rating(Integer rating) { + this.rating = rating; + return this; + } + + /** + * Get rating + * minimum: 0 + * maximum: 10 + * @return rating + */ + @NotNull @Min(0) @Max(10) + @Schema(name = "rating", requiredMode = Schema.RequiredMode.REQUIRED) + @JsonProperty("rating") + public Integer getRating() { + return rating; + } + + public void setRating(Integer rating) { + this.rating = rating; + } + @Override public boolean equals(Object o) { if (this == o) { @@ -112,12 +137,13 @@ public boolean equals(Object o) { FeedbackCreate feedbackCreate = (FeedbackCreate) o; return Objects.equals(this.event, feedbackCreate.event) && Objects.equals(this.member, feedbackCreate.member) && - Objects.equals(this.feedback, feedbackCreate.feedback); + Objects.equals(this.feedback, feedbackCreate.feedback) && + Objects.equals(this.rating, feedbackCreate.rating); } @Override public int hashCode() { - return Objects.hash(event, member, feedback); + return Objects.hash(event, member, feedback, rating); } @Override @@ -127,6 +153,7 @@ public String toString() { sb.append(" event: ").append(toIndentedString(event)).append("\n"); sb.append(" member: ").append(toIndentedString(member)).append("\n"); sb.append(" feedback: ").append(toIndentedString(feedback)).append("\n"); + sb.append(" rating: ").append(toIndentedString(rating)).append("\n"); sb.append("}"); return sb.toString(); } diff --git a/services/spring-feedback/src/generated/java/tum/devoops/feedbackservice/model/FeedbackPartialUpdate.java b/services/spring-feedback/src/generated/java/tum/devoops/feedbackservice/model/FeedbackPartialUpdate.java index 9f74f19..1db8ea3 100644 --- a/services/spring-feedback/src/generated/java/tum/devoops/feedbackservice/model/FeedbackPartialUpdate.java +++ b/services/spring-feedback/src/generated/java/tum/devoops/feedbackservice/model/FeedbackPartialUpdate.java @@ -28,6 +28,8 @@ public class FeedbackPartialUpdate { private @Nullable String feedback; + private @Nullable Integer rating; + public FeedbackPartialUpdate event(@Nullable String event) { this.event = event; return this; @@ -88,6 +90,28 @@ public void setFeedback(@Nullable String feedback) { this.feedback = feedback; } + public FeedbackPartialUpdate rating(@Nullable Integer rating) { + this.rating = rating; + return this; + } + + /** + * Get rating + * minimum: 0 + * maximum: 10 + * @return rating + */ + @Min(0) @Max(10) + @Schema(name = "rating", requiredMode = Schema.RequiredMode.NOT_REQUIRED) + @JsonProperty("rating") + public @Nullable Integer getRating() { + return rating; + } + + public void setRating(@Nullable Integer rating) { + this.rating = rating; + } + @Override public boolean equals(Object o) { if (this == o) { @@ -99,12 +123,13 @@ public boolean equals(Object o) { FeedbackPartialUpdate feedbackPartialUpdate = (FeedbackPartialUpdate) o; return Objects.equals(this.event, feedbackPartialUpdate.event) && Objects.equals(this.member, feedbackPartialUpdate.member) && - Objects.equals(this.feedback, feedbackPartialUpdate.feedback); + Objects.equals(this.feedback, feedbackPartialUpdate.feedback) && + Objects.equals(this.rating, feedbackPartialUpdate.rating); } @Override public int hashCode() { - return Objects.hash(event, member, feedback); + return Objects.hash(event, member, feedback, rating); } @Override @@ -114,6 +139,7 @@ public String toString() { sb.append(" event: ").append(toIndentedString(event)).append("\n"); sb.append(" member: ").append(toIndentedString(member)).append("\n"); sb.append(" feedback: ").append(toIndentedString(feedback)).append("\n"); + sb.append(" rating: ").append(toIndentedString(rating)).append("\n"); sb.append("}"); return sb.toString(); } diff --git a/services/spring-feedback/src/generated/java/tum/devoops/feedbackservice/model/FeedbackSummary.java b/services/spring-feedback/src/generated/java/tum/devoops/feedbackservice/model/FeedbackSummary.java index 53e6e74..48ca51f 100644 --- a/services/spring-feedback/src/generated/java/tum/devoops/feedbackservice/model/FeedbackSummary.java +++ b/services/spring-feedback/src/generated/java/tum/devoops/feedbackservice/model/FeedbackSummary.java @@ -37,6 +37,8 @@ public class FeedbackSummary { @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) private OffsetDateTime createdAt; + private Integer rating; + public FeedbackSummary() { super(); } @@ -44,12 +46,13 @@ public FeedbackSummary() { /** * Constructor with only required parameters */ - public FeedbackSummary(UUID id, Reference event, Reference member, Reference creator, OffsetDateTime createdAt) { + public FeedbackSummary(UUID id, Reference event, Reference member, Reference creator, OffsetDateTime createdAt, Integer rating) { this.id = id; this.event = event; this.member = member; this.creator = creator; this.createdAt = createdAt; + this.rating = rating; } public FeedbackSummary id(UUID id) { @@ -152,6 +155,28 @@ public void setCreatedAt(OffsetDateTime createdAt) { this.createdAt = createdAt; } + public FeedbackSummary rating(Integer rating) { + this.rating = rating; + return this; + } + + /** + * Get rating + * minimum: 0 + * maximum: 10 + * @return rating + */ + @NotNull @Min(0) @Max(10) + @Schema(name = "rating", requiredMode = Schema.RequiredMode.REQUIRED) + @JsonProperty("rating") + public Integer getRating() { + return rating; + } + + public void setRating(Integer rating) { + this.rating = rating; + } + @Override public boolean equals(Object o) { if (this == o) { @@ -165,12 +190,13 @@ public boolean equals(Object o) { Objects.equals(this.event, feedbackSummary.event) && Objects.equals(this.member, feedbackSummary.member) && Objects.equals(this.creator, feedbackSummary.creator) && - Objects.equals(this.createdAt, feedbackSummary.createdAt); + Objects.equals(this.createdAt, feedbackSummary.createdAt) && + Objects.equals(this.rating, feedbackSummary.rating); } @Override public int hashCode() { - return Objects.hash(id, event, member, creator, createdAt); + return Objects.hash(id, event, member, creator, createdAt, rating); } @Override @@ -182,6 +208,7 @@ public String toString() { sb.append(" member: ").append(toIndentedString(member)).append("\n"); sb.append(" creator: ").append(toIndentedString(creator)).append("\n"); sb.append(" createdAt: ").append(toIndentedString(createdAt)).append("\n"); + sb.append(" rating: ").append(toIndentedString(rating)).append("\n"); sb.append("}"); return sb.toString(); } diff --git a/services/spring-feedback/src/main/java/tum/devoops/feedbackservice/entity/FeedbackEntity.java b/services/spring-feedback/src/main/java/tum/devoops/feedbackservice/entity/FeedbackEntity.java index 1cd7ba7..f7e2f48 100644 --- a/services/spring-feedback/src/main/java/tum/devoops/feedbackservice/entity/FeedbackEntity.java +++ b/services/spring-feedback/src/main/java/tum/devoops/feedbackservice/entity/FeedbackEntity.java @@ -42,4 +42,7 @@ public class FeedbackEntity { @Column(name = "feedback", nullable = false, columnDefinition = "TEXT") private String feedback; + + @Column(name = "rating", nullable = false) + private Integer rating; } diff --git a/services/spring-feedback/src/main/java/tum/devoops/feedbackservice/service/FeedbackService.java b/services/spring-feedback/src/main/java/tum/devoops/feedbackservice/service/FeedbackService.java index 9f9dd5b..8169259 100644 --- a/services/spring-feedback/src/main/java/tum/devoops/feedbackservice/service/FeedbackService.java +++ b/services/spring-feedback/src/main/java/tum/devoops/feedbackservice/service/FeedbackService.java @@ -81,12 +81,15 @@ public Feedback createFeedback(FeedbackCreate body, UUID requesterId, boolean is assertTrainerOfMember(requesterId, memberId); } + assertRatingInRange(body.getRating()); + FeedbackEntity entity = new FeedbackEntity(); entity.setEventId(eventId); entity.setMemberId(memberId); entity.setCreatorId(requesterId); entity.setCreatedAt(Instant.now()); entity.setFeedback(body.getFeedback()); + entity.setRating(body.getRating()); return toFeedback(feedbackRepository.save(entity)); } @@ -126,6 +129,10 @@ public Feedback updateFeedbackDetails(UUID feedbackId, FeedbackPartialUpdate bod if (body.getFeedback() != null) { entity.setFeedback(body.getFeedback()); } + if (body.getRating() != null) { + assertRatingInRange(body.getRating()); + entity.setRating(body.getRating()); + } return toFeedback(feedbackRepository.save(entity)); } @@ -157,6 +164,12 @@ private FeedbackEntity findFeedbackOrThrow(UUID feedbackId) { .orElseThrow(() -> new NotFoundException("Feedback not found: " + feedbackId)); } + private void assertRatingInRange(Integer rating) { + if (rating != null && (rating < 0 || rating > 10)) { + throw new BadRequestException("rating must be between 0 and 10"); + } + } + private UUID parseUuid(String value, String fieldName) { if (value == null) { throw new BadRequestException("Field '" + fieldName + "' is required"); @@ -175,7 +188,8 @@ private Feedback toFeedback(FeedbackEntity entity) { memberReference(entity.getMemberId()), memberReference(entity.getCreatorId()), entity.getCreatedAt().atOffset(ZoneOffset.UTC), - entity.getFeedback() + entity.getFeedback(), + entity.getRating() ); } @@ -185,7 +199,8 @@ private FeedbackSummary toFeedbackSummary(FeedbackEntity entity) { eventReference(entity.getEventId()), memberReference(entity.getMemberId()), memberReference(entity.getCreatorId()), - entity.getCreatedAt().atOffset(ZoneOffset.UTC) + entity.getCreatedAt().atOffset(ZoneOffset.UTC), + entity.getRating() ); } diff --git a/services/spring-feedback/src/main/resources/db/migration/V3__add_rating.sql b/services/spring-feedback/src/main/resources/db/migration/V3__add_rating.sql new file mode 100644 index 0000000..1ce8d1e --- /dev/null +++ b/services/spring-feedback/src/main/resources/db/migration/V3__add_rating.sql @@ -0,0 +1,4 @@ +-- Add a numeric rating (0-10) to feedback. Existing rows are backfilled with 0; new rows always +-- supply it (required on create). The CHECK mirrors the API's 0-10 constraint as DB-level defense. +ALTER TABLE feedback.feedback + ADD COLUMN rating INTEGER NOT NULL DEFAULT 0 CHECK (rating >= 0 AND rating <= 10); diff --git a/services/spring-feedback/src/test/java/tum/devoops/feedbackservice/controller/FeedbackControllerTest.java b/services/spring-feedback/src/test/java/tum/devoops/feedbackservice/controller/FeedbackControllerTest.java index 7953fa1..ecdc09a 100644 --- a/services/spring-feedback/src/test/java/tum/devoops/feedbackservice/controller/FeedbackControllerTest.java +++ b/services/spring-feedback/src/test/java/tum/devoops/feedbackservice/controller/FeedbackControllerTest.java @@ -54,17 +54,18 @@ class FeedbackControllerTest { private Feedback sampleFeedback() { return new Feedback(FEEDBACK_ID, new Reference(EVENT_ID, "Training"), new Reference(MEMBER_ID, "Mary Member"), - new Reference(REQUESTER_ID, "Casey Creator"), OffsetDateTime.now(), "Great work!"); + new Reference(REQUESTER_ID, "Casey Creator"), OffsetDateTime.now(), "Great work!", 8); } private FeedbackSummary sampleSummary() { return new FeedbackSummary(FEEDBACK_ID, new Reference(EVENT_ID, "Training"), new Reference(MEMBER_ID, "Mary Member"), - new Reference(REQUESTER_ID, "Casey Creator"), OffsetDateTime.now()); + new Reference(REQUESTER_ID, "Casey Creator"), OffsetDateTime.now(), 8); } private String feedbackCreateJson(UUID eventId, UUID memberId, String text) { - return "{\"event\":\"" + eventId + "\",\"member\":\"" + memberId + "\",\"feedback\":\"" + text + "\"}"; + return "{\"event\":\"" + eventId + "\",\"member\":\"" + memberId + + "\",\"feedback\":\"" + text + "\",\"rating\":8}"; } private static RequestPostProcessor memberJwt() { @@ -133,7 +134,19 @@ void createFeedbackWithAuthReturns201AndBody() throws Exception { .andExpect(jsonPath("$.event").exists()) .andExpect(jsonPath("$.member").exists()) .andExpect(jsonPath("$.creator").exists()) - .andExpect(jsonPath("$.feedback").exists()); + .andExpect(jsonPath("$.feedback").exists()) + .andExpect(jsonPath("$.rating").value(8)); + } + + @Test + void createFeedbackWithOutOfRangeRatingReturns400() throws Exception { + String json = "{\"event\":\"" + EVENT_ID + "\",\"member\":\"" + MEMBER_ID + + "\",\"feedback\":\"x\",\"rating\":11}"; + mockMvc.perform(post("/feedback") + .with(memberJwt()) + .contentType(MediaType.APPLICATION_JSON) + .content(json)) + .andExpect(status().isBadRequest()); } @Test diff --git a/services/spring-feedback/src/test/java/tum/devoops/feedbackservice/service/FeedbackServiceTest.java b/services/spring-feedback/src/test/java/tum/devoops/feedbackservice/service/FeedbackServiceTest.java index 2e03bf8..28c881e 100644 --- a/services/spring-feedback/src/test/java/tum/devoops/feedbackservice/service/FeedbackServiceTest.java +++ b/services/spring-feedback/src/test/java/tum/devoops/feedbackservice/service/FeedbackServiceTest.java @@ -65,6 +65,7 @@ private FeedbackEntity makeEntity(UUID id, UUID eventId, UUID memberId, UUID cre e.setCreatorId(creatorId); e.setCreatedAt(Instant.now()); e.setFeedback("test feedback"); + e.setRating(7); return e; } @@ -135,7 +136,7 @@ void createFeedbackAsAdminSkipsTrainerCheck() { stubExistingEventAndMember(); FeedbackEntity saved = makeEntity(FEEDBACK_ID, EVENT_ID, MEMBER_ID, REQUESTER_ID); when(feedbackRepository.save(any())).thenReturn(saved); - FeedbackCreate body = new FeedbackCreate(EVENT_ID.toString(), MEMBER_ID.toString(), "Great work!"); + FeedbackCreate body = new FeedbackCreate(EVENT_ID.toString(), MEMBER_ID.toString(), "Great work!", 5); Feedback result = service.createFeedback(body, REQUESTER_ID, true); @@ -149,7 +150,7 @@ void createFeedbackAsTrainerWithSharedTeamSucceeds() { stubTrainerOfMember(); FeedbackEntity saved = makeEntity(FEEDBACK_ID, EVENT_ID, MEMBER_ID, REQUESTER_ID); when(feedbackRepository.save(any())).thenReturn(saved); - FeedbackCreate body = new FeedbackCreate(EVENT_ID.toString(), MEMBER_ID.toString(), "Keep it up!"); + FeedbackCreate body = new FeedbackCreate(EVENT_ID.toString(), MEMBER_ID.toString(), "Keep it up!", 5); Feedback result = service.createFeedback(body, REQUESTER_ID, false); @@ -164,7 +165,7 @@ void createFeedbackAsTrainerWithoutSharedTeamThrowsForbidden() { .thenReturn(List.of(new TrainerEntity(new TrainerEntity.Id(TEAM_ID, REQUESTER_ID)))); when(traineeRepository.findAllById_MemberId(MEMBER_ID)) .thenReturn(List.of(new TraineeEntity(new TraineeEntity.Id(otherTeam, MEMBER_ID)))); - FeedbackCreate body = new FeedbackCreate(EVENT_ID.toString(), MEMBER_ID.toString(), "x"); + FeedbackCreate body = new FeedbackCreate(EVENT_ID.toString(), MEMBER_ID.toString(), "x", 5); assertThatThrownBy(() -> service.createFeedback(body, REQUESTER_ID, false)) .isInstanceOf(ForbiddenException.class); @@ -175,7 +176,7 @@ void createFeedbackAsTrainerWithNoTeamsThrowsForbidden() { stubExistingEventAndMember(); when(trainerRepository.findAllById_MemberId(REQUESTER_ID)).thenReturn(List.of()); when(traineeRepository.findAllById_MemberId(MEMBER_ID)).thenReturn(List.of()); - FeedbackCreate body = new FeedbackCreate(EVENT_ID.toString(), MEMBER_ID.toString(), "x"); + FeedbackCreate body = new FeedbackCreate(EVENT_ID.toString(), MEMBER_ID.toString(), "x", 5); assertThatThrownBy(() -> service.createFeedback(body, REQUESTER_ID, false)) .isInstanceOf(ForbiddenException.class); @@ -184,7 +185,7 @@ void createFeedbackAsTrainerWithNoTeamsThrowsForbidden() { @Test void createFeedbackWithNonExistentEventThrowsBadRequest() { when(eventRepository.existsById(EVENT_ID)).thenReturn(false); - FeedbackCreate body = new FeedbackCreate(EVENT_ID.toString(), MEMBER_ID.toString(), "x"); + FeedbackCreate body = new FeedbackCreate(EVENT_ID.toString(), MEMBER_ID.toString(), "x", 5); assertThatThrownBy(() -> service.createFeedback(body, REQUESTER_ID, true)) .isInstanceOf(BadRequestException.class) @@ -195,7 +196,7 @@ void createFeedbackWithNonExistentEventThrowsBadRequest() { void createFeedbackWithNonExistentMemberThrowsBadRequest() { when(eventRepository.existsById(EVENT_ID)).thenReturn(true); when(memberRepository.existsById(MEMBER_ID)).thenReturn(false); - FeedbackCreate body = new FeedbackCreate(EVENT_ID.toString(), MEMBER_ID.toString(), "x"); + FeedbackCreate body = new FeedbackCreate(EVENT_ID.toString(), MEMBER_ID.toString(), "x", 5); assertThatThrownBy(() -> service.createFeedback(body, REQUESTER_ID, true)) .isInstanceOf(BadRequestException.class) @@ -204,7 +205,7 @@ void createFeedbackWithNonExistentMemberThrowsBadRequest() { @Test void createFeedbackWithInvalidEventUuidThrowsBadRequest() { - FeedbackCreate body = new FeedbackCreate("not-a-uuid", MEMBER_ID.toString(), "x"); + FeedbackCreate body = new FeedbackCreate("not-a-uuid", MEMBER_ID.toString(), "x", 5); assertThatThrownBy(() -> service.createFeedback(body, REQUESTER_ID, true)) .isInstanceOf(BadRequestException.class); @@ -212,7 +213,29 @@ void createFeedbackWithInvalidEventUuidThrowsBadRequest() { @Test void createFeedbackWithInvalidMemberUuidThrowsBadRequest() { - FeedbackCreate body = new FeedbackCreate(EVENT_ID.toString(), "not-a-uuid", "x"); + FeedbackCreate body = new FeedbackCreate(EVENT_ID.toString(), "not-a-uuid", "x", 5); + + assertThatThrownBy(() -> service.createFeedback(body, REQUESTER_ID, true)) + .isInstanceOf(BadRequestException.class); + } + + @Test + void createFeedbackPersistsRating() { + stubExistingEventAndMember(); + ArgumentCaptor captor = ArgumentCaptor.forClass(FeedbackEntity.class); + when(feedbackRepository.save(captor.capture())).thenAnswer(inv -> inv.getArgument(0)); + FeedbackCreate body = new FeedbackCreate(EVENT_ID.toString(), MEMBER_ID.toString(), "ok", 9); + + Feedback result = service.createFeedback(body, REQUESTER_ID, true); + + assertThat(captor.getValue().getRating()).isEqualTo(9); + assertThat(result.getRating()).isEqualTo(9); + } + + @Test + void createFeedbackWithOutOfRangeRatingThrowsBadRequest() { + stubExistingEventAndMember(); + FeedbackCreate body = new FeedbackCreate(EVENT_ID.toString(), MEMBER_ID.toString(), "ok", 11); assertThatThrownBy(() -> service.createFeedback(body, REQUESTER_ID, true)) .isInstanceOf(BadRequestException.class); @@ -324,6 +347,30 @@ void updateFeedbackDetailsWithAllNullFieldsDoesNotModifyEntity() { assertThat(captor.getValue().getFeedback()).isEqualTo("test feedback"); assertThat(captor.getValue().getEventId()).isEqualTo(EVENT_ID); assertThat(captor.getValue().getMemberId()).isEqualTo(MEMBER_ID); + assertThat(captor.getValue().getRating()).isEqualTo(7); + } + + @Test + void updateFeedbackDetailsSetsRating() { + FeedbackEntity e = makeEntity(FEEDBACK_ID, EVENT_ID, MEMBER_ID, REQUESTER_ID); + when(feedbackRepository.findById(FEEDBACK_ID)).thenReturn(Optional.of(e)); + ArgumentCaptor captor = ArgumentCaptor.forClass(FeedbackEntity.class); + when(feedbackRepository.save(captor.capture())).thenReturn(e); + + service.updateFeedbackDetails( + FEEDBACK_ID, new FeedbackPartialUpdate().rating(3), REQUESTER_ID, false); + + assertThat(captor.getValue().getRating()).isEqualTo(3); + } + + @Test + void updateFeedbackDetailsWithOutOfRangeRatingThrowsBadRequest() { + FeedbackEntity e = makeEntity(FEEDBACK_ID, EVENT_ID, MEMBER_ID, REQUESTER_ID); + when(feedbackRepository.findById(FEEDBACK_ID)).thenReturn(Optional.of(e)); + + assertThatThrownBy(() -> service.updateFeedbackDetails( + FEEDBACK_ID, new FeedbackPartialUpdate().rating(-1), REQUESTER_ID, false)) + .isInstanceOf(BadRequestException.class); } @Test diff --git a/web-client/src/api.ts b/web-client/src/api.ts index a920f7a..6f5a422 100644 --- a/web-client/src/api.ts +++ b/web-client/src/api.ts @@ -685,6 +685,8 @@ export interface components { /** Format: date-time */ created_at: string; feedback: string; + /** Format: int32 */ + rating: number; }; /** @description A simplified representation of a Feedback, typically used in list views. */ FeedbackSummary: { @@ -695,18 +697,24 @@ export interface components { creator: components["schemas"]["Reference"]; /** Format: date-time */ created_at: string; + /** Format: int32 */ + rating: number; }; /** @description Data transfer object for partially updating an existing Feedback (PATCH operation). */ FeedbackPartialUpdate: { event?: string; member?: string; feedback?: string; + /** Format: int32 */ + rating?: number; }; /** @description Data transfer object for creating a new Feedback. */ FeedbackCreate: { event: string; member: string; feedback: string; + /** Format: int32 */ + rating: number; }; /** @description The object representation of a Transaction, which includes details such as the member, creator, amount, and timestamps. */ Transaction: { From fa290c9bf0b5606359dd7b2276ebafe7dadcf73d Mon Sep 17 00:00:00 2001 From: Raphael Frank <04.raphael.frank@gmail.com> Date: Sun, 28 Jun 2026 12:31:39 +0200 Subject: [PATCH 06/16] add report storage and endpoints --- api/openapi.yaml | 217 +++++++++++++- infra/docker-compose.yml | 9 + .../templates/postgres-init-configmap.yaml | 21 +- .../team-devoops/templates/secret-db.yaml | 1 + infra/helm/team-devoops/values.yaml | 6 +- infra/postgres/init-db.sh | 20 +- infra/postgres/provision-reports.sql | 44 +++ services/py-genai-helper/Dockerfile | 7 +- services/py-genai-helper/app.py | 121 +++++++- services/py-genai-helper/auth.py | 14 +- services/py-genai-helper/conftest.py | 20 ++ services/py-genai-helper/db.py | 206 +++++++++++++ .../py-genai-helper/generated/__init__.py | 0 services/py-genai-helper/generated/models.py | 29 +- services/py-genai-helper/reports.py | 58 ++++ services/py-genai-helper/requirements.txt | 1 + .../py-genai-helper/tests/test_reports.py | 276 ++++++++++++++++++ web-client/src/api.ts | 229 ++++++++++++++- 18 files changed, 1241 insertions(+), 38 deletions(-) create mode 100644 infra/postgres/provision-reports.sql create mode 100644 services/py-genai-helper/conftest.py create mode 100644 services/py-genai-helper/db.py create mode 100644 services/py-genai-helper/generated/__init__.py create mode 100644 services/py-genai-helper/reports.py create mode 100644 services/py-genai-helper/tests/test_reports.py diff --git a/api/openapi.yaml b/api/openapi.yaml index f725cbb..6f92ddb 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -1080,25 +1080,49 @@ paths: text/html: schema: type: string - /helper/report/{member_id}: - get: - operationId: generateReport + /helper/reports/member/{member_id}: + post: + operationId: generateMemberReport tags: - helper - summary: Generate report + summary: Generate member report description: | - Generates an AI-based report for a member. Members can only generate reports for themselves. - - All authenticated users: can generate a report for themselves. - - Trainers: can generate reports for members of their team. + Kicks off asynchronous generation of an AI report for a member. Returns immediately; the + finished report is persisted and can later be fetched via the report endpoints. + - The member themselves: can generate their own report. - Admin: can generate a report for any member. + responses: + "202": + description: The request was accepted and report generation has been started. + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "500": + $ref: "#/components/responses/InternalServerError" + parameters: + - $ref: "#/components/parameters/member_id" + security: + - BearerJwt: [] + get: + operationId: listMemberReports + tags: + - helper + summary: List member reports + description: | + Lists the stored report summaries (without text) for a member, newest first. + - The member themselves: can list their own reports. + - Admin: can list reports for any member. responses: "200": description: The request was successful, and the server has returned the requested resource in the response body. content: - text/plain: + application/json: schema: - type: string + type: array + items: + $ref: "#/components/schemas/MemberReportSummary" "401": $ref: "#/components/responses/Unauthorized" "403": @@ -1109,6 +1133,114 @@ paths: - $ref: "#/components/parameters/member_id" security: - BearerJwt: [] + /helper/reports/team/{team_id}: + post: + operationId: generateTeamReport + tags: + - helper + summary: Generate team report + description: | + Kicks off asynchronous generation of an AI report for a team. Returns immediately; the + finished report is persisted and can later be fetched via the report endpoints. + - Trainers of the team: can generate the team's report. + - Admin: can generate a report for any team. + responses: + "202": + description: The request was accepted and report generation has been started. + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "500": + $ref: "#/components/responses/InternalServerError" + parameters: + - $ref: "#/components/parameters/team_id" + security: + - BearerJwt: [] + get: + operationId: listTeamReports + tags: + - helper + summary: List team reports + description: | + Lists the stored report summaries (without text) for a team, newest first. + - Trainers of the team: can list the team's reports. + - Admin: can list reports for any team. + responses: + "200": + description: The request was successful, and the server has returned the + requested resource in the response body. + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/TeamReportSummary" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "500": + $ref: "#/components/responses/InternalServerError" + parameters: + - $ref: "#/components/parameters/team_id" + security: + - BearerJwt: [] + /helper/reports/{report_id}: + get: + operationId: getReport + tags: + - helper + summary: Get report + description: | + Returns a single stored report including its full text. Works for both member and team + reports; the `kind` field indicates which, and the matching reference is populated. + - Member reports: the member themselves or an admin. + - Team reports: a trainer of the team or an admin. + responses: + "200": + description: The request was successful, and the server has returned the + requested resource in the response body. + content: + application/json: + schema: + $ref: "#/components/schemas/Report" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "404": + $ref: "#/components/responses/NotFound" + "500": + $ref: "#/components/responses/InternalServerError" + parameters: + - $ref: "#/components/parameters/report_id" + security: + - BearerJwt: [] + delete: + operationId: deleteReport + tags: + - helper + summary: Delete report + description: | + Deletes a single stored report (member or team). + - Member reports: the member themselves or an admin. + - Team reports: a trainer of the team or an admin. + responses: + "204": + $ref: "#/components/responses/NoContent" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "404": + $ref: "#/components/responses/NotFound" + "500": + $ref: "#/components/responses/InternalServerError" + parameters: + - $ref: "#/components/parameters/report_id" + security: + - BearerJwt: [] components: responses: InternalServerError: @@ -1206,6 +1338,13 @@ components: schema: type: string format: uuid + report_id: + name: report_id + in: path + required: true + schema: + type: string + format: uuid schemas: ErrorResponse: type: object @@ -1237,6 +1376,66 @@ components: - id - name description: A lightweight reference to another entity — its id plus a display name. + MemberReportSummary: + type: object + properties: + id: + type: string + format: uuid + member: + $ref: "#/components/schemas/Reference" + created_at: + type: string + format: date-time + required: + - id + - member + - created_at + description: Summary of a stored member report, without its generated text. + TeamReportSummary: + type: object + properties: + id: + type: string + format: uuid + team: + $ref: "#/components/schemas/Reference" + created_at: + type: string + format: date-time + required: + - id + - team + - created_at + description: Summary of a stored team report, without its generated text. + Report: + type: object + properties: + id: + type: string + format: uuid + kind: + type: string + enum: + - member + - team + member: + $ref: "#/components/schemas/Reference" + team: + $ref: "#/components/schemas/Reference" + created_at: + type: string + format: date-time + text: + type: string + required: + - id + - kind + - created_at + - text + description: | + A stored report including its generated text. `kind` indicates whether it is a member or + team report; the matching `member`/`team` reference is populated accordingly. Sport: type: object properties: diff --git a/infra/docker-compose.yml b/infra/docker-compose.yml index 06e3c0d..28b6bd3 100644 --- a/infra/docker-compose.yml +++ b/infra/docker-compose.yml @@ -4,11 +4,18 @@ services: py-genai-helper: build: ../services/py-genai-helper/ container_name: py-genai-helper + restart: on-failure env_file: - ../services/py-genai-helper/.env + depends_on: + app-database: + condition: service_healthy environment: - KEYCLOAK_ISSUER_URL=https://team-devoops.uaenorth.cloudapp.azure.com/auth/realms/devops - KEYCLOAK_JWKS_URL=http://keycloak:8080/auth/realms/devops/protocol/openid-connect/certs + - SPRING_DATASOURCE_URL=jdbc:postgresql://app-database:5432/app_db + - SPRING_DATASOURCE_USERNAME=reports_user + - SPRING_DATASOURCE_PASSWORD=reports_password expose: - 5000 labels: @@ -22,6 +29,7 @@ services: - "traefik.http.services.py-genai-helper.loadbalancer.server.port=5000" networks: - proxy + - data organization-service: build: ../services/spring-organization @@ -363,6 +371,7 @@ services: FEEDBACK_USER_PASSWORD: feedback_password FINANCE_USER_PASSWORD: finance_password LETTER_USER_PASSWORD: letter_password + REPORTS_USER_PASSWORD: reports_password volumes: - app_db_data:/var/lib/postgresql/data - ./postgres/init-db.sh:/docker-entrypoint-initdb.d/init-db.sh:ro diff --git a/infra/helm/team-devoops/templates/postgres-init-configmap.yaml b/infra/helm/team-devoops/templates/postgres-init-configmap.yaml index a7b6a96..d959249 100644 --- a/infra/helm/team-devoops/templates/postgres-init-configmap.yaml +++ b/infra/helm/team-devoops/templates/postgres-init-configmap.yaml @@ -31,16 +31,17 @@ data: CREATE USER feedback_user WITH PASSWORD '${FEEDBACK_USER_PASSWORD}'; CREATE USER finance_user WITH PASSWORD '${FINANCE_USER_PASSWORD}'; CREATE USER letter_user WITH PASSWORD '${LETTER_USER_PASSWORD}'; + CREATE USER reports_user WITH PASSWORD '${REPORTS_USER_PASSWORD}'; -- Allow all users to connect to the application database GRANT CONNECT ON DATABASE ${DB} TO organization_user, member_user, event_user, - feedback_user, finance_user, letter_user; + feedback_user, finance_user, letter_user, reports_user; -- All users inherit the reader role GRANT reader TO organization_user, member_user, event_user, - feedback_user, finance_user, letter_user; + feedback_user, finance_user, letter_user, reports_user; -- Schemas: organization CREATE SCHEMA organization; @@ -57,21 +58,25 @@ data: -- Schemas: finance CREATE SCHEMA finance; + -- Schemas: reports + CREATE SCHEMA reports; + -- Ownership ALTER SCHEMA organization OWNER TO organization_user; ALTER SCHEMA member OWNER TO member_user; ALTER SCHEMA event OWNER TO event_user; ALTER SCHEMA feedback OWNER TO feedback_user; ALTER SCHEMA finance OWNER TO finance_user; + ALTER SCHEMA reports OWNER TO reports_user; -- Reader USAGE on all schemas GRANT USAGE ON SCHEMA - organization, member, event, feedback, finance + organization, member, event, feedback, finance, reports TO reader; -- Reader SELECT on existing tables (defensive) GRANT SELECT ON ALL TABLES IN SCHEMA - organization, member, event, feedback, finance + organization, member, event, feedback, finance, reports TO reader; -- Default privileges for future tables @@ -95,17 +100,21 @@ data: IN SCHEMA finance GRANT SELECT ON TABLES TO reader; + ALTER DEFAULT PRIVILEGES FOR ROLE reports_user + IN SCHEMA reports + GRANT SELECT ON TABLES TO reader; + -- ----------------------------------------------------------------------- -- Cross-schema REFERENCES: required for FK constraints across schemas. -- Granted per-user on the schemas they reference. -- ----------------------------------------------------------------------- ALTER DEFAULT PRIVILEGES FOR ROLE member_user IN SCHEMA member - GRANT REFERENCES ON TABLES TO organization_user, event_user, feedback_user, finance_user; + GRANT REFERENCES ON TABLES TO organization_user, event_user, feedback_user, finance_user, reports_user; ALTER DEFAULT PRIVILEGES FOR ROLE organization_user IN SCHEMA organization - GRANT REFERENCES ON TABLES TO event_user; + GRANT REFERENCES ON TABLES TO event_user, reports_user; ALTER DEFAULT PRIVILEGES FOR ROLE event_user IN SCHEMA event diff --git a/infra/helm/team-devoops/templates/secret-db.yaml b/infra/helm/team-devoops/templates/secret-db.yaml index 9eb50cc..2c0fc78 100644 --- a/infra/helm/team-devoops/templates/secret-db.yaml +++ b/infra/helm/team-devoops/templates/secret-db.yaml @@ -27,6 +27,7 @@ stringData: FEEDBACK_USER_PASSWORD: {{ .Values.database.users.feedback.password | quote }} FINANCE_USER_PASSWORD: {{ .Values.database.users.finance.password | quote }} LETTER_USER_PASSWORD: {{ .Values.database.users.letter.password | quote }} + REPORTS_USER_PASSWORD: {{ .Values.database.users.reports.password | quote }} --- # Per-service datasource password secrets {{- range $key, $user := .Values.database.users }} diff --git a/infra/helm/team-devoops/values.yaml b/infra/helm/team-devoops/values.yaml index d1a1ab8..5b5df55 100644 --- a/infra/helm/team-devoops/values.yaml +++ b/infra/helm/team-devoops/values.yaml @@ -45,6 +45,9 @@ database: letter: username: letter_user password: letter_password + reports: + username: reports_user + password: reports_password image: postgres:15.6-alpine storageSize: 5Gi resources: @@ -239,7 +242,8 @@ services: py-genai-helper: path: /api/v1/helper port: 5000 - db: false + db: true + dbUser: reports health: /health stripPrefix: true envFromSecret: genai-env diff --git a/infra/postgres/init-db.sh b/infra/postgres/init-db.sh index 3043874..f7032f6 100755 --- a/infra/postgres/init-db.sh +++ b/infra/postgres/init-db.sh @@ -12,6 +12,7 @@ # FEEDBACK_USER_PASSWORD # FINANCE_USER_PASSWORD # LETTER_USER_PASSWORD +# REPORTS_USER_PASSWORD set -euo pipefail DB="${POSTGRES_DB}" @@ -33,16 +34,17 @@ CREATE USER event_user WITH PASSWORD '${EVENT_USER_PASSWORD}'; CREATE USER feedback_user WITH PASSWORD '${FEEDBACK_USER_PASSWORD}'; CREATE USER finance_user WITH PASSWORD '${FINANCE_USER_PASSWORD}'; CREATE USER letter_user WITH PASSWORD '${LETTER_USER_PASSWORD}'; +CREATE USER reports_user WITH PASSWORD '${REPORTS_USER_PASSWORD}'; -- Allow all users to connect to the application database GRANT CONNECT ON DATABASE ${DB} TO organization_user, member_user, event_user, - feedback_user, finance_user, letter_user; + feedback_user, finance_user, letter_user, reports_user; -- All users inherit the reader role GRANT reader TO organization_user, member_user, event_user, - feedback_user, finance_user, letter_user; + feedback_user, finance_user, letter_user, reports_user; -- ------------------------------------------------------------------------- -- Schemas (one per service) @@ -52,6 +54,7 @@ CREATE SCHEMA member; CREATE SCHEMA event; CREATE SCHEMA feedback; CREATE SCHEMA finance; +CREATE SCHEMA reports; -- ------------------------------------------------------------------------- -- Ownership: each service user owns its schema @@ -61,17 +64,18 @@ ALTER SCHEMA member OWNER TO member_user; ALTER SCHEMA event OWNER TO event_user; ALTER SCHEMA feedback OWNER TO feedback_user; ALTER SCHEMA finance OWNER TO finance_user; +ALTER SCHEMA reports OWNER TO reports_user; -- ------------------------------------------------------------------------- -- Reader role: USAGE on all schemas + SELECT on all current tables -- ------------------------------------------------------------------------- GRANT USAGE ON SCHEMA - organization, member, event, feedback, finance + organization, member, event, feedback, finance, reports TO reader; -- SELECT on any tables that already exist (none yet, but defensive) GRANT SELECT ON ALL TABLES IN SCHEMA - organization, member, event, feedback, finance + organization, member, event, feedback, finance, reports TO reader; -- ------------------------------------------------------------------------- @@ -98,17 +102,21 @@ ALTER DEFAULT PRIVILEGES FOR ROLE finance_user IN SCHEMA finance GRANT SELECT ON TABLES TO reader; +ALTER DEFAULT PRIVILEGES FOR ROLE reports_user + IN SCHEMA reports + GRANT SELECT ON TABLES TO reader; + -- ------------------------------------------------------------------------- -- Cross-schema REFERENCES: required for FK constraints across schemas. -- Granted per-user on the schemas they reference. -- ------------------------------------------------------------------------- ALTER DEFAULT PRIVILEGES FOR ROLE member_user IN SCHEMA member - GRANT REFERENCES ON TABLES TO organization_user, event_user, feedback_user, finance_user; + GRANT REFERENCES ON TABLES TO organization_user, event_user, feedback_user, finance_user, reports_user; ALTER DEFAULT PRIVILEGES FOR ROLE organization_user IN SCHEMA organization - GRANT REFERENCES ON TABLES TO event_user; + GRANT REFERENCES ON TABLES TO event_user, reports_user; ALTER DEFAULT PRIVILEGES FOR ROLE event_user IN SCHEMA event diff --git a/infra/postgres/provision-reports.sql b/infra/postgres/provision-reports.sql new file mode 100644 index 0000000..d1e8ca0 --- /dev/null +++ b/infra/postgres/provision-reports.sql @@ -0,0 +1,44 @@ +-- provision-reports.sql — one-time provisioning of the reports user + schema for an EXISTING +-- database (where init-db.sh has already run and will not run again on the persistent volume). +-- +-- Idempotent: safe to run more than once. Run as the database admin against the application DB, e.g. +-- docker exec -i app-database psql -U app_admin -d app_db < infra/postgres/provision-reports.sql +-- kubectl exec -i -- psql -U app_admin -d app_db < infra/postgres/provision-reports.sql +-- +-- The report TABLES themselves are created by the genai-helper on startup (CREATE TABLE IF NOT +-- EXISTS); this script only sets up the role, schema, and grants. The password below is the dev +-- default used in docker-compose/Helm — change it for a real secret before running in production. + +DO $$ +BEGIN + IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = 'reports_user') THEN + CREATE USER reports_user WITH PASSWORD 'reports_password'; + END IF; + EXECUTE format('GRANT CONNECT ON DATABASE %I TO reports_user', current_database()); +END +$$; + +GRANT reader TO reports_user; + +CREATE SCHEMA IF NOT EXISTS reports AUTHORIZATION reports_user; + +GRANT USAGE ON SCHEMA reports TO reader; +GRANT SELECT ON ALL TABLES IN SCHEMA reports TO reader; + +ALTER DEFAULT PRIVILEGES FOR ROLE reports_user + IN SCHEMA reports + GRANT SELECT ON TABLES TO reader; + +-- REFERENCES on the existing member/organization tables so the report tables (created by the +-- genai-helper at startup) can declare foreign keys into them. On a fresh DB this is covered by the +-- ALTER DEFAULT PRIVILEGES in init-db.sh; here we grant it explicitly on the already-existing tables. +DO $$ +BEGIN + IF to_regclass('member.members') IS NOT NULL THEN + GRANT REFERENCES ON member.members TO reports_user; + END IF; + IF to_regclass('organization.teams') IS NOT NULL THEN + GRANT REFERENCES ON organization.teams TO reports_user; + END IF; +END +$$; diff --git a/services/py-genai-helper/Dockerfile b/services/py-genai-helper/Dockerfile index 0c4da64..6a688b9 100644 --- a/services/py-genai-helper/Dockerfile +++ b/services/py-genai-helper/Dockerfile @@ -2,8 +2,8 @@ FROM python:3.12.9-alpine3.21 AS builder WORKDIR /code -# Installs gcc and other dependencies -RUN apk add --no-cache gcc musl-dev linux-headers +# Installs gcc and other dependencies (postgresql-dev provides libpq + pg_config for psycopg2) +RUN apk add --no-cache gcc musl-dev linux-headers postgresql-dev # Install dependencies COPY requirements.txt requirements.txt @@ -13,6 +13,9 @@ RUN pip install --no-cache-dir --prefix=/install -r requirements.txt FROM python:3.12.9-alpine3.21 WORKDIR /code +# Runtime shared library for psycopg2 (libpq) +RUN apk add --no-cache libpq + # Copy installed python packages from builder stage COPY --from=builder /install /usr/local diff --git a/services/py-genai-helper/app.py b/services/py-genai-helper/app.py index c6da44d..6de64c1 100644 --- a/services/py-genai-helper/app.py +++ b/services/py-genai-helper/app.py @@ -1,10 +1,24 @@ -from flask import Flask, request +from flask import Flask, g, jsonify, request -from auth import require_auth +import db +import reports +from auth import is_admin, require_auth +from generated.models import ( + MemberReportSummary, + Reference, + Report, + TeamReportSummary, +) from service import generate_rag_response, hello app = Flask("genai-service") +# Ensure the report tables exist. This intentionally fails loudly: if the database (or the tables it +# references) isn't ready yet, startup aborts and the container restarts and retries — the same +# behaviour the Spring services rely on for cross-schema foreign keys. The test suite stubs init_db +# (see conftest.py), so this does not require a live database in tests. +db.init_db() + @app.route("/hello") @require_auth @@ -30,3 +44,106 @@ def rag_response(): response = generate_rag_response(question) return {"response": response}, 200 + + +# --------------------------------------------------------------------------- # +# Reports +# --------------------------------------------------------------------------- # +def _member_reference(member_id) -> Reference: + return Reference(id=member_id, name=db.resolve_member_name(member_id) or "") + + +def _team_reference(team_id) -> Reference: + return Reference(id=team_id, name=db.resolve_team_name(team_id) or "") + + +@app.route("/reports/member/", methods=["POST"]) +@require_auth +def generate_member_report(member_id): + if g.user_id != member_id and not is_admin(): + return {"error": "Access denied"}, 403 + reports.trigger_member_report(member_id, g.token) + return "", 202 + + +@app.route("/reports/member/", methods=["GET"]) +@require_auth +def list_member_reports(member_id): + if g.user_id != member_id and not is_admin(): + return {"error": "Access denied"}, 403 + reference = _member_reference(member_id) + summaries = [ + MemberReportSummary(id=row["id"], member=reference, created_at=row["created_at"]) + for row in db.list_member_reports(member_id) + ] + return jsonify([s.model_dump(mode="json") for s in summaries]), 200 + + +@app.route("/reports/team/", methods=["POST"]) +@require_auth +def generate_team_report(team_id): + if not is_admin() and not db.is_trainer_of_team(g.user_id, team_id): + return {"error": "Access denied"}, 403 + reports.trigger_team_report(team_id, g.token) + return "", 202 + + +@app.route("/reports/team/", methods=["GET"]) +@require_auth +def list_team_reports(team_id): + if not is_admin() and not db.is_trainer_of_team(g.user_id, team_id): + return {"error": "Access denied"}, 403 + reference = _team_reference(team_id) + summaries = [ + TeamReportSummary(id=row["id"], team=reference, created_at=row["created_at"]) + for row in db.list_team_reports(team_id) + ] + return jsonify([s.model_dump(mode="json") for s in summaries]), 200 + + +def _authorize_report(row) -> bool: + if is_admin(): + return True + if row["kind"] == "member": + return g.user_id == str(row["member_id"]) + return db.is_trainer_of_team(g.user_id, str(row["team_id"])) + + +@app.route("/reports/", methods=["GET"]) +@require_auth +def get_report(report_id): + row = db.get_report(report_id) + if row is None: + return {"error": "Report not found"}, 404 + if not _authorize_report(row): + return {"error": "Access denied"}, 403 + + if row["kind"] == "member": + report = Report( + id=row["id"], + kind="member", + member=_member_reference(row["member_id"]), + created_at=row["created_at"], + text=row["text"], + ) + else: + report = Report( + id=row["id"], + kind="team", + team=_team_reference(row["team_id"]), + created_at=row["created_at"], + text=row["text"], + ) + return jsonify(report.model_dump(mode="json", exclude_none=True)), 200 + + +@app.route("/reports/", methods=["DELETE"]) +@require_auth +def delete_report(report_id): + row = db.get_report(report_id) + if row is None: + return {"error": "Report not found"}, 404 + if not _authorize_report(row): + return {"error": "Access denied"}, 403 + db.delete_report(report_id) + return "", 204 diff --git a/services/py-genai-helper/auth.py b/services/py-genai-helper/auth.py index 136b89e..932e301 100644 --- a/services/py-genai-helper/auth.py +++ b/services/py-genai-helper/auth.py @@ -2,7 +2,7 @@ from functools import wraps import jwt -from flask import request +from flask import g, request KEYCLOAK_ISSUER_URL = os.environ.get( "KEYCLOAK_ISSUER_URL", @@ -33,7 +33,7 @@ def decorated(*args, **kwargs): token = auth_header[len("Bearer ") :] try: signing_key = _get_signing_key(token) - jwt.decode( + payload = jwt.decode( token, signing_key.key, algorithms=["RS256"], @@ -45,6 +45,16 @@ def decorated(*args, **kwargs): except Exception: return {"error": "Invalid token"}, 401 + # Expose the caller's identity and roles for authorization checks. + g.user_id = payload.get("sub") + g.roles = payload.get("realm_access", {}).get("roles", []) + g.token = token + return f(*args, **kwargs) return decorated + + +def is_admin() -> bool: + """True if the authenticated caller has the admin realm role.""" + return "admin" in getattr(g, "roles", []) diff --git a/services/py-genai-helper/conftest.py b/services/py-genai-helper/conftest.py new file mode 100644 index 0000000..ad41bdf --- /dev/null +++ b/services/py-genai-helper/conftest.py @@ -0,0 +1,20 @@ +"""Test setup shared across the suite. + +Importing ``app`` pulls in ``service``/``rag``, which at import time build an LLM agent and a FAISS +vector store from the bundled PDFs (real OpenAI calls). Tests stub ``service`` out before ``app`` is +imported, and neutralise the startup DB initialisation so no live database is required. +""" + +import sys +import types + +import db + +# Stub the heavy LLM service module so importing `app` doesn't trigger model/embedding setup. +_service_stub = types.ModuleType("service") +_service_stub.generate_rag_response = lambda question: "stub" +_service_stub.hello = lambda: "stub" +sys.modules.setdefault("service", _service_stub) + +# `app` calls db.init_db() at import; there is no database in the test environment. +db.init_db = lambda: None diff --git a/services/py-genai-helper/db.py b/services/py-genai-helper/db.py new file mode 100644 index 0000000..2064491 --- /dev/null +++ b/services/py-genai-helper/db.py @@ -0,0 +1,206 @@ +"""Persistence for generated reports. + +The genai-helper owns the ``reports`` schema (two tables: member and team reports). The schema and +its user are provisioned out-of-band (infra/postgres/init-db.sh on a fresh DB, or +provision-reports.sql on an existing one); the tables themselves are created here on startup via +idempotent ``CREATE TABLE IF NOT EXISTS`` (Python has no Flyway). Member/team display names and the +trainer-of-team relationship are read from the other services' schemas via the shared reader role. +""" + +import os +import uuid +from datetime import UTC, datetime + +from sqlalchemy import create_engine, text +from sqlalchemy.engine import make_url + +_engine = None + + +def _build_url(): + # Reuse the SPRING_DATASOURCE_* convention so docker-compose and Helm inject DB config the same + # way they do for the Spring services. The JDBC URL (jdbc:postgresql://host:port/db) is converted + # to a SQLAlchemy URL with the psycopg2 driver and the reports_user credentials. + jdbc = os.environ.get("SPRING_DATASOURCE_URL", "jdbc:postgresql://app-database:5432/app_db") + url = make_url(jdbc.removeprefix("jdbc:")).set( + drivername="postgresql+psycopg2", + username=os.environ.get("SPRING_DATASOURCE_USERNAME", "reports_user"), + password=os.environ.get("SPRING_DATASOURCE_PASSWORD", "reports_password"), + ) + return url + + +def get_engine(): + global _engine + if _engine is None: + _engine = create_engine(_build_url(), pool_pre_ping=True) + return _engine + + +def init_db() -> None: + """Create the report tables if they don't exist yet (idempotent). + + The foreign keys into member.members / organization.teams use the default ON DELETE NO ACTION, + matching every other schema: a member/team cannot be deleted while a report references it. The + referenced tables must already exist, so a first boot that wins the race against the member / + organization services raises and the container retries (restart: on-failure), the same way the + Spring services' cross-schema FK migrations do. + """ + with get_engine().begin() as conn: + conn.execute( + text( + """ + CREATE TABLE IF NOT EXISTS reports.member_reports ( + id UUID NOT NULL, + member_id UUID NOT NULL, + created_at TIMESTAMPTZ NOT NULL, + text TEXT NOT NULL, + CONSTRAINT pk_member_reports PRIMARY KEY (id), + CONSTRAINT fk_member_reports_member + FOREIGN KEY (member_id) REFERENCES member.members (id) + ) + """ + ) + ) + conn.execute( + text( + """ + CREATE TABLE IF NOT EXISTS reports.team_reports ( + id UUID NOT NULL, + team_id UUID NOT NULL, + created_at TIMESTAMPTZ NOT NULL, + text TEXT NOT NULL, + CONSTRAINT pk_team_reports PRIMARY KEY (id), + CONSTRAINT fk_team_reports_team + FOREIGN KEY (team_id) REFERENCES organization.teams (id) + ) + """ + ) + ) + + +def insert_member_report(member_id: str, report_text: str) -> str: + report_id = str(uuid.uuid4()) + with get_engine().begin() as conn: + conn.execute( + text( + "INSERT INTO reports.member_reports (id, member_id, created_at, text) " + "VALUES (:id, :member_id, :created_at, :text)" + ), + { + "id": report_id, + "member_id": member_id, + "created_at": datetime.now(UTC), + "text": report_text, + }, + ) + return report_id + + +def insert_team_report(team_id: str, report_text: str) -> str: + report_id = str(uuid.uuid4()) + with get_engine().begin() as conn: + conn.execute( + text( + "INSERT INTO reports.team_reports (id, team_id, created_at, text) " + "VALUES (:id, :team_id, :created_at, :text)" + ), + { + "id": report_id, + "team_id": team_id, + "created_at": datetime.now(UTC), + "text": report_text, + }, + ) + return report_id + + +def list_member_reports(member_id: str) -> list[dict]: + with get_engine().connect() as conn: + rows = conn.execute( + text( + "SELECT id, member_id, created_at FROM reports.member_reports " + "WHERE member_id = :member_id ORDER BY created_at DESC" + ), + {"member_id": member_id}, + ).mappings() + return [dict(row) for row in rows] + + +def list_team_reports(team_id: str) -> list[dict]: + with get_engine().connect() as conn: + rows = conn.execute( + text( + "SELECT id, team_id, created_at FROM reports.team_reports " + "WHERE team_id = :team_id ORDER BY created_at DESC" + ), + {"team_id": team_id}, + ).mappings() + return [dict(row) for row in rows] + + +def get_report(report_id: str) -> dict | None: + """Return the report (member or team) with the given id, including its text, or None.""" + with get_engine().connect() as conn: + member = ( + conn.execute( + text("SELECT id, member_id, created_at, text FROM reports.member_reports WHERE id = :id"), + {"id": report_id}, + ) + .mappings() + .first() + ) + if member is not None: + return {"kind": "member", **dict(member)} + + team = ( + conn.execute( + text("SELECT id, team_id, created_at, text FROM reports.team_reports WHERE id = :id"), + {"id": report_id}, + ) + .mappings() + .first() + ) + if team is not None: + return {"kind": "team", **dict(team)} + + return None + + +def delete_report(report_id: str) -> str | None: + """Delete the member or team report with the given id; return its kind, or None if missing.""" + with get_engine().begin() as conn: + deleted = conn.execute(text("DELETE FROM reports.member_reports WHERE id = :id"), {"id": report_id}).rowcount + if deleted: + return "member" + deleted = conn.execute(text("DELETE FROM reports.team_reports WHERE id = :id"), {"id": report_id}).rowcount + if deleted: + return "team" + return None + + +def resolve_member_name(member_id: str) -> str | None: + with get_engine().connect() as conn: + row = conn.execute( + text("SELECT first_name, last_name FROM member.members WHERE id = :id"), + {"id": member_id}, + ).first() + return f"{row[0]} {row[1]}" if row is not None else None + + +def resolve_team_name(team_id: str) -> str | None: + with get_engine().connect() as conn: + row = conn.execute( + text("SELECT name FROM organization.teams WHERE id = :id"), + {"id": team_id}, + ).first() + return row[0] if row is not None else None + + +def is_trainer_of_team(member_id: str, team_id: str) -> bool: + with get_engine().connect() as conn: + row = conn.execute( + text("SELECT 1 FROM organization.trainers " "WHERE member_id = :member_id AND team_id = :team_id"), + {"member_id": member_id, "team_id": team_id}, + ).first() + return row is not None diff --git a/services/py-genai-helper/generated/__init__.py b/services/py-genai-helper/generated/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/services/py-genai-helper/generated/models.py b/services/py-genai-helper/generated/models.py index 494e593..e5799cc 100644 --- a/services/py-genai-helper/generated/models.py +++ b/services/py-genai-helper/generated/models.py @@ -1,10 +1,11 @@ # generated by datamodel-codegen: # filename: openapi.yaml -# timestamp: 2026-06-27T22:44:38+00:00 +# timestamp: 2026-06-28T10:31:53+00:00 from __future__ import annotations from pydantic import AwareDatetime, BaseModel, Field, SecretStr from uuid import UUID +from enum import StrEnum from datetime import date from typing import Annotated @@ -23,6 +24,32 @@ class Reference(BaseModel): name: str +class MemberReportSummary(BaseModel): + id: UUID + member: Reference + created_at: AwareDatetime + + +class TeamReportSummary(BaseModel): + id: UUID + team: Reference + created_at: AwareDatetime + + +class Kind(StrEnum): + member = 'member' + team = 'team' + + +class Report(BaseModel): + id: UUID + kind: Kind + member: Reference | None = None + team: Reference | None = None + created_at: AwareDatetime + text: str + + class Sport(BaseModel): id: UUID name: str diff --git a/services/py-genai-helper/reports.py b/services/py-genai-helper/reports.py new file mode 100644 index 0000000..a4ce29d --- /dev/null +++ b/services/py-genai-helper/reports.py @@ -0,0 +1,58 @@ +"""Report generation: kicks off generation in the background and persists the result. + +Generation is fire-and-forget — the HTTP request returns immediately (202) and a daemon thread runs +the (currently stubbed) generator, then writes the finished report to the database. + +NOTE: the actual generation logic (aggregating member/team data and calling the LLM) is not +implemented yet. ``generate_member_report_text`` / ``generate_team_report_text`` are stubs and are +the single seam to fill in later; the async + persistence scaffold around them is real. +""" + +import logging +import threading + +import db + +logger = logging.getLogger(__name__) + + +def generate_member_report_text(member_id: str, token: str) -> str: + # TODO: real generation — fetch the member's feedback and run the LLM. Deferred. + return f"Report generation is not yet implemented for member {member_id}." + + +def generate_team_report_text(team_id: str, token: str) -> str: + # TODO: real generation — aggregate the team's trainees' data and run the LLM. Deferred. + return f"Report generation is not yet implemented for team {team_id}." + + +def generate_and_store_member_report(member_id: str, token: str) -> None: + try: + report_text = generate_member_report_text(member_id, token) + db.insert_member_report(member_id, report_text) + except Exception: + logger.exception("Failed to generate member report for %s", member_id) + + +def generate_and_store_team_report(team_id: str, token: str) -> None: + try: + report_text = generate_team_report_text(team_id, token) + db.insert_team_report(team_id, report_text) + except Exception: + logger.exception("Failed to generate team report for %s", team_id) + + +def trigger_member_report(member_id: str, token: str) -> None: + threading.Thread( + target=generate_and_store_member_report, + args=(member_id, token), + daemon=True, + ).start() + + +def trigger_team_report(team_id: str, token: str) -> None: + threading.Thread( + target=generate_and_store_team_report, + args=(team_id, token), + daemon=True, + ).start() diff --git a/services/py-genai-helper/requirements.txt b/services/py-genai-helper/requirements.txt index 6eac4d9..10a3a1e 100644 --- a/services/py-genai-helper/requirements.txt +++ b/services/py-genai-helper/requirements.txt @@ -49,6 +49,7 @@ orjson==3.11.9 ormsgpack==1.12.2 packaging==26.2 propcache==0.5.2 +psycopg2-binary==2.9.10 pydantic==2.13.4 pydantic-settings==2.14.1 pydantic_core==2.46.4 diff --git a/services/py-genai-helper/tests/test_reports.py b/services/py-genai-helper/tests/test_reports.py new file mode 100644 index 0000000..aa5099a --- /dev/null +++ b/services/py-genai-helper/tests/test_reports.py @@ -0,0 +1,276 @@ +import types +from datetime import UTC, datetime + +import pytest + +import auth +import db +import reports +from app import app + +MEMBER_A = "11111111-1111-1111-1111-111111111111" +MEMBER_B = "22222222-2222-2222-2222-222222222222" +TEAM_A = "33333333-3333-3333-3333-333333333333" +REPORT_ID = "44444444-4444-4444-4444-444444444444" +CREATED_AT = datetime(2026, 6, 28, 12, 0, tzinfo=UTC) + +AUTH_HEADER = {"Authorization": "Bearer test"} + + +@pytest.fixture +def client(): + return app.test_client() + + +def authenticate(monkeypatch, sub, roles=None): + """Make require_auth accept the request and inject the given identity/roles.""" + monkeypatch.setattr(auth, "_get_signing_key", lambda token: types.SimpleNamespace(key="k")) + monkeypatch.setattr( + auth.jwt, + "decode", + lambda *a, **k: {"sub": sub, "realm_access": {"roles": roles or []}}, + ) + + +# --------------------------------------------------------------------------- # +# Auth gate +# --------------------------------------------------------------------------- # +def test_unauthenticated_returns_401(client): + assert client.get(f"/reports/member/{MEMBER_A}").status_code == 401 + + +# --------------------------------------------------------------------------- # +# Generate (trigger) — member +# --------------------------------------------------------------------------- # +def test_generate_member_report_self_returns_202(client, monkeypatch): + authenticate(monkeypatch, sub=MEMBER_A) + calls = [] + monkeypatch.setattr(reports, "trigger_member_report", lambda m, t: calls.append((m, t))) + + resp = client.post(f"/reports/member/{MEMBER_A}", headers=AUTH_HEADER) + + assert resp.status_code == 202 + assert calls == [(MEMBER_A, "test")] + + +def test_generate_member_report_forbidden_for_other(client, monkeypatch): + authenticate(monkeypatch, sub=MEMBER_B) + monkeypatch.setattr(reports, "trigger_member_report", lambda m, t: pytest.fail("not allowed")) + + resp = client.post(f"/reports/member/{MEMBER_A}", headers=AUTH_HEADER) + + assert resp.status_code == 403 + + +def test_generate_member_report_admin_allowed(client, monkeypatch): + authenticate(monkeypatch, sub=MEMBER_B, roles=["admin"]) + calls = [] + monkeypatch.setattr(reports, "trigger_member_report", lambda m, t: calls.append((m, t))) + + resp = client.post(f"/reports/member/{MEMBER_A}", headers=AUTH_HEADER) + + assert resp.status_code == 202 + assert calls == [(MEMBER_A, "test")] + + +# --------------------------------------------------------------------------- # +# Generate (trigger) — team +# --------------------------------------------------------------------------- # +def test_generate_team_report_trainer_allowed(client, monkeypatch): + authenticate(monkeypatch, sub=MEMBER_A) + monkeypatch.setattr(db, "is_trainer_of_team", lambda member_id, team_id: True) + calls = [] + monkeypatch.setattr(reports, "trigger_team_report", lambda t, tok: calls.append((t, tok))) + + resp = client.post(f"/reports/team/{TEAM_A}", headers=AUTH_HEADER) + + assert resp.status_code == 202 + assert calls == [(TEAM_A, "test")] + + +def test_generate_team_report_forbidden_non_trainer(client, monkeypatch): + authenticate(monkeypatch, sub=MEMBER_A) + monkeypatch.setattr(db, "is_trainer_of_team", lambda member_id, team_id: False) + monkeypatch.setattr(reports, "trigger_team_report", lambda t, tok: pytest.fail("not allowed")) + + resp = client.post(f"/reports/team/{TEAM_A}", headers=AUTH_HEADER) + + assert resp.status_code == 403 + + +# --------------------------------------------------------------------------- # +# List summaries +# --------------------------------------------------------------------------- # +def test_list_member_reports_returns_summaries(client, monkeypatch): + authenticate(monkeypatch, sub=MEMBER_A) + monkeypatch.setattr(db, "resolve_member_name", lambda member_id: "Jane Doe") + monkeypatch.setattr( + db, + "list_member_reports", + lambda member_id: [{"id": REPORT_ID, "member_id": MEMBER_A, "created_at": CREATED_AT}], + ) + + resp = client.get(f"/reports/member/{MEMBER_A}", headers=AUTH_HEADER) + + assert resp.status_code == 200 + body = resp.get_json() + assert len(body) == 1 + assert body[0]["id"] == REPORT_ID + assert body[0]["member"] == {"id": MEMBER_A, "name": "Jane Doe"} + assert "text" not in body[0] + + +def test_list_member_reports_forbidden_for_other(client, monkeypatch): + authenticate(monkeypatch, sub=MEMBER_B) + resp = client.get(f"/reports/member/{MEMBER_A}", headers=AUTH_HEADER) + assert resp.status_code == 403 + + +def test_list_team_reports_returns_summaries(client, monkeypatch): + authenticate(monkeypatch, sub=MEMBER_A) + monkeypatch.setattr(db, "is_trainer_of_team", lambda member_id, team_id: True) + monkeypatch.setattr(db, "resolve_team_name", lambda team_id: "First Team") + monkeypatch.setattr( + db, + "list_team_reports", + lambda team_id: [{"id": REPORT_ID, "team_id": TEAM_A, "created_at": CREATED_AT}], + ) + + resp = client.get(f"/reports/team/{TEAM_A}", headers=AUTH_HEADER) + + assert resp.status_code == 200 + body = resp.get_json() + assert body[0]["team"] == {"id": TEAM_A, "name": "First Team"} + assert "text" not in body[0] + + +# --------------------------------------------------------------------------- # +# Detail +# --------------------------------------------------------------------------- # +def test_get_member_report_detail(client, monkeypatch): + authenticate(monkeypatch, sub=MEMBER_A) + monkeypatch.setattr( + db, + "get_report", + lambda report_id: { + "kind": "member", + "id": REPORT_ID, + "member_id": MEMBER_A, + "created_at": CREATED_AT, + "text": "the report", + }, + ) + monkeypatch.setattr(db, "resolve_member_name", lambda member_id: "Jane Doe") + + resp = client.get(f"/reports/{REPORT_ID}", headers=AUTH_HEADER) + + assert resp.status_code == 200 + body = resp.get_json() + assert body["kind"] == "member" + assert body["member"] == {"id": MEMBER_A, "name": "Jane Doe"} + assert body["text"] == "the report" + assert "team" not in body + + +def test_get_team_report_detail_trainer(client, monkeypatch): + authenticate(monkeypatch, sub=MEMBER_A) + monkeypatch.setattr( + db, + "get_report", + lambda report_id: { + "kind": "team", + "id": REPORT_ID, + "team_id": TEAM_A, + "created_at": CREATED_AT, + "text": "team report", + }, + ) + monkeypatch.setattr(db, "is_trainer_of_team", lambda member_id, team_id: True) + monkeypatch.setattr(db, "resolve_team_name", lambda team_id: "First Team") + + resp = client.get(f"/reports/{REPORT_ID}", headers=AUTH_HEADER) + + assert resp.status_code == 200 + body = resp.get_json() + assert body["kind"] == "team" + assert body["team"] == {"id": TEAM_A, "name": "First Team"} + assert "member" not in body + + +def test_get_report_not_found(client, monkeypatch): + authenticate(monkeypatch, sub=MEMBER_A) + monkeypatch.setattr(db, "get_report", lambda report_id: None) + resp = client.get(f"/reports/{REPORT_ID}", headers=AUTH_HEADER) + assert resp.status_code == 404 + + +def test_get_member_report_forbidden_for_other(client, monkeypatch): + authenticate(monkeypatch, sub=MEMBER_B) + monkeypatch.setattr( + db, + "get_report", + lambda report_id: { + "kind": "member", + "id": REPORT_ID, + "member_id": MEMBER_A, + "created_at": CREATED_AT, + "text": "the report", + }, + ) + resp = client.get(f"/reports/{REPORT_ID}", headers=AUTH_HEADER) + assert resp.status_code == 403 + + +# --------------------------------------------------------------------------- # +# Delete +# --------------------------------------------------------------------------- # +def test_delete_report_success(client, monkeypatch): + authenticate(monkeypatch, sub=MEMBER_A) + monkeypatch.setattr( + db, + "get_report", + lambda report_id: { + "kind": "member", + "id": REPORT_ID, + "member_id": MEMBER_A, + "created_at": CREATED_AT, + "text": "the report", + }, + ) + deleted = [] + monkeypatch.setattr(db, "delete_report", lambda report_id: deleted.append(report_id) or "member") + + resp = client.delete(f"/reports/{REPORT_ID}", headers=AUTH_HEADER) + + assert resp.status_code == 204 + assert deleted == [REPORT_ID] + + +def test_delete_report_not_found(client, monkeypatch): + authenticate(monkeypatch, sub=MEMBER_A) + monkeypatch.setattr(db, "get_report", lambda report_id: None) + resp = client.delete(f"/reports/{REPORT_ID}", headers=AUTH_HEADER) + assert resp.status_code == 404 + + +# --------------------------------------------------------------------------- # +# Background worker persists the (stubbed) generated text +# --------------------------------------------------------------------------- # +def test_worker_persists_generated_member_text(monkeypatch): + monkeypatch.setattr(reports, "generate_member_report_text", lambda m, t: "generated text") + inserted = [] + monkeypatch.setattr(db, "insert_member_report", lambda member_id, text: inserted.append((member_id, text))) + + reports.generate_and_store_member_report(MEMBER_A, "test") + + assert inserted == [(MEMBER_A, "generated text")] + + +def test_worker_persists_generated_team_text(monkeypatch): + monkeypatch.setattr(reports, "generate_team_report_text", lambda t, tok: "team text") + inserted = [] + monkeypatch.setattr(db, "insert_team_report", lambda team_id, text: inserted.append((team_id, text))) + + reports.generate_and_store_team_report(TEAM_A, "test") + + assert inserted == [(TEAM_A, "team text")] diff --git a/web-client/src/api.ts b/web-client/src/api.ts index 6f5a422..108b445 100644 --- a/web-client/src/api.ts +++ b/web-client/src/api.ts @@ -473,7 +473,7 @@ export interface paths { patch?: never; trace?: never; }; - "/helper/report/{member_id}": { + "/helper/reports/member/{member_id}": { parameters: { query?: never; header?: never; @@ -481,21 +481,85 @@ export interface paths { cookie?: never; }; /** - * Generate report - * @description Generates an AI-based report for a member. Members can only generate reports for themselves. - * - All authenticated users: can generate a report for themselves. - * - Trainers: can generate reports for members of their team. + * List member reports + * @description Lists the stored report summaries (without text) for a member, newest first. + * - The member themselves: can list their own reports. + * - Admin: can list reports for any member. + */ + get: operations["listMemberReports"]; + put?: never; + /** + * Generate member report + * @description Kicks off asynchronous generation of an AI report for a member. Returns immediately; the + * finished report is persisted and can later be fetched via the report endpoints. + * - The member themselves: can generate their own report. * - Admin: can generate a report for any member. */ - get: operations["generateReport"]; + post: operations["generateMemberReport"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/helper/reports/team/{team_id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * List team reports + * @description Lists the stored report summaries (without text) for a team, newest first. + * - Trainers of the team: can list the team's reports. + * - Admin: can list reports for any team. + */ + get: operations["listTeamReports"]; put?: never; - post?: never; + /** + * Generate team report + * @description Kicks off asynchronous generation of an AI report for a team. Returns immediately; the + * finished report is persisted and can later be fetched via the report endpoints. + * - Trainers of the team: can generate the team's report. + * - Admin: can generate a report for any team. + */ + post: operations["generateTeamReport"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; + "/helper/reports/{report_id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get report + * @description Returns a single stored report including its full text. Works for both member and team + * reports; the `kind` field indicates which, and the matching reference is populated. + * - Member reports: the member themselves or an admin. + * - Team reports: a trainer of the team or an admin. + */ + get: operations["getReport"]; + put?: never; + post?: never; + /** + * Delete report + * @description Deletes a single stored report (member or team). + * - Member reports: the member themselves or an admin. + * - Team reports: a trainer of the team or an admin. + */ + delete: operations["deleteReport"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; } export type webhooks = Record; export interface components { @@ -513,6 +577,37 @@ export interface components { id: string; name: string; }; + /** @description Summary of a stored member report, without its generated text. */ + MemberReportSummary: { + /** Format: uuid */ + id: string; + member: components["schemas"]["Reference"]; + /** Format: date-time */ + created_at: string; + }; + /** @description Summary of a stored team report, without its generated text. */ + TeamReportSummary: { + /** Format: uuid */ + id: string; + team: components["schemas"]["Reference"]; + /** Format: date-time */ + created_at: string; + }; + /** + * @description A stored report including its generated text. `kind` indicates whether it is a member or + * team report; the matching `member`/`team` reference is populated accordingly. + */ + Report: { + /** Format: uuid */ + id: string; + /** @enum {string} */ + kind: "member" | "team"; + member?: components["schemas"]["Reference"]; + team?: components["schemas"]["Reference"]; + /** Format: date-time */ + created_at: string; + text: string; + }; /** @description The object representation of a Sport within the organization. */ Sport: { /** Format: uuid */ @@ -818,6 +913,7 @@ export interface components { event_id: string; feedback_id: string; transaction_id: string; + report_id: string; }; requestBodies: never; headers: never; @@ -1734,7 +1830,7 @@ export interface operations { 500: components["responses"]["InternalServerError"]; }; }; - generateReport: { + listMemberReports: { parameters: { query?: never; header?: never; @@ -1751,7 +1847,7 @@ export interface operations { [name: string]: unknown; }; content: { - "text/plain": string; + "application/json": components["schemas"]["MemberReportSummary"][]; }; }; 401: components["responses"]["Unauthorized"]; @@ -1759,4 +1855,119 @@ export interface operations { 500: components["responses"]["InternalServerError"]; }; }; + generateMemberReport: { + parameters: { + query?: never; + header?: never; + path: { + member_id: components["parameters"]["member_id"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description The request was accepted and report generation has been started. */ + 202: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + 401: components["responses"]["Unauthorized"]; + 403: components["responses"]["Forbidden"]; + 500: components["responses"]["InternalServerError"]; + }; + }; + listTeamReports: { + parameters: { + query?: never; + header?: never; + path: { + team_id: components["parameters"]["team_id"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description The request was successful, and the server has returned the requested resource in the response body. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["TeamReportSummary"][]; + }; + }; + 401: components["responses"]["Unauthorized"]; + 403: components["responses"]["Forbidden"]; + 500: components["responses"]["InternalServerError"]; + }; + }; + generateTeamReport: { + parameters: { + query?: never; + header?: never; + path: { + team_id: components["parameters"]["team_id"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description The request was accepted and report generation has been started. */ + 202: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + 401: components["responses"]["Unauthorized"]; + 403: components["responses"]["Forbidden"]; + 500: components["responses"]["InternalServerError"]; + }; + }; + getReport: { + parameters: { + query?: never; + header?: never; + path: { + report_id: components["parameters"]["report_id"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description The request was successful, and the server has returned the requested resource in the response body. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Report"]; + }; + }; + 401: components["responses"]["Unauthorized"]; + 403: components["responses"]["Forbidden"]; + 404: components["responses"]["NotFound"]; + 500: components["responses"]["InternalServerError"]; + }; + }; + deleteReport: { + parameters: { + query?: never; + header?: never; + path: { + report_id: components["parameters"]["report_id"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + 204: components["responses"]["NoContent"]; + 401: components["responses"]["Unauthorized"]; + 403: components["responses"]["Forbidden"]; + 404: components["responses"]["NotFound"]; + 500: components["responses"]["InternalServerError"]; + }; + }; } From 831a34d4e32365bb5b046a112ee65433a46817c6 Mon Sep 17 00:00:00 2001 From: Raphael Frank <04.raphael.frank@gmail.com> Date: Sun, 28 Jun 2026 18:41:52 +0200 Subject: [PATCH 07/16] add on delete cascade for entries and null for creators --- api/openapi.yaml | 20 ++++++++--- services/py-genai-helper/db.py | 14 ++++---- services/py-genai-helper/generated/models.py | 2 +- .../devoops/eventservice/api/EventsApi.java | 6 ++-- .../tum/devoops/eventservice/model/Event.java | 2 +- .../converter/EventConverter.java | 4 ++- .../eventservice/entity/EventEntity.java | 4 +-- .../db/migration/V4__cascade_foreign_keys.sql | 35 +++++++++++++++++++ .../service/EventServiceTest.java | 10 ++++++ .../feedbackservice/api/FeedbackApi.java | 8 ++--- .../feedbackservice/model/Feedback.java | 2 +- .../model/FeedbackSummary.java | 2 +- .../entity/FeedbackEntity.java | 4 +-- .../service/FeedbackService.java | 5 +++ .../db/migration/V4__cascade_foreign_keys.sql | 18 ++++++++++ .../service/FeedbackServiceTest.java | 12 +++++++ .../financeservice/api/FinanceApi.java | 8 ++--- .../financeservice/model/Transaction.java | 2 +- .../entity/TransactionEntity.java | 4 +-- .../db/migration/V3__cascade_foreign_keys.sql | 14 ++++++++ .../db/migration/V4__cascade_foreign_keys.sql | 32 +++++++++++++++++ web-client/src/api.ts | 8 ++--- 22 files changed, 178 insertions(+), 38 deletions(-) create mode 100644 services/spring-event/src/main/resources/db/migration/V4__cascade_foreign_keys.sql create mode 100644 services/spring-feedback/src/main/resources/db/migration/V4__cascade_foreign_keys.sql create mode 100644 services/spring-finance/src/main/resources/db/migration/V3__cascade_foreign_keys.sql create mode 100644 services/spring-organization/src/main/resources/db/migration/V4__cascade_foreign_keys.sql diff --git a/api/openapi.yaml b/api/openapi.yaml index 6f92ddb..2014ba9 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -1701,7 +1701,10 @@ components: $ref: "#/components/schemas/Reference" description: Teams associated with this event. creator: - $ref: "#/components/schemas/Reference" + type: object + nullable: true + allOf: + - $ref: "#/components/schemas/Reference" required: - id - name @@ -1806,7 +1809,10 @@ components: member: $ref: "#/components/schemas/Reference" creator: - $ref: "#/components/schemas/Reference" + type: object + nullable: true + allOf: + - $ref: "#/components/schemas/Reference" created_at: type: string format: date-time @@ -1837,7 +1843,10 @@ components: member: $ref: "#/components/schemas/Reference" creator: - $ref: "#/components/schemas/Reference" + type: object + nullable: true + allOf: + - $ref: "#/components/schemas/Reference" created_at: type: string format: date-time @@ -1899,7 +1908,10 @@ components: member: $ref: "#/components/schemas/Reference" creator: - $ref: "#/components/schemas/Reference" + type: object + nullable: true + allOf: + - $ref: "#/components/schemas/Reference" amount_cents: type: integer created_at: diff --git a/services/py-genai-helper/db.py b/services/py-genai-helper/db.py index 2064491..7c3963d 100644 --- a/services/py-genai-helper/db.py +++ b/services/py-genai-helper/db.py @@ -40,11 +40,11 @@ def get_engine(): def init_db() -> None: """Create the report tables if they don't exist yet (idempotent). - The foreign keys into member.members / organization.teams use the default ON DELETE NO ACTION, - matching every other schema: a member/team cannot be deleted while a report references it. The - referenced tables must already exist, so a first boot that wins the race against the member / - organization services raises and the container retries (restart: on-failure), the same way the - Spring services' cross-schema FK migrations do. + The foreign keys into member.members / organization.teams use ON DELETE CASCADE, matching the rest + of the system: deleting a member/team removes their reports. The referenced tables must already + exist, so a first boot that wins the race against the member / organization services raises and the + container retries (restart: on-failure), the same way the Spring services' cross-schema FK + migrations do. """ with get_engine().begin() as conn: conn.execute( @@ -57,7 +57,7 @@ def init_db() -> None: text TEXT NOT NULL, CONSTRAINT pk_member_reports PRIMARY KEY (id), CONSTRAINT fk_member_reports_member - FOREIGN KEY (member_id) REFERENCES member.members (id) + FOREIGN KEY (member_id) REFERENCES member.members (id) ON DELETE CASCADE ) """ ) @@ -72,7 +72,7 @@ def init_db() -> None: text TEXT NOT NULL, CONSTRAINT pk_team_reports PRIMARY KEY (id), CONSTRAINT fk_team_reports_team - FOREIGN KEY (team_id) REFERENCES organization.teams (id) + FOREIGN KEY (team_id) REFERENCES organization.teams (id) ON DELETE CASCADE ) """ ) diff --git a/services/py-genai-helper/generated/models.py b/services/py-genai-helper/generated/models.py index e5799cc..2de6bb8 100644 --- a/services/py-genai-helper/generated/models.py +++ b/services/py-genai-helper/generated/models.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: openapi.yaml -# timestamp: 2026-06-28T10:31:53+00:00 +# timestamp: 2026-06-28T16:42:08+00:00 from __future__ import annotations from pydantic import AwareDatetime, BaseModel, Field, SecretStr 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 568b1d3..c29794b 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\" : { \"name\" : \"name\", \"id\" : \"046b6c7f-0b8a-43b9-b35d-6489e6daee91\" }, \"attendees\" : [ { \"name\" : \"name\", \"id\" : \"046b6c7f-0b8a-43b9-b35d-6489e6daee91\" }, { \"name\" : \"name\", \"id\" : \"046b6c7f-0b8a-43b9-b35d-6489e6daee91\" } ], \"name\" : \"name\", \"end_time\" : \"2000-01-23T04:56:07.000+00:00\", \"description\" : \"description\", \"id\" : \"046b6c7f-0b8a-43b9-b35d-6489e6daee91\", \"sports_linked\" : [ { \"name\" : \"name\", \"id\" : \"046b6c7f-0b8a-43b9-b35d-6489e6daee91\" }, { \"name\" : \"name\", \"id\" : \"046b6c7f-0b8a-43b9-b35d-6489e6daee91\" } ], \"teams_linked\" : [ { \"name\" : \"name\", \"id\" : \"046b6c7f-0b8a-43b9-b35d-6489e6daee91\" }, { \"name\" : \"name\", \"id\" : \"046b6c7f-0b8a-43b9-b35d-6489e6daee91\" } ] }"; + String exampleString = "{ \"start_time\" : \"2000-01-23T04:56:07.000+00:00\", \"creator\" : \"{}\", \"attendees\" : [ { \"name\" : \"name\", \"id\" : \"046b6c7f-0b8a-43b9-b35d-6489e6daee91\" }, { \"name\" : \"name\", \"id\" : \"046b6c7f-0b8a-43b9-b35d-6489e6daee91\" } ], \"name\" : \"name\", \"end_time\" : \"2000-01-23T04:56:07.000+00:00\", \"description\" : \"description\", \"id\" : \"046b6c7f-0b8a-43b9-b35d-6489e6daee91\", \"sports_linked\" : [ { \"name\" : \"name\", \"id\" : \"046b6c7f-0b8a-43b9-b35d-6489e6daee91\" }, { \"name\" : \"name\", \"id\" : \"046b6c7f-0b8a-43b9-b35d-6489e6daee91\" } ], \"teams_linked\" : [ { \"name\" : \"name\", \"id\" : \"046b6c7f-0b8a-43b9-b35d-6489e6daee91\" }, { \"name\" : \"name\", \"id\" : \"046b6c7f-0b8a-43b9-b35d-6489e6daee91\" } ] }"; 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\" : { \"name\" : \"name\", \"id\" : \"046b6c7f-0b8a-43b9-b35d-6489e6daee91\" }, \"attendees\" : [ { \"name\" : \"name\", \"id\" : \"046b6c7f-0b8a-43b9-b35d-6489e6daee91\" }, { \"name\" : \"name\", \"id\" : \"046b6c7f-0b8a-43b9-b35d-6489e6daee91\" } ], \"name\" : \"name\", \"end_time\" : \"2000-01-23T04:56:07.000+00:00\", \"description\" : \"description\", \"id\" : \"046b6c7f-0b8a-43b9-b35d-6489e6daee91\", \"sports_linked\" : [ { \"name\" : \"name\", \"id\" : \"046b6c7f-0b8a-43b9-b35d-6489e6daee91\" }, { \"name\" : \"name\", \"id\" : \"046b6c7f-0b8a-43b9-b35d-6489e6daee91\" } ], \"teams_linked\" : [ { \"name\" : \"name\", \"id\" : \"046b6c7f-0b8a-43b9-b35d-6489e6daee91\" }, { \"name\" : \"name\", \"id\" : \"046b6c7f-0b8a-43b9-b35d-6489e6daee91\" } ] }"; + String exampleString = "{ \"start_time\" : \"2000-01-23T04:56:07.000+00:00\", \"creator\" : \"{}\", \"attendees\" : [ { \"name\" : \"name\", \"id\" : \"046b6c7f-0b8a-43b9-b35d-6489e6daee91\" }, { \"name\" : \"name\", \"id\" : \"046b6c7f-0b8a-43b9-b35d-6489e6daee91\" } ], \"name\" : \"name\", \"end_time\" : \"2000-01-23T04:56:07.000+00:00\", \"description\" : \"description\", \"id\" : \"046b6c7f-0b8a-43b9-b35d-6489e6daee91\", \"sports_linked\" : [ { \"name\" : \"name\", \"id\" : \"046b6c7f-0b8a-43b9-b35d-6489e6daee91\" }, { \"name\" : \"name\", \"id\" : \"046b6c7f-0b8a-43b9-b35d-6489e6daee91\" } ], \"teams_linked\" : [ { \"name\" : \"name\", \"id\" : \"046b6c7f-0b8a-43b9-b35d-6489e6daee91\" }, { \"name\" : \"name\", \"id\" : \"046b6c7f-0b8a-43b9-b35d-6489e6daee91\" } ] }"; 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\" : { \"name\" : \"name\", \"id\" : \"046b6c7f-0b8a-43b9-b35d-6489e6daee91\" }, \"attendees\" : [ { \"name\" : \"name\", \"id\" : \"046b6c7f-0b8a-43b9-b35d-6489e6daee91\" }, { \"name\" : \"name\", \"id\" : \"046b6c7f-0b8a-43b9-b35d-6489e6daee91\" } ], \"name\" : \"name\", \"end_time\" : \"2000-01-23T04:56:07.000+00:00\", \"description\" : \"description\", \"id\" : \"046b6c7f-0b8a-43b9-b35d-6489e6daee91\", \"sports_linked\" : [ { \"name\" : \"name\", \"id\" : \"046b6c7f-0b8a-43b9-b35d-6489e6daee91\" }, { \"name\" : \"name\", \"id\" : \"046b6c7f-0b8a-43b9-b35d-6489e6daee91\" } ], \"teams_linked\" : [ { \"name\" : \"name\", \"id\" : \"046b6c7f-0b8a-43b9-b35d-6489e6daee91\" }, { \"name\" : \"name\", \"id\" : \"046b6c7f-0b8a-43b9-b35d-6489e6daee91\" } ] }"; + String exampleString = "{ \"start_time\" : \"2000-01-23T04:56:07.000+00:00\", \"creator\" : \"{}\", \"attendees\" : [ { \"name\" : \"name\", \"id\" : \"046b6c7f-0b8a-43b9-b35d-6489e6daee91\" }, { \"name\" : \"name\", \"id\" : \"046b6c7f-0b8a-43b9-b35d-6489e6daee91\" } ], \"name\" : \"name\", \"end_time\" : \"2000-01-23T04:56:07.000+00:00\", \"description\" : \"description\", \"id\" : \"046b6c7f-0b8a-43b9-b35d-6489e6daee91\", \"sports_linked\" : [ { \"name\" : \"name\", \"id\" : \"046b6c7f-0b8a-43b9-b35d-6489e6daee91\" }, { \"name\" : \"name\", \"id\" : \"046b6c7f-0b8a-43b9-b35d-6489e6daee91\" } ], \"teams_linked\" : [ { \"name\" : \"name\", \"id\" : \"046b6c7f-0b8a-43b9-b35d-6489e6daee91\" }, { \"name\" : \"name\", \"id\" : \"046b6c7f-0b8a-43b9-b35d-6489e6daee91\" } ] }"; ApiUtil.setExampleResponse(request, "application/json", exampleString); break; } 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 23b2181..d5823d5 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 @@ -50,7 +50,7 @@ public class Event { @Valid private @Nullable List<@Valid Reference> teamsLinked; - private Reference creator; + private Reference creator = null; public Event() { super(); 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 fa13699..b6f6247 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 @@ -53,7 +53,9 @@ public static Event toEvent(EventEntity entity, } private static Reference reference(UUID id, Map names) { - return new Reference(id, names.get(id)); + // id is null only for a creator whose member was deleted (ON DELETE SET NULL); attendee, + // sport and team ids are PK components and never null. + return id == null ? null : new Reference(id, names.get(id)); } public static EventSummary toSummary(EventEntity entity, diff --git a/services/spring-event/src/main/java/tum/devoops/eventservice/entity/EventEntity.java b/services/spring-event/src/main/java/tum/devoops/eventservice/entity/EventEntity.java index d958836..1584a9b 100644 --- a/services/spring-event/src/main/java/tum/devoops/eventservice/entity/EventEntity.java +++ b/services/spring-event/src/main/java/tum/devoops/eventservice/entity/EventEntity.java @@ -39,8 +39,8 @@ public class EventEntity { private Instant endTime; // UUID of the member who created this event. - // FK to member.member(id) added in V3 migration. - @Column(name = "creator_id", nullable = false) + // FK to member.member(id); ON DELETE SET NULL, so this is cleared if the creator is deleted. + @Column(name = "creator_id") private UUID creatorId; @OneToMany diff --git a/services/spring-event/src/main/resources/db/migration/V4__cascade_foreign_keys.sql b/services/spring-event/src/main/resources/db/migration/V4__cascade_foreign_keys.sql new file mode 100644 index 0000000..e1507d1 --- /dev/null +++ b/services/spring-event/src/main/resources/db/migration/V4__cascade_foreign_keys.sql @@ -0,0 +1,35 @@ +-- Switch every event foreign key from the default ON DELETE NO ACTION to a non-blocking action. +-- Subject/ownership references CASCADE (deleting an event removes its attendances/links; deleting a +-- member removes their attendances; deleting a team/sport removes the corresponding event links). +-- The creator reference uses SET NULL: deleting the member who created an event preserves the event +-- (it belongs to its attendees) and just clears the creator, so creator_id becomes nullable. + +ALTER TABLE event.attendances DROP CONSTRAINT fk_attendances_event; +ALTER TABLE event.attendances + ADD CONSTRAINT fk_attendances_event FOREIGN KEY (event_id) REFERENCES event.events (id) ON DELETE CASCADE; + +ALTER TABLE event.sport_events DROP CONSTRAINT fk_sport_events_event; +ALTER TABLE event.sport_events + ADD CONSTRAINT fk_sport_events_event FOREIGN KEY (event_id) REFERENCES event.events (id) ON DELETE CASCADE; + +ALTER TABLE event.team_events DROP CONSTRAINT fk_team_events_event; +ALTER TABLE event.team_events + ADD CONSTRAINT fk_team_events_event FOREIGN KEY (event_id) REFERENCES event.events (id) ON DELETE CASCADE; + +ALTER TABLE event.attendances DROP CONSTRAINT fk_attendances_member; +ALTER TABLE event.attendances + ADD CONSTRAINT fk_attendances_member FOREIGN KEY (member_id) REFERENCES member.members (id) ON DELETE CASCADE; + +ALTER TABLE event.team_events DROP CONSTRAINT fk_team_events_team; +ALTER TABLE event.team_events + ADD CONSTRAINT fk_team_events_team FOREIGN KEY (team_id) REFERENCES organization.teams (id) ON DELETE CASCADE; + +ALTER TABLE event.sport_events DROP CONSTRAINT fk_sport_events_sport; +ALTER TABLE event.sport_events + ADD CONSTRAINT fk_sport_events_sport FOREIGN KEY (sport_id) REFERENCES organization.sports (id) ON DELETE CASCADE; + +-- creator: preserve the event, null the creator on member deletion. +ALTER TABLE event.events ALTER COLUMN creator_id DROP NOT NULL; +ALTER TABLE event.events DROP CONSTRAINT fk_events_creator; +ALTER TABLE event.events + ADD CONSTRAINT fk_events_creator FOREIGN KEY (creator_id) REFERENCES member.members (id) ON DELETE SET NULL; 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 b201344..3d41fbf 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 @@ -265,6 +265,16 @@ void getEventDetailsAsAdminSucceeds() { assertThat(result.getId()).isEqualTo(EVENT_ID); } + @Test + void getEventDetailsWithNullCreatorReturnsNullCreator() { + // creator_id is SET NULL when the creator member is deleted; the event survives. + when(eventRepository.findById(EVENT_ID)).thenReturn(Optional.of(eventEntity(EVENT_ID, null))); + + Event result = service.getEventDetails(EVENT_ID, REQUESTER_ID, true); + + assertThat(result.getCreator()).isNull(); + } + // ─── updateEventDetails ────────────────────────────────────────────────────── @Test diff --git a/services/spring-feedback/src/generated/java/tum/devoops/feedbackservice/api/FeedbackApi.java b/services/spring-feedback/src/generated/java/tum/devoops/feedbackservice/api/FeedbackApi.java index 2c3dfd0..b2c544d 100644 --- a/services/spring-feedback/src/generated/java/tum/devoops/feedbackservice/api/FeedbackApi.java +++ b/services/spring-feedback/src/generated/java/tum/devoops/feedbackservice/api/FeedbackApi.java @@ -103,7 +103,7 @@ default ResponseEntity createFeedback( getRequest().ifPresent(request -> { for (MediaType mediaType: MediaType.parseMediaTypes(request.getHeader("Accept"))) { if (mediaType.isCompatibleWith(MediaType.valueOf("application/json"))) { - String exampleString = "{ \"feedback\" : \"feedback\", \"creator\" : { \"name\" : \"name\", \"id\" : \"046b6c7f-0b8a-43b9-b35d-6489e6daee91\" }, \"member\" : { \"name\" : \"name\", \"id\" : \"046b6c7f-0b8a-43b9-b35d-6489e6daee91\" }, \"rating\" : 0, \"created_at\" : \"2000-01-23T04:56:07.000+00:00\", \"id\" : \"046b6c7f-0b8a-43b9-b35d-6489e6daee91\", \"event\" : { \"name\" : \"name\", \"id\" : \"046b6c7f-0b8a-43b9-b35d-6489e6daee91\" } }"; + String exampleString = "{ \"feedback\" : \"feedback\", \"creator\" : \"{}\", \"member\" : { \"name\" : \"name\", \"id\" : \"046b6c7f-0b8a-43b9-b35d-6489e6daee91\" }, \"rating\" : 0, \"created_at\" : \"2000-01-23T04:56:07.000+00:00\", \"id\" : \"046b6c7f-0b8a-43b9-b35d-6489e6daee91\", \"event\" : { \"name\" : \"name\", \"id\" : \"046b6c7f-0b8a-43b9-b35d-6489e6daee91\" } }"; ApiUtil.setExampleResponse(request, "application/json", exampleString); break; } @@ -256,7 +256,7 @@ default ResponseEntity> getAllFeedback( getRequest().ifPresent(request -> { for (MediaType mediaType: MediaType.parseMediaTypes(request.getHeader("Accept"))) { if (mediaType.isCompatibleWith(MediaType.valueOf("application/json"))) { - String exampleString = "[ { \"creator\" : { \"name\" : \"name\", \"id\" : \"046b6c7f-0b8a-43b9-b35d-6489e6daee91\" }, \"member\" : { \"name\" : \"name\", \"id\" : \"046b6c7f-0b8a-43b9-b35d-6489e6daee91\" }, \"rating\" : 0, \"created_at\" : \"2000-01-23T04:56:07.000+00:00\", \"id\" : \"046b6c7f-0b8a-43b9-b35d-6489e6daee91\", \"event\" : { \"name\" : \"name\", \"id\" : \"046b6c7f-0b8a-43b9-b35d-6489e6daee91\" } }, { \"creator\" : { \"name\" : \"name\", \"id\" : \"046b6c7f-0b8a-43b9-b35d-6489e6daee91\" }, \"member\" : { \"name\" : \"name\", \"id\" : \"046b6c7f-0b8a-43b9-b35d-6489e6daee91\" }, \"rating\" : 0, \"created_at\" : \"2000-01-23T04:56:07.000+00:00\", \"id\" : \"046b6c7f-0b8a-43b9-b35d-6489e6daee91\", \"event\" : { \"name\" : \"name\", \"id\" : \"046b6c7f-0b8a-43b9-b35d-6489e6daee91\" } } ]"; + String exampleString = "[ { \"creator\" : \"{}\", \"member\" : { \"name\" : \"name\", \"id\" : \"046b6c7f-0b8a-43b9-b35d-6489e6daee91\" }, \"rating\" : 0, \"created_at\" : \"2000-01-23T04:56:07.000+00:00\", \"id\" : \"046b6c7f-0b8a-43b9-b35d-6489e6daee91\", \"event\" : { \"name\" : \"name\", \"id\" : \"046b6c7f-0b8a-43b9-b35d-6489e6daee91\" } }, { \"creator\" : \"{}\", \"member\" : { \"name\" : \"name\", \"id\" : \"046b6c7f-0b8a-43b9-b35d-6489e6daee91\" }, \"rating\" : 0, \"created_at\" : \"2000-01-23T04:56:07.000+00:00\", \"id\" : \"046b6c7f-0b8a-43b9-b35d-6489e6daee91\", \"event\" : { \"name\" : \"name\", \"id\" : \"046b6c7f-0b8a-43b9-b35d-6489e6daee91\" } } ]"; ApiUtil.setExampleResponse(request, "application/json", exampleString); break; } @@ -331,7 +331,7 @@ default ResponseEntity getFeedbackDetails( getRequest().ifPresent(request -> { for (MediaType mediaType: MediaType.parseMediaTypes(request.getHeader("Accept"))) { if (mediaType.isCompatibleWith(MediaType.valueOf("application/json"))) { - String exampleString = "{ \"feedback\" : \"feedback\", \"creator\" : { \"name\" : \"name\", \"id\" : \"046b6c7f-0b8a-43b9-b35d-6489e6daee91\" }, \"member\" : { \"name\" : \"name\", \"id\" : \"046b6c7f-0b8a-43b9-b35d-6489e6daee91\" }, \"rating\" : 0, \"created_at\" : \"2000-01-23T04:56:07.000+00:00\", \"id\" : \"046b6c7f-0b8a-43b9-b35d-6489e6daee91\", \"event\" : { \"name\" : \"name\", \"id\" : \"046b6c7f-0b8a-43b9-b35d-6489e6daee91\" } }"; + String exampleString = "{ \"feedback\" : \"feedback\", \"creator\" : \"{}\", \"member\" : { \"name\" : \"name\", \"id\" : \"046b6c7f-0b8a-43b9-b35d-6489e6daee91\" }, \"rating\" : 0, \"created_at\" : \"2000-01-23T04:56:07.000+00:00\", \"id\" : \"046b6c7f-0b8a-43b9-b35d-6489e6daee91\", \"event\" : { \"name\" : \"name\", \"id\" : \"046b6c7f-0b8a-43b9-b35d-6489e6daee91\" } }"; ApiUtil.setExampleResponse(request, "application/json", exampleString); break; } @@ -418,7 +418,7 @@ default ResponseEntity updateFeedbackDetails( getRequest().ifPresent(request -> { for (MediaType mediaType: MediaType.parseMediaTypes(request.getHeader("Accept"))) { if (mediaType.isCompatibleWith(MediaType.valueOf("application/json"))) { - String exampleString = "{ \"feedback\" : \"feedback\", \"creator\" : { \"name\" : \"name\", \"id\" : \"046b6c7f-0b8a-43b9-b35d-6489e6daee91\" }, \"member\" : { \"name\" : \"name\", \"id\" : \"046b6c7f-0b8a-43b9-b35d-6489e6daee91\" }, \"rating\" : 0, \"created_at\" : \"2000-01-23T04:56:07.000+00:00\", \"id\" : \"046b6c7f-0b8a-43b9-b35d-6489e6daee91\", \"event\" : { \"name\" : \"name\", \"id\" : \"046b6c7f-0b8a-43b9-b35d-6489e6daee91\" } }"; + String exampleString = "{ \"feedback\" : \"feedback\", \"creator\" : \"{}\", \"member\" : { \"name\" : \"name\", \"id\" : \"046b6c7f-0b8a-43b9-b35d-6489e6daee91\" }, \"rating\" : 0, \"created_at\" : \"2000-01-23T04:56:07.000+00:00\", \"id\" : \"046b6c7f-0b8a-43b9-b35d-6489e6daee91\", \"event\" : { \"name\" : \"name\", \"id\" : \"046b6c7f-0b8a-43b9-b35d-6489e6daee91\" } }"; ApiUtil.setExampleResponse(request, "application/json", exampleString); break; } diff --git a/services/spring-feedback/src/generated/java/tum/devoops/feedbackservice/model/Feedback.java b/services/spring-feedback/src/generated/java/tum/devoops/feedbackservice/model/Feedback.java index eb2fa47..fb1c30f 100644 --- a/services/spring-feedback/src/generated/java/tum/devoops/feedbackservice/model/Feedback.java +++ b/services/spring-feedback/src/generated/java/tum/devoops/feedbackservice/model/Feedback.java @@ -32,7 +32,7 @@ public class Feedback { private Reference member; - private Reference creator; + private Reference creator = null; @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) private OffsetDateTime createdAt; diff --git a/services/spring-feedback/src/generated/java/tum/devoops/feedbackservice/model/FeedbackSummary.java b/services/spring-feedback/src/generated/java/tum/devoops/feedbackservice/model/FeedbackSummary.java index 48ca51f..322e8e1 100644 --- a/services/spring-feedback/src/generated/java/tum/devoops/feedbackservice/model/FeedbackSummary.java +++ b/services/spring-feedback/src/generated/java/tum/devoops/feedbackservice/model/FeedbackSummary.java @@ -32,7 +32,7 @@ public class FeedbackSummary { private Reference member; - private Reference creator; + private Reference creator = null; @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) private OffsetDateTime createdAt; diff --git a/services/spring-feedback/src/main/java/tum/devoops/feedbackservice/entity/FeedbackEntity.java b/services/spring-feedback/src/main/java/tum/devoops/feedbackservice/entity/FeedbackEntity.java index f7e2f48..a93662a 100644 --- a/services/spring-feedback/src/main/java/tum/devoops/feedbackservice/entity/FeedbackEntity.java +++ b/services/spring-feedback/src/main/java/tum/devoops/feedbackservice/entity/FeedbackEntity.java @@ -33,8 +33,8 @@ public class FeedbackEntity { private UUID memberId; // UUID of the member who wrote this feedback. - // FK to member.member(id) added in V3 migration. - @Column(name = "creator_id", nullable = false) + // FK to member.member(id); ON DELETE SET NULL, so this is cleared if the creator is deleted. + @Column(name = "creator_id") private UUID creatorId; @Column(name = "created_at", nullable = false) diff --git a/services/spring-feedback/src/main/java/tum/devoops/feedbackservice/service/FeedbackService.java b/services/spring-feedback/src/main/java/tum/devoops/feedbackservice/service/FeedbackService.java index 8169259..34707d9 100644 --- a/services/spring-feedback/src/main/java/tum/devoops/feedbackservice/service/FeedbackService.java +++ b/services/spring-feedback/src/main/java/tum/devoops/feedbackservice/service/FeedbackService.java @@ -210,6 +210,11 @@ private Reference eventReference(UUID eventId) { } private Reference memberReference(UUID memberId) { + // memberId is null only for a creator whose member was deleted (ON DELETE SET NULL); the + // subject member is never null. + if (memberId == null) { + return null; + } String name = memberRepository.findById(memberId) .map(m -> m.getFirstName() + " " + m.getLastName()).orElse(null); return new Reference(memberId, name); diff --git a/services/spring-feedback/src/main/resources/db/migration/V4__cascade_foreign_keys.sql b/services/spring-feedback/src/main/resources/db/migration/V4__cascade_foreign_keys.sql new file mode 100644 index 0000000..b697c50 --- /dev/null +++ b/services/spring-feedback/src/main/resources/db/migration/V4__cascade_foreign_keys.sql @@ -0,0 +1,18 @@ +-- Switch feedback foreign keys from the default ON DELETE NO ACTION to a non-blocking action. +-- The event and subject-member references CASCADE (deleting the event or the member the feedback is +-- about removes the feedback). The creator reference uses SET NULL: deleting the member who wrote the +-- feedback preserves it (it is about another member) and just clears the creator, so creator_id +-- becomes nullable. + +ALTER TABLE feedback.feedback DROP CONSTRAINT fk_feedback_event; +ALTER TABLE feedback.feedback + ADD CONSTRAINT fk_feedback_event FOREIGN KEY (event_id) REFERENCES event.events (id) ON DELETE CASCADE; + +ALTER TABLE feedback.feedback DROP CONSTRAINT fk_feedback_member; +ALTER TABLE feedback.feedback + ADD CONSTRAINT fk_feedback_member FOREIGN KEY (member_id) REFERENCES member.members (id) ON DELETE CASCADE; + +ALTER TABLE feedback.feedback ALTER COLUMN creator_id DROP NOT NULL; +ALTER TABLE feedback.feedback DROP CONSTRAINT fk_feedback_creator; +ALTER TABLE feedback.feedback + ADD CONSTRAINT fk_feedback_creator FOREIGN KEY (creator_id) REFERENCES member.members (id) ON DELETE SET NULL; diff --git a/services/spring-feedback/src/test/java/tum/devoops/feedbackservice/service/FeedbackServiceTest.java b/services/spring-feedback/src/test/java/tum/devoops/feedbackservice/service/FeedbackServiceTest.java index 28c881e..f5360f6 100644 --- a/services/spring-feedback/src/test/java/tum/devoops/feedbackservice/service/FeedbackServiceTest.java +++ b/services/spring-feedback/src/test/java/tum/devoops/feedbackservice/service/FeedbackServiceTest.java @@ -263,6 +263,18 @@ void getFeedbackDetailsAsCreatorSucceeds() { assertThat(result.getCreator().getId()).isEqualTo(REQUESTER_ID); } + @Test + void getFeedbackDetailsWithNullCreatorReturnsNullCreator() { + // creator_id is SET NULL when the author member is deleted; the feedback survives. + FeedbackEntity e = makeEntity(FEEDBACK_ID, EVENT_ID, MEMBER_ID, null); + when(feedbackRepository.findById(FEEDBACK_ID)).thenReturn(Optional.of(e)); + + Feedback result = service.getFeedbackDetails(FEEDBACK_ID, REQUESTER_ID, true); + + assertThat(result.getCreator()).isNull(); + assertThat(result.getMember().getId()).isEqualTo(MEMBER_ID); + } + @Test void getFeedbackDetailsAsMemberSubjectSucceeds() { FeedbackEntity e = makeEntity(FEEDBACK_ID, EVENT_ID, REQUESTER_ID, ANOTHER_ID); diff --git a/services/spring-finance/src/generated/java/tum/devoops/financeservice/api/FinanceApi.java b/services/spring-finance/src/generated/java/tum/devoops/financeservice/api/FinanceApi.java index 0a32e69..bca2101 100644 --- a/services/spring-finance/src/generated/java/tum/devoops/financeservice/api/FinanceApi.java +++ b/services/spring-finance/src/generated/java/tum/devoops/financeservice/api/FinanceApi.java @@ -103,7 +103,7 @@ default ResponseEntity createTransaction( getRequest().ifPresent(request -> { for (MediaType mediaType: MediaType.parseMediaTypes(request.getHeader("Accept"))) { if (mediaType.isCompatibleWith(MediaType.valueOf("application/json"))) { - String exampleString = "{ \"creator\" : { \"name\" : \"name\", \"id\" : \"046b6c7f-0b8a-43b9-b35d-6489e6daee91\" }, \"amount_cents\" : 0, \"member\" : { \"name\" : \"name\", \"id\" : \"046b6c7f-0b8a-43b9-b35d-6489e6daee91\" }, \"created_at\" : \"2000-01-23T04:56:07.000+00:00\", \"description\" : \"description\", \"id\" : \"046b6c7f-0b8a-43b9-b35d-6489e6daee91\", \"title\" : \"title\" }"; + String exampleString = "{ \"creator\" : \"{}\", \"amount_cents\" : 0, \"member\" : { \"name\" : \"name\", \"id\" : \"046b6c7f-0b8a-43b9-b35d-6489e6daee91\" }, \"created_at\" : \"2000-01-23T04:56:07.000+00:00\", \"description\" : \"description\", \"id\" : \"046b6c7f-0b8a-43b9-b35d-6489e6daee91\", \"title\" : \"title\" }"; ApiUtil.setExampleResponse(request, "application/json", exampleString); break; } @@ -326,7 +326,7 @@ default ResponseEntity> getAllTransactions( getRequest().ifPresent(request -> { for (MediaType mediaType: MediaType.parseMediaTypes(request.getHeader("Accept"))) { if (mediaType.isCompatibleWith(MediaType.valueOf("application/json"))) { - String exampleString = "[ { \"creator\" : { \"name\" : \"name\", \"id\" : \"046b6c7f-0b8a-43b9-b35d-6489e6daee91\" }, \"amount_cents\" : 0, \"member\" : { \"name\" : \"name\", \"id\" : \"046b6c7f-0b8a-43b9-b35d-6489e6daee91\" }, \"created_at\" : \"2000-01-23T04:56:07.000+00:00\", \"description\" : \"description\", \"id\" : \"046b6c7f-0b8a-43b9-b35d-6489e6daee91\", \"title\" : \"title\" }, { \"creator\" : { \"name\" : \"name\", \"id\" : \"046b6c7f-0b8a-43b9-b35d-6489e6daee91\" }, \"amount_cents\" : 0, \"member\" : { \"name\" : \"name\", \"id\" : \"046b6c7f-0b8a-43b9-b35d-6489e6daee91\" }, \"created_at\" : \"2000-01-23T04:56:07.000+00:00\", \"description\" : \"description\", \"id\" : \"046b6c7f-0b8a-43b9-b35d-6489e6daee91\", \"title\" : \"title\" } ]"; + String exampleString = "[ { \"creator\" : \"{}\", \"amount_cents\" : 0, \"member\" : { \"name\" : \"name\", \"id\" : \"046b6c7f-0b8a-43b9-b35d-6489e6daee91\" }, \"created_at\" : \"2000-01-23T04:56:07.000+00:00\", \"description\" : \"description\", \"id\" : \"046b6c7f-0b8a-43b9-b35d-6489e6daee91\", \"title\" : \"title\" }, { \"creator\" : \"{}\", \"amount_cents\" : 0, \"member\" : { \"name\" : \"name\", \"id\" : \"046b6c7f-0b8a-43b9-b35d-6489e6daee91\" }, \"created_at\" : \"2000-01-23T04:56:07.000+00:00\", \"description\" : \"description\", \"id\" : \"046b6c7f-0b8a-43b9-b35d-6489e6daee91\", \"title\" : \"title\" } ]"; ApiUtil.setExampleResponse(request, "application/json", exampleString); break; } @@ -481,7 +481,7 @@ default ResponseEntity getTransaction( getRequest().ifPresent(request -> { for (MediaType mediaType: MediaType.parseMediaTypes(request.getHeader("Accept"))) { if (mediaType.isCompatibleWith(MediaType.valueOf("application/json"))) { - String exampleString = "{ \"creator\" : { \"name\" : \"name\", \"id\" : \"046b6c7f-0b8a-43b9-b35d-6489e6daee91\" }, \"amount_cents\" : 0, \"member\" : { \"name\" : \"name\", \"id\" : \"046b6c7f-0b8a-43b9-b35d-6489e6daee91\" }, \"created_at\" : \"2000-01-23T04:56:07.000+00:00\", \"description\" : \"description\", \"id\" : \"046b6c7f-0b8a-43b9-b35d-6489e6daee91\", \"title\" : \"title\" }"; + String exampleString = "{ \"creator\" : \"{}\", \"amount_cents\" : 0, \"member\" : { \"name\" : \"name\", \"id\" : \"046b6c7f-0b8a-43b9-b35d-6489e6daee91\" }, \"created_at\" : \"2000-01-23T04:56:07.000+00:00\", \"description\" : \"description\", \"id\" : \"046b6c7f-0b8a-43b9-b35d-6489e6daee91\", \"title\" : \"title\" }"; ApiUtil.setExampleResponse(request, "application/json", exampleString); break; } @@ -568,7 +568,7 @@ default ResponseEntity updateTransaction( getRequest().ifPresent(request -> { for (MediaType mediaType: MediaType.parseMediaTypes(request.getHeader("Accept"))) { if (mediaType.isCompatibleWith(MediaType.valueOf("application/json"))) { - String exampleString = "{ \"creator\" : { \"name\" : \"name\", \"id\" : \"046b6c7f-0b8a-43b9-b35d-6489e6daee91\" }, \"amount_cents\" : 0, \"member\" : { \"name\" : \"name\", \"id\" : \"046b6c7f-0b8a-43b9-b35d-6489e6daee91\" }, \"created_at\" : \"2000-01-23T04:56:07.000+00:00\", \"description\" : \"description\", \"id\" : \"046b6c7f-0b8a-43b9-b35d-6489e6daee91\", \"title\" : \"title\" }"; + String exampleString = "{ \"creator\" : \"{}\", \"amount_cents\" : 0, \"member\" : { \"name\" : \"name\", \"id\" : \"046b6c7f-0b8a-43b9-b35d-6489e6daee91\" }, \"created_at\" : \"2000-01-23T04:56:07.000+00:00\", \"description\" : \"description\", \"id\" : \"046b6c7f-0b8a-43b9-b35d-6489e6daee91\", \"title\" : \"title\" }"; ApiUtil.setExampleResponse(request, "application/json", exampleString); break; } diff --git a/services/spring-finance/src/generated/java/tum/devoops/financeservice/model/Transaction.java b/services/spring-finance/src/generated/java/tum/devoops/financeservice/model/Transaction.java index 87afea9..ad658b6 100644 --- a/services/spring-finance/src/generated/java/tum/devoops/financeservice/model/Transaction.java +++ b/services/spring-finance/src/generated/java/tum/devoops/financeservice/model/Transaction.java @@ -30,7 +30,7 @@ public class Transaction { private Reference member; - private Reference creator; + private Reference creator = null; private Integer amountCents; diff --git a/services/spring-finance/src/main/java/tum/devoops/financeservice/entity/TransactionEntity.java b/services/spring-finance/src/main/java/tum/devoops/financeservice/entity/TransactionEntity.java index bda6351..8df09d5 100644 --- a/services/spring-finance/src/main/java/tum/devoops/financeservice/entity/TransactionEntity.java +++ b/services/spring-finance/src/main/java/tum/devoops/financeservice/entity/TransactionEntity.java @@ -28,8 +28,8 @@ public class TransactionEntity { private UUID memberId; // UUID of the member who created this transaction. - // FK to member.member(id) added in V3 migration. - @Column(name = "creator_id", nullable = false) + // FK to member.member(id); ON DELETE SET NULL, so this is cleared if the creator is deleted. + @Column(name = "creator_id") private UUID creatorId; // Amount in cents (e.g. 1000 = €10.00). Positive = credit, negative = debit. diff --git a/services/spring-finance/src/main/resources/db/migration/V3__cascade_foreign_keys.sql b/services/spring-finance/src/main/resources/db/migration/V3__cascade_foreign_keys.sql new file mode 100644 index 0000000..fe92bc8 --- /dev/null +++ b/services/spring-finance/src/main/resources/db/migration/V3__cascade_foreign_keys.sql @@ -0,0 +1,14 @@ +-- Switch finance foreign keys from the default ON DELETE NO ACTION to a non-blocking action. +-- The subject-member reference CASCADEs (deleting the member the transaction is about removes the +-- transaction). The creator reference uses SET NULL: deleting the member who recorded the transaction +-- preserves it (it belongs to another member) and just clears the creator, so creator_id becomes +-- nullable. + +ALTER TABLE finance.transactions DROP CONSTRAINT fk_transactions_member; +ALTER TABLE finance.transactions + ADD CONSTRAINT fk_transactions_member FOREIGN KEY (member_id) REFERENCES member.members (id) ON DELETE CASCADE; + +ALTER TABLE finance.transactions ALTER COLUMN creator_id DROP NOT NULL; +ALTER TABLE finance.transactions DROP CONSTRAINT fk_transactions_creator; +ALTER TABLE finance.transactions + ADD CONSTRAINT fk_transactions_creator FOREIGN KEY (creator_id) REFERENCES member.members (id) ON DELETE SET NULL; diff --git a/services/spring-organization/src/main/resources/db/migration/V4__cascade_foreign_keys.sql b/services/spring-organization/src/main/resources/db/migration/V4__cascade_foreign_keys.sql new file mode 100644 index 0000000..1d20176 --- /dev/null +++ b/services/spring-organization/src/main/resources/db/migration/V4__cascade_foreign_keys.sql @@ -0,0 +1,32 @@ +-- Switch every organization foreign key from the default ON DELETE NO ACTION (which blocks a delete +-- while referenced) to ON DELETE CASCADE, so deleting a parent removes its children: deleting a sport +-- removes its teams, deleting a team removes its trainers/trainees, deleting a member removes their +-- director/trainer/trainee memberships. + +ALTER TABLE organization.teams DROP CONSTRAINT fk_teams_sport; +ALTER TABLE organization.teams + ADD CONSTRAINT fk_teams_sport FOREIGN KEY (sport_id) REFERENCES organization.sports (id) ON DELETE CASCADE; + +ALTER TABLE organization.directors DROP CONSTRAINT fk_directors_sport; +ALTER TABLE organization.directors + ADD CONSTRAINT fk_directors_sport FOREIGN KEY (sport_id) REFERENCES organization.sports (id) ON DELETE CASCADE; + +ALTER TABLE organization.directors DROP CONSTRAINT fk_directors_member; +ALTER TABLE organization.directors + ADD CONSTRAINT fk_directors_member FOREIGN KEY (member_id) REFERENCES member.members (id) ON DELETE CASCADE; + +ALTER TABLE organization.trainers DROP CONSTRAINT fk_trainers_team; +ALTER TABLE organization.trainers + ADD CONSTRAINT fk_trainers_team FOREIGN KEY (team_id) REFERENCES organization.teams (id) ON DELETE CASCADE; + +ALTER TABLE organization.trainers DROP CONSTRAINT fk_trainers_member; +ALTER TABLE organization.trainers + ADD CONSTRAINT fk_trainers_member FOREIGN KEY (member_id) REFERENCES member.members (id) ON DELETE CASCADE; + +ALTER TABLE organization.trainees DROP CONSTRAINT fk_trainees_team; +ALTER TABLE organization.trainees + ADD CONSTRAINT fk_trainees_team FOREIGN KEY (team_id) REFERENCES organization.teams (id) ON DELETE CASCADE; + +ALTER TABLE organization.trainees DROP CONSTRAINT fk_trainees_member; +ALTER TABLE organization.trainees + ADD CONSTRAINT fk_trainees_member FOREIGN KEY (member_id) REFERENCES member.members (id) ON DELETE CASCADE; diff --git a/web-client/src/api.ts b/web-client/src/api.ts index 108b445..5a2ea96 100644 --- a/web-client/src/api.ts +++ b/web-client/src/api.ts @@ -731,7 +731,7 @@ export interface components { sports_linked?: components["schemas"]["Reference"][]; /** @description Teams associated with this event. */ teams_linked?: components["schemas"]["Reference"][]; - creator: components["schemas"]["Reference"]; + creator: components["schemas"]["Reference"] | null; }; /** @description A simplified representation of a Event, typically used in list views. */ EventSummary: { @@ -776,7 +776,7 @@ export interface components { id: string; event: components["schemas"]["Reference"]; member: components["schemas"]["Reference"]; - creator: components["schemas"]["Reference"]; + creator: components["schemas"]["Reference"] | null; /** Format: date-time */ created_at: string; feedback: string; @@ -789,7 +789,7 @@ export interface components { id: string; event: components["schemas"]["Reference"]; member: components["schemas"]["Reference"]; - creator: components["schemas"]["Reference"]; + creator: components["schemas"]["Reference"] | null; /** Format: date-time */ created_at: string; /** Format: int32 */ @@ -816,7 +816,7 @@ export interface components { /** Format: uuid */ id: string; member: components["schemas"]["Reference"]; - creator: components["schemas"]["Reference"]; + creator: components["schemas"]["Reference"] | null; amount_cents: number; /** Format: date-time */ created_at: string; From 7c92ef4bf3e8b4a99557e4302cd9eeca7fc41261 Mon Sep 17 00:00:00 2001 From: Raphael Frank <04.raphael.frank@gmail.com> Date: Mon, 29 Jun 2026 22:37:32 +0200 Subject: [PATCH 08/16] add members/dashboard endpoint --- api/openapi.yaml | 166 +++++++++ api/scripts/gen-all.sh | 2 +- services/py-genai-helper/generated/models.py | 62 +++- .../config/checkstyle/checkstyle.xml | 14 + .../generated/java/.openapi-generator/FILES | 10 + .../devoops/memberservice/api/MembersApi.java | 71 ++++ .../memberservice/model/AdminDashboard.java | 276 +++++++++++++++ .../memberservice/model/Dashboard.java | 52 +++ .../model/DirectorDashboard.java | 262 +++++++++++++++ .../memberservice/model/EventSummary.java | 212 ++++++++++++ .../memberservice/model/FeedbackSummary.java | 227 +++++++++++++ .../model/MemberReportSummary.java | 150 +++++++++ .../memberservice/model/Reference.java | 121 +++++++ .../model/TeamBalanceSummary.java | 146 ++++++++ .../memberservice/model/TraineeDashboard.java | 247 ++++++++++++++ .../memberservice/model/TrainerDashboard.java | 212 ++++++++++++ .../controller/DashboardController.java | 34 ++ .../memberservice/entity/DirectorEntity.java | 36 ++ .../memberservice/entity/EventEntity.java | 32 ++ .../memberservice/entity/FeedbackEntity.java | 41 +++ .../memberservice/entity/SportEntity.java | 26 ++ .../entity/SportEventEntity.java | 36 ++ .../memberservice/entity/TeamEntity.java | 29 ++ .../memberservice/entity/TeamEventEntity.java | 36 ++ .../memberservice/entity/TraineeEntity.java | 36 ++ .../memberservice/entity/TrainerEntity.java | 36 ++ .../entity/TransactionEntity.java | 29 ++ .../repository/DirectorRepository.java | 14 + .../repository/EventRepository.java | 17 + .../repository/FeedbackRepository.java | 17 + .../repository/SportEventRepository.java | 14 + .../repository/SportRepository.java | 10 + .../repository/TeamEventRepository.java | 14 + .../repository/TeamRepository.java | 14 + .../repository/TraineeRepository.java | 17 + .../repository/TrainerRepository.java | 17 + .../repository/TransactionRepository.java | 20 ++ .../service/DashboardService.java | 288 ++++++++++++++++ .../service/ReportQueryService.java | 48 +++ .../MemberServiceApplicationTests.java | 10 + .../controller/DashboardControllerTest.java | 83 +++++ .../service/DashboardServiceTest.java | 317 ++++++++++++++++++ .../service/ReportQueryServiceTest.java | 49 +++ web-client/src/api.ts | 111 ++++++ 44 files changed, 3657 insertions(+), 4 deletions(-) create mode 100644 services/spring-member/src/generated/java/tum/devoops/memberservice/model/AdminDashboard.java create mode 100644 services/spring-member/src/generated/java/tum/devoops/memberservice/model/Dashboard.java create mode 100644 services/spring-member/src/generated/java/tum/devoops/memberservice/model/DirectorDashboard.java create mode 100644 services/spring-member/src/generated/java/tum/devoops/memberservice/model/EventSummary.java create mode 100644 services/spring-member/src/generated/java/tum/devoops/memberservice/model/FeedbackSummary.java create mode 100644 services/spring-member/src/generated/java/tum/devoops/memberservice/model/MemberReportSummary.java create mode 100644 services/spring-member/src/generated/java/tum/devoops/memberservice/model/Reference.java create mode 100644 services/spring-member/src/generated/java/tum/devoops/memberservice/model/TeamBalanceSummary.java create mode 100644 services/spring-member/src/generated/java/tum/devoops/memberservice/model/TraineeDashboard.java create mode 100644 services/spring-member/src/generated/java/tum/devoops/memberservice/model/TrainerDashboard.java create mode 100644 services/spring-member/src/main/java/tum/devoops/memberservice/controller/DashboardController.java create mode 100644 services/spring-member/src/main/java/tum/devoops/memberservice/entity/DirectorEntity.java create mode 100644 services/spring-member/src/main/java/tum/devoops/memberservice/entity/EventEntity.java create mode 100644 services/spring-member/src/main/java/tum/devoops/memberservice/entity/FeedbackEntity.java create mode 100644 services/spring-member/src/main/java/tum/devoops/memberservice/entity/SportEntity.java create mode 100644 services/spring-member/src/main/java/tum/devoops/memberservice/entity/SportEventEntity.java create mode 100644 services/spring-member/src/main/java/tum/devoops/memberservice/entity/TeamEntity.java create mode 100644 services/spring-member/src/main/java/tum/devoops/memberservice/entity/TeamEventEntity.java create mode 100644 services/spring-member/src/main/java/tum/devoops/memberservice/entity/TraineeEntity.java create mode 100644 services/spring-member/src/main/java/tum/devoops/memberservice/entity/TrainerEntity.java create mode 100644 services/spring-member/src/main/java/tum/devoops/memberservice/entity/TransactionEntity.java create mode 100644 services/spring-member/src/main/java/tum/devoops/memberservice/repository/DirectorRepository.java create mode 100644 services/spring-member/src/main/java/tum/devoops/memberservice/repository/EventRepository.java create mode 100644 services/spring-member/src/main/java/tum/devoops/memberservice/repository/FeedbackRepository.java create mode 100644 services/spring-member/src/main/java/tum/devoops/memberservice/repository/SportEventRepository.java create mode 100644 services/spring-member/src/main/java/tum/devoops/memberservice/repository/SportRepository.java create mode 100644 services/spring-member/src/main/java/tum/devoops/memberservice/repository/TeamEventRepository.java create mode 100644 services/spring-member/src/main/java/tum/devoops/memberservice/repository/TeamRepository.java create mode 100644 services/spring-member/src/main/java/tum/devoops/memberservice/repository/TraineeRepository.java create mode 100644 services/spring-member/src/main/java/tum/devoops/memberservice/repository/TrainerRepository.java create mode 100644 services/spring-member/src/main/java/tum/devoops/memberservice/repository/TransactionRepository.java create mode 100644 services/spring-member/src/main/java/tum/devoops/memberservice/service/DashboardService.java create mode 100644 services/spring-member/src/main/java/tum/devoops/memberservice/service/ReportQueryService.java create mode 100644 services/spring-member/src/test/java/tum/devoops/memberservice/controller/DashboardControllerTest.java create mode 100644 services/spring-member/src/test/java/tum/devoops/memberservice/service/DashboardServiceTest.java create mode 100644 services/spring-member/src/test/java/tum/devoops/memberservice/service/ReportQueryServiceTest.java diff --git a/api/openapi.yaml b/api/openapi.yaml index 2014ba9..6c24ce7 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -377,6 +377,33 @@ paths: application/json: schema: $ref: "#/components/schemas/MemberCreate" + /members/dashboard: + get: + operationId: getDashboard + tags: + - members + summary: Get the caller's dashboard + description: | + Returns dashboard data tailored to the caller's highest role + (admin > director > trainer > trainee). The `role` field discriminates the + concrete shape of the response. + - All authenticated users: can access their own dashboard. + responses: + "200": + description: The request was successful, and the server has returned the + requested resource in the response body. + content: + application/json: + schema: + $ref: "#/components/schemas/Dashboard" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "500": + $ref: "#/components/responses/InternalServerError" + security: + - BearerJwt: [] /members/{member_id}: get: operationId: getMemberDetails @@ -1436,6 +1463,145 @@ components: description: | A stored report including its generated text. `kind` indicates whether it is a member or team report; the matching `member`/`team` reference is populated accordingly. + Dashboard: + oneOf: + - $ref: "#/components/schemas/AdminDashboard" + - $ref: "#/components/schemas/DirectorDashboard" + - $ref: "#/components/schemas/TrainerDashboard" + - $ref: "#/components/schemas/TraineeDashboard" + discriminator: + propertyName: role + mapping: + admin: "#/components/schemas/AdminDashboard" + director: "#/components/schemas/DirectorDashboard" + trainer: "#/components/schemas/TrainerDashboard" + trainee: "#/components/schemas/TraineeDashboard" + description: | + Role-specific dashboard payload. The `role` property discriminates which concrete + shape is returned, following the caller's highest role (admin > director > trainer > trainee). + AdminDashboard: + type: object + properties: + role: + type: string + total_members: + type: integer + total_sports: + type: integer + total_teams: + type: integer + total_directors: + type: integer + total_trainers: + type: integer + total_balance_cents: + type: integer + events_this_week: + type: integer + required: + - role + - total_members + - total_sports + - total_teams + - total_directors + - total_trainers + - total_balance_cents + - events_this_week + description: Club-wide aggregates shown to administrators. + DirectorDashboard: + type: object + properties: + role: + type: string + sport: + $ref: "#/components/schemas/Reference" + total_teams: + type: integer + total_members: + type: integer + sport_balance_cents: + type: integer + upcoming_events: + type: integer + teams: + type: array + items: + $ref: "#/components/schemas/TeamBalanceSummary" + required: + - role + - sport + - total_teams + - total_members + - sport_balance_cents + - upcoming_events + - teams + description: Aggregates for the sport a director manages. + TeamBalanceSummary: + type: object + properties: + team: + $ref: "#/components/schemas/Reference" + member_count: + type: integer + balance_cents: + type: integer + required: + - team + - member_count + - balance_cents + description: Per-team rollup of trainee count and aggregate balance. + TrainerDashboard: + type: object + properties: + role: + type: string + team: + $ref: "#/components/schemas/Reference" + total_members: + type: integer + upcoming_events: + type: integer + recent_feedback: + type: array + items: + $ref: "#/components/schemas/FeedbackSummary" + required: + - role + - team + - total_members + - upcoming_events + - recent_feedback + description: Aggregates for the team a trainer manages, including feedback they authored. + TraineeDashboard: + type: object + properties: + role: + type: string + balance_cents: + type: integer + next_event: + type: object + nullable: true + allOf: + - $ref: "#/components/schemas/EventSummary" + upcoming_events: + type: integer + recent_feedback: + type: array + items: + $ref: "#/components/schemas/FeedbackSummary" + recent_reports: + type: array + items: + $ref: "#/components/schemas/MemberReportSummary" + required: + - role + - balance_cents + - next_event + - upcoming_events + - recent_feedback + - recent_reports + description: Personal aggregates shown to a trainee/member. Sport: type: object properties: diff --git a/api/scripts/gen-all.sh b/api/scripts/gen-all.sh index b45195f..54bb6bc 100755 --- a/api/scripts/gen-all.sh +++ b/api/scripts/gen-all.sh @@ -14,7 +14,7 @@ echo "Running OpenAPI code generation..." # Spring services — each receives only its own tag's API interface + relevant models "$SCRIPT_DIR/gen-spring.sh" spring-organization organization organizationservice "Reference:Sport:SportCreate:SportPartialUpdate:Team:TeamCreate:TeamPartialUpdate:ErrorResponse:BadRequestResponse" -"$SCRIPT_DIR/gen-spring.sh" spring-member members memberservice "Member:MemberSummary:MemberCreate:MemberPartialUpdate:ErrorResponse:BadRequestResponse" +"$SCRIPT_DIR/gen-spring.sh" spring-member members memberservice "Member:MemberSummary:MemberCreate:MemberPartialUpdate:ErrorResponse:BadRequestResponse:Reference:Dashboard:AdminDashboard:DirectorDashboard:TrainerDashboard:TraineeDashboard:TeamBalanceSummary:FeedbackSummary:EventSummary:MemberReportSummary" "$SCRIPT_DIR/gen-spring.sh" spring-event events eventservice "Reference:Event:EventSummary:EventCreate:EventPartialUpdate:ErrorResponse:BadRequestResponse" "$SCRIPT_DIR/gen-spring.sh" spring-feedback feedback feedbackservice "Reference:Feedback:FeedbackSummary:FeedbackCreate:FeedbackPartialUpdate:ErrorResponse:BadRequestResponse" "$SCRIPT_DIR/gen-spring.sh" spring-finance finance financeservice "Reference:Balance:Transaction:TransactionCreate:TransactionPartialUpdate:ErrorResponse:BadRequestResponse" diff --git a/services/py-genai-helper/generated/models.py b/services/py-genai-helper/generated/models.py index 2de6bb8..d543555 100644 --- a/services/py-genai-helper/generated/models.py +++ b/services/py-genai-helper/generated/models.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: openapi.yaml -# timestamp: 2026-06-28T16:42:08+00:00 +# timestamp: 2026-06-29T20:37:48+00:00 from __future__ import annotations -from pydantic import AwareDatetime, BaseModel, Field, SecretStr +from pydantic import AwareDatetime, BaseModel, Field, RootModel, SecretStr from uuid import UUID from enum import StrEnum from datetime import date -from typing import Annotated +from typing import Annotated, Literal class ErrorResponse(BaseModel): @@ -50,6 +50,23 @@ class Report(BaseModel): text: str +class AdminDashboard(BaseModel): + role: Literal['admin'] + total_members: int + total_sports: int + total_teams: int + total_directors: int + total_trainers: int + total_balance_cents: int + events_this_week: int + + +class TeamBalanceSummary(BaseModel): + team: Reference + member_count: int + balance_cents: int + + class Sport(BaseModel): id: UUID name: str @@ -251,3 +268,42 @@ class TransactionCreate(BaseModel): class Balance(BaseModel): member: Reference balance_cents: int + + +class DirectorDashboard(BaseModel): + role: Literal['director'] + sport: Reference + total_teams: int + total_members: int + sport_balance_cents: int + upcoming_events: int + teams: list[TeamBalanceSummary] + + +class TrainerDashboard(BaseModel): + role: Literal['trainer'] + team: Reference + total_members: int + upcoming_events: int + recent_feedback: list[FeedbackSummary] + + +class TraineeDashboard(BaseModel): + role: Literal['trainee'] + balance_cents: int + next_event: EventSummary + upcoming_events: int + recent_feedback: list[FeedbackSummary] + recent_reports: list[MemberReportSummary] + + +class Dashboard( + RootModel[AdminDashboard | DirectorDashboard | TrainerDashboard | TraineeDashboard] +): + root: Annotated[ + AdminDashboard | DirectorDashboard | TrainerDashboard | TraineeDashboard, + Field( + description="Role-specific dashboard payload. The `role` property discriminates which concrete\nshape is returned, following the caller's highest role (admin > director > trainer > trainee).\n", + discriminator='role', + ), + ] diff --git a/services/spring-member/config/checkstyle/checkstyle.xml b/services/spring-member/config/checkstyle/checkstyle.xml index 580dda9..f1398eb 100644 --- a/services/spring-member/config/checkstyle/checkstyle.xml +++ b/services/spring-member/config/checkstyle/checkstyle.xml @@ -15,6 +15,20 @@ + + + + + + + + + + + + diff --git a/services/spring-member/src/generated/java/.openapi-generator/FILES b/services/spring-member/src/generated/java/.openapi-generator/FILES index 1e3a676..5b05545 100644 --- a/services/spring-member/src/generated/java/.openapi-generator/FILES +++ b/services/spring-member/src/generated/java/.openapi-generator/FILES @@ -1,8 +1,18 @@ tum/devoops/memberservice/api/ApiUtil.java tum/devoops/memberservice/api/MembersApi.java +tum/devoops/memberservice/model/AdminDashboard.java tum/devoops/memberservice/model/BadRequestResponse.java +tum/devoops/memberservice/model/Dashboard.java +tum/devoops/memberservice/model/DirectorDashboard.java tum/devoops/memberservice/model/ErrorResponse.java +tum/devoops/memberservice/model/EventSummary.java +tum/devoops/memberservice/model/FeedbackSummary.java tum/devoops/memberservice/model/Member.java tum/devoops/memberservice/model/MemberCreate.java tum/devoops/memberservice/model/MemberPartialUpdate.java +tum/devoops/memberservice/model/MemberReportSummary.java tum/devoops/memberservice/model/MemberSummary.java +tum/devoops/memberservice/model/Reference.java +tum/devoops/memberservice/model/TeamBalanceSummary.java +tum/devoops/memberservice/model/TraineeDashboard.java +tum/devoops/memberservice/model/TrainerDashboard.java diff --git a/services/spring-member/src/generated/java/tum/devoops/memberservice/api/MembersApi.java b/services/spring-member/src/generated/java/tum/devoops/memberservice/api/MembersApi.java index 9f7a8e6..f17dc1f 100644 --- a/services/spring-member/src/generated/java/tum/devoops/memberservice/api/MembersApi.java +++ b/services/spring-member/src/generated/java/tum/devoops/memberservice/api/MembersApi.java @@ -6,6 +6,7 @@ package tum.devoops.memberservice.api; import tum.devoops.memberservice.model.BadRequestResponse; +import tum.devoops.memberservice.model.Dashboard; import tum.devoops.memberservice.model.ErrorResponse; import tum.devoops.memberservice.model.Member; import tum.devoops.memberservice.model.MemberCreate; @@ -282,6 +283,76 @@ default ResponseEntity> getAllMembers( } + /** + * GET /members/dashboard : Get the caller's dashboard + * Returns dashboard data tailored to the caller's highest role (admin > director > trainer > trainee). The `role` field discriminates the concrete shape of the response. - All authenticated users: can access their own dashboard. + * + * @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) + * or The server encountered an unexpected condition that prevented it from fulfilling the request. (status code 500) + */ + @Operation( + operationId = "getDashboard", + summary = "Get the caller's dashboard", + description = "Returns dashboard data tailored to the caller's highest role (admin > director > trainer > trainee). The `role` field discriminates the concrete shape of the response. - All authenticated users: can access their own dashboard. ", + tags = { "members" }, + responses = { + @ApiResponse(responseCode = "200", description = "The request was successful, and the server has returned the requested resource in the response body.", content = { + @Content(mediaType = "application/json", schema = @Schema(implementation = Dashboard.class)) + }), + @ApiResponse(responseCode = "401", description = "Authentication is required to access the requested resource. The client must include the appropriate credentials.", content = { + @Content(mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class)) + }), + @ApiResponse(responseCode = "403", description = "The server understood the request, but refuses to authorize it. Ensure the client has appropriate permissions.", content = { + @Content(mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class)) + }), + @ApiResponse(responseCode = "500", description = "The server encountered an unexpected condition that prevented it from fulfilling the request.", content = { + @Content(mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class)) + }) + }, + security = { + @SecurityRequirement(name = "BearerJwt") + } + ) + @RequestMapping( + method = RequestMethod.GET, + value = "/members/dashboard", + produces = { "application/json" } + ) + + default ResponseEntity getDashboard( + + ) { + getRequest().ifPresent(request -> { + for (MediaType mediaType: MediaType.parseMediaTypes(request.getHeader("Accept"))) { + if (mediaType.isCompatibleWith(MediaType.valueOf("application/json"))) { + String exampleString = "{ \"total_sports\" : 6, \"role\" : \"role\", \"total_balance_cents\" : 2, \"total_teams\" : 1, \"total_members\" : 0, \"events_this_week\" : 7, \"total_directors\" : 5, \"total_trainers\" : 5 }"; + ApiUtil.setExampleResponse(request, "application/json", exampleString); + break; + } + if (mediaType.isCompatibleWith(MediaType.valueOf("application/json"))) { + String exampleString = "{ \"message\" : \"message\" }"; + ApiUtil.setExampleResponse(request, "application/json", exampleString); + break; + } + if (mediaType.isCompatibleWith(MediaType.valueOf("application/json"))) { + String exampleString = "{ \"message\" : \"message\" }"; + ApiUtil.setExampleResponse(request, "application/json", exampleString); + break; + } + if (mediaType.isCompatibleWith(MediaType.valueOf("application/json"))) { + String exampleString = "{ \"message\" : \"message\" }"; + ApiUtil.setExampleResponse(request, "application/json", exampleString); + break; + } + } + }); + return new ResponseEntity<>(HttpStatus.NOT_IMPLEMENTED); + + } + + /** * GET /members/{member_id} : Get member details * Returns the full details of a specific member. - Members themselves: can view their own details. - Team members: can view details of others in the same team. - Trainers: can view details of members in their team. - Directors: can view details of members in their sport. - Admins: can view any member's details. diff --git a/services/spring-member/src/generated/java/tum/devoops/memberservice/model/AdminDashboard.java b/services/spring-member/src/generated/java/tum/devoops/memberservice/model/AdminDashboard.java new file mode 100644 index 0000000..0029e0f --- /dev/null +++ b/services/spring-member/src/generated/java/tum/devoops/memberservice/model/AdminDashboard.java @@ -0,0 +1,276 @@ +package tum.devoops.memberservice.model; + +import java.net.URI; +import java.util.Objects; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.JsonCreator; +import org.springframework.lang.Nullable; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonSubTypes; +import com.fasterxml.jackson.annotation.JsonTypeInfo; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.time.OffsetDateTime; +import jakarta.validation.Valid; +import jakarta.validation.constraints.*; +import io.swagger.v3.oas.annotations.media.Schema; + + +import java.util.*; +import jakarta.annotation.Generated; + +/** + * Club-wide aggregates shown to administrators. + */ + +@Schema(name = "AdminDashboard", description = "Club-wide aggregates shown to administrators.") +@Generated(value = "org.openapitools.codegen.languages.SpringCodegen", comments = "Generator version: 7.14.0") +public class AdminDashboard implements Dashboard { + + private String role; + + private Integer totalMembers; + + private Integer totalSports; + + private Integer totalTeams; + + private Integer totalDirectors; + + private Integer totalTrainers; + + private Integer totalBalanceCents; + + private Integer eventsThisWeek; + + public AdminDashboard() { + super(); + } + + /** + * Constructor with only required parameters + */ + public AdminDashboard(String role, Integer totalMembers, Integer totalSports, Integer totalTeams, Integer totalDirectors, Integer totalTrainers, Integer totalBalanceCents, Integer eventsThisWeek) { + this.role = role; + this.totalMembers = totalMembers; + this.totalSports = totalSports; + this.totalTeams = totalTeams; + this.totalDirectors = totalDirectors; + this.totalTrainers = totalTrainers; + this.totalBalanceCents = totalBalanceCents; + this.eventsThisWeek = eventsThisWeek; + } + + public AdminDashboard role(String role) { + this.role = role; + return this; + } + + /** + * Get role + * @return role + */ + @NotNull + @Schema(name = "role", requiredMode = Schema.RequiredMode.REQUIRED) + @JsonProperty("role") + public String getRole() { + return role; + } + + public void setRole(String role) { + this.role = role; + } + + public AdminDashboard totalMembers(Integer totalMembers) { + this.totalMembers = totalMembers; + return this; + } + + /** + * Get totalMembers + * @return totalMembers + */ + @NotNull + @Schema(name = "total_members", requiredMode = Schema.RequiredMode.REQUIRED) + @JsonProperty("total_members") + public Integer getTotalMembers() { + return totalMembers; + } + + public void setTotalMembers(Integer totalMembers) { + this.totalMembers = totalMembers; + } + + public AdminDashboard totalSports(Integer totalSports) { + this.totalSports = totalSports; + return this; + } + + /** + * Get totalSports + * @return totalSports + */ + @NotNull + @Schema(name = "total_sports", requiredMode = Schema.RequiredMode.REQUIRED) + @JsonProperty("total_sports") + public Integer getTotalSports() { + return totalSports; + } + + public void setTotalSports(Integer totalSports) { + this.totalSports = totalSports; + } + + public AdminDashboard totalTeams(Integer totalTeams) { + this.totalTeams = totalTeams; + return this; + } + + /** + * Get totalTeams + * @return totalTeams + */ + @NotNull + @Schema(name = "total_teams", requiredMode = Schema.RequiredMode.REQUIRED) + @JsonProperty("total_teams") + public Integer getTotalTeams() { + return totalTeams; + } + + public void setTotalTeams(Integer totalTeams) { + this.totalTeams = totalTeams; + } + + public AdminDashboard totalDirectors(Integer totalDirectors) { + this.totalDirectors = totalDirectors; + return this; + } + + /** + * Get totalDirectors + * @return totalDirectors + */ + @NotNull + @Schema(name = "total_directors", requiredMode = Schema.RequiredMode.REQUIRED) + @JsonProperty("total_directors") + public Integer getTotalDirectors() { + return totalDirectors; + } + + public void setTotalDirectors(Integer totalDirectors) { + this.totalDirectors = totalDirectors; + } + + public AdminDashboard totalTrainers(Integer totalTrainers) { + this.totalTrainers = totalTrainers; + return this; + } + + /** + * Get totalTrainers + * @return totalTrainers + */ + @NotNull + @Schema(name = "total_trainers", requiredMode = Schema.RequiredMode.REQUIRED) + @JsonProperty("total_trainers") + public Integer getTotalTrainers() { + return totalTrainers; + } + + public void setTotalTrainers(Integer totalTrainers) { + this.totalTrainers = totalTrainers; + } + + public AdminDashboard totalBalanceCents(Integer totalBalanceCents) { + this.totalBalanceCents = totalBalanceCents; + return this; + } + + /** + * Get totalBalanceCents + * @return totalBalanceCents + */ + @NotNull + @Schema(name = "total_balance_cents", requiredMode = Schema.RequiredMode.REQUIRED) + @JsonProperty("total_balance_cents") + public Integer getTotalBalanceCents() { + return totalBalanceCents; + } + + public void setTotalBalanceCents(Integer totalBalanceCents) { + this.totalBalanceCents = totalBalanceCents; + } + + public AdminDashboard eventsThisWeek(Integer eventsThisWeek) { + this.eventsThisWeek = eventsThisWeek; + return this; + } + + /** + * Get eventsThisWeek + * @return eventsThisWeek + */ + @NotNull + @Schema(name = "events_this_week", requiredMode = Schema.RequiredMode.REQUIRED) + @JsonProperty("events_this_week") + public Integer getEventsThisWeek() { + return eventsThisWeek; + } + + public void setEventsThisWeek(Integer eventsThisWeek) { + this.eventsThisWeek = eventsThisWeek; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + AdminDashboard adminDashboard = (AdminDashboard) o; + return Objects.equals(this.role, adminDashboard.role) && + Objects.equals(this.totalMembers, adminDashboard.totalMembers) && + Objects.equals(this.totalSports, adminDashboard.totalSports) && + Objects.equals(this.totalTeams, adminDashboard.totalTeams) && + Objects.equals(this.totalDirectors, adminDashboard.totalDirectors) && + Objects.equals(this.totalTrainers, adminDashboard.totalTrainers) && + Objects.equals(this.totalBalanceCents, adminDashboard.totalBalanceCents) && + Objects.equals(this.eventsThisWeek, adminDashboard.eventsThisWeek); + } + + @Override + public int hashCode() { + return Objects.hash(role, totalMembers, totalSports, totalTeams, totalDirectors, totalTrainers, totalBalanceCents, eventsThisWeek); + } + + @Override + public String toString() { + StringBuilder sb = new StringBuilder(); + sb.append("class AdminDashboard {\n"); + sb.append(" role: ").append(toIndentedString(role)).append("\n"); + sb.append(" totalMembers: ").append(toIndentedString(totalMembers)).append("\n"); + sb.append(" totalSports: ").append(toIndentedString(totalSports)).append("\n"); + sb.append(" totalTeams: ").append(toIndentedString(totalTeams)).append("\n"); + sb.append(" totalDirectors: ").append(toIndentedString(totalDirectors)).append("\n"); + sb.append(" totalTrainers: ").append(toIndentedString(totalTrainers)).append("\n"); + sb.append(" totalBalanceCents: ").append(toIndentedString(totalBalanceCents)).append("\n"); + sb.append(" eventsThisWeek: ").append(toIndentedString(eventsThisWeek)).append("\n"); + sb.append("}"); + return sb.toString(); + } + + /** + * Convert the given object to string with each line indented by 4 spaces + * (except the first line). + */ + private String toIndentedString(Object o) { + if (o == null) { + return "null"; + } + return o.toString().replace("\n", "\n "); + } +} + diff --git a/services/spring-member/src/generated/java/tum/devoops/memberservice/model/Dashboard.java b/services/spring-member/src/generated/java/tum/devoops/memberservice/model/Dashboard.java new file mode 100644 index 0000000..ca58851 --- /dev/null +++ b/services/spring-member/src/generated/java/tum/devoops/memberservice/model/Dashboard.java @@ -0,0 +1,52 @@ +package tum.devoops.memberservice.model; + +import java.net.URI; +import java.util.Objects; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonSubTypes; +import com.fasterxml.jackson.annotation.JsonTypeInfo; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import org.springframework.lang.Nullable; +import tum.devoops.memberservice.model.AdminDashboard; +import tum.devoops.memberservice.model.DirectorDashboard; +import tum.devoops.memberservice.model.EventSummary; +import tum.devoops.memberservice.model.FeedbackSummary; +import tum.devoops.memberservice.model.MemberReportSummary; +import tum.devoops.memberservice.model.Reference; +import tum.devoops.memberservice.model.TeamBalanceSummary; +import tum.devoops.memberservice.model.TraineeDashboard; +import tum.devoops.memberservice.model.TrainerDashboard; +import java.time.OffsetDateTime; +import jakarta.validation.Valid; +import jakarta.validation.constraints.*; +import io.swagger.v3.oas.annotations.media.Schema; + + +import java.util.*; +import jakarta.annotation.Generated; + + +@JsonIgnoreProperties( + value = "role", // ignore manually set role, it will be automatically generated by Jackson during serialization + allowSetters = true // allows the role to be set during deserialization +) +@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, include = JsonTypeInfo.As.PROPERTY, property = "role", visible = true) +@JsonSubTypes({ + @JsonSubTypes.Type(value = AdminDashboard.class, name = "admin"), + @JsonSubTypes.Type(value = DirectorDashboard.class, name = "director"), + @JsonSubTypes.Type(value = TraineeDashboard.class, name = "trainee"), + @JsonSubTypes.Type(value = TrainerDashboard.class, name = "trainer"), + @JsonSubTypes.Type(value = AdminDashboard.class, name = "AdminDashboard"), + @JsonSubTypes.Type(value = DirectorDashboard.class, name = "DirectorDashboard"), + @JsonSubTypes.Type(value = TraineeDashboard.class, name = "TraineeDashboard"), + @JsonSubTypes.Type(value = TrainerDashboard.class, name = "TrainerDashboard") +}) + +@Generated(value = "org.openapitools.codegen.languages.SpringCodegen", comments = "Generator version: 7.14.0") +public interface Dashboard { + public String getRole(); +} diff --git a/services/spring-member/src/generated/java/tum/devoops/memberservice/model/DirectorDashboard.java b/services/spring-member/src/generated/java/tum/devoops/memberservice/model/DirectorDashboard.java new file mode 100644 index 0000000..2009627 --- /dev/null +++ b/services/spring-member/src/generated/java/tum/devoops/memberservice/model/DirectorDashboard.java @@ -0,0 +1,262 @@ +package tum.devoops.memberservice.model; + +import java.net.URI; +import java.util.Objects; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.JsonCreator; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import org.springframework.lang.Nullable; +import tum.devoops.memberservice.model.Reference; +import tum.devoops.memberservice.model.TeamBalanceSummary; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonSubTypes; +import com.fasterxml.jackson.annotation.JsonTypeInfo; +import java.time.OffsetDateTime; +import jakarta.validation.Valid; +import jakarta.validation.constraints.*; +import io.swagger.v3.oas.annotations.media.Schema; + + +import java.util.*; +import jakarta.annotation.Generated; + +/** + * Aggregates for the sport a director manages. + */ + +@Schema(name = "DirectorDashboard", description = "Aggregates for the sport a director manages.") +@Generated(value = "org.openapitools.codegen.languages.SpringCodegen", comments = "Generator version: 7.14.0") +public class DirectorDashboard implements Dashboard { + + private String role; + + private Reference sport; + + private Integer totalTeams; + + private Integer totalMembers; + + private Integer sportBalanceCents; + + private Integer upcomingEvents; + + @Valid + private List<@Valid TeamBalanceSummary> teams; + + public DirectorDashboard() { + super(); + } + + /** + * Constructor with only required parameters + */ + public DirectorDashboard(String role, Reference sport, Integer totalTeams, Integer totalMembers, Integer sportBalanceCents, Integer upcomingEvents, List<@Valid TeamBalanceSummary> teams) { + this.role = role; + this.sport = sport; + this.totalTeams = totalTeams; + this.totalMembers = totalMembers; + this.sportBalanceCents = sportBalanceCents; + this.upcomingEvents = upcomingEvents; + this.teams = teams; + } + + public DirectorDashboard role(String role) { + this.role = role; + return this; + } + + /** + * Get role + * @return role + */ + @NotNull + @Schema(name = "role", requiredMode = Schema.RequiredMode.REQUIRED) + @JsonProperty("role") + public String getRole() { + return role; + } + + public void setRole(String role) { + this.role = role; + } + + public DirectorDashboard sport(Reference sport) { + this.sport = sport; + return this; + } + + /** + * Get sport + * @return sport + */ + @NotNull @Valid + @Schema(name = "sport", requiredMode = Schema.RequiredMode.REQUIRED) + @JsonProperty("sport") + public Reference getSport() { + return sport; + } + + public void setSport(Reference sport) { + this.sport = sport; + } + + public DirectorDashboard totalTeams(Integer totalTeams) { + this.totalTeams = totalTeams; + return this; + } + + /** + * Get totalTeams + * @return totalTeams + */ + @NotNull + @Schema(name = "total_teams", requiredMode = Schema.RequiredMode.REQUIRED) + @JsonProperty("total_teams") + public Integer getTotalTeams() { + return totalTeams; + } + + public void setTotalTeams(Integer totalTeams) { + this.totalTeams = totalTeams; + } + + public DirectorDashboard totalMembers(Integer totalMembers) { + this.totalMembers = totalMembers; + return this; + } + + /** + * Get totalMembers + * @return totalMembers + */ + @NotNull + @Schema(name = "total_members", requiredMode = Schema.RequiredMode.REQUIRED) + @JsonProperty("total_members") + public Integer getTotalMembers() { + return totalMembers; + } + + public void setTotalMembers(Integer totalMembers) { + this.totalMembers = totalMembers; + } + + public DirectorDashboard sportBalanceCents(Integer sportBalanceCents) { + this.sportBalanceCents = sportBalanceCents; + return this; + } + + /** + * Get sportBalanceCents + * @return sportBalanceCents + */ + @NotNull + @Schema(name = "sport_balance_cents", requiredMode = Schema.RequiredMode.REQUIRED) + @JsonProperty("sport_balance_cents") + public Integer getSportBalanceCents() { + return sportBalanceCents; + } + + public void setSportBalanceCents(Integer sportBalanceCents) { + this.sportBalanceCents = sportBalanceCents; + } + + public DirectorDashboard upcomingEvents(Integer upcomingEvents) { + this.upcomingEvents = upcomingEvents; + return this; + } + + /** + * Get upcomingEvents + * @return upcomingEvents + */ + @NotNull + @Schema(name = "upcoming_events", requiredMode = Schema.RequiredMode.REQUIRED) + @JsonProperty("upcoming_events") + public Integer getUpcomingEvents() { + return upcomingEvents; + } + + public void setUpcomingEvents(Integer upcomingEvents) { + this.upcomingEvents = upcomingEvents; + } + + public DirectorDashboard teams(List<@Valid TeamBalanceSummary> teams) { + this.teams = teams; + return this; + } + + public DirectorDashboard addTeamsItem(TeamBalanceSummary teamsItem) { + if (this.teams == null) { + this.teams = new ArrayList<>(); + } + this.teams.add(teamsItem); + return this; + } + + /** + * Get teams + * @return teams + */ + @NotNull @Valid + @Schema(name = "teams", requiredMode = Schema.RequiredMode.REQUIRED) + @JsonProperty("teams") + public List<@Valid TeamBalanceSummary> getTeams() { + return teams; + } + + public void setTeams(List<@Valid TeamBalanceSummary> teams) { + this.teams = teams; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + DirectorDashboard directorDashboard = (DirectorDashboard) o; + return Objects.equals(this.role, directorDashboard.role) && + Objects.equals(this.sport, directorDashboard.sport) && + Objects.equals(this.totalTeams, directorDashboard.totalTeams) && + Objects.equals(this.totalMembers, directorDashboard.totalMembers) && + Objects.equals(this.sportBalanceCents, directorDashboard.sportBalanceCents) && + Objects.equals(this.upcomingEvents, directorDashboard.upcomingEvents) && + Objects.equals(this.teams, directorDashboard.teams); + } + + @Override + public int hashCode() { + return Objects.hash(role, sport, totalTeams, totalMembers, sportBalanceCents, upcomingEvents, teams); + } + + @Override + public String toString() { + StringBuilder sb = new StringBuilder(); + sb.append("class DirectorDashboard {\n"); + sb.append(" role: ").append(toIndentedString(role)).append("\n"); + sb.append(" sport: ").append(toIndentedString(sport)).append("\n"); + sb.append(" totalTeams: ").append(toIndentedString(totalTeams)).append("\n"); + sb.append(" totalMembers: ").append(toIndentedString(totalMembers)).append("\n"); + sb.append(" sportBalanceCents: ").append(toIndentedString(sportBalanceCents)).append("\n"); + sb.append(" upcomingEvents: ").append(toIndentedString(upcomingEvents)).append("\n"); + sb.append(" teams: ").append(toIndentedString(teams)).append("\n"); + sb.append("}"); + return sb.toString(); + } + + /** + * Convert the given object to string with each line indented by 4 spaces + * (except the first line). + */ + private String toIndentedString(Object o) { + if (o == null) { + return "null"; + } + return o.toString().replace("\n", "\n "); + } +} + diff --git a/services/spring-member/src/generated/java/tum/devoops/memberservice/model/EventSummary.java b/services/spring-member/src/generated/java/tum/devoops/memberservice/model/EventSummary.java new file mode 100644 index 0000000..a6b7257 --- /dev/null +++ b/services/spring-member/src/generated/java/tum/devoops/memberservice/model/EventSummary.java @@ -0,0 +1,212 @@ +package tum.devoops.memberservice.model; + +import java.net.URI; +import java.util.Objects; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.JsonCreator; +import java.time.OffsetDateTime; +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 tum.devoops.memberservice.model.Reference; +import java.time.OffsetDateTime; +import jakarta.validation.Valid; +import jakarta.validation.constraints.*; +import io.swagger.v3.oas.annotations.media.Schema; + + +import java.util.*; +import jakarta.annotation.Generated; + +/** + * A simplified representation of a Event, typically used in list views. + */ + +@Schema(name = "EventSummary", description = "A simplified representation of a Event, typically used in list views.") +@Generated(value = "org.openapitools.codegen.languages.SpringCodegen", comments = "Generator version: 7.14.0") +public class EventSummary { + + private UUID id; + + private String name; + + @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) + private OffsetDateTime startTime; + + @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) + private OffsetDateTime endTime; + + @Valid + private @Nullable List<@Valid Reference> attendees; + + public EventSummary() { + super(); + } + + /** + * Constructor with only required parameters + */ + public EventSummary(UUID id, String name, OffsetDateTime startTime, OffsetDateTime endTime) { + this.id = id; + this.name = name; + this.startTime = startTime; + this.endTime = endTime; + } + + public EventSummary 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 EventSummary name(String name) { + this.name = name; + return this; + } + + /** + * Get name + * @return name + */ + @NotNull + @Schema(name = "name", requiredMode = Schema.RequiredMode.REQUIRED) + @JsonProperty("name") + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public EventSummary startTime(OffsetDateTime startTime) { + this.startTime = startTime; + return this; + } + + /** + * Get startTime + * @return startTime + */ + @NotNull @Valid + @Schema(name = "start_time", requiredMode = Schema.RequiredMode.REQUIRED) + @JsonProperty("start_time") + public OffsetDateTime getStartTime() { + return startTime; + } + + public void setStartTime(OffsetDateTime startTime) { + this.startTime = startTime; + } + + public EventSummary endTime(OffsetDateTime endTime) { + this.endTime = endTime; + return this; + } + + /** + * Get endTime + * @return endTime + */ + @NotNull @Valid + @Schema(name = "end_time", requiredMode = Schema.RequiredMode.REQUIRED) + @JsonProperty("end_time") + public OffsetDateTime getEndTime() { + return endTime; + } + + public void setEndTime(OffsetDateTime endTime) { + this.endTime = endTime; + } + + public EventSummary attendees(@Nullable List<@Valid Reference> attendees) { + this.attendees = attendees; + return this; + } + + public EventSummary addAttendeesItem(Reference attendeesItem) { + if (this.attendees == null) { + this.attendees = new ArrayList<>(); + } + this.attendees.add(attendeesItem); + return this; + } + + /** + * Get attendees + * @return attendees + */ + @Valid + @Schema(name = "attendees", requiredMode = Schema.RequiredMode.NOT_REQUIRED) + @JsonProperty("attendees") + public @Nullable List<@Valid Reference> getAttendees() { + return attendees; + } + + public void setAttendees(@Nullable List<@Valid Reference> attendees) { + this.attendees = attendees; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + EventSummary eventSummary = (EventSummary) o; + return Objects.equals(this.id, eventSummary.id) && + Objects.equals(this.name, eventSummary.name) && + Objects.equals(this.startTime, eventSummary.startTime) && + Objects.equals(this.endTime, eventSummary.endTime) && + Objects.equals(this.attendees, eventSummary.attendees); + } + + @Override + public int hashCode() { + return Objects.hash(id, name, startTime, endTime, attendees); + } + + @Override + public String toString() { + StringBuilder sb = new StringBuilder(); + sb.append("class EventSummary {\n"); + sb.append(" id: ").append(toIndentedString(id)).append("\n"); + sb.append(" name: ").append(toIndentedString(name)).append("\n"); + sb.append(" startTime: ").append(toIndentedString(startTime)).append("\n"); + sb.append(" endTime: ").append(toIndentedString(endTime)).append("\n"); + sb.append(" attendees: ").append(toIndentedString(attendees)).append("\n"); + sb.append("}"); + return sb.toString(); + } + + /** + * Convert the given object to string with each line indented by 4 spaces + * (except the first line). + */ + private String toIndentedString(Object o) { + if (o == null) { + return "null"; + } + return o.toString().replace("\n", "\n "); + } +} + diff --git a/services/spring-member/src/generated/java/tum/devoops/memberservice/model/FeedbackSummary.java b/services/spring-member/src/generated/java/tum/devoops/memberservice/model/FeedbackSummary.java new file mode 100644 index 0000000..5ad6e2c --- /dev/null +++ b/services/spring-member/src/generated/java/tum/devoops/memberservice/model/FeedbackSummary.java @@ -0,0 +1,227 @@ +package tum.devoops.memberservice.model; + +import java.net.URI; +import java.util.Objects; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.JsonCreator; +import java.time.OffsetDateTime; +import java.util.UUID; +import org.springframework.format.annotation.DateTimeFormat; +import org.springframework.lang.Nullable; +import tum.devoops.memberservice.model.Reference; +import java.time.OffsetDateTime; +import jakarta.validation.Valid; +import jakarta.validation.constraints.*; +import io.swagger.v3.oas.annotations.media.Schema; + + +import java.util.*; +import jakarta.annotation.Generated; + +/** + * A simplified representation of a Feedback, typically used in list views. + */ + +@Schema(name = "FeedbackSummary", description = "A simplified representation of a Feedback, typically used in list views.") +@Generated(value = "org.openapitools.codegen.languages.SpringCodegen", comments = "Generator version: 7.14.0") +public class FeedbackSummary { + + private UUID id; + + private Reference event; + + private Reference member; + + private Reference creator = null; + + @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) + private OffsetDateTime createdAt; + + private Integer rating; + + public FeedbackSummary() { + super(); + } + + /** + * Constructor with only required parameters + */ + public FeedbackSummary(UUID id, Reference event, Reference member, Reference creator, OffsetDateTime createdAt, Integer rating) { + this.id = id; + this.event = event; + this.member = member; + this.creator = creator; + this.createdAt = createdAt; + this.rating = rating; + } + + public FeedbackSummary 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 FeedbackSummary event(Reference event) { + this.event = event; + return this; + } + + /** + * Get event + * @return event + */ + @NotNull @Valid + @Schema(name = "event", requiredMode = Schema.RequiredMode.REQUIRED) + @JsonProperty("event") + public Reference getEvent() { + return event; + } + + public void setEvent(Reference event) { + this.event = event; + } + + public FeedbackSummary member(Reference member) { + this.member = member; + return this; + } + + /** + * Get member + * @return member + */ + @NotNull @Valid + @Schema(name = "member", requiredMode = Schema.RequiredMode.REQUIRED) + @JsonProperty("member") + public Reference getMember() { + return member; + } + + public void setMember(Reference member) { + this.member = member; + } + + public FeedbackSummary creator(Reference creator) { + this.creator = creator; + return this; + } + + /** + * Get creator + * @return creator + */ + @NotNull @Valid + @Schema(name = "creator", requiredMode = Schema.RequiredMode.REQUIRED) + @JsonProperty("creator") + public Reference getCreator() { + return creator; + } + + public void setCreator(Reference creator) { + this.creator = creator; + } + + public FeedbackSummary createdAt(OffsetDateTime createdAt) { + this.createdAt = createdAt; + return this; + } + + /** + * Get createdAt + * @return createdAt + */ + @NotNull @Valid + @Schema(name = "created_at", requiredMode = Schema.RequiredMode.REQUIRED) + @JsonProperty("created_at") + public OffsetDateTime getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(OffsetDateTime createdAt) { + this.createdAt = createdAt; + } + + public FeedbackSummary rating(Integer rating) { + this.rating = rating; + return this; + } + + /** + * Get rating + * minimum: 0 + * maximum: 10 + * @return rating + */ + @NotNull @Min(0) @Max(10) + @Schema(name = "rating", requiredMode = Schema.RequiredMode.REQUIRED) + @JsonProperty("rating") + public Integer getRating() { + return rating; + } + + public void setRating(Integer rating) { + this.rating = rating; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + FeedbackSummary feedbackSummary = (FeedbackSummary) o; + return Objects.equals(this.id, feedbackSummary.id) && + Objects.equals(this.event, feedbackSummary.event) && + Objects.equals(this.member, feedbackSummary.member) && + Objects.equals(this.creator, feedbackSummary.creator) && + Objects.equals(this.createdAt, feedbackSummary.createdAt) && + Objects.equals(this.rating, feedbackSummary.rating); + } + + @Override + public int hashCode() { + return Objects.hash(id, event, member, creator, createdAt, rating); + } + + @Override + public String toString() { + StringBuilder sb = new StringBuilder(); + sb.append("class FeedbackSummary {\n"); + sb.append(" id: ").append(toIndentedString(id)).append("\n"); + sb.append(" event: ").append(toIndentedString(event)).append("\n"); + sb.append(" member: ").append(toIndentedString(member)).append("\n"); + sb.append(" creator: ").append(toIndentedString(creator)).append("\n"); + sb.append(" createdAt: ").append(toIndentedString(createdAt)).append("\n"); + sb.append(" rating: ").append(toIndentedString(rating)).append("\n"); + sb.append("}"); + return sb.toString(); + } + + /** + * Convert the given object to string with each line indented by 4 spaces + * (except the first line). + */ + private String toIndentedString(Object o) { + if (o == null) { + return "null"; + } + return o.toString().replace("\n", "\n "); + } +} + diff --git a/services/spring-member/src/generated/java/tum/devoops/memberservice/model/MemberReportSummary.java b/services/spring-member/src/generated/java/tum/devoops/memberservice/model/MemberReportSummary.java new file mode 100644 index 0000000..e7049ba --- /dev/null +++ b/services/spring-member/src/generated/java/tum/devoops/memberservice/model/MemberReportSummary.java @@ -0,0 +1,150 @@ +package tum.devoops.memberservice.model; + +import java.net.URI; +import java.util.Objects; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.JsonCreator; +import java.time.OffsetDateTime; +import java.util.UUID; +import org.springframework.format.annotation.DateTimeFormat; +import org.springframework.lang.Nullable; +import tum.devoops.memberservice.model.Reference; +import java.time.OffsetDateTime; +import jakarta.validation.Valid; +import jakarta.validation.constraints.*; +import io.swagger.v3.oas.annotations.media.Schema; + + +import java.util.*; +import jakarta.annotation.Generated; + +/** + * Summary of a stored member report, without its generated text. + */ + +@Schema(name = "MemberReportSummary", description = "Summary of a stored member report, without its generated text.") +@Generated(value = "org.openapitools.codegen.languages.SpringCodegen", comments = "Generator version: 7.14.0") +public class MemberReportSummary { + + private UUID id; + + private Reference member; + + @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) + private OffsetDateTime createdAt; + + public MemberReportSummary() { + super(); + } + + /** + * Constructor with only required parameters + */ + public MemberReportSummary(UUID id, Reference member, OffsetDateTime createdAt) { + this.id = id; + this.member = member; + this.createdAt = createdAt; + } + + public MemberReportSummary 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 MemberReportSummary member(Reference member) { + this.member = member; + return this; + } + + /** + * Get member + * @return member + */ + @NotNull @Valid + @Schema(name = "member", requiredMode = Schema.RequiredMode.REQUIRED) + @JsonProperty("member") + public Reference getMember() { + return member; + } + + public void setMember(Reference member) { + this.member = member; + } + + public MemberReportSummary createdAt(OffsetDateTime createdAt) { + this.createdAt = createdAt; + return this; + } + + /** + * Get createdAt + * @return createdAt + */ + @NotNull @Valid + @Schema(name = "created_at", requiredMode = Schema.RequiredMode.REQUIRED) + @JsonProperty("created_at") + public OffsetDateTime getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(OffsetDateTime createdAt) { + this.createdAt = createdAt; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + MemberReportSummary memberReportSummary = (MemberReportSummary) o; + return Objects.equals(this.id, memberReportSummary.id) && + Objects.equals(this.member, memberReportSummary.member) && + Objects.equals(this.createdAt, memberReportSummary.createdAt); + } + + @Override + public int hashCode() { + return Objects.hash(id, member, createdAt); + } + + @Override + public String toString() { + StringBuilder sb = new StringBuilder(); + sb.append("class MemberReportSummary {\n"); + sb.append(" id: ").append(toIndentedString(id)).append("\n"); + sb.append(" member: ").append(toIndentedString(member)).append("\n"); + sb.append(" createdAt: ").append(toIndentedString(createdAt)).append("\n"); + sb.append("}"); + return sb.toString(); + } + + /** + * Convert the given object to string with each line indented by 4 spaces + * (except the first line). + */ + private String toIndentedString(Object o) { + if (o == null) { + return "null"; + } + return o.toString().replace("\n", "\n "); + } +} + diff --git a/services/spring-member/src/generated/java/tum/devoops/memberservice/model/Reference.java b/services/spring-member/src/generated/java/tum/devoops/memberservice/model/Reference.java new file mode 100644 index 0000000..7b7fc02 --- /dev/null +++ b/services/spring-member/src/generated/java/tum/devoops/memberservice/model/Reference.java @@ -0,0 +1,121 @@ +package tum.devoops.memberservice.model; + +import java.net.URI; +import java.util.Objects; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.JsonCreator; +import java.util.UUID; +import org.springframework.lang.Nullable; +import java.time.OffsetDateTime; +import jakarta.validation.Valid; +import jakarta.validation.constraints.*; +import io.swagger.v3.oas.annotations.media.Schema; + + +import java.util.*; +import jakarta.annotation.Generated; + +/** + * A lightweight reference to another entity — its id plus a display name. + */ + +@Schema(name = "Reference", description = "A lightweight reference to another entity — its id plus a display name.") +@Generated(value = "org.openapitools.codegen.languages.SpringCodegen", comments = "Generator version: 7.14.0") +public class Reference { + + private UUID id; + + private String name; + + public Reference() { + super(); + } + + /** + * Constructor with only required parameters + */ + public Reference(UUID id, String name) { + this.id = id; + this.name = name; + } + + public Reference 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 Reference name(String name) { + this.name = name; + return this; + } + + /** + * Get name + * @return name + */ + @NotNull + @Schema(name = "name", requiredMode = Schema.RequiredMode.REQUIRED) + @JsonProperty("name") + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + Reference reference = (Reference) o; + return Objects.equals(this.id, reference.id) && + Objects.equals(this.name, reference.name); + } + + @Override + public int hashCode() { + return Objects.hash(id, name); + } + + @Override + public String toString() { + StringBuilder sb = new StringBuilder(); + sb.append("class Reference {\n"); + sb.append(" id: ").append(toIndentedString(id)).append("\n"); + sb.append(" name: ").append(toIndentedString(name)).append("\n"); + sb.append("}"); + return sb.toString(); + } + + /** + * Convert the given object to string with each line indented by 4 spaces + * (except the first line). + */ + private String toIndentedString(Object o) { + if (o == null) { + return "null"; + } + return o.toString().replace("\n", "\n "); + } +} + diff --git a/services/spring-member/src/generated/java/tum/devoops/memberservice/model/TeamBalanceSummary.java b/services/spring-member/src/generated/java/tum/devoops/memberservice/model/TeamBalanceSummary.java new file mode 100644 index 0000000..7fccd44 --- /dev/null +++ b/services/spring-member/src/generated/java/tum/devoops/memberservice/model/TeamBalanceSummary.java @@ -0,0 +1,146 @@ +package tum.devoops.memberservice.model; + +import java.net.URI; +import java.util.Objects; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.JsonCreator; +import org.springframework.lang.Nullable; +import tum.devoops.memberservice.model.Reference; +import java.time.OffsetDateTime; +import jakarta.validation.Valid; +import jakarta.validation.constraints.*; +import io.swagger.v3.oas.annotations.media.Schema; + + +import java.util.*; +import jakarta.annotation.Generated; + +/** + * Per-team rollup of trainee count and aggregate balance. + */ + +@Schema(name = "TeamBalanceSummary", description = "Per-team rollup of trainee count and aggregate balance.") +@Generated(value = "org.openapitools.codegen.languages.SpringCodegen", comments = "Generator version: 7.14.0") +public class TeamBalanceSummary { + + private Reference team; + + private Integer memberCount; + + private Integer balanceCents; + + public TeamBalanceSummary() { + super(); + } + + /** + * Constructor with only required parameters + */ + public TeamBalanceSummary(Reference team, Integer memberCount, Integer balanceCents) { + this.team = team; + this.memberCount = memberCount; + this.balanceCents = balanceCents; + } + + public TeamBalanceSummary team(Reference team) { + this.team = team; + return this; + } + + /** + * Get team + * @return team + */ + @NotNull @Valid + @Schema(name = "team", requiredMode = Schema.RequiredMode.REQUIRED) + @JsonProperty("team") + public Reference getTeam() { + return team; + } + + public void setTeam(Reference team) { + this.team = team; + } + + public TeamBalanceSummary memberCount(Integer memberCount) { + this.memberCount = memberCount; + return this; + } + + /** + * Get memberCount + * @return memberCount + */ + @NotNull + @Schema(name = "member_count", requiredMode = Schema.RequiredMode.REQUIRED) + @JsonProperty("member_count") + public Integer getMemberCount() { + return memberCount; + } + + public void setMemberCount(Integer memberCount) { + this.memberCount = memberCount; + } + + public TeamBalanceSummary balanceCents(Integer balanceCents) { + this.balanceCents = balanceCents; + return this; + } + + /** + * Get balanceCents + * @return balanceCents + */ + @NotNull + @Schema(name = "balance_cents", requiredMode = Schema.RequiredMode.REQUIRED) + @JsonProperty("balance_cents") + public Integer getBalanceCents() { + return balanceCents; + } + + public void setBalanceCents(Integer balanceCents) { + this.balanceCents = balanceCents; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + TeamBalanceSummary teamBalanceSummary = (TeamBalanceSummary) o; + return Objects.equals(this.team, teamBalanceSummary.team) && + Objects.equals(this.memberCount, teamBalanceSummary.memberCount) && + Objects.equals(this.balanceCents, teamBalanceSummary.balanceCents); + } + + @Override + public int hashCode() { + return Objects.hash(team, memberCount, balanceCents); + } + + @Override + public String toString() { + StringBuilder sb = new StringBuilder(); + sb.append("class TeamBalanceSummary {\n"); + sb.append(" team: ").append(toIndentedString(team)).append("\n"); + sb.append(" memberCount: ").append(toIndentedString(memberCount)).append("\n"); + sb.append(" balanceCents: ").append(toIndentedString(balanceCents)).append("\n"); + sb.append("}"); + return sb.toString(); + } + + /** + * Convert the given object to string with each line indented by 4 spaces + * (except the first line). + */ + private String toIndentedString(Object o) { + if (o == null) { + return "null"; + } + return o.toString().replace("\n", "\n "); + } +} + diff --git a/services/spring-member/src/generated/java/tum/devoops/memberservice/model/TraineeDashboard.java b/services/spring-member/src/generated/java/tum/devoops/memberservice/model/TraineeDashboard.java new file mode 100644 index 0000000..1cee6eb --- /dev/null +++ b/services/spring-member/src/generated/java/tum/devoops/memberservice/model/TraineeDashboard.java @@ -0,0 +1,247 @@ +package tum.devoops.memberservice.model; + +import java.net.URI; +import java.util.Objects; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.JsonCreator; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import org.springframework.lang.Nullable; +import tum.devoops.memberservice.model.EventSummary; +import tum.devoops.memberservice.model.FeedbackSummary; +import tum.devoops.memberservice.model.MemberReportSummary; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonSubTypes; +import com.fasterxml.jackson.annotation.JsonTypeInfo; +import java.time.OffsetDateTime; +import jakarta.validation.Valid; +import jakarta.validation.constraints.*; +import io.swagger.v3.oas.annotations.media.Schema; + + +import java.util.*; +import jakarta.annotation.Generated; + +/** + * Personal aggregates shown to a trainee/member. + */ + +@Schema(name = "TraineeDashboard", description = "Personal aggregates shown to a trainee/member.") +@Generated(value = "org.openapitools.codegen.languages.SpringCodegen", comments = "Generator version: 7.14.0") +public class TraineeDashboard implements Dashboard { + + private String role; + + private Integer balanceCents; + + private EventSummary nextEvent = null; + + private Integer upcomingEvents; + + @Valid + private List<@Valid FeedbackSummary> recentFeedback; + + @Valid + private List<@Valid MemberReportSummary> recentReports; + + public TraineeDashboard() { + super(); + } + + /** + * Constructor with only required parameters + */ + public TraineeDashboard(String role, Integer balanceCents, EventSummary nextEvent, Integer upcomingEvents, List<@Valid FeedbackSummary> recentFeedback, List<@Valid MemberReportSummary> recentReports) { + this.role = role; + this.balanceCents = balanceCents; + this.nextEvent = nextEvent; + this.upcomingEvents = upcomingEvents; + this.recentFeedback = recentFeedback; + this.recentReports = recentReports; + } + + public TraineeDashboard role(String role) { + this.role = role; + return this; + } + + /** + * Get role + * @return role + */ + @NotNull + @Schema(name = "role", requiredMode = Schema.RequiredMode.REQUIRED) + @JsonProperty("role") + public String getRole() { + return role; + } + + public void setRole(String role) { + this.role = role; + } + + public TraineeDashboard balanceCents(Integer balanceCents) { + this.balanceCents = balanceCents; + return this; + } + + /** + * Get balanceCents + * @return balanceCents + */ + @NotNull + @Schema(name = "balance_cents", requiredMode = Schema.RequiredMode.REQUIRED) + @JsonProperty("balance_cents") + public Integer getBalanceCents() { + return balanceCents; + } + + public void setBalanceCents(Integer balanceCents) { + this.balanceCents = balanceCents; + } + + public TraineeDashboard nextEvent(EventSummary nextEvent) { + this.nextEvent = nextEvent; + return this; + } + + /** + * Get nextEvent + * @return nextEvent + */ + @NotNull @Valid + @Schema(name = "next_event", requiredMode = Schema.RequiredMode.REQUIRED) + @JsonProperty("next_event") + public EventSummary getNextEvent() { + return nextEvent; + } + + public void setNextEvent(EventSummary nextEvent) { + this.nextEvent = nextEvent; + } + + public TraineeDashboard upcomingEvents(Integer upcomingEvents) { + this.upcomingEvents = upcomingEvents; + return this; + } + + /** + * Get upcomingEvents + * @return upcomingEvents + */ + @NotNull + @Schema(name = "upcoming_events", requiredMode = Schema.RequiredMode.REQUIRED) + @JsonProperty("upcoming_events") + public Integer getUpcomingEvents() { + return upcomingEvents; + } + + public void setUpcomingEvents(Integer upcomingEvents) { + this.upcomingEvents = upcomingEvents; + } + + public TraineeDashboard recentFeedback(List<@Valid FeedbackSummary> recentFeedback) { + this.recentFeedback = recentFeedback; + return this; + } + + public TraineeDashboard addRecentFeedbackItem(FeedbackSummary recentFeedbackItem) { + if (this.recentFeedback == null) { + this.recentFeedback = new ArrayList<>(); + } + this.recentFeedback.add(recentFeedbackItem); + return this; + } + + /** + * Get recentFeedback + * @return recentFeedback + */ + @NotNull @Valid + @Schema(name = "recent_feedback", requiredMode = Schema.RequiredMode.REQUIRED) + @JsonProperty("recent_feedback") + public List<@Valid FeedbackSummary> getRecentFeedback() { + return recentFeedback; + } + + public void setRecentFeedback(List<@Valid FeedbackSummary> recentFeedback) { + this.recentFeedback = recentFeedback; + } + + public TraineeDashboard recentReports(List<@Valid MemberReportSummary> recentReports) { + this.recentReports = recentReports; + return this; + } + + public TraineeDashboard addRecentReportsItem(MemberReportSummary recentReportsItem) { + if (this.recentReports == null) { + this.recentReports = new ArrayList<>(); + } + this.recentReports.add(recentReportsItem); + return this; + } + + /** + * Get recentReports + * @return recentReports + */ + @NotNull @Valid + @Schema(name = "recent_reports", requiredMode = Schema.RequiredMode.REQUIRED) + @JsonProperty("recent_reports") + public List<@Valid MemberReportSummary> getRecentReports() { + return recentReports; + } + + public void setRecentReports(List<@Valid MemberReportSummary> recentReports) { + this.recentReports = recentReports; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + TraineeDashboard traineeDashboard = (TraineeDashboard) o; + return Objects.equals(this.role, traineeDashboard.role) && + Objects.equals(this.balanceCents, traineeDashboard.balanceCents) && + Objects.equals(this.nextEvent, traineeDashboard.nextEvent) && + Objects.equals(this.upcomingEvents, traineeDashboard.upcomingEvents) && + Objects.equals(this.recentFeedback, traineeDashboard.recentFeedback) && + Objects.equals(this.recentReports, traineeDashboard.recentReports); + } + + @Override + public int hashCode() { + return Objects.hash(role, balanceCents, nextEvent, upcomingEvents, recentFeedback, recentReports); + } + + @Override + public String toString() { + StringBuilder sb = new StringBuilder(); + sb.append("class TraineeDashboard {\n"); + sb.append(" role: ").append(toIndentedString(role)).append("\n"); + sb.append(" balanceCents: ").append(toIndentedString(balanceCents)).append("\n"); + sb.append(" nextEvent: ").append(toIndentedString(nextEvent)).append("\n"); + sb.append(" upcomingEvents: ").append(toIndentedString(upcomingEvents)).append("\n"); + sb.append(" recentFeedback: ").append(toIndentedString(recentFeedback)).append("\n"); + sb.append(" recentReports: ").append(toIndentedString(recentReports)).append("\n"); + sb.append("}"); + return sb.toString(); + } + + /** + * Convert the given object to string with each line indented by 4 spaces + * (except the first line). + */ + private String toIndentedString(Object o) { + if (o == null) { + return "null"; + } + return o.toString().replace("\n", "\n "); + } +} + diff --git a/services/spring-member/src/generated/java/tum/devoops/memberservice/model/TrainerDashboard.java b/services/spring-member/src/generated/java/tum/devoops/memberservice/model/TrainerDashboard.java new file mode 100644 index 0000000..c106f23 --- /dev/null +++ b/services/spring-member/src/generated/java/tum/devoops/memberservice/model/TrainerDashboard.java @@ -0,0 +1,212 @@ +package tum.devoops.memberservice.model; + +import java.net.URI; +import java.util.Objects; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.JsonCreator; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import org.springframework.lang.Nullable; +import tum.devoops.memberservice.model.FeedbackSummary; +import tum.devoops.memberservice.model.Reference; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonSubTypes; +import com.fasterxml.jackson.annotation.JsonTypeInfo; +import java.time.OffsetDateTime; +import jakarta.validation.Valid; +import jakarta.validation.constraints.*; +import io.swagger.v3.oas.annotations.media.Schema; + + +import java.util.*; +import jakarta.annotation.Generated; + +/** + * Aggregates for the team a trainer manages, including feedback they authored. + */ + +@Schema(name = "TrainerDashboard", description = "Aggregates for the team a trainer manages, including feedback they authored.") +@Generated(value = "org.openapitools.codegen.languages.SpringCodegen", comments = "Generator version: 7.14.0") +public class TrainerDashboard implements Dashboard { + + private String role; + + private Reference team; + + private Integer totalMembers; + + private Integer upcomingEvents; + + @Valid + private List<@Valid FeedbackSummary> recentFeedback; + + public TrainerDashboard() { + super(); + } + + /** + * Constructor with only required parameters + */ + public TrainerDashboard(String role, Reference team, Integer totalMembers, Integer upcomingEvents, List<@Valid FeedbackSummary> recentFeedback) { + this.role = role; + this.team = team; + this.totalMembers = totalMembers; + this.upcomingEvents = upcomingEvents; + this.recentFeedback = recentFeedback; + } + + public TrainerDashboard role(String role) { + this.role = role; + return this; + } + + /** + * Get role + * @return role + */ + @NotNull + @Schema(name = "role", requiredMode = Schema.RequiredMode.REQUIRED) + @JsonProperty("role") + public String getRole() { + return role; + } + + public void setRole(String role) { + this.role = role; + } + + public TrainerDashboard team(Reference team) { + this.team = team; + return this; + } + + /** + * Get team + * @return team + */ + @NotNull @Valid + @Schema(name = "team", requiredMode = Schema.RequiredMode.REQUIRED) + @JsonProperty("team") + public Reference getTeam() { + return team; + } + + public void setTeam(Reference team) { + this.team = team; + } + + public TrainerDashboard totalMembers(Integer totalMembers) { + this.totalMembers = totalMembers; + return this; + } + + /** + * Get totalMembers + * @return totalMembers + */ + @NotNull + @Schema(name = "total_members", requiredMode = Schema.RequiredMode.REQUIRED) + @JsonProperty("total_members") + public Integer getTotalMembers() { + return totalMembers; + } + + public void setTotalMembers(Integer totalMembers) { + this.totalMembers = totalMembers; + } + + public TrainerDashboard upcomingEvents(Integer upcomingEvents) { + this.upcomingEvents = upcomingEvents; + return this; + } + + /** + * Get upcomingEvents + * @return upcomingEvents + */ + @NotNull + @Schema(name = "upcoming_events", requiredMode = Schema.RequiredMode.REQUIRED) + @JsonProperty("upcoming_events") + public Integer getUpcomingEvents() { + return upcomingEvents; + } + + public void setUpcomingEvents(Integer upcomingEvents) { + this.upcomingEvents = upcomingEvents; + } + + public TrainerDashboard recentFeedback(List<@Valid FeedbackSummary> recentFeedback) { + this.recentFeedback = recentFeedback; + return this; + } + + public TrainerDashboard addRecentFeedbackItem(FeedbackSummary recentFeedbackItem) { + if (this.recentFeedback == null) { + this.recentFeedback = new ArrayList<>(); + } + this.recentFeedback.add(recentFeedbackItem); + return this; + } + + /** + * Get recentFeedback + * @return recentFeedback + */ + @NotNull @Valid + @Schema(name = "recent_feedback", requiredMode = Schema.RequiredMode.REQUIRED) + @JsonProperty("recent_feedback") + public List<@Valid FeedbackSummary> getRecentFeedback() { + return recentFeedback; + } + + public void setRecentFeedback(List<@Valid FeedbackSummary> recentFeedback) { + this.recentFeedback = recentFeedback; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + TrainerDashboard trainerDashboard = (TrainerDashboard) o; + return Objects.equals(this.role, trainerDashboard.role) && + Objects.equals(this.team, trainerDashboard.team) && + Objects.equals(this.totalMembers, trainerDashboard.totalMembers) && + Objects.equals(this.upcomingEvents, trainerDashboard.upcomingEvents) && + Objects.equals(this.recentFeedback, trainerDashboard.recentFeedback); + } + + @Override + public int hashCode() { + return Objects.hash(role, team, totalMembers, upcomingEvents, recentFeedback); + } + + @Override + public String toString() { + StringBuilder sb = new StringBuilder(); + sb.append("class TrainerDashboard {\n"); + sb.append(" role: ").append(toIndentedString(role)).append("\n"); + sb.append(" team: ").append(toIndentedString(team)).append("\n"); + sb.append(" totalMembers: ").append(toIndentedString(totalMembers)).append("\n"); + sb.append(" upcomingEvents: ").append(toIndentedString(upcomingEvents)).append("\n"); + sb.append(" recentFeedback: ").append(toIndentedString(recentFeedback)).append("\n"); + sb.append("}"); + return sb.toString(); + } + + /** + * Convert the given object to string with each line indented by 4 spaces + * (except the first line). + */ + private String toIndentedString(Object o) { + if (o == null) { + return "null"; + } + return o.toString().replace("\n", "\n "); + } +} + diff --git a/services/spring-member/src/main/java/tum/devoops/memberservice/controller/DashboardController.java b/services/spring-member/src/main/java/tum/devoops/memberservice/controller/DashboardController.java new file mode 100644 index 0000000..bf3f08f --- /dev/null +++ b/services/spring-member/src/main/java/tum/devoops/memberservice/controller/DashboardController.java @@ -0,0 +1,34 @@ +package tum.devoops.memberservice.controller; + +import java.util.UUID; + +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; + +import tum.devoops.memberservice.model.Dashboard; +import tum.devoops.memberservice.service.DashboardService; + +@RestController +public class DashboardController { + + private final DashboardService dashboardService; + + public DashboardController(DashboardService dashboardService) { + this.dashboardService = dashboardService; + } + + @PreAuthorize("hasAnyRole('member', 'admin')") + @GetMapping("/dashboard") + public ResponseEntity getDashboard() { + Authentication auth = SecurityContextHolder.getContext().getAuthentication(); + UUID requesterId = UUID.fromString(((Jwt) auth.getPrincipal()).getSubject()); + boolean isAdmin = auth.getAuthorities().stream() + .anyMatch(a -> "ROLE_admin".equals(a.getAuthority())); + return ResponseEntity.ok(dashboardService.getDashboard(requesterId, isAdmin)); + } +} diff --git a/services/spring-member/src/main/java/tum/devoops/memberservice/entity/DirectorEntity.java b/services/spring-member/src/main/java/tum/devoops/memberservice/entity/DirectorEntity.java new file mode 100644 index 0000000..2996a36 --- /dev/null +++ b/services/spring-member/src/main/java/tum/devoops/memberservice/entity/DirectorEntity.java @@ -0,0 +1,36 @@ +package tum.devoops.memberservice.entity; + +import java.io.Serializable; +import java.util.UUID; + +import jakarta.persistence.Column; +import jakarta.persistence.Embeddable; +import jakarta.persistence.EmbeddedId; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +// Read-only view of organization.directors for dashboard aggregation. +@Entity +@Table(schema = "organization", name = "directors") +@Getter @Setter @NoArgsConstructor @AllArgsConstructor +public class DirectorEntity { + + // Composite PK: (sport_id, member_id). + @EmbeddedId + private Id id; + + @Embeddable + @Data @NoArgsConstructor @AllArgsConstructor + public static class Id implements Serializable { + @Column(name = "sport_id", nullable = false) + private UUID sportId; + + @Column(name = "member_id", nullable = false) + private UUID memberId; + } +} diff --git a/services/spring-member/src/main/java/tum/devoops/memberservice/entity/EventEntity.java b/services/spring-member/src/main/java/tum/devoops/memberservice/entity/EventEntity.java new file mode 100644 index 0000000..2de3406 --- /dev/null +++ b/services/spring-member/src/main/java/tum/devoops/memberservice/entity/EventEntity.java @@ -0,0 +1,32 @@ +package tum.devoops.memberservice.entity; + +import java.time.Instant; +import java.util.UUID; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +// Read-only view of event.events for dashboard aggregation. +@Entity +@Table(schema = "event", name = "events") +@Getter @Setter @NoArgsConstructor +public class EventEntity { + + @Id + @Column(name = "id", nullable = false, updatable = false) + private UUID id; + + @Column(name = "name", nullable = false) + private String name; + + @Column(name = "start_time", nullable = false) + private Instant startTime; + + @Column(name = "end_time", nullable = false) + private Instant endTime; +} diff --git a/services/spring-member/src/main/java/tum/devoops/memberservice/entity/FeedbackEntity.java b/services/spring-member/src/main/java/tum/devoops/memberservice/entity/FeedbackEntity.java new file mode 100644 index 0000000..a595d1f --- /dev/null +++ b/services/spring-member/src/main/java/tum/devoops/memberservice/entity/FeedbackEntity.java @@ -0,0 +1,41 @@ +package tum.devoops.memberservice.entity; + +import java.time.Instant; +import java.util.UUID; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +// Read-only view of feedback.feedback for dashboard aggregation. The feedback text +// column is intentionally not mapped — dashboards only surface summaries. +@Entity +@Table(schema = "feedback", name = "feedback") +@Getter @Setter @NoArgsConstructor +public class FeedbackEntity { + + @Id + @Column(name = "id", nullable = false, updatable = false) + private UUID id; + + @Column(name = "event_id", nullable = false) + private UUID eventId; + + // Member this feedback is about. + @Column(name = "member_id", nullable = false) + private UUID memberId; + + // Member who wrote it; null if the creator was deleted (ON DELETE SET NULL). + @Column(name = "creator_id") + private UUID creatorId; + + @Column(name = "created_at", nullable = false) + private Instant createdAt; + + @Column(name = "rating", nullable = false) + private Integer rating; +} diff --git a/services/spring-member/src/main/java/tum/devoops/memberservice/entity/SportEntity.java b/services/spring-member/src/main/java/tum/devoops/memberservice/entity/SportEntity.java new file mode 100644 index 0000000..d57e2b6 --- /dev/null +++ b/services/spring-member/src/main/java/tum/devoops/memberservice/entity/SportEntity.java @@ -0,0 +1,26 @@ +package tum.devoops.memberservice.entity; + +import java.util.UUID; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +// Read-only view of organization.sports for dashboard aggregation. Owned by the +// organization service; the member service has cross-schema SELECT via the reader role. +@Entity +@Table(schema = "organization", name = "sports") +@Getter @Setter @NoArgsConstructor +public class SportEntity { + + @Id + @Column(name = "id", nullable = false, updatable = false) + private UUID id; + + @Column(name = "name", nullable = false) + private String name; +} diff --git a/services/spring-member/src/main/java/tum/devoops/memberservice/entity/SportEventEntity.java b/services/spring-member/src/main/java/tum/devoops/memberservice/entity/SportEventEntity.java new file mode 100644 index 0000000..4a174d4 --- /dev/null +++ b/services/spring-member/src/main/java/tum/devoops/memberservice/entity/SportEventEntity.java @@ -0,0 +1,36 @@ +package tum.devoops.memberservice.entity; + +import java.io.Serializable; +import java.util.UUID; + +import jakarta.persistence.Column; +import jakarta.persistence.Embeddable; +import jakarta.persistence.EmbeddedId; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +// Read-only view of event.sport_events for dashboard aggregation. +@Entity +@Table(schema = "event", name = "sport_events") +@Getter @Setter @NoArgsConstructor @AllArgsConstructor +public class SportEventEntity { + + // Composite PK: (event_id, sport_id). + @EmbeddedId + private Id id; + + @Embeddable + @Data @NoArgsConstructor @AllArgsConstructor + public static class Id implements Serializable { + @Column(name = "event_id", nullable = false) + private UUID eventId; + + @Column(name = "sport_id", nullable = false) + private UUID sportId; + } +} diff --git a/services/spring-member/src/main/java/tum/devoops/memberservice/entity/TeamEntity.java b/services/spring-member/src/main/java/tum/devoops/memberservice/entity/TeamEntity.java new file mode 100644 index 0000000..908f6b8 --- /dev/null +++ b/services/spring-member/src/main/java/tum/devoops/memberservice/entity/TeamEntity.java @@ -0,0 +1,29 @@ +package tum.devoops.memberservice.entity; + +import java.util.UUID; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +// Read-only view of organization.teams for dashboard aggregation. +@Entity +@Table(schema = "organization", name = "teams") +@Getter @Setter @NoArgsConstructor +public class TeamEntity { + + @Id + @Column(name = "id", nullable = false, updatable = false) + private UUID id; + + @Column(name = "name", nullable = false) + private String name; + + // FK to organization.sports(id). + @Column(name = "sport_id", nullable = false) + private UUID sportId; +} diff --git a/services/spring-member/src/main/java/tum/devoops/memberservice/entity/TeamEventEntity.java b/services/spring-member/src/main/java/tum/devoops/memberservice/entity/TeamEventEntity.java new file mode 100644 index 0000000..cdacca3 --- /dev/null +++ b/services/spring-member/src/main/java/tum/devoops/memberservice/entity/TeamEventEntity.java @@ -0,0 +1,36 @@ +package tum.devoops.memberservice.entity; + +import java.io.Serializable; +import java.util.UUID; + +import jakarta.persistence.Column; +import jakarta.persistence.Embeddable; +import jakarta.persistence.EmbeddedId; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +// Read-only view of event.team_events for dashboard aggregation. +@Entity +@Table(schema = "event", name = "team_events") +@Getter @Setter @NoArgsConstructor @AllArgsConstructor +public class TeamEventEntity { + + // Composite PK: (event_id, team_id). + @EmbeddedId + private Id id; + + @Embeddable + @Data @NoArgsConstructor @AllArgsConstructor + public static class Id implements Serializable { + @Column(name = "event_id", nullable = false) + private UUID eventId; + + @Column(name = "team_id", nullable = false) + private UUID teamId; + } +} diff --git a/services/spring-member/src/main/java/tum/devoops/memberservice/entity/TraineeEntity.java b/services/spring-member/src/main/java/tum/devoops/memberservice/entity/TraineeEntity.java new file mode 100644 index 0000000..87a7109 --- /dev/null +++ b/services/spring-member/src/main/java/tum/devoops/memberservice/entity/TraineeEntity.java @@ -0,0 +1,36 @@ +package tum.devoops.memberservice.entity; + +import java.io.Serializable; +import java.util.UUID; + +import jakarta.persistence.Column; +import jakarta.persistence.Embeddable; +import jakarta.persistence.EmbeddedId; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +// Read-only view of organization.trainees for dashboard aggregation. +@Entity +@Table(schema = "organization", name = "trainees") +@Getter @Setter @NoArgsConstructor @AllArgsConstructor +public class TraineeEntity { + + // Composite PK: (team_id, member_id). + @EmbeddedId + private Id id; + + @Embeddable + @Data @NoArgsConstructor @AllArgsConstructor + public static class Id implements Serializable { + @Column(name = "team_id", nullable = false) + private UUID teamId; + + @Column(name = "member_id", nullable = false) + private UUID memberId; + } +} diff --git a/services/spring-member/src/main/java/tum/devoops/memberservice/entity/TrainerEntity.java b/services/spring-member/src/main/java/tum/devoops/memberservice/entity/TrainerEntity.java new file mode 100644 index 0000000..e63f337 --- /dev/null +++ b/services/spring-member/src/main/java/tum/devoops/memberservice/entity/TrainerEntity.java @@ -0,0 +1,36 @@ +package tum.devoops.memberservice.entity; + +import java.io.Serializable; +import java.util.UUID; + +import jakarta.persistence.Column; +import jakarta.persistence.Embeddable; +import jakarta.persistence.EmbeddedId; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +// Read-only view of organization.trainers for dashboard aggregation. +@Entity +@Table(schema = "organization", name = "trainers") +@Getter @Setter @NoArgsConstructor @AllArgsConstructor +public class TrainerEntity { + + // Composite PK: (team_id, member_id). + @EmbeddedId + private Id id; + + @Embeddable + @Data @NoArgsConstructor @AllArgsConstructor + public static class Id implements Serializable { + @Column(name = "team_id", nullable = false) + private UUID teamId; + + @Column(name = "member_id", nullable = false) + private UUID memberId; + } +} diff --git a/services/spring-member/src/main/java/tum/devoops/memberservice/entity/TransactionEntity.java b/services/spring-member/src/main/java/tum/devoops/memberservice/entity/TransactionEntity.java new file mode 100644 index 0000000..a36f6ed --- /dev/null +++ b/services/spring-member/src/main/java/tum/devoops/memberservice/entity/TransactionEntity.java @@ -0,0 +1,29 @@ +package tum.devoops.memberservice.entity; + +import java.util.UUID; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +// Read-only view of finance.transactions for dashboard balance aggregation. +@Entity +@Table(schema = "finance", name = "transactions") +@Getter @Setter @NoArgsConstructor +public class TransactionEntity { + + @Id + @Column(name = "id", nullable = false, updatable = false) + private UUID id; + + @Column(name = "member_id", nullable = false) + private UUID memberId; + + // Amount in cents (positive = credit, negative = debit). + @Column(name = "amount_cents", nullable = false) + private int amountCents; +} diff --git a/services/spring-member/src/main/java/tum/devoops/memberservice/repository/DirectorRepository.java b/services/spring-member/src/main/java/tum/devoops/memberservice/repository/DirectorRepository.java new file mode 100644 index 0000000..39df1f2 --- /dev/null +++ b/services/spring-member/src/main/java/tum/devoops/memberservice/repository/DirectorRepository.java @@ -0,0 +1,14 @@ +package tum.devoops.memberservice.repository; + +import java.util.List; +import java.util.UUID; + +import org.springframework.data.jpa.repository.JpaRepository; + +import tum.devoops.memberservice.entity.DirectorEntity; + +public interface DirectorRepository extends JpaRepository { + + // SELECT * FROM organization.directors WHERE member_id = ? + List findAllById_MemberId(UUID memberId); +} diff --git a/services/spring-member/src/main/java/tum/devoops/memberservice/repository/EventRepository.java b/services/spring-member/src/main/java/tum/devoops/memberservice/repository/EventRepository.java new file mode 100644 index 0000000..e0a06ce --- /dev/null +++ b/services/spring-member/src/main/java/tum/devoops/memberservice/repository/EventRepository.java @@ -0,0 +1,17 @@ +package tum.devoops.memberservice.repository; + +import java.time.Instant; +import java.util.UUID; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import tum.devoops.memberservice.entity.EventEntity; + +public interface EventRepository extends JpaRepository { + + // Count events whose start_time falls in [start, end) — used for "events this week". + @Query("SELECT COUNT(e) FROM EventEntity e WHERE e.startTime >= :start AND e.startTime < :end") + long countInWindow(@Param("start") Instant start, @Param("end") Instant end); +} diff --git a/services/spring-member/src/main/java/tum/devoops/memberservice/repository/FeedbackRepository.java b/services/spring-member/src/main/java/tum/devoops/memberservice/repository/FeedbackRepository.java new file mode 100644 index 0000000..285bc2a --- /dev/null +++ b/services/spring-member/src/main/java/tum/devoops/memberservice/repository/FeedbackRepository.java @@ -0,0 +1,17 @@ +package tum.devoops.memberservice.repository; + +import java.util.List; +import java.util.UUID; + +import org.springframework.data.jpa.repository.JpaRepository; + +import tum.devoops.memberservice.entity.FeedbackEntity; + +public interface FeedbackRepository extends JpaRepository { + + // SELECT * FROM feedback.feedback WHERE member_id = ? (feedback about a member) + List findAllByMemberId(UUID memberId); + + // SELECT * FROM feedback.feedback WHERE creator_id = ? (feedback authored by a member) + List findAllByCreatorId(UUID creatorId); +} diff --git a/services/spring-member/src/main/java/tum/devoops/memberservice/repository/SportEventRepository.java b/services/spring-member/src/main/java/tum/devoops/memberservice/repository/SportEventRepository.java new file mode 100644 index 0000000..574b08c --- /dev/null +++ b/services/spring-member/src/main/java/tum/devoops/memberservice/repository/SportEventRepository.java @@ -0,0 +1,14 @@ +package tum.devoops.memberservice.repository; + +import java.util.List; +import java.util.UUID; + +import org.springframework.data.jpa.repository.JpaRepository; + +import tum.devoops.memberservice.entity.SportEventEntity; + +public interface SportEventRepository extends JpaRepository { + + // SELECT * FROM event.sport_events WHERE sport_id = ? + List findAllById_SportId(UUID sportId); +} diff --git a/services/spring-member/src/main/java/tum/devoops/memberservice/repository/SportRepository.java b/services/spring-member/src/main/java/tum/devoops/memberservice/repository/SportRepository.java new file mode 100644 index 0000000..e630828 --- /dev/null +++ b/services/spring-member/src/main/java/tum/devoops/memberservice/repository/SportRepository.java @@ -0,0 +1,10 @@ +package tum.devoops.memberservice.repository; + +import java.util.UUID; + +import org.springframework.data.jpa.repository.JpaRepository; + +import tum.devoops.memberservice.entity.SportEntity; + +public interface SportRepository extends JpaRepository { +} diff --git a/services/spring-member/src/main/java/tum/devoops/memberservice/repository/TeamEventRepository.java b/services/spring-member/src/main/java/tum/devoops/memberservice/repository/TeamEventRepository.java new file mode 100644 index 0000000..7aa5b0c --- /dev/null +++ b/services/spring-member/src/main/java/tum/devoops/memberservice/repository/TeamEventRepository.java @@ -0,0 +1,14 @@ +package tum.devoops.memberservice.repository; + +import java.util.List; +import java.util.UUID; + +import org.springframework.data.jpa.repository.JpaRepository; + +import tum.devoops.memberservice.entity.TeamEventEntity; + +public interface TeamEventRepository extends JpaRepository { + + // SELECT * FROM event.team_events WHERE team_id = ? + List findAllById_TeamId(UUID teamId); +} diff --git a/services/spring-member/src/main/java/tum/devoops/memberservice/repository/TeamRepository.java b/services/spring-member/src/main/java/tum/devoops/memberservice/repository/TeamRepository.java new file mode 100644 index 0000000..a38b7f5 --- /dev/null +++ b/services/spring-member/src/main/java/tum/devoops/memberservice/repository/TeamRepository.java @@ -0,0 +1,14 @@ +package tum.devoops.memberservice.repository; + +import java.util.List; +import java.util.UUID; + +import org.springframework.data.jpa.repository.JpaRepository; + +import tum.devoops.memberservice.entity.TeamEntity; + +public interface TeamRepository extends JpaRepository { + + // SELECT * FROM organization.teams WHERE sport_id = ? + List findAllBySportId(UUID sportId); +} diff --git a/services/spring-member/src/main/java/tum/devoops/memberservice/repository/TraineeRepository.java b/services/spring-member/src/main/java/tum/devoops/memberservice/repository/TraineeRepository.java new file mode 100644 index 0000000..afc2e68 --- /dev/null +++ b/services/spring-member/src/main/java/tum/devoops/memberservice/repository/TraineeRepository.java @@ -0,0 +1,17 @@ +package tum.devoops.memberservice.repository; + +import java.util.List; +import java.util.UUID; + +import org.springframework.data.jpa.repository.JpaRepository; + +import tum.devoops.memberservice.entity.TraineeEntity; + +public interface TraineeRepository extends JpaRepository { + + // SELECT * FROM organization.trainees WHERE member_id = ? + List findAllById_MemberId(UUID memberId); + + // SELECT * FROM organization.trainees WHERE team_id = ? + List findAllById_TeamId(UUID teamId); +} diff --git a/services/spring-member/src/main/java/tum/devoops/memberservice/repository/TrainerRepository.java b/services/spring-member/src/main/java/tum/devoops/memberservice/repository/TrainerRepository.java new file mode 100644 index 0000000..bebd46b --- /dev/null +++ b/services/spring-member/src/main/java/tum/devoops/memberservice/repository/TrainerRepository.java @@ -0,0 +1,17 @@ +package tum.devoops.memberservice.repository; + +import java.util.List; +import java.util.UUID; + +import org.springframework.data.jpa.repository.JpaRepository; + +import tum.devoops.memberservice.entity.TrainerEntity; + +public interface TrainerRepository extends JpaRepository { + + // SELECT * FROM organization.trainers WHERE member_id = ? + List findAllById_MemberId(UUID memberId); + + // SELECT * FROM organization.trainers WHERE team_id = ? + List findAllById_TeamId(UUID teamId); +} diff --git a/services/spring-member/src/main/java/tum/devoops/memberservice/repository/TransactionRepository.java b/services/spring-member/src/main/java/tum/devoops/memberservice/repository/TransactionRepository.java new file mode 100644 index 0000000..00df2d2 --- /dev/null +++ b/services/spring-member/src/main/java/tum/devoops/memberservice/repository/TransactionRepository.java @@ -0,0 +1,20 @@ +package tum.devoops.memberservice.repository; + +import java.util.UUID; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import tum.devoops.memberservice.entity.TransactionEntity; + +public interface TransactionRepository extends JpaRepository { + + // Total balance of a single member (sum of all their transaction amounts, in cents). + @Query("SELECT COALESCE(SUM(t.amountCents), 0) FROM TransactionEntity t WHERE t.memberId = :memberId") + long sumAmountByMemberId(@Param("memberId") UUID memberId); + + // Total balance across the whole club (sum of every transaction amount, in cents). + @Query("SELECT COALESCE(SUM(t.amountCents), 0) FROM TransactionEntity t") + long sumAllAmounts(); +} diff --git a/services/spring-member/src/main/java/tum/devoops/memberservice/service/DashboardService.java b/services/spring-member/src/main/java/tum/devoops/memberservice/service/DashboardService.java new file mode 100644 index 0000000..38ffc08 --- /dev/null +++ b/services/spring-member/src/main/java/tum/devoops/memberservice/service/DashboardService.java @@ -0,0 +1,288 @@ +package tum.devoops.memberservice.service; + +import java.time.DayOfWeek; +import java.time.Instant; +import java.time.LocalDate; +import java.time.OffsetDateTime; +import java.time.ZoneOffset; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; +import java.util.UUID; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import tum.devoops.memberservice.entity.DirectorEntity; +import tum.devoops.memberservice.entity.EventEntity; +import tum.devoops.memberservice.entity.FeedbackEntity; +import tum.devoops.memberservice.entity.SportEntity; +import tum.devoops.memberservice.entity.TeamEntity; +import tum.devoops.memberservice.entity.TraineeEntity; +import tum.devoops.memberservice.entity.TrainerEntity; +import tum.devoops.memberservice.model.AdminDashboard; +import tum.devoops.memberservice.model.Dashboard; +import tum.devoops.memberservice.model.DirectorDashboard; +import tum.devoops.memberservice.model.EventSummary; +import tum.devoops.memberservice.model.FeedbackSummary; +import tum.devoops.memberservice.model.MemberReportSummary; +import tum.devoops.memberservice.model.Reference; +import tum.devoops.memberservice.model.TeamBalanceSummary; +import tum.devoops.memberservice.model.TraineeDashboard; +import tum.devoops.memberservice.model.TrainerDashboard; +import tum.devoops.memberservice.repository.DirectorRepository; +import tum.devoops.memberservice.repository.EventRepository; +import tum.devoops.memberservice.repository.FeedbackRepository; +import tum.devoops.memberservice.repository.MemberRepository; +import tum.devoops.memberservice.repository.SportEventRepository; +import tum.devoops.memberservice.repository.SportRepository; +import tum.devoops.memberservice.repository.TeamEventRepository; +import tum.devoops.memberservice.repository.TeamRepository; +import tum.devoops.memberservice.repository.TraineeRepository; +import tum.devoops.memberservice.repository.TrainerRepository; +import tum.devoops.memberservice.repository.TransactionRepository; + +/** + * Builds the role-specific dashboard for the calling member by aggregating read-only data across the + * organization, event, feedback, finance and reports schemas. The caller's highest role decides the + * shape: admin > director > trainer > trainee. A director directs exactly one sport, a trainer + * trains exactly one team and a trainee belongs to exactly one team (simplifying assumption). + */ +@Service +public class DashboardService { + + private static final int TRAINER_FEEDBACK_LIMIT = 10; + private static final int TRAINEE_REPORT_LIMIT = 3; + + // Discriminator values for the Dashboard oneOf (must match the schema's discriminator mapping). + private static final String ROLE_ADMIN = "admin"; + private static final String ROLE_DIRECTOR = "director"; + private static final String ROLE_TRAINER = "trainer"; + private static final String ROLE_TRAINEE = "trainee"; + + @Autowired + private MemberRepository memberRepository; + @Autowired + private SportRepository sportRepository; + @Autowired + private TeamRepository teamRepository; + @Autowired + private DirectorRepository directorRepository; + @Autowired + private TrainerRepository trainerRepository; + @Autowired + private TraineeRepository traineeRepository; + @Autowired + private EventRepository eventRepository; + @Autowired + private TeamEventRepository teamEventRepository; + @Autowired + private SportEventRepository sportEventRepository; + @Autowired + private FeedbackRepository feedbackRepository; + @Autowired + private TransactionRepository transactionRepository; + @Autowired + private ReportQueryService reportQueryService; + + @Transactional(readOnly = true) + public Dashboard getDashboard(UUID requesterId, boolean isAdmin) { + if (isAdmin) { + return adminDashboard(); + } + List directorRoles = directorRepository.findAllById_MemberId(requesterId); + if (!directorRoles.isEmpty()) { + return directorDashboard(directorRoles.get(0).getId().getSportId()); + } + List trainerRoles = trainerRepository.findAllById_MemberId(requesterId); + if (!trainerRoles.isEmpty()) { + return trainerDashboard(requesterId, trainerRoles.get(0).getId().getTeamId()); + } + // Default view: trainee/member (also covers a plain member with no team membership). + List traineeRoles = traineeRepository.findAllById_MemberId(requesterId); + UUID teamId = traineeRoles.isEmpty() ? null : traineeRoles.get(0).getId().getTeamId(); + return traineeDashboard(requesterId, teamId); + } + + private AdminDashboard adminDashboard() { + Instant[] week = currentWeek(); + return new AdminDashboard( + ROLE_ADMIN, + (int) memberRepository.count(), + (int) sportRepository.count(), + (int) teamRepository.count(), + (int) directorRepository.count(), + (int) trainerRepository.count(), + (int) transactionRepository.sumAllAmounts(), + (int) eventRepository.countInWindow(week[0], week[1])); + } + + private DirectorDashboard directorDashboard(UUID sportId) { + List teams = teamRepository.findAllBySportId(sportId); + List teamSummaries = new ArrayList<>(); + int totalMembers = 0; + long sportBalance = 0; + for (TeamEntity team : teams) { + List trainees = traineeRepository.findAllById_TeamId(team.getId()); + long teamBalance = 0; + for (TraineeEntity trainee : trainees) { + teamBalance += transactionRepository.sumAmountByMemberId(trainee.getId().getMemberId()); + } + totalMembers += trainees.size(); + sportBalance += teamBalance; + teamSummaries.add(new TeamBalanceSummary( + new Reference(team.getId(), team.getName()), + trainees.size(), + (int) teamBalance)); + } + int upcoming = upcomingEvents(sportEventIds(sportId, teams)).size(); + return new DirectorDashboard( + ROLE_DIRECTOR, + sportReference(sportId), + teams.size(), + totalMembers, + (int) sportBalance, + upcoming, + teamSummaries); + } + + private TrainerDashboard trainerDashboard(UUID requesterId, UUID teamId) { + int totalMembers = traineeRepository.findAllById_TeamId(teamId).size(); + int upcoming = upcomingEvents(teamEventIds(teamId)).size(); + List recentFeedback = feedbackRepository.findAllByCreatorId(requesterId).stream() + .sorted(Comparator.comparing(FeedbackEntity::getCreatedAt).reversed()) + .limit(TRAINER_FEEDBACK_LIMIT) + .map(this::feedbackSummary) + .toList(); + return new TrainerDashboard( + ROLE_TRAINER, + teamReference(teamId), + totalMembers, + upcoming, + recentFeedback); + } + + private TraineeDashboard traineeDashboard(UUID requesterId, UUID teamId) { + List upcoming = upcomingEvents(traineeEventIds(teamId)); + EventSummary nextEvent = upcoming.isEmpty() ? null : eventSummary(upcoming.get(0)); + Instant monthAgo = OffsetDateTime.now(ZoneOffset.UTC).minusMonths(1).toInstant(); + List recentFeedback = feedbackRepository.findAllByMemberId(requesterId).stream() + .filter(f -> f.getCreatedAt().isAfter(monthAgo)) + .sorted(Comparator.comparing(FeedbackEntity::getCreatedAt).reversed()) + .map(this::feedbackSummary) + .toList(); + List recentReports = + reportQueryService.recentMemberReports(requesterId, TRAINEE_REPORT_LIMIT).stream() + .map(this::memberReportSummary) + .toList(); + return new TraineeDashboard( + ROLE_TRAINEE, + (int) transactionRepository.sumAmountByMemberId(requesterId), + nextEvent, + upcoming.size(), + recentFeedback, + recentReports); + } + + // --- event helpers --- + + // Distinct event ids linked to a sport directly or via any of its teams. + private Set sportEventIds(UUID sportId, List teams) { + Set ids = new LinkedHashSet<>(); + sportEventRepository.findAllById_SportId(sportId).forEach(se -> ids.add(se.getId().getEventId())); + for (TeamEntity team : teams) { + teamEventRepository.findAllById_TeamId(team.getId()).forEach(te -> ids.add(te.getId().getEventId())); + } + return ids; + } + + private Set teamEventIds(UUID teamId) { + Set ids = new LinkedHashSet<>(); + teamEventRepository.findAllById_TeamId(teamId).forEach(te -> ids.add(te.getId().getEventId())); + return ids; + } + + // Event ids relevant to a trainee: their team's events plus their team's sport's events. + private Set traineeEventIds(UUID teamId) { + Set ids = new LinkedHashSet<>(); + if (teamId == null) { + return ids; + } + teamEventRepository.findAllById_TeamId(teamId).forEach(te -> ids.add(te.getId().getEventId())); + teamRepository.findById(teamId).ifPresent(team -> + sportEventRepository.findAllById_SportId(team.getSportId()) + .forEach(se -> ids.add(se.getId().getEventId()))); + return ids; + } + + // Future events among the given ids, soonest first. + private List upcomingEvents(Set eventIds) { + if (eventIds.isEmpty()) { + return List.of(); + } + Instant now = Instant.now(); + return eventRepository.findAllById(eventIds).stream() + .filter(e -> e.getStartTime().isAfter(now)) + .sorted(Comparator.comparing(EventEntity::getStartTime)) + .toList(); + } + + private static Instant[] currentWeek() { + LocalDate monday = LocalDate.now(ZoneOffset.UTC).with(DayOfWeek.MONDAY); + Instant start = monday.atStartOfDay().toInstant(ZoneOffset.UTC); + Instant end = monday.plusWeeks(1).atStartOfDay().toInstant(ZoneOffset.UTC); + return new Instant[]{start, end}; + } + + // --- reference / summary builders --- + + private Reference memberReference(UUID memberId) { + // memberId is null only for a feedback creator whose member was deleted (ON DELETE SET NULL). + if (memberId == null) { + return null; + } + String name = memberRepository.findById(memberId) + .map(m -> m.getFirstName() + " " + m.getLastName()).orElse(null); + return new Reference(memberId, name); + } + + private Reference eventReference(UUID eventId) { + String name = eventRepository.findById(eventId).map(EventEntity::getName).orElse(null); + return new Reference(eventId, name); + } + + private Reference sportReference(UUID sportId) { + String name = sportRepository.findById(sportId).map(SportEntity::getName).orElse(null); + return new Reference(sportId, name); + } + + private Reference teamReference(UUID teamId) { + String name = teamRepository.findById(teamId).map(TeamEntity::getName).orElse(null); + return new Reference(teamId, name); + } + + private FeedbackSummary feedbackSummary(FeedbackEntity f) { + return new FeedbackSummary( + f.getId(), + eventReference(f.getEventId()), + memberReference(f.getMemberId()), + memberReference(f.getCreatorId()), + f.getCreatedAt().atOffset(ZoneOffset.UTC), + f.getRating()); + } + + private EventSummary eventSummary(EventEntity e) { + return new EventSummary( + e.getId(), + e.getName(), + e.getStartTime().atOffset(ZoneOffset.UTC), + e.getEndTime().atOffset(ZoneOffset.UTC)); + } + + private MemberReportSummary memberReportSummary(ReportQueryService.MemberReportRow row) { + return new MemberReportSummary(row.id(), memberReference(row.memberId()), row.createdAt()); + } +} diff --git a/services/spring-member/src/main/java/tum/devoops/memberservice/service/ReportQueryService.java b/services/spring-member/src/main/java/tum/devoops/memberservice/service/ReportQueryService.java new file mode 100644 index 0000000..494c816 --- /dev/null +++ b/services/spring-member/src/main/java/tum/devoops/memberservice/service/ReportQueryService.java @@ -0,0 +1,48 @@ +package tum.devoops.memberservice.service; + +import java.time.OffsetDateTime; +import java.util.List; +import java.util.UUID; + +import org.springframework.dao.DataAccessException; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Service; + +/** + * Reads member report summaries from the {@code reports} schema. + * + *

The reports tables are created at runtime by the Python genai-helper (not via Flyway), so they + * may not exist when the member service starts. To avoid coupling member-service startup to the + * genai-helper, reports are read with a plain native query (no mapped {@code @Entity}, so + * {@code ddl-auto=validate} ignores them) and any access error degrades to an empty list. + */ +@Service +public class ReportQueryService { + + private final JdbcTemplate jdbcTemplate; + + public ReportQueryService(JdbcTemplate jdbcTemplate) { + this.jdbcTemplate = jdbcTemplate; + } + + /** A row of reports.member_reports, without the generated text. */ + public record MemberReportRow(UUID id, UUID memberId, OffsetDateTime createdAt) { + } + + /** The most recent {@code limit} reports for a member, newest first; empty if none or unavailable. */ + public List recentMemberReports(UUID memberId, int limit) { + try { + return jdbcTemplate.query( + "SELECT id, member_id, created_at FROM reports.member_reports " + + "WHERE member_id = ? ORDER BY created_at DESC LIMIT ?", + (rs, rowNum) -> new MemberReportRow( + rs.getObject("id", UUID.class), + rs.getObject("member_id", UUID.class), + rs.getObject("created_at", OffsetDateTime.class)), + memberId, limit); + } catch (DataAccessException ex) { + // Table not created yet (genai-helper not started) or otherwise unavailable — degrade gracefully. + return List.of(); + } + } +} diff --git a/services/spring-member/src/test/java/tum/devoops/memberservice/MemberServiceApplicationTests.java b/services/spring-member/src/test/java/tum/devoops/memberservice/MemberServiceApplicationTests.java index 1b4e4e6..26c36bb 100644 --- a/services/spring-member/src/test/java/tum/devoops/memberservice/MemberServiceApplicationTests.java +++ b/services/spring-member/src/test/java/tum/devoops/memberservice/MemberServiceApplicationTests.java @@ -6,7 +6,9 @@ import org.springframework.test.context.TestPropertySource; import org.springframework.test.context.bean.override.mockito.MockitoBean; +import tum.devoops.memberservice.service.DashboardService; import tum.devoops.memberservice.service.MemberService; +import tum.devoops.memberservice.service.ReportQueryService; /** * Context-load smoke test. @@ -33,6 +35,14 @@ class MemberServiceApplicationTests { @MockitoBean private MemberService memberService; + // Depends on the cross-schema JPA repositories, which are not created without a DataSource. + @MockitoBean + private DashboardService dashboardService; + + // Depends on JdbcTemplate, which is not created without a DataSource. + @MockitoBean + private ReportQueryService reportQueryService; + @MockitoBean private JwtDecoder jwtDecoder; diff --git a/services/spring-member/src/test/java/tum/devoops/memberservice/controller/DashboardControllerTest.java b/services/spring-member/src/test/java/tum/devoops/memberservice/controller/DashboardControllerTest.java new file mode 100644 index 0000000..142bd41 --- /dev/null +++ b/services/spring-member/src/test/java/tum/devoops/memberservice/controller/DashboardControllerTest.java @@ -0,0 +1,83 @@ +package tum.devoops.memberservice.controller; + +import java.util.List; +import java.util.UUID; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.context.annotation.Import; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.test.context.support.WithAnonymousUser; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; + +import tum.devoops.memberservice.config.SecurityConfig; +import tum.devoops.memberservice.model.AdminDashboard; +import tum.devoops.memberservice.model.TraineeDashboard; +import tum.devoops.memberservice.service.DashboardService; + +import static org.mockito.Mockito.when; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.jwt; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@WebMvcTest(DashboardController.class) +@Import(SecurityConfig.class) +public class DashboardControllerTest { + + @Autowired + private MockMvc mockMvc; + + @MockitoBean + private DashboardService dashboardService; + + private static final UUID REQUESTER_ID = UUID.randomUUID(); + + @Test + void getDashboard_returns200WithTraineeShape_forMember() throws Exception { + TraineeDashboard dashboard = new TraineeDashboard("trainee", 1500, null, 2, List.of(), List.of()); + when(dashboardService.getDashboard(REQUESTER_ID, false)).thenReturn(dashboard); + + mockMvc.perform(get("/dashboard") + .with(jwt() + .jwt(j -> j.subject(REQUESTER_ID.toString())) + .authorities(new SimpleGrantedAuthority("ROLE_member")))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.role").value("trainee")) + .andExpect(jsonPath("$.balance_cents").value(1500)) + .andExpect(jsonPath("$.upcoming_events").value(2)); + } + + @Test + void getDashboard_returns200WithAdminShape_forAdmin() throws Exception { + AdminDashboard dashboard = new AdminDashboard("admin", 42, 3, 7, 2, 5, 99000, 4); + when(dashboardService.getDashboard(REQUESTER_ID, true)).thenReturn(dashboard); + + mockMvc.perform(get("/dashboard") + .with(jwt() + .jwt(j -> j.subject(REQUESTER_ID.toString())) + .authorities(new SimpleGrantedAuthority("ROLE_admin")))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.role").value("admin")) + .andExpect(jsonPath("$.total_members").value(42)) + .andExpect(jsonPath("$.total_balance_cents").value(99000)); + } + + @Test + void getDashboard_returns403_forDisallowedRole() throws Exception { + mockMvc.perform(get("/dashboard") + .with(jwt() + .jwt(j -> j.subject(REQUESTER_ID.toString())) + .authorities(new SimpleGrantedAuthority("ROLE_guest")))) + .andExpect(status().isForbidden()); + } + + @Test + @WithAnonymousUser + void getDashboard_returns401_whenAnonymous() throws Exception { + mockMvc.perform(get("/dashboard")) + .andExpect(status().isUnauthorized()); + } +} diff --git a/services/spring-member/src/test/java/tum/devoops/memberservice/service/DashboardServiceTest.java b/services/spring-member/src/test/java/tum/devoops/memberservice/service/DashboardServiceTest.java new file mode 100644 index 0000000..7fafedd --- /dev/null +++ b/services/spring-member/src/test/java/tum/devoops/memberservice/service/DashboardServiceTest.java @@ -0,0 +1,317 @@ +package tum.devoops.memberservice.service; + +import java.time.Instant; +import java.time.LocalDate; +import java.time.OffsetDateTime; +import java.time.ZoneOffset; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +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 org.mockito.junit.jupiter.MockitoExtension; + +import tum.devoops.memberservice.entity.DirectorEntity; +import tum.devoops.memberservice.entity.EventEntity; +import tum.devoops.memberservice.entity.FeedbackEntity; +import tum.devoops.memberservice.entity.MemberEntity; +import tum.devoops.memberservice.entity.SportEntity; +import tum.devoops.memberservice.entity.SportEventEntity; +import tum.devoops.memberservice.entity.TeamEntity; +import tum.devoops.memberservice.entity.TeamEventEntity; +import tum.devoops.memberservice.entity.TraineeEntity; +import tum.devoops.memberservice.entity.TrainerEntity; +import tum.devoops.memberservice.model.AdminDashboard; +import tum.devoops.memberservice.model.Dashboard; +import tum.devoops.memberservice.model.DirectorDashboard; +import tum.devoops.memberservice.model.Reference; +import tum.devoops.memberservice.model.TraineeDashboard; +import tum.devoops.memberservice.model.TrainerDashboard; +import tum.devoops.memberservice.repository.DirectorRepository; +import tum.devoops.memberservice.repository.EventRepository; +import tum.devoops.memberservice.repository.FeedbackRepository; +import tum.devoops.memberservice.repository.MemberRepository; +import tum.devoops.memberservice.repository.SportEventRepository; +import tum.devoops.memberservice.repository.SportRepository; +import tum.devoops.memberservice.repository.TeamEventRepository; +import tum.devoops.memberservice.repository.TeamRepository; +import tum.devoops.memberservice.repository.TraineeRepository; +import tum.devoops.memberservice.repository.TrainerRepository; +import tum.devoops.memberservice.repository.TransactionRepository; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class DashboardServiceTest { + + @Mock private MemberRepository memberRepository; + @Mock private SportRepository sportRepository; + @Mock private TeamRepository teamRepository; + @Mock private DirectorRepository directorRepository; + @Mock private TrainerRepository trainerRepository; + @Mock private TraineeRepository traineeRepository; + @Mock private EventRepository eventRepository; + @Mock private TeamEventRepository teamEventRepository; + @Mock private SportEventRepository sportEventRepository; + @Mock private FeedbackRepository feedbackRepository; + @Mock private TransactionRepository transactionRepository; + @Mock private ReportQueryService reportQueryService; + + @InjectMocks private DashboardService service; + + private static final UUID REQUESTER_ID = UUID.randomUUID(); + private static final Instant FUTURE = Instant.now().plusSeconds(86_400); + private static final Instant PAST = Instant.now().minusSeconds(86_400); + + @Test + void getDashboard_asAdmin_returnsClubWideAggregates() { + when(memberRepository.count()).thenReturn(42L); + when(sportRepository.count()).thenReturn(3L); + when(teamRepository.count()).thenReturn(7L); + when(directorRepository.count()).thenReturn(2L); + when(trainerRepository.count()).thenReturn(5L); + when(transactionRepository.sumAllAmounts()).thenReturn(99_000L); + when(eventRepository.countInWindow(any(), any())).thenReturn(4L); + + Dashboard result = service.getDashboard(REQUESTER_ID, true); + + assertThat(result).isInstanceOf(AdminDashboard.class); + AdminDashboard admin = (AdminDashboard) result; + assertThat(admin.getRole()).isEqualTo("admin"); + assertThat(admin.getTotalMembers()).isEqualTo(42); + assertThat(admin.getTotalSports()).isEqualTo(3); + assertThat(admin.getTotalTeams()).isEqualTo(7); + assertThat(admin.getTotalDirectors()).isEqualTo(2); + assertThat(admin.getTotalTrainers()).isEqualTo(5); + assertThat(admin.getTotalBalanceCents()).isEqualTo(99_000); + assertThat(admin.getEventsThisWeek()).isEqualTo(4); + } + + @Test + void getDashboard_asDirector_rollsUpTeamsAndBalances() { + UUID sportId = UUID.randomUUID(); + UUID teamA = UUID.randomUUID(); + UUID teamB = UUID.randomUUID(); + UUID m1 = UUID.randomUUID(); + UUID m2 = UUID.randomUUID(); + UUID m3 = UUID.randomUUID(); + UUID eSport = UUID.randomUUID(); + UUID eTeam = UUID.randomUUID(); + + when(directorRepository.findAllById_MemberId(REQUESTER_ID)) + .thenReturn(List.of(director(sportId, REQUESTER_ID))); + when(sportRepository.findById(sportId)).thenReturn(Optional.of(sport(sportId, "Soccer"))); + when(teamRepository.findAllBySportId(sportId)) + .thenReturn(List.of(team(teamA, "Team A", sportId), team(teamB, "Team B", sportId))); + when(traineeRepository.findAllById_TeamId(teamA)) + .thenReturn(List.of(trainee(teamA, m1), trainee(teamA, m2))); + when(traineeRepository.findAllById_TeamId(teamB)).thenReturn(List.of(trainee(teamB, m3))); + when(transactionRepository.sumAmountByMemberId(m1)).thenReturn(1_000L); + when(transactionRepository.sumAmountByMemberId(m2)).thenReturn(500L); + when(transactionRepository.sumAmountByMemberId(m3)).thenReturn(2_000L); + when(sportEventRepository.findAllById_SportId(sportId)).thenReturn(List.of(sportEvent(eSport, sportId))); + when(teamEventRepository.findAllById_TeamId(teamA)).thenReturn(List.of(teamEvent(eTeam, teamA))); + when(teamEventRepository.findAllById_TeamId(teamB)).thenReturn(List.of()); + when(eventRepository.findAllById(any())) + .thenReturn(List.of(event(eSport, "Upcoming", FUTURE), event(eTeam, "Past", PAST))); + + Dashboard result = service.getDashboard(REQUESTER_ID, false); + + assertThat(result).isInstanceOf(DirectorDashboard.class); + DirectorDashboard director = (DirectorDashboard) result; + assertThat(director.getRole()).isEqualTo("director"); + assertThat(director.getSport().getName()).isEqualTo("Soccer"); + assertThat(director.getTotalTeams()).isEqualTo(2); + assertThat(director.getTotalMembers()).isEqualTo(3); + assertThat(director.getSportBalanceCents()).isEqualTo(3_500); + assertThat(director.getUpcomingEvents()).isEqualTo(1); + assertThat(director.getTeams()).hasSize(2); + assertThat(director.getTeams().get(0).getTeam().getName()).isEqualTo("Team A"); + assertThat(director.getTeams().get(0).getMemberCount()).isEqualTo(2); + assertThat(director.getTeams().get(0).getBalanceCents()).isEqualTo(1_500); + assertThat(director.getTeams().get(1).getMemberCount()).isEqualTo(1); + assertThat(director.getTeams().get(1).getBalanceCents()).isEqualTo(2_000); + } + + @Test + void getDashboard_asTrainer_limitsAuthoredFeedbackToTen() { + UUID teamId = UUID.randomUUID(); + UUID athlete = UUID.randomUUID(); + UUID feedbackEvent = UUID.randomUUID(); + UUID upcomingEvent = UUID.randomUUID(); + + when(directorRepository.findAllById_MemberId(REQUESTER_ID)).thenReturn(List.of()); + when(trainerRepository.findAllById_MemberId(REQUESTER_ID)) + .thenReturn(List.of(trainer(teamId, REQUESTER_ID))); + when(teamRepository.findById(teamId)).thenReturn(Optional.of(team(teamId, "Team A", UUID.randomUUID()))); + when(traineeRepository.findAllById_TeamId(teamId)) + .thenReturn(List.of(trainee(teamId, UUID.randomUUID()), + trainee(teamId, UUID.randomUUID()), trainee(teamId, UUID.randomUUID()))); + when(teamEventRepository.findAllById_TeamId(teamId)).thenReturn(List.of(teamEvent(upcomingEvent, teamId))); + when(eventRepository.findAllById(any())).thenReturn(List.of(event(upcomingEvent, "Game", FUTURE))); + + List authored = new ArrayList<>(); + Instant base = Instant.now(); + for (int i = 0; i < 11; i++) { + authored.add(feedback(UUID.randomUUID(), feedbackEvent, athlete, REQUESTER_ID, base.minusSeconds(i))); + } + when(feedbackRepository.findAllByCreatorId(REQUESTER_ID)).thenReturn(authored); + when(memberRepository.findById(athlete)).thenReturn(Optional.of(member(athlete, "Ann", "Athlete"))); + when(memberRepository.findById(REQUESTER_ID)).thenReturn(Optional.of(member(REQUESTER_ID, "Tim", "Trainer"))); + when(eventRepository.findById(feedbackEvent)).thenReturn(Optional.of(event(feedbackEvent, "Match", base))); + + Dashboard result = service.getDashboard(REQUESTER_ID, false); + + assertThat(result).isInstanceOf(TrainerDashboard.class); + TrainerDashboard trainer = (TrainerDashboard) result; + assertThat(trainer.getRole()).isEqualTo("trainer"); + assertThat(trainer.getTeam().getName()).isEqualTo("Team A"); + assertThat(trainer.getTotalMembers()).isEqualTo(3); + assertThat(trainer.getUpcomingEvents()).isEqualTo(1); + assertThat(trainer.getRecentFeedback()).hasSize(10); + assertThat(trainer.getRecentFeedback().get(0).getMember()) + .extracting(Reference::getName).isEqualTo("Ann Athlete"); + assertThat(trainer.getRecentFeedback().get(0).getCreator()) + .extracting(Reference::getName).isEqualTo("Tim Trainer"); + assertThat(trainer.getRecentFeedback().get(0).getEvent()) + .extracting(Reference::getName).isEqualTo("Match"); + } + + @Test + void getDashboard_asTrainee_includesBalanceNextEventFeedbackAndReports() { + UUID teamId = UUID.randomUUID(); + UUID sportId = UUID.randomUUID(); + UUID teamEvent = UUID.randomUUID(); + UUID sportEvent = UUID.randomUUID(); + UUID trainerId = UUID.randomUUID(); + UUID feedbackEvent = UUID.randomUUID(); + UUID reportId = UUID.randomUUID(); + + when(directorRepository.findAllById_MemberId(REQUESTER_ID)).thenReturn(List.of()); + when(trainerRepository.findAllById_MemberId(REQUESTER_ID)).thenReturn(List.of()); + when(traineeRepository.findAllById_MemberId(REQUESTER_ID)) + .thenReturn(List.of(trainee(teamId, REQUESTER_ID))); + when(transactionRepository.sumAmountByMemberId(REQUESTER_ID)).thenReturn(2_500L); + when(teamEventRepository.findAllById_TeamId(teamId)).thenReturn(List.of(teamEvent(teamEvent, teamId))); + when(teamRepository.findById(teamId)).thenReturn(Optional.of(team(teamId, "Team A", sportId))); + when(sportEventRepository.findAllById_SportId(sportId)).thenReturn(List.of(sportEvent(sportEvent, sportId))); + Instant soon = Instant.now().plusSeconds(3_600); + Instant later = Instant.now().plusSeconds(7_200); + when(eventRepository.findAllById(any())) + .thenReturn(List.of(event(teamEvent, "Soon", soon), event(sportEvent, "Later", later))); + + FeedbackEntity recent = feedback(UUID.randomUUID(), feedbackEvent, REQUESTER_ID, trainerId, Instant.now().minusSeconds(86_400)); + FeedbackEntity old = feedback(UUID.randomUUID(), UUID.randomUUID(), REQUESTER_ID, trainerId, + OffsetDateTime.now(ZoneOffset.UTC).minusMonths(2).toInstant()); + when(feedbackRepository.findAllByMemberId(REQUESTER_ID)).thenReturn(List.of(recent, old)); + when(memberRepository.findById(REQUESTER_ID)).thenReturn(Optional.of(member(REQUESTER_ID, "Tom", "Trainee"))); + when(memberRepository.findById(trainerId)).thenReturn(Optional.of(member(trainerId, "Tim", "Trainer"))); + when(eventRepository.findById(feedbackEvent)).thenReturn(Optional.of(event(feedbackEvent, "Match", soon))); + when(reportQueryService.recentMemberReports(REQUESTER_ID, 3)) + .thenReturn(List.of(new ReportQueryService.MemberReportRow( + reportId, REQUESTER_ID, OffsetDateTime.now(ZoneOffset.UTC)))); + + Dashboard result = service.getDashboard(REQUESTER_ID, false); + + assertThat(result).isInstanceOf(TraineeDashboard.class); + TraineeDashboard trainee = (TraineeDashboard) result; + assertThat(trainee.getRole()).isEqualTo("trainee"); + assertThat(trainee.getBalanceCents()).isEqualTo(2_500); + assertThat(trainee.getNextEvent()).isNotNull(); + assertThat(trainee.getNextEvent().getName()).isEqualTo("Soon"); + assertThat(trainee.getUpcomingEvents()).isEqualTo(2); + assertThat(trainee.getRecentFeedback()).hasSize(1); + assertThat(trainee.getRecentFeedback().get(0).getCreator()) + .extracting(Reference::getName).isEqualTo("Tim Trainer"); + assertThat(trainee.getRecentReports()).hasSize(1); + assertThat(trainee.getRecentReports().get(0).getId()).isEqualTo(reportId); + assertThat(trainee.getRecentReports().get(0).getMember().getName()).isEqualTo("Tom Trainee"); + } + + @Test + void getDashboard_plainMemberWithNoMembership_returnsEmptyTraineeDashboard() { + when(directorRepository.findAllById_MemberId(REQUESTER_ID)).thenReturn(List.of()); + when(trainerRepository.findAllById_MemberId(REQUESTER_ID)).thenReturn(List.of()); + when(traineeRepository.findAllById_MemberId(REQUESTER_ID)).thenReturn(List.of()); + when(transactionRepository.sumAmountByMemberId(REQUESTER_ID)).thenReturn(0L); + when(feedbackRepository.findAllByMemberId(REQUESTER_ID)).thenReturn(List.of()); + when(reportQueryService.recentMemberReports(REQUESTER_ID, 3)).thenReturn(List.of()); + + Dashboard result = service.getDashboard(REQUESTER_ID, false); + + assertThat(result).isInstanceOf(TraineeDashboard.class); + TraineeDashboard trainee = (TraineeDashboard) result; + assertThat(trainee.getBalanceCents()).isEqualTo(0); + assertThat(trainee.getNextEvent()).isNull(); + assertThat(trainee.getUpcomingEvents()).isEqualTo(0); + assertThat(trainee.getRecentFeedback()).isEmpty(); + assertThat(trainee.getRecentReports()).isEmpty(); + } + + // --- entity factories --- + + private static MemberEntity member(UUID id, String first, String last) { + return new MemberEntity(id, first, last, first + "@example.com", null, null, null, LocalDate.now(), null); + } + + private static EventEntity event(UUID id, String name, Instant start) { + EventEntity e = new EventEntity(); + e.setId(id); + e.setName(name); + e.setStartTime(start); + e.setEndTime(start.plusSeconds(3_600)); + return e; + } + + private static FeedbackEntity feedback(UUID id, UUID eventId, UUID memberId, UUID creatorId, Instant createdAt) { + FeedbackEntity f = new FeedbackEntity(); + f.setId(id); + f.setEventId(eventId); + f.setMemberId(memberId); + f.setCreatorId(creatorId); + f.setCreatedAt(createdAt); + f.setRating(5); + return f; + } + + private static SportEntity sport(UUID id, String name) { + SportEntity s = new SportEntity(); + s.setId(id); + s.setName(name); + return s; + } + + private static TeamEntity team(UUID id, String name, UUID sportId) { + TeamEntity t = new TeamEntity(); + t.setId(id); + t.setName(name); + t.setSportId(sportId); + return t; + } + + private static DirectorEntity director(UUID sportId, UUID memberId) { + return new DirectorEntity(new DirectorEntity.Id(sportId, memberId)); + } + + private static TrainerEntity trainer(UUID teamId, UUID memberId) { + return new TrainerEntity(new TrainerEntity.Id(teamId, memberId)); + } + + private static TraineeEntity trainee(UUID teamId, UUID memberId) { + return new TraineeEntity(new TraineeEntity.Id(teamId, memberId)); + } + + private static TeamEventEntity teamEvent(UUID eventId, UUID teamId) { + return new TeamEventEntity(new TeamEventEntity.Id(eventId, teamId)); + } + + private static SportEventEntity sportEvent(UUID eventId, UUID sportId) { + return new SportEventEntity(new SportEventEntity.Id(eventId, sportId)); + } +} diff --git a/services/spring-member/src/test/java/tum/devoops/memberservice/service/ReportQueryServiceTest.java b/services/spring-member/src/test/java/tum/devoops/memberservice/service/ReportQueryServiceTest.java new file mode 100644 index 0000000..f107443 --- /dev/null +++ b/services/spring-member/src/test/java/tum/devoops/memberservice/service/ReportQueryServiceTest.java @@ -0,0 +1,49 @@ +package tum.devoops.memberservice.service; + +import java.time.OffsetDateTime; +import java.time.ZoneOffset; +import java.util.List; +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 org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.dao.InvalidDataAccessResourceUsageException; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.RowMapper; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class ReportQueryServiceTest { + + @Mock private JdbcTemplate jdbcTemplate; + + @InjectMocks private ReportQueryService service; + + private static final UUID MEMBER_ID = UUID.randomUUID(); + + @Test + @SuppressWarnings("unchecked") + void recentMemberReports_returnsMappedRows() { + List rows = List.of( + new ReportQueryService.MemberReportRow(UUID.randomUUID(), MEMBER_ID, OffsetDateTime.now(ZoneOffset.UTC))); + when(jdbcTemplate.query(anyString(), any(RowMapper.class), any(), any())).thenReturn(rows); + + assertThat(service.recentMemberReports(MEMBER_ID, 3)).isEqualTo(rows); + } + + @Test + @SuppressWarnings("unchecked") + void recentMemberReports_returnsEmptyWhenTableUnavailable() { + when(jdbcTemplate.query(anyString(), any(RowMapper.class), any(), any())) + .thenThrow(new InvalidDataAccessResourceUsageException("relation \"reports.member_reports\" does not exist")); + + assertThat(service.recentMemberReports(MEMBER_ID, 3)).isEmpty(); + } +} diff --git a/web-client/src/api.ts b/web-client/src/api.ts index 5a2ea96..2079405 100644 --- a/web-client/src/api.ts +++ b/web-client/src/api.ts @@ -149,6 +149,29 @@ export interface paths { patch?: never; trace?: never; }; + "/members/dashboard": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get the caller's dashboard + * @description Returns dashboard data tailored to the caller's highest role + * (admin > director > trainer > trainee). The `role` field discriminates the + * concrete shape of the response. + * - All authenticated users: can access their own dashboard. + */ + get: operations["getDashboard"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/members/{member_id}": { parameters: { query?: never; @@ -608,6 +631,71 @@ export interface components { created_at: string; text: string; }; + /** + * @description Role-specific dashboard payload. The `role` property discriminates which concrete + * shape is returned, following the caller's highest role (admin > director > trainer > trainee). + */ + Dashboard: components["schemas"]["AdminDashboard"] | components["schemas"]["DirectorDashboard"] | components["schemas"]["TrainerDashboard"] | components["schemas"]["TraineeDashboard"]; + /** @description Club-wide aggregates shown to administrators. */ + AdminDashboard: { + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + role: "admin"; + total_members: number; + total_sports: number; + total_teams: number; + total_directors: number; + total_trainers: number; + total_balance_cents: number; + events_this_week: number; + }; + /** @description Aggregates for the sport a director manages. */ + DirectorDashboard: { + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + role: "director"; + sport: components["schemas"]["Reference"]; + total_teams: number; + total_members: number; + sport_balance_cents: number; + upcoming_events: number; + teams: components["schemas"]["TeamBalanceSummary"][]; + }; + /** @description Per-team rollup of trainee count and aggregate balance. */ + TeamBalanceSummary: { + team: components["schemas"]["Reference"]; + member_count: number; + balance_cents: number; + }; + /** @description Aggregates for the team a trainer manages, including feedback they authored. */ + TrainerDashboard: { + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + role: "trainer"; + team: components["schemas"]["Reference"]; + total_members: number; + upcoming_events: number; + recent_feedback: components["schemas"]["FeedbackSummary"][]; + }; + /** @description Personal aggregates shown to a trainee/member. */ + TraineeDashboard: { + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + role: "trainee"; + balance_cents: number; + next_event: components["schemas"]["EventSummary"] | null; + upcoming_events: number; + recent_feedback: components["schemas"]["FeedbackSummary"][]; + recent_reports: components["schemas"]["MemberReportSummary"][]; + }; /** @description The object representation of a Sport within the organization. */ Sport: { /** Format: uuid */ @@ -1244,6 +1332,29 @@ export interface operations { 500: components["responses"]["InternalServerError"]; }; }; + getDashboard: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description The request was successful, and the server has returned the requested resource in the response body. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Dashboard"]; + }; + }; + 401: components["responses"]["Unauthorized"]; + 403: components["responses"]["Forbidden"]; + 500: components["responses"]["InternalServerError"]; + }; + }; getMemberDetails: { parameters: { query?: never; From 6d1a0e5b1e23257fb8d0056212f75270dd861b83 Mon Sep 17 00:00:00 2001 From: Raphael Frank <04.raphael.frank@gmail.com> Date: Tue, 30 Jun 2026 11:50:57 +0200 Subject: [PATCH 09/16] refine letter service endpoints --- api/openapi.yaml | 87 +++++++++++-- api/scripts/gen-all.sh | 2 +- services/py-genai-helper/generated/models.py | 21 ++- .../generated/java/.openapi-generator/FILES | 2 + .../devoops/letterservice/api/LettersApi.java | 22 ++-- .../letterservice/model/MailRequest.java | 120 ++++++++++++++++++ .../letterservice/model/PdfRequest.java | 95 ++++++++++++++ web-client/src/api.ts | 75 +++++++++-- 8 files changed, 386 insertions(+), 38 deletions(-) create mode 100644 services/spring-letter/src/generated/java/tum/devoops/letterservice/model/MailRequest.java create mode 100644 services/spring-letter/src/generated/java/tum/devoops/letterservice/model/PdfRequest.java diff --git a/api/openapi.yaml b/api/openapi.yaml index 6c24ce7..0f1d490 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -1044,10 +1044,25 @@ paths: - letters summary: Send mail description: | - Sends an email based on the provided HTML template. - - Trainers: can send mail to members of their team. - - Directors: can send mail to members related to their sport. - - Admins: can send mail to any member. + Sends a personalized mass email. The body carries a `subject` and an HTML `template`; the + template's placeholder tokens are replaced with each receiver's data, and one email is sent + per receiver. + + Receivers are determined from the caller's highest role: + - **Admin**: all members. + - **Director**: all directors, trainers, and trainees in their sport. + - **Trainer**: all trainers and trainees of their team. + - **Trainee / member-only**: forbidden — cannot use the letter service (`403`). + + Assumes a director directs exactly one sport, a trainer trains exactly one team, and a + trainee belongs to exactly one team. + + Supported placeholder tokens (`{{snake_case}}`; an unknown or empty value resolves to an + empty string): + - Member: `{{first_name}}`, `{{last_name}}`, `{{full_name}}`, `{{email}}`, `{{address}}`, + `{{phone_number}}`, `{{birthday}}`, `{{joining_date}}`. + - Organization: `{{team_name}}`, `{{sport_name}}` — the receiver's team/sport, blank if none. + - Finance: `{{balance}}` — the receiver's current balance, formatted (e.g. `€12.50`). responses: "204": $ref: "#/components/responses/NoContent" @@ -1063,11 +1078,11 @@ paths: - BearerJwt: [] requestBody: required: true - description: The request body for sending mail. It will be used in the email content. It must be a valid HTML string using the template format with placeholders for dynamic content. + description: The subject and HTML template for the personalized mass email. content: - text/html: + application/json: schema: - type: string + $ref: "#/components/schemas/MailRequest" parameters: [] /letters/pdf: post: @@ -1076,10 +1091,26 @@ paths: - letters summary: Get pdf description: | - Generates and returns a PDF document from the provided HTML template. - - Trainers: can generate PDFs related to their team. - - Directors: can generate PDFs related to their sport. - - Admins: can generate PDFs related to any member. + Generates a personalized mass-letter PDF. The body carries an HTML `template` whose + placeholder tokens are replaced with each receiver's data. One letter is rendered per + receiver — a layout block with the recipient's name and address followed by the + token-substituted template — and all letters are concatenated into a single PDF. + + Receivers are determined from the caller's highest role: + - **Admin**: all members. + - **Director**: all directors, trainers, and trainees in their sport. + - **Trainer**: all trainers and trainees of their team. + - **Trainee / member-only**: forbidden — cannot use the letter service (`403`). + + Assumes a director directs exactly one sport, a trainer trains exactly one team, and a + trainee belongs to exactly one team. + + Supported placeholder tokens (`{{snake_case}}`; an unknown or empty value resolves to an + empty string): + - Member: `{{first_name}}`, `{{last_name}}`, `{{full_name}}`, `{{email}}`, `{{address}}`, + `{{phone_number}}`, `{{birthday}}`, `{{joining_date}}`. + - Organization: `{{team_name}}`, `{{sport_name}}` — the receiver's team/sport, blank if none. + - Finance: `{{balance}}` — the receiver's current balance, formatted (e.g. `€12.50`). responses: "200": description: The request was successful, and the server has returned the @@ -1102,11 +1133,11 @@ paths: parameters: [] requestBody: required: true - description: The request body for generating a pdf from a template. It must be a valid HTML string using the template format with placeholders for dynamic content. + description: The HTML template for the personalized mass-letter PDF. content: - text/html: + application/json: schema: - type: string + $ref: "#/components/schemas/PdfRequest" /helper/reports/member/{member_id}: post: operationId: generateMemberReport @@ -1836,6 +1867,34 @@ components: - email - password description: Data transfer object for creating a new Member. + PdfRequest: + type: object + properties: + template: + type: string + description: | + HTML letter body. Supports per-receiver placeholder tokens (see the operation + description); each token is replaced with that receiver's data. One personalized letter + — a name and address layout block followed by the substituted template — is rendered per + receiver and concatenated into a single PDF. + required: + - template + description: Request body for generating a personalized mass-letter PDF for the caller's receivers. + MailRequest: + type: object + properties: + subject: + type: string + description: Subject line of the email. + template: + type: string + description: | + HTML email body. Supports per-receiver placeholder tokens (see the operation + description); each token is replaced with that receiver's data before the email is sent. + required: + - subject + - template + description: Request body for sending a personalized mass email to the caller's receivers. Event: type: object properties: diff --git a/api/scripts/gen-all.sh b/api/scripts/gen-all.sh index 54bb6bc..82a407b 100755 --- a/api/scripts/gen-all.sh +++ b/api/scripts/gen-all.sh @@ -18,7 +18,7 @@ echo "Running OpenAPI code generation..." "$SCRIPT_DIR/gen-spring.sh" spring-event events eventservice "Reference:Event:EventSummary:EventCreate:EventPartialUpdate:ErrorResponse:BadRequestResponse" "$SCRIPT_DIR/gen-spring.sh" spring-feedback feedback feedbackservice "Reference:Feedback:FeedbackSummary:FeedbackCreate:FeedbackPartialUpdate:ErrorResponse:BadRequestResponse" "$SCRIPT_DIR/gen-spring.sh" spring-finance finance financeservice "Reference:Balance:Transaction:TransactionCreate:TransactionPartialUpdate:ErrorResponse:BadRequestResponse" -"$SCRIPT_DIR/gen-spring.sh" spring-letter letters letterservice "ErrorResponse:BadRequestResponse" +"$SCRIPT_DIR/gen-spring.sh" spring-letter letters letterservice "ErrorResponse:BadRequestResponse:MailRequest:PdfRequest" # Pydantic models for py-genai-helper "$SCRIPT_DIR/gen-python-models.sh" diff --git a/services/py-genai-helper/generated/models.py b/services/py-genai-helper/generated/models.py index d543555..f3eb323 100644 --- a/services/py-genai-helper/generated/models.py +++ b/services/py-genai-helper/generated/models.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: openapi.yaml -# timestamp: 2026-06-29T20:37:48+00:00 +# timestamp: 2026-06-30T09:51:20+00:00 from __future__ import annotations from pydantic import AwareDatetime, BaseModel, Field, RootModel, SecretStr @@ -158,6 +158,25 @@ class MemberCreate(BaseModel): information: str | None = None +class PdfRequest(BaseModel): + template: Annotated[ + str, + Field( + description="HTML letter body. Supports per-receiver placeholder tokens (see the operation\ndescription); each token is replaced with that receiver's data. One personalized letter\n— a name and address layout block followed by the substituted template — is rendered per\nreceiver and concatenated into a single PDF.\n" + ), + ] + + +class MailRequest(BaseModel): + subject: Annotated[str, Field(description='Subject line of the email.')] + template: Annotated[ + str, + Field( + description="HTML email body. Supports per-receiver placeholder tokens (see the operation\ndescription); each token is replaced with that receiver's data before the email is sent.\n" + ), + ] + + class Event(BaseModel): id: UUID name: str diff --git a/services/spring-letter/src/generated/java/.openapi-generator/FILES b/services/spring-letter/src/generated/java/.openapi-generator/FILES index 6518e73..c82139e 100644 --- a/services/spring-letter/src/generated/java/.openapi-generator/FILES +++ b/services/spring-letter/src/generated/java/.openapi-generator/FILES @@ -2,3 +2,5 @@ tum/devoops/letterservice/api/ApiUtil.java tum/devoops/letterservice/api/LettersApi.java tum/devoops/letterservice/model/BadRequestResponse.java tum/devoops/letterservice/model/ErrorResponse.java +tum/devoops/letterservice/model/MailRequest.java +tum/devoops/letterservice/model/PdfRequest.java diff --git a/services/spring-letter/src/generated/java/tum/devoops/letterservice/api/LettersApi.java b/services/spring-letter/src/generated/java/tum/devoops/letterservice/api/LettersApi.java index 57d5639..8d0ada7 100644 --- a/services/spring-letter/src/generated/java/tum/devoops/letterservice/api/LettersApi.java +++ b/services/spring-letter/src/generated/java/tum/devoops/letterservice/api/LettersApi.java @@ -7,6 +7,8 @@ import tum.devoops.letterservice.model.BadRequestResponse; import tum.devoops.letterservice.model.ErrorResponse; +import tum.devoops.letterservice.model.MailRequest; +import tum.devoops.letterservice.model.PdfRequest; import io.swagger.v3.oas.annotations.ExternalDocumentation; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; @@ -46,9 +48,9 @@ default Optional getRequest() { /** * POST /letters/pdf : Get pdf - * Generates and returns a PDF document from the provided HTML template. - Trainers: can generate PDFs related to their team. - Directors: can generate PDFs related to their sport. - Admins: can generate PDFs related to any member. + * Generates a personalized mass-letter PDF. The body carries an HTML `template` whose placeholder tokens are replaced with each receiver's data. One letter is rendered per receiver — a layout block with the recipient's name and address followed by the token-substituted template — and all letters are concatenated into a single PDF. Receivers are determined from the caller's highest role: - **Admin**: all members. - **Director**: all directors, trainers, and trainees in their sport. - **Trainer**: all trainers and trainees of their team. - **Trainee / member-only**: forbidden — cannot use the letter service (`403`). Assumes a director directs exactly one sport, a trainer trains exactly one team, and a trainee belongs to exactly one team. Supported placeholder tokens (`{{snake_case}}`; an unknown or empty value resolves to an empty string): - Member: `{{first_name}}`, `{{last_name}}`, `{{full_name}}`, `{{email}}`, `{{address}}`, `{{phone_number}}`, `{{birthday}}`, `{{joining_date}}`. - Organization: `{{team_name}}`, `{{sport_name}}` — the receiver's team/sport, blank if none. - Finance: `{{balance}}` — the receiver's current balance, formatted (e.g. `€12.50`). * - * @param body The request body for generating a pdf from a template. It must be a valid HTML string using the template format with placeholders for dynamic content. (required) + * @param pdfRequest The HTML template for the personalized mass-letter PDF. (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) * or Authentication is required to access the requested resource. The client must include the appropriate credentials. (status code 401) @@ -58,7 +60,7 @@ default Optional getRequest() { @Operation( operationId = "getPdf", summary = "Get pdf", - description = "Generates and returns a PDF document from the provided HTML template. - Trainers: can generate PDFs related to their team. - Directors: can generate PDFs related to their sport. - Admins: can generate PDFs related to any member. ", + description = "Generates a personalized mass-letter PDF. The body carries an HTML `template` whose placeholder tokens are replaced with each receiver's data. One letter is rendered per receiver — a layout block with the recipient's name and address followed by the token-substituted template — and all letters are concatenated into a single PDF. Receivers are determined from the caller's highest role: - **Admin**: all members. - **Director**: all directors, trainers, and trainees in their sport. - **Trainer**: all trainers and trainees of their team. - **Trainee / member-only**: forbidden — cannot use the letter service (`403`). Assumes a director directs exactly one sport, a trainer trains exactly one team, and a trainee belongs to exactly one team. Supported placeholder tokens (`{{snake_case}}`; an unknown or empty value resolves to an empty string): - Member: `{{first_name}}`, `{{last_name}}`, `{{full_name}}`, `{{email}}`, `{{address}}`, `{{phone_number}}`, `{{birthday}}`, `{{joining_date}}`. - Organization: `{{team_name}}`, `{{sport_name}}` — the receiver's team/sport, blank if none. - Finance: `{{balance}}` — the receiver's current balance, formatted (e.g. `€12.50`). ", tags = { "letters" }, responses = { @ApiResponse(responseCode = "200", description = "The request was successful, and the server has returned the requested resource in the response body.", content = { @@ -90,11 +92,11 @@ default Optional getRequest() { method = RequestMethod.POST, value = "/letters/pdf", produces = { "application/pdf", "application/json" }, - consumes = { "text/html" } + consumes = { "application/json" } ) default ResponseEntity getPdf( - @Parameter(name = "body", description = "The request body for generating a pdf from a template. It must be a valid HTML string using the template format with placeholders for dynamic content.", required = true) @Valid @RequestBody String body + @Parameter(name = "PdfRequest", description = "The HTML template for the personalized mass-letter PDF.", required = true) @Valid @RequestBody PdfRequest pdfRequest ) { getRequest().ifPresent(request -> { for (MediaType mediaType: MediaType.parseMediaTypes(request.getHeader("Accept"))) { @@ -127,9 +129,9 @@ default ResponseEntity getPdf( /** * POST /letters/mail : Send mail - * Sends an email based on the provided HTML template. - Trainers: can send mail to members of their team. - Directors: can send mail to members related to their sport. - Admins: can send mail to any member. + * Sends a personalized mass email. The body carries a `subject` and an HTML `template`; the template's placeholder tokens are replaced with each receiver's data, and one email is sent per receiver. Receivers are determined from the caller's highest role: - **Admin**: all members. - **Director**: all directors, trainers, and trainees in their sport. - **Trainer**: all trainers and trainees of their team. - **Trainee / member-only**: forbidden — cannot use the letter service (`403`). Assumes a director directs exactly one sport, a trainer trains exactly one team, and a trainee belongs to exactly one team. Supported placeholder tokens (`{{snake_case}}`; an unknown or empty value resolves to an empty string): - Member: `{{first_name}}`, `{{last_name}}`, `{{full_name}}`, `{{email}}`, `{{address}}`, `{{phone_number}}`, `{{birthday}}`, `{{joining_date}}`. - Organization: `{{team_name}}`, `{{sport_name}}` — the receiver's team/sport, blank if none. - Finance: `{{balance}}` — the receiver's current balance, formatted (e.g. `€12.50`). * - * @param body The request body for sending mail. It will be used in the email content. It must be a valid HTML string using the template format with placeholders for dynamic content. (required) + * @param mailRequest The subject and HTML template for the personalized mass email. (required) * @return The request was successful, but there is no content to return in the response. (status code 204) * or The server could not understand the request due to invalid syntax. The client should modify the request and try again. (status code 400) * or Authentication is required to access the requested resource. The client must include the appropriate credentials. (status code 401) @@ -139,7 +141,7 @@ default ResponseEntity getPdf( @Operation( operationId = "sendMail", summary = "Send mail", - description = "Sends an email based on the provided HTML template. - Trainers: can send mail to members of their team. - Directors: can send mail to members related to their sport. - Admins: can send mail to any member. ", + description = "Sends a personalized mass email. The body carries a `subject` and an HTML `template`; the template's placeholder tokens are replaced with each receiver's data, and one email is sent per receiver. Receivers are determined from the caller's highest role: - **Admin**: all members. - **Director**: all directors, trainers, and trainees in their sport. - **Trainer**: all trainers and trainees of their team. - **Trainee / member-only**: forbidden — cannot use the letter service (`403`). Assumes a director directs exactly one sport, a trainer trains exactly one team, and a trainee belongs to exactly one team. Supported placeholder tokens (`{{snake_case}}`; an unknown or empty value resolves to an empty string): - Member: `{{first_name}}`, `{{last_name}}`, `{{full_name}}`, `{{email}}`, `{{address}}`, `{{phone_number}}`, `{{birthday}}`, `{{joining_date}}`. - Organization: `{{team_name}}`, `{{sport_name}}` — the receiver's team/sport, blank if none. - Finance: `{{balance}}` — the receiver's current balance, formatted (e.g. `€12.50`). ", tags = { "letters" }, responses = { @ApiResponse(responseCode = "204", description = "The request was successful, but there is no content to return in the response."), @@ -164,11 +166,11 @@ default ResponseEntity getPdf( method = RequestMethod.POST, value = "/letters/mail", produces = { "application/json" }, - consumes = { "text/html" } + consumes = { "application/json" } ) default ResponseEntity sendMail( - @Parameter(name = "body", description = "The request body for sending mail. It will be used in the email content. It must be a valid HTML string using the template format with placeholders for dynamic content.", required = true) @Valid @RequestBody String body + @Parameter(name = "MailRequest", description = "The subject and HTML template for the personalized mass email.", required = true) @Valid @RequestBody MailRequest mailRequest ) { getRequest().ifPresent(request -> { for (MediaType mediaType: MediaType.parseMediaTypes(request.getHeader("Accept"))) { diff --git a/services/spring-letter/src/generated/java/tum/devoops/letterservice/model/MailRequest.java b/services/spring-letter/src/generated/java/tum/devoops/letterservice/model/MailRequest.java new file mode 100644 index 0000000..e0ceb34 --- /dev/null +++ b/services/spring-letter/src/generated/java/tum/devoops/letterservice/model/MailRequest.java @@ -0,0 +1,120 @@ +package tum.devoops.letterservice.model; + +import java.net.URI; +import java.util.Objects; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.JsonCreator; +import org.springframework.lang.Nullable; +import java.time.OffsetDateTime; +import jakarta.validation.Valid; +import jakarta.validation.constraints.*; +import io.swagger.v3.oas.annotations.media.Schema; + + +import java.util.*; +import jakarta.annotation.Generated; + +/** + * Request body for sending a personalized mass email to the caller's receivers. + */ + +@Schema(name = "MailRequest", description = "Request body for sending a personalized mass email to the caller's receivers.") +@Generated(value = "org.openapitools.codegen.languages.SpringCodegen", comments = "Generator version: 7.14.0") +public class MailRequest { + + private String subject; + + private String template; + + public MailRequest() { + super(); + } + + /** + * Constructor with only required parameters + */ + public MailRequest(String subject, String template) { + this.subject = subject; + this.template = template; + } + + public MailRequest subject(String subject) { + this.subject = subject; + return this; + } + + /** + * Subject line of the email. + * @return subject + */ + @NotNull + @Schema(name = "subject", description = "Subject line of the email.", requiredMode = Schema.RequiredMode.REQUIRED) + @JsonProperty("subject") + public String getSubject() { + return subject; + } + + public void setSubject(String subject) { + this.subject = subject; + } + + public MailRequest template(String template) { + this.template = template; + return this; + } + + /** + * HTML email body. Supports per-receiver placeholder tokens (see the operation description); each token is replaced with that receiver's data before the email is sent. + * @return template + */ + @NotNull + @Schema(name = "template", description = "HTML email body. Supports per-receiver placeholder tokens (see the operation description); each token is replaced with that receiver's data before the email is sent. ", requiredMode = Schema.RequiredMode.REQUIRED) + @JsonProperty("template") + public String getTemplate() { + return template; + } + + public void setTemplate(String template) { + this.template = template; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + MailRequest mailRequest = (MailRequest) o; + return Objects.equals(this.subject, mailRequest.subject) && + Objects.equals(this.template, mailRequest.template); + } + + @Override + public int hashCode() { + return Objects.hash(subject, template); + } + + @Override + public String toString() { + StringBuilder sb = new StringBuilder(); + sb.append("class MailRequest {\n"); + sb.append(" subject: ").append(toIndentedString(subject)).append("\n"); + sb.append(" template: ").append(toIndentedString(template)).append("\n"); + sb.append("}"); + return sb.toString(); + } + + /** + * Convert the given object to string with each line indented by 4 spaces + * (except the first line). + */ + private String toIndentedString(Object o) { + if (o == null) { + return "null"; + } + return o.toString().replace("\n", "\n "); + } +} + diff --git a/services/spring-letter/src/generated/java/tum/devoops/letterservice/model/PdfRequest.java b/services/spring-letter/src/generated/java/tum/devoops/letterservice/model/PdfRequest.java new file mode 100644 index 0000000..f33a91a --- /dev/null +++ b/services/spring-letter/src/generated/java/tum/devoops/letterservice/model/PdfRequest.java @@ -0,0 +1,95 @@ +package tum.devoops.letterservice.model; + +import java.net.URI; +import java.util.Objects; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.JsonCreator; +import org.springframework.lang.Nullable; +import java.time.OffsetDateTime; +import jakarta.validation.Valid; +import jakarta.validation.constraints.*; +import io.swagger.v3.oas.annotations.media.Schema; + + +import java.util.*; +import jakarta.annotation.Generated; + +/** + * Request body for generating a personalized mass-letter PDF for the caller's receivers. + */ + +@Schema(name = "PdfRequest", description = "Request body for generating a personalized mass-letter PDF for the caller's receivers.") +@Generated(value = "org.openapitools.codegen.languages.SpringCodegen", comments = "Generator version: 7.14.0") +public class PdfRequest { + + private String template; + + public PdfRequest() { + super(); + } + + /** + * Constructor with only required parameters + */ + public PdfRequest(String template) { + this.template = template; + } + + public PdfRequest template(String template) { + this.template = template; + return this; + } + + /** + * HTML letter body. Supports per-receiver placeholder tokens (see the operation description); each token is replaced with that receiver's data. One personalized letter — a name and address layout block followed by the substituted template — is rendered per receiver and concatenated into a single PDF. + * @return template + */ + @NotNull + @Schema(name = "template", description = "HTML letter body. Supports per-receiver placeholder tokens (see the operation description); each token is replaced with that receiver's data. One personalized letter — a name and address layout block followed by the substituted template — is rendered per receiver and concatenated into a single PDF. ", requiredMode = Schema.RequiredMode.REQUIRED) + @JsonProperty("template") + public String getTemplate() { + return template; + } + + public void setTemplate(String template) { + this.template = template; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + PdfRequest pdfRequest = (PdfRequest) o; + return Objects.equals(this.template, pdfRequest.template); + } + + @Override + public int hashCode() { + return Objects.hash(template); + } + + @Override + public String toString() { + StringBuilder sb = new StringBuilder(); + sb.append("class PdfRequest {\n"); + sb.append(" template: ").append(toIndentedString(template)).append("\n"); + sb.append("}"); + return sb.toString(); + } + + /** + * Convert the given object to string with each line indented by 4 spaces + * (except the first line). + */ + private String toIndentedString(Object o) { + if (o == null) { + return "null"; + } + return o.toString().replace("\n", "\n "); + } +} + diff --git a/web-client/src/api.ts b/web-client/src/api.ts index 2079405..f470139 100644 --- a/web-client/src/api.ts +++ b/web-client/src/api.ts @@ -461,10 +461,25 @@ export interface paths { put?: never; /** * Send mail - * @description Sends an email based on the provided HTML template. - * - Trainers: can send mail to members of their team. - * - Directors: can send mail to members related to their sport. - * - Admins: can send mail to any member. + * @description Sends a personalized mass email. The body carries a `subject` and an HTML `template`; the + * template's placeholder tokens are replaced with each receiver's data, and one email is sent + * per receiver. + * + * Receivers are determined from the caller's highest role: + * - **Admin**: all members. + * - **Director**: all directors, trainers, and trainees in their sport. + * - **Trainer**: all trainers and trainees of their team. + * - **Trainee / member-only**: forbidden — cannot use the letter service (`403`). + * + * Assumes a director directs exactly one sport, a trainer trains exactly one team, and a + * trainee belongs to exactly one team. + * + * Supported placeholder tokens (`{{snake_case}}`; an unknown or empty value resolves to an + * empty string): + * - Member: `{{first_name}}`, `{{last_name}}`, `{{full_name}}`, `{{email}}`, `{{address}}`, + * `{{phone_number}}`, `{{birthday}}`, `{{joining_date}}`. + * - Organization: `{{team_name}}`, `{{sport_name}}` — the receiver's team/sport, blank if none. + * - Finance: `{{balance}}` — the receiver's current balance, formatted (e.g. `€12.50`). */ post: operations["sendMail"]; delete?: never; @@ -484,10 +499,26 @@ export interface paths { put?: never; /** * Get pdf - * @description Generates and returns a PDF document from the provided HTML template. - * - Trainers: can generate PDFs related to their team. - * - Directors: can generate PDFs related to their sport. - * - Admins: can generate PDFs related to any member. + * @description Generates a personalized mass-letter PDF. The body carries an HTML `template` whose + * placeholder tokens are replaced with each receiver's data. One letter is rendered per + * receiver — a layout block with the recipient's name and address followed by the + * token-substituted template — and all letters are concatenated into a single PDF. + * + * Receivers are determined from the caller's highest role: + * - **Admin**: all members. + * - **Director**: all directors, trainers, and trainees in their sport. + * - **Trainer**: all trainers and trainees of their team. + * - **Trainee / member-only**: forbidden — cannot use the letter service (`403`). + * + * Assumes a director directs exactly one sport, a trainer trains exactly one team, and a + * trainee belongs to exactly one team. + * + * Supported placeholder tokens (`{{snake_case}}`; an unknown or empty value resolves to an + * empty string): + * - Member: `{{first_name}}`, `{{last_name}}`, `{{full_name}}`, `{{email}}`, `{{address}}`, + * `{{phone_number}}`, `{{birthday}}`, `{{joining_date}}`. + * - Organization: `{{team_name}}`, `{{sport_name}}` — the receiver's team/sport, blank if none. + * - Finance: `{{balance}}` — the receiver's current balance, formatted (e.g. `€12.50`). */ post: operations["getPdf"]; delete?: never; @@ -804,6 +835,26 @@ export interface components { address?: string; information?: string; }; + /** @description Request body for generating a personalized mass-letter PDF for the caller's receivers. */ + PdfRequest: { + /** + * @description HTML letter body. Supports per-receiver placeholder tokens (see the operation + * description); each token is replaced with that receiver's data. One personalized letter + * — a name and address layout block followed by the substituted template — is rendered per + * receiver and concatenated into a single PDF. + */ + template: string; + }; + /** @description Request body for sending a personalized mass email to the caller's receivers. */ + MailRequest: { + /** @description Subject line of the email. */ + subject: string; + /** + * @description HTML email body. Supports per-receiver placeholder tokens (see the operation + * description); each token is replaced with that receiver's data before the email is sent. + */ + template: string; + }; /** @description The object representation of an Event (e.g., a training session or a match). */ Event: { /** Format: uuid */ @@ -1898,10 +1949,10 @@ export interface operations { path?: never; cookie?: never; }; - /** @description The request body for sending mail. It will be used in the email content. It must be a valid HTML string using the template format with placeholders for dynamic content. */ + /** @description The subject and HTML template for the personalized mass email. */ requestBody: { content: { - "text/html": string; + "application/json": components["schemas"]["MailRequest"]; }; }; responses: { @@ -1919,10 +1970,10 @@ export interface operations { path?: never; cookie?: never; }; - /** @description The request body for generating a pdf from a template. It must be a valid HTML string using the template format with placeholders for dynamic content. */ + /** @description The HTML template for the personalized mass-letter PDF. */ requestBody: { content: { - "text/html": string; + "application/json": components["schemas"]["PdfRequest"]; }; }; responses: { From ba7a82ed07a964e5814294a56c53549df61f55bd Mon Sep 17 00:00:00 2001 From: f-s-h Date: Tue, 30 Jun 2026 16:22:48 +0200 Subject: [PATCH 10/16] Fixed migration --- .../db/migration/V3__sport_uuid_id.sql | 23 ++++------------- .../migration/V4__sport_uuid_id_migrate.sql | 16 ++++++++++++ ..._keys.sql => V5__cascade_foreign_keys.sql} | 0 .../financeservice/entity/DirectorEntity.java | 4 +-- .../financeservice/entity/TeamEntity.java | 4 +-- .../repository/DirectorRepository.java | 4 +-- .../repository/TeamRepository.java | 4 +-- .../service/TransactionService.java | 8 +++--- .../db/migration/V3__sport_uuid_id.sql | 25 ++++++------------- 9 files changed, 40 insertions(+), 48 deletions(-) create mode 100644 services/spring-event/src/main/resources/db/migration/V4__sport_uuid_id_migrate.sql rename services/spring-event/src/main/resources/db/migration/{V4__cascade_foreign_keys.sql => V5__cascade_foreign_keys.sql} (100%) 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 index e28612a..da43c27 100644 --- 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 @@ -1,19 +1,6 @@ --- 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). +-- Drop the old FK from event.sport_events -> organization.sports(name) so that the +-- organization service's V3 migration can swap the sports primary key from name to id. +-- This must commit before org V3 runs (separate migration so it's its own transaction). +-- The backfill and new FK are applied in V4 once org V3 has completed. -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); +ALTER TABLE event.sport_events DROP CONSTRAINT IF EXISTS fk_sport_events_sport; diff --git a/services/spring-event/src/main/resources/db/migration/V4__sport_uuid_id_migrate.sql b/services/spring-event/src/main/resources/db/migration/V4__sport_uuid_id_migrate.sql new file mode 100644 index 0000000..7a84345 --- /dev/null +++ b/services/spring-event/src/main/resources/db/migration/V4__sport_uuid_id_migrate.sql @@ -0,0 +1,16 @@ +-- Switch sport_events from referencing the sport name to the sport UUID id. +-- Assumes organization V3 has already committed: sports.id now exists and is the PK. +-- The old FK (fk_sport_events_sport) was already dropped in V3. + +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/main/resources/db/migration/V4__cascade_foreign_keys.sql b/services/spring-event/src/main/resources/db/migration/V5__cascade_foreign_keys.sql similarity index 100% rename from services/spring-event/src/main/resources/db/migration/V4__cascade_foreign_keys.sql rename to services/spring-event/src/main/resources/db/migration/V5__cascade_foreign_keys.sql diff --git a/services/spring-finance/src/main/java/tum/devoops/financeservice/entity/DirectorEntity.java b/services/spring-finance/src/main/java/tum/devoops/financeservice/entity/DirectorEntity.java index ff667f9..b845758 100644 --- a/services/spring-finance/src/main/java/tum/devoops/financeservice/entity/DirectorEntity.java +++ b/services/spring-finance/src/main/java/tum/devoops/financeservice/entity/DirectorEntity.java @@ -23,8 +23,8 @@ public class DirectorEntity { @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-finance/src/main/java/tum/devoops/financeservice/entity/TeamEntity.java b/services/spring-finance/src/main/java/tum/devoops/financeservice/entity/TeamEntity.java index ed97c54..8319c4b 100644 --- a/services/spring-finance/src/main/java/tum/devoops/financeservice/entity/TeamEntity.java +++ b/services/spring-finance/src/main/java/tum/devoops/financeservice/entity/TeamEntity.java @@ -22,8 +22,8 @@ public class TeamEntity { @Column(name = "id", nullable = false) UUID id; - @Column(name = "sport_name", nullable = false) - private String sportName; + @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-finance/src/main/java/tum/devoops/financeservice/repository/DirectorRepository.java b/services/spring-finance/src/main/java/tum/devoops/financeservice/repository/DirectorRepository.java index 2656935..aaa98b8 100644 --- a/services/spring-finance/src/main/java/tum/devoops/financeservice/repository/DirectorRepository.java +++ b/services/spring-finance/src/main/java/tum/devoops/financeservice/repository/DirectorRepository.java @@ -9,6 +9,6 @@ import java.util.UUID; public interface DirectorRepository extends JpaRepository { - @Query("SELECT d.id.sportName FROM DirectorEntity d WHERE d.id.memberId = :memberId") - List findSportNamesByMemberId(@Param("memberId") UUID memberId); + @Query("SELECT d.id.sportId FROM DirectorEntity d WHERE d.id.memberId = :memberId") + List findSportIdsByMemberId(@Param("memberId") UUID memberId); } diff --git a/services/spring-finance/src/main/java/tum/devoops/financeservice/repository/TeamRepository.java b/services/spring-finance/src/main/java/tum/devoops/financeservice/repository/TeamRepository.java index 8b8a172..c24afe5 100644 --- a/services/spring-finance/src/main/java/tum/devoops/financeservice/repository/TeamRepository.java +++ b/services/spring-finance/src/main/java/tum/devoops/financeservice/repository/TeamRepository.java @@ -10,8 +10,8 @@ import java.util.UUID; public interface TeamRepository extends JpaRepository { - @Query("SELECT t.trainees FROM TeamEntity t WHERE t.sportName = :sportName") - List findTraineesBySportName(@Param("sportName") String sportName); + @Query("SELECT t.trainees FROM TeamEntity t WHERE t.sportId = :sportId") + List findTraineesBySportId(@Param("sportId") UUID sportId); @Query("SELECT t.trainees FROM TeamEntity t WHERE t.id = :teamId") List findTraineesByTeamId(@Param("teamId") UUID teamId); diff --git a/services/spring-finance/src/main/java/tum/devoops/financeservice/service/TransactionService.java b/services/spring-finance/src/main/java/tum/devoops/financeservice/service/TransactionService.java index 88e4385..f72ea59 100644 --- a/services/spring-finance/src/main/java/tum/devoops/financeservice/service/TransactionService.java +++ b/services/spring-finance/src/main/java/tum/devoops/financeservice/service/TransactionService.java @@ -215,8 +215,8 @@ private Reference memberReference(UUID memberId) { // Returns distinct IDs of all members the requester can manage (as director or trainer). private List getManagedMemberIds(UUID requesterId) { Set ids = new LinkedHashSet<>(); - for (String sport : directorRepository.findSportNamesByMemberId(requesterId)) { - teamRepository.findTraineesBySportName(sport).stream() + for (UUID sport : directorRepository.findSportIdsByMemberId(requesterId)) { + teamRepository.findTraineesBySportId(sport).stream() .map(t -> t.getId().getMemberId()) .forEach(ids::add); } @@ -230,8 +230,8 @@ private List getManagedMemberIds(UUID requesterId) { } private boolean isDirectorOfMember(UUID requesterId, UUID memberId) { - for (String sport : directorRepository.findSportNamesByMemberId(requesterId)) { - boolean found = teamRepository.findTraineesBySportName(sport).stream() + for (UUID sport : directorRepository.findSportIdsByMemberId(requesterId)) { + boolean found = teamRepository.findTraineesBySportId(sport).stream() .map(t -> t.getId().getMemberId()) .anyMatch(memberId::equals); if (found) { 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 index f453c35..0459f8c 100644 --- 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 @@ -2,11 +2,9 @@ -- 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). +-- The cross-schema FK event.sport_events -> organization.sports(name) is dropped by the +-- event service's own V3 migration (event_user owns that table). This migration assumes +-- that has happened before the sports PK is swapped in step 4. -- 1. Add the new id column and backfill a stable UUID per sport. ALTER TABLE organization.sports @@ -29,23 +27,14 @@ UPDATE organization.directors d 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. +-- 4. Swap the sports primary key from name to id; keep name as a unique field. +-- NOTE: the cross-schema FK event.sport_events -> organization.sports(name) must be dropped +-- before this step. The event service's V3 migration drops it (event_user owns that table). 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). +-- 5. 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 From 125b575458bf9613cdb641c24a692baefc2465fb Mon Sep 17 00:00:00 2001 From: f-s-h Date: Tue, 30 Jun 2026 17:28:48 +0200 Subject: [PATCH 11/16] Fixed path mismatch --- .gitignore | 1 + infra/docker-compose.override.yml | 12 ++++++------ infra/docker-compose.yml | 12 ++++++------ 3 files changed, 13 insertions(+), 12 deletions(-) diff --git a/.gitignore b/.gitignore index e38da20..c08e94e 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ settings.json +services/spring-letter/.env diff --git a/infra/docker-compose.override.yml b/infra/docker-compose.override.yml index d79336e..6f195fd 100644 --- a/infra/docker-compose.override.yml +++ b/infra/docker-compose.override.yml @@ -53,7 +53,7 @@ services: - "traefik.enable=true" - "traefik.http.routers.organization-service.entrypoints=web" - "traefik.http.routers.organization-service.rule=PathPrefix(`/api/v1/organization`)" - - "traefik.http.middlewares.organization-stripprefix.stripprefix.prefixes=/api/v1/organization" + - "traefik.http.middlewares.organization-stripprefix.stripprefix.prefixes=/api/v1" - "traefik.http.routers.organization-service.middlewares=organization-stripprefix,forward-auth@file" - "traefik.http.services.organization-service.loadbalancer.server.port=8080" @@ -64,7 +64,7 @@ services: - "traefik.enable=true" - "traefik.http.routers.member-service.entrypoints=web" - "traefik.http.routers.member-service.rule=PathPrefix(`/api/v1/members`)" - - "traefik.http.middlewares.member-stripprefix.stripprefix.prefixes=/api/v1/members" + - "traefik.http.middlewares.member-stripprefix.stripprefix.prefixes=/api/v1" - "traefik.http.routers.member-service.middlewares=member-stripprefix,forward-auth@file" - "traefik.http.services.member-service.loadbalancer.server.port=8080" @@ -75,7 +75,7 @@ services: - "traefik.enable=true" - "traefik.http.routers.event-service.entrypoints=web" - "traefik.http.routers.event-service.rule=PathPrefix(`/api/v1/events`)" - - "traefik.http.middlewares.event-stripprefix.stripprefix.prefixes=/api/v1/events" + - "traefik.http.middlewares.event-stripprefix.stripprefix.prefixes=/api/v1" - "traefik.http.routers.event-service.middlewares=event-stripprefix,forward-auth@file" - "traefik.http.services.event-service.loadbalancer.server.port=8080" @@ -86,7 +86,7 @@ services: - "traefik.enable=true" - "traefik.http.routers.feedback-service.entrypoints=web" - "traefik.http.routers.feedback-service.rule=PathPrefix(`/api/v1/feedback`)" - - "traefik.http.middlewares.feedback-stripprefix.stripprefix.prefixes=/api/v1/feedback" + - "traefik.http.middlewares.feedback-stripprefix.stripprefix.prefixes=/api/v1" - "traefik.http.routers.feedback-service.middlewares=feedback-stripprefix,forward-auth@file" - "traefik.http.services.feedback-service.loadbalancer.server.port=8080" @@ -97,7 +97,7 @@ services: - "traefik.enable=true" - "traefik.http.routers.finance-service.entrypoints=web" - "traefik.http.routers.finance-service.rule=PathPrefix(`/api/v1/finance`)" - - "traefik.http.middlewares.finance-stripprefix.stripprefix.prefixes=/api/v1/finance" + - "traefik.http.middlewares.finance-stripprefix.stripprefix.prefixes=/api/v1" - "traefik.http.routers.finance-service.middlewares=finance-stripprefix,forward-auth@file" - "traefik.http.services.finance-service.loadbalancer.server.port=8080" @@ -108,7 +108,7 @@ services: - "traefik.enable=true" - "traefik.http.routers.letter-service.entrypoints=web" - "traefik.http.routers.letter-service.rule=PathPrefix(`/api/v1/letters`)" - - "traefik.http.middlewares.letter-stripprefix.stripprefix.prefixes=/api/v1/letters" + - "traefik.http.middlewares.letter-stripprefix.stripprefix.prefixes=/api/v1" - "traefik.http.routers.letter-service.middlewares=letter-stripprefix,forward-auth@file" - "traefik.http.services.letter-service.loadbalancer.server.port=8080" diff --git a/infra/docker-compose.yml b/infra/docker-compose.yml index 28b6bd3..178c724 100644 --- a/infra/docker-compose.yml +++ b/infra/docker-compose.yml @@ -51,7 +51,7 @@ services: - "traefik.http.routers.organization-service.rule=Host(`team-devoops.uaenorth.cloudapp.azure.com`) && PathPrefix(`/api/v1/organization`)" - "traefik.http.routers.organization-service.tls=true" - "traefik.http.routers.organization-service.tls.certresolver=le" - - "traefik.http.middlewares.organization-stripprefix.stripprefix.prefixes=/api/v1/organization" + - "traefik.http.middlewares.organization-stripprefix.stripprefix.prefixes=/api/v1" - "traefik.http.routers.organization-service.middlewares=organization-stripprefix,forward-auth@file" - "traefik.http.services.organization-service.loadbalancer.server.port=8080" networks: @@ -78,7 +78,7 @@ services: - "traefik.http.routers.member-service.rule=Host(`team-devoops.uaenorth.cloudapp.azure.com`) && PathPrefix(`/api/v1/members`)" - "traefik.http.routers.member-service.tls=true" - "traefik.http.routers.member-service.tls.certresolver=le" - - "traefik.http.middlewares.member-stripprefix.stripprefix.prefixes=/api/v1/members" + - "traefik.http.middlewares.member-stripprefix.stripprefix.prefixes=/api/v1" - "traefik.http.routers.member-service.middlewares=member-stripprefix,forward-auth@file" - "traefik.http.services.member-service.loadbalancer.server.port=8080" networks: @@ -105,7 +105,7 @@ services: - "traefik.http.routers.event-service.rule=Host(`team-devoops.uaenorth.cloudapp.azure.com`) && PathPrefix(`/api/v1/events`)" - "traefik.http.routers.event-service.tls=true" - "traefik.http.routers.event-service.tls.certresolver=le" - - "traefik.http.middlewares.event-stripprefix.stripprefix.prefixes=/api/v1/events" + - "traefik.http.middlewares.event-stripprefix.stripprefix.prefixes=/api/v1" - "traefik.http.routers.event-service.middlewares=event-stripprefix,forward-auth@file" - "traefik.http.services.event-service.loadbalancer.server.port=8080" networks: @@ -132,7 +132,7 @@ services: - "traefik.http.routers.feedback-service.rule=Host(`team-devoops.uaenorth.cloudapp.azure.com`) && PathPrefix(`/api/v1/feedback`)" - "traefik.http.routers.feedback-service.tls=true" - "traefik.http.routers.feedback-service.tls.certresolver=le" - - "traefik.http.middlewares.feedback-stripprefix.stripprefix.prefixes=/api/v1/feedback" + - "traefik.http.middlewares.feedback-stripprefix.stripprefix.prefixes=/api/v1" - "traefik.http.routers.feedback-service.middlewares=feedback-stripprefix,forward-auth@file" - "traefik.http.services.feedback-service.loadbalancer.server.port=8080" networks: @@ -159,7 +159,7 @@ services: - "traefik.http.routers.finance-service.rule=Host(`team-devoops.uaenorth.cloudapp.azure.com`) && PathPrefix(`/api/v1/finance`)" - "traefik.http.routers.finance-service.tls=true" - "traefik.http.routers.finance-service.tls.certresolver=le" - - "traefik.http.middlewares.finance-stripprefix.stripprefix.prefixes=/api/v1/finance" + - "traefik.http.middlewares.finance-stripprefix.stripprefix.prefixes=/api/v1" - "traefik.http.routers.finance-service.middlewares=finance-stripprefix,forward-auth@file" - "traefik.http.services.finance-service.loadbalancer.server.port=8080" networks: @@ -186,7 +186,7 @@ services: - "traefik.http.routers.letter-service.rule=Host(`team-devoops.uaenorth.cloudapp.azure.com`) && PathPrefix(`/api/v1/letters`)" - "traefik.http.routers.letter-service.tls=true" - "traefik.http.routers.letter-service.tls.certresolver=le" - - "traefik.http.middlewares.letter-stripprefix.stripprefix.prefixes=/api/v1/letters" + - "traefik.http.middlewares.letter-stripprefix.stripprefix.prefixes=/api/v1" - "traefik.http.routers.letter-service.middlewares=letter-stripprefix,forward-auth@file" - "traefik.http.services.letter-service.loadbalancer.server.port=8080" networks: From 06cfa755ff21b63cf2ae6752858762b9a819b45b Mon Sep 17 00:00:00 2001 From: f-s-h Date: Tue, 30 Jun 2026 17:41:38 +0200 Subject: [PATCH 12/16] Fixed HelloController path change --- .../eventservice/controller/HelloController.java | 2 +- .../eventservice/controller/HelloControllerTest.java | 6 +++--- .../tum/devoops/feedbackservice/HelloController.java | 2 +- .../devoops/feedbackservice/HelloControllerTest.java | 6 +++--- .../financeservice/controller/HelloController.java | 2 +- .../devoops/financeservice/HelloControllerTest.java | 6 +++--- .../tum/devoops/letterservice/HelloController.java | 2 +- .../devoops/letterservice/HelloControllerTest.java | 6 +++--- .../memberservice/controller/HelloController.java | 4 ++-- .../controller/HelloControllerTest.java | 12 ++++++------ .../devoops/organizationservice/HelloController.java | 2 +- .../organizationservice/HelloControllerTest.java | 6 +++--- 12 files changed, 28 insertions(+), 28 deletions(-) diff --git a/services/spring-event/src/main/java/tum/devoops/eventservice/controller/HelloController.java b/services/spring-event/src/main/java/tum/devoops/eventservice/controller/HelloController.java index 73e460f..09bfcb7 100644 --- a/services/spring-event/src/main/java/tum/devoops/eventservice/controller/HelloController.java +++ b/services/spring-event/src/main/java/tum/devoops/eventservice/controller/HelloController.java @@ -6,7 +6,7 @@ @RestController public class HelloController { - @GetMapping("/hello") + @GetMapping("/events/hello") public String hello() { return "Hello world from event-service!"; } diff --git a/services/spring-event/src/test/java/tum/devoops/eventservice/controller/HelloControllerTest.java b/services/spring-event/src/test/java/tum/devoops/eventservice/controller/HelloControllerTest.java index 55382eb..eaf4c75 100644 --- a/services/spring-event/src/test/java/tum/devoops/eventservice/controller/HelloControllerTest.java +++ b/services/spring-event/src/test/java/tum/devoops/eventservice/controller/HelloControllerTest.java @@ -23,20 +23,20 @@ class HelloControllerTest { @Test @WithMockUser void helloReturnsExpectedMessage() throws Exception { - mockMvc.perform(get("/hello")) + mockMvc.perform(get("/events/hello")) .andExpect(status().isOk()) .andExpect(content().string("Hello world from event-service!")); } @Test void helloRequiresAuthentication() throws Exception { - mockMvc.perform(get("/hello")) + mockMvc.perform(get("/events/hello")) .andExpect(status().isUnauthorized()); } @Test void helloWithValidJwtReturns200() throws Exception { - mockMvc.perform(get("/hello").with(jwt())) + mockMvc.perform(get("/events/hello").with(jwt())) .andExpect(status().isOk()); } diff --git a/services/spring-feedback/src/main/java/tum/devoops/feedbackservice/HelloController.java b/services/spring-feedback/src/main/java/tum/devoops/feedbackservice/HelloController.java index 2e77a9b..a317764 100644 --- a/services/spring-feedback/src/main/java/tum/devoops/feedbackservice/HelloController.java +++ b/services/spring-feedback/src/main/java/tum/devoops/feedbackservice/HelloController.java @@ -6,7 +6,7 @@ @RestController public class HelloController { - @GetMapping("/hello") + @GetMapping("/feedback/hello") public String hello() { return "Hello world from feedback-service!"; } diff --git a/services/spring-feedback/src/test/java/tum/devoops/feedbackservice/HelloControllerTest.java b/services/spring-feedback/src/test/java/tum/devoops/feedbackservice/HelloControllerTest.java index 6f9c6c4..a593b82 100644 --- a/services/spring-feedback/src/test/java/tum/devoops/feedbackservice/HelloControllerTest.java +++ b/services/spring-feedback/src/test/java/tum/devoops/feedbackservice/HelloControllerTest.java @@ -23,20 +23,20 @@ class HelloControllerTest { @Test @WithMockUser void helloReturnsExpectedMessage() throws Exception { - mockMvc.perform(get("/hello")) + mockMvc.perform(get("/feedback/hello")) .andExpect(status().isOk()) .andExpect(content().string("Hello world from feedback-service!")); } @Test void helloRequiresAuthentication() throws Exception { - mockMvc.perform(get("/hello")) + mockMvc.perform(get("/feedback/hello")) .andExpect(status().isUnauthorized()); } @Test void helloWithValidJwtReturns200() throws Exception { - mockMvc.perform(get("/hello").with(jwt())) + mockMvc.perform(get("/feedback/hello").with(jwt())) .andExpect(status().isOk()); } diff --git a/services/spring-finance/src/main/java/tum/devoops/financeservice/controller/HelloController.java b/services/spring-finance/src/main/java/tum/devoops/financeservice/controller/HelloController.java index 7db57a9..ec1903b 100644 --- a/services/spring-finance/src/main/java/tum/devoops/financeservice/controller/HelloController.java +++ b/services/spring-finance/src/main/java/tum/devoops/financeservice/controller/HelloController.java @@ -6,7 +6,7 @@ @RestController public class HelloController { - @GetMapping("/hello") + @GetMapping("/finance/hello") public String hello() { return "Hello world from finance-service!"; } diff --git a/services/spring-finance/src/test/java/tum/devoops/financeservice/HelloControllerTest.java b/services/spring-finance/src/test/java/tum/devoops/financeservice/HelloControllerTest.java index b6d5343..38e32bb 100644 --- a/services/spring-finance/src/test/java/tum/devoops/financeservice/HelloControllerTest.java +++ b/services/spring-finance/src/test/java/tum/devoops/financeservice/HelloControllerTest.java @@ -24,20 +24,20 @@ class HelloControllerTest { @Test @WithMockUser void helloReturnsExpectedMessage() throws Exception { - mockMvc.perform(get("/hello")) + mockMvc.perform(get("/finance/hello")) .andExpect(status().isOk()) .andExpect(content().string("Hello world from finance-service!")); } @Test void helloRequiresAuthentication() throws Exception { - mockMvc.perform(get("/hello")) + mockMvc.perform(get("/finance/hello")) .andExpect(status().isUnauthorized()); } @Test void helloWithValidJwtReturns200() throws Exception { - mockMvc.perform(get("/hello").with(jwt())) + mockMvc.perform(get("/finance/hello").with(jwt())) .andExpect(status().isOk()); } diff --git a/services/spring-letter/src/main/java/tum/devoops/letterservice/HelloController.java b/services/spring-letter/src/main/java/tum/devoops/letterservice/HelloController.java index 3214701..42d2c2e 100644 --- a/services/spring-letter/src/main/java/tum/devoops/letterservice/HelloController.java +++ b/services/spring-letter/src/main/java/tum/devoops/letterservice/HelloController.java @@ -6,7 +6,7 @@ @RestController public class HelloController { - @GetMapping("/hello") + @GetMapping("/letters/hello") public String hello() { return "Hello world from letter-service!"; } diff --git a/services/spring-letter/src/test/java/tum/devoops/letterservice/HelloControllerTest.java b/services/spring-letter/src/test/java/tum/devoops/letterservice/HelloControllerTest.java index 50da366..353f40f 100644 --- a/services/spring-letter/src/test/java/tum/devoops/letterservice/HelloControllerTest.java +++ b/services/spring-letter/src/test/java/tum/devoops/letterservice/HelloControllerTest.java @@ -23,20 +23,20 @@ class HelloControllerTest { @Test @WithMockUser void helloReturnsExpectedMessage() throws Exception { - mockMvc.perform(get("/hello")) + mockMvc.perform(get("/letters/hello")) .andExpect(status().isOk()) .andExpect(content().string("Hello world from letter-service!")); } @Test void helloRequiresAuthentication() throws Exception { - mockMvc.perform(get("/hello")) + mockMvc.perform(get("/letters/hello")) .andExpect(status().isUnauthorized()); } @Test void helloWithValidJwtReturns200() throws Exception { - mockMvc.perform(get("/hello").with(jwt())) + mockMvc.perform(get("/letters/hello").with(jwt())) .andExpect(status().isOk()); } diff --git a/services/spring-member/src/main/java/tum/devoops/memberservice/controller/HelloController.java b/services/spring-member/src/main/java/tum/devoops/memberservice/controller/HelloController.java index c0715a3..26f32e7 100644 --- a/services/spring-member/src/main/java/tum/devoops/memberservice/controller/HelloController.java +++ b/services/spring-member/src/main/java/tum/devoops/memberservice/controller/HelloController.java @@ -8,13 +8,13 @@ public class HelloController { @PreAuthorize("hasRole('member')") - @GetMapping("/hello") + @GetMapping("/members/hello") public String hello() { return "Hello world from member-service!"; } @PreAuthorize("hasRole('admin')") - @GetMapping("/helloAdmin") + @GetMapping("/members/helloAdmin") public String helloAdmin() { return "Hello world to admin from member-service!"; } diff --git a/services/spring-member/src/test/java/tum/devoops/memberservice/controller/HelloControllerTest.java b/services/spring-member/src/test/java/tum/devoops/memberservice/controller/HelloControllerTest.java index 358f358..0d95124 100644 --- a/services/spring-member/src/test/java/tum/devoops/memberservice/controller/HelloControllerTest.java +++ b/services/spring-member/src/test/java/tum/devoops/memberservice/controller/HelloControllerTest.java @@ -24,7 +24,7 @@ class HelloControllerTest { @Test @WithMockUser(roles = "member") void helloReturnsExpectedMessage() throws Exception { - mockMvc.perform(get("/hello")) + mockMvc.perform(get("/members/hello")) .andExpect(status().isOk()) .andExpect(content().string("Hello world from member-service!")); } @@ -32,7 +32,7 @@ void helloReturnsExpectedMessage() throws Exception { @Test @WithMockUser(roles = "admin") void helloAdminReturnsExpectedMessage() throws Exception { - mockMvc.perform(get("/helloAdmin")) + mockMvc.perform(get("/members/helloAdmin")) .andExpect(status().isOk()) .andExpect(content().string("Hello world to admin from member-service!")); } @@ -40,13 +40,13 @@ void helloAdminReturnsExpectedMessage() throws Exception { @Test @WithMockUser(roles = "member") void helloAdminForbiddenForMember() throws Exception { - mockMvc.perform(get("/helloAdmin")) + mockMvc.perform(get("/members/helloAdmin")) .andExpect(status().isForbidden()); } @Test void helloWithJwtMemberRoleReturns200() throws Exception { - mockMvc.perform(get("/hello") + mockMvc.perform(get("/members/hello") .with(jwt().authorities(new SimpleGrantedAuthority("ROLE_member")))) .andExpect(status().isOk()) .andExpect(content().string("Hello world from member-service!")); @@ -54,7 +54,7 @@ void helloWithJwtMemberRoleReturns200() throws Exception { @Test void helloAdminWithJwtAdminRoleReturns200() throws Exception { - mockMvc.perform(get("/helloAdmin") + mockMvc.perform(get("/members/helloAdmin") .with(jwt().authorities(new SimpleGrantedAuthority("ROLE_admin")))) .andExpect(status().isOk()) .andExpect(content().string("Hello world to admin from member-service!")); @@ -62,7 +62,7 @@ void helloAdminWithJwtAdminRoleReturns200() throws Exception { @Test void helloAdminWithJwtMemberRoleReturns403() throws Exception { - mockMvc.perform(get("/helloAdmin") + mockMvc.perform(get("/members/helloAdmin") .with(jwt().authorities(new SimpleGrantedAuthority("ROLE_member")))) .andExpect(status().isForbidden()); } diff --git a/services/spring-organization/src/main/java/tum/devoops/organizationservice/HelloController.java b/services/spring-organization/src/main/java/tum/devoops/organizationservice/HelloController.java index ee9a966..2524e29 100644 --- a/services/spring-organization/src/main/java/tum/devoops/organizationservice/HelloController.java +++ b/services/spring-organization/src/main/java/tum/devoops/organizationservice/HelloController.java @@ -6,7 +6,7 @@ @RestController public class HelloController { - @GetMapping("/hello") + @GetMapping("/organization/hello") public String hello() { return "Hello world from organization-service!"; } diff --git a/services/spring-organization/src/test/java/tum/devoops/organizationservice/HelloControllerTest.java b/services/spring-organization/src/test/java/tum/devoops/organizationservice/HelloControllerTest.java index ef2e93d..5c7d931 100644 --- a/services/spring-organization/src/test/java/tum/devoops/organizationservice/HelloControllerTest.java +++ b/services/spring-organization/src/test/java/tum/devoops/organizationservice/HelloControllerTest.java @@ -23,20 +23,20 @@ class HelloControllerTest { @Test @WithMockUser void helloReturnsExpectedMessage() throws Exception { - mockMvc.perform(get("/hello")) + mockMvc.perform(get("/organization/hello")) .andExpect(status().isOk()) .andExpect(content().string("Hello world from organization-service!")); } @Test void helloRequiresAuthentication() throws Exception { - mockMvc.perform(get("/hello")) + mockMvc.perform(get("/organization/hello")) .andExpect(status().isUnauthorized()); } @Test void helloWithValidJwtReturns200() throws Exception { - mockMvc.perform(get("/hello").with(jwt())) + mockMvc.perform(get("/organization/hello").with(jwt())) .andExpect(status().isOk()); } From f7a94cf11ada2f3c0d957afe2e839160033d4a09 Mon Sep 17 00:00:00 2001 From: Raphael Frank <04.raphael.frank@gmail.com> Date: Tue, 30 Jun 2026 22:11:02 +0200 Subject: [PATCH 13/16] fix finance tests --- .../TransactionServiceTest.java | 48 ++++++++++--------- 1 file changed, 25 insertions(+), 23 deletions(-) diff --git a/services/spring-finance/src/test/java/tum/devoops/financeservice/TransactionServiceTest.java b/services/spring-finance/src/test/java/tum/devoops/financeservice/TransactionServiceTest.java index aee2025..091ddd4 100644 --- a/services/spring-finance/src/test/java/tum/devoops/financeservice/TransactionServiceTest.java +++ b/services/spring-finance/src/test/java/tum/devoops/financeservice/TransactionServiceTest.java @@ -52,6 +52,7 @@ class TransactionServiceTest { private static final UUID REQUESTER_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-000000000003"); + private static final UUID SPORT_ID = UUID.fromString("00000000-0000-0000-0000-000000000005"); private static final UUID TX_ID = UUID.fromString("00000000-0000-0000-0000-000000000099"); // ── createTransaction: input validation ─────────────────────────────────── @@ -75,7 +76,7 @@ void createTransactionMemberNotFoundThrowsNotFoundException() { @Test void createTransactionNeitherDirectorNorTrainerNorAdminThrowsForbidden() { memberExists(); - when(directorRepository.findSportNamesByMemberId(REQUESTER_ID)).thenReturn(List.of()); + when(directorRepository.findSportIdsByMemberId(REQUESTER_ID)).thenReturn(List.of()); when(trainerRepository.findTeamIdByMemberId(REQUESTER_ID)).thenReturn(List.of()); assertThrows(ForbiddenException.class, @@ -98,8 +99,8 @@ void createTransactionAdminPassesAuthWithoutDirectorOrTrainerRole() { @Test void createTransactionAsDirectorOfMembersTeamReturnsCreatedTransaction() { memberExists(); - when(directorRepository.findSportNamesByMemberId(REQUESTER_ID)).thenReturn(List.of("tennis")); - when(teamRepository.findTraineesBySportName("tennis")).thenReturn(List.of(trainee(MEMBER_ID))); + when(directorRepository.findSportIdsByMemberId(REQUESTER_ID)).thenReturn(List.of(SPORT_ID)); + when(teamRepository.findTraineesBySportId(SPORT_ID)).thenReturn(List.of(trainee(MEMBER_ID))); when(transactionRepository.save(any())).thenReturn(savedEntity()); Transaction result = service.createTransaction(validRequest(), REQUESTER_ID, false); @@ -112,7 +113,7 @@ void createTransactionAsDirectorOfMembersTeamReturnsCreatedTransaction() { void createTransactionAsTrainerOfMembersTeamReturnsCreatedTransaction() { memberExists(); TeamEntity team = mockTeam(TEAM_ID); - when(directorRepository.findSportNamesByMemberId(REQUESTER_ID)).thenReturn(List.of()); + when(directorRepository.findSportIdsByMemberId(REQUESTER_ID)).thenReturn(List.of()); when(trainerRepository.findTeamIdByMemberId(REQUESTER_ID)).thenReturn(List.of(TEAM_ID)); when(teamRepository.findById(TEAM_ID)).thenReturn(Optional.of(team)); when(teamRepository.findTraineesByTeamId(TEAM_ID)).thenReturn(List.of(trainee(MEMBER_ID))); @@ -128,8 +129,8 @@ void createTransactionAsTrainerOfMembersTeamReturnsCreatedTransaction() { @Test void createTransactionDirectorOfDifferentSportThrowsForbidden() { memberExists(); - when(directorRepository.findSportNamesByMemberId(REQUESTER_ID)).thenReturn(List.of("football")); - when(teamRepository.findTraineesBySportName("football")).thenReturn(List.of()); + when(directorRepository.findSportIdsByMemberId(REQUESTER_ID)).thenReturn(List.of(SPORT_ID)); + when(teamRepository.findTraineesBySportId(SPORT_ID)).thenReturn(List.of()); when(trainerRepository.findTeamIdByMemberId(REQUESTER_ID)).thenReturn(List.of()); assertThrows(ForbiddenException.class, @@ -140,9 +141,10 @@ void createTransactionDirectorOfDifferentSportThrowsForbidden() { void createTransactionDirectorOfMultipleSportsFindsMemberInSecondSport() { memberExists(); UUID otherMemberId = UUID.randomUUID(); - when(directorRepository.findSportNamesByMemberId(REQUESTER_ID)).thenReturn(List.of("football", "tennis")); - when(teamRepository.findTraineesBySportName("football")).thenReturn(List.of(trainee(otherMemberId))); - when(teamRepository.findTraineesBySportName("tennis")).thenReturn(List.of(trainee(MEMBER_ID))); + UUID otherSportId = UUID.fromString("00000000-0000-0000-0000-000000000006"); + when(directorRepository.findSportIdsByMemberId(REQUESTER_ID)).thenReturn(List.of(otherSportId, SPORT_ID)); + when(teamRepository.findTraineesBySportId(otherSportId)).thenReturn(List.of(trainee(otherMemberId))); + when(teamRepository.findTraineesBySportId(SPORT_ID)).thenReturn(List.of(trainee(MEMBER_ID))); // Short-circuits at director — trainer check never reached. when(transactionRepository.save(any())).thenReturn(savedEntity()); @@ -158,7 +160,7 @@ void createTransactionTrainerButMemberOnDifferentTeamThrowsForbidden() { memberExists(); UUID otherMemberId = UUID.randomUUID(); TeamEntity team = mockTeam(TEAM_ID); - when(directorRepository.findSportNamesByMemberId(REQUESTER_ID)).thenReturn(List.of()); + when(directorRepository.findSportIdsByMemberId(REQUESTER_ID)).thenReturn(List.of()); when(trainerRepository.findTeamIdByMemberId(REQUESTER_ID)).thenReturn(List.of(TEAM_ID)); when(teamRepository.findById(TEAM_ID)).thenReturn(Optional.of(team)); when(teamRepository.findTraineesByTeamId(TEAM_ID)).thenReturn(List.of(trainee(otherMemberId))); @@ -170,7 +172,7 @@ void createTransactionTrainerButMemberOnDifferentTeamThrowsForbidden() { @Test void createTransactionTrainerTeamNotInDatabaseThrowsForbidden() { memberExists(); - when(directorRepository.findSportNamesByMemberId(REQUESTER_ID)).thenReturn(List.of()); + when(directorRepository.findSportIdsByMemberId(REQUESTER_ID)).thenReturn(List.of()); when(trainerRepository.findTeamIdByMemberId(REQUESTER_ID)).thenReturn(List.of(TEAM_ID)); when(teamRepository.findById(TEAM_ID)).thenReturn(Optional.empty()); @@ -183,7 +185,7 @@ void createTransactionTrainerOnMultipleTeamsFindsMemberInSecondTeam() { memberExists(); UUID otherTeamId = UUID.fromString("00000000-0000-0000-0000-000000000004"); UUID otherMemberId = UUID.randomUUID(); - when(directorRepository.findSportNamesByMemberId(REQUESTER_ID)).thenReturn(List.of()); + when(directorRepository.findSportIdsByMemberId(REQUESTER_ID)).thenReturn(List.of()); TeamEntity team1 = mockTeam(TEAM_ID); TeamEntity team2 = mockTeam(otherTeamId); when(trainerRepository.findTeamIdByMemberId(REQUESTER_ID)).thenReturn(List.of(TEAM_ID, otherTeamId)); @@ -218,9 +220,9 @@ void getAllTransactionsAsNonAdminReturnsOwnAndManagedDeduplicated() { UUID asCreatorTxId = UUID.fromString("00000000-0000-0000-0000-0000000000a2"); UUID managedTxId = UUID.fromString("00000000-0000-0000-0000-0000000000a3"); - // Requester is a director of "tennis", which contains managedMemberId. - when(directorRepository.findSportNamesByMemberId(REQUESTER_ID)).thenReturn(List.of("tennis")); - when(teamRepository.findTraineesBySportName("tennis")).thenReturn(List.of(trainee(managedMemberId))); + // Requester is a director of SPORT_ID, whose teams contain managedMemberId. + when(directorRepository.findSportIdsByMemberId(REQUESTER_ID)).thenReturn(List.of(SPORT_ID)); + when(teamRepository.findTraineesBySportId(SPORT_ID)).thenReturn(List.of(trainee(managedMemberId))); when(trainerRepository.findTeamIdByMemberId(REQUESTER_ID)).thenReturn(List.of()); TransactionEntity asMember = txEntity(asMemberTxId, REQUESTER_ID, UUID.randomUUID(), 100); @@ -240,7 +242,7 @@ void getAllTransactionsAsNonAdminReturnsOwnAndManagedDeduplicated() { @Test void getAllTransactionsAsNonAdminWithNothingReturnsEmpty() { - when(directorRepository.findSportNamesByMemberId(REQUESTER_ID)).thenReturn(List.of()); + when(directorRepository.findSportIdsByMemberId(REQUESTER_ID)).thenReturn(List.of()); when(trainerRepository.findTeamIdByMemberId(REQUESTER_ID)).thenReturn(List.of()); when(transactionRepository.findAllByMemberId(REQUESTER_ID)).thenReturn(List.of()); when(transactionRepository.findAllByCreatorId(REQUESTER_ID)).thenReturn(List.of()); @@ -285,7 +287,7 @@ void getTransactionAsCreatorSucceeds() { @Test void getTransactionAsUnrelatedUserThrowsForbidden() { when(transactionRepository.findById(TX_ID)).thenReturn(Optional.of(txEntity(TX_ID, MEMBER_ID, UUID.randomUUID(), 500))); - when(directorRepository.findSportNamesByMemberId(REQUESTER_ID)).thenReturn(List.of()); + when(directorRepository.findSportIdsByMemberId(REQUESTER_ID)).thenReturn(List.of()); when(trainerRepository.findTeamIdByMemberId(REQUESTER_ID)).thenReturn(List.of()); assertThrows(ForbiddenException.class, () -> service.getTransaction(TX_ID, REQUESTER_ID, false)); @@ -436,7 +438,7 @@ void getAllBalancesAsAdminSumsTransactionsPerMember() { @Test void getAllBalancesAsNonAdminWithNoManagedMembersThrowsForbidden() { - when(directorRepository.findSportNamesByMemberId(REQUESTER_ID)).thenReturn(List.of()); + when(directorRepository.findSportIdsByMemberId(REQUESTER_ID)).thenReturn(List.of()); when(trainerRepository.findTeamIdByMemberId(REQUESTER_ID)).thenReturn(List.of()); assertThrows(ForbiddenException.class, () -> service.getAllBalances(REQUESTER_ID, false)); @@ -444,8 +446,8 @@ void getAllBalancesAsNonAdminWithNoManagedMembersThrowsForbidden() { @Test void getAllBalancesAsDirectorSumsManagedMembersOnly() { - when(directorRepository.findSportNamesByMemberId(REQUESTER_ID)).thenReturn(List.of("tennis")); - when(teamRepository.findTraineesBySportName("tennis")).thenReturn(List.of(trainee(MEMBER_ID))); + when(directorRepository.findSportIdsByMemberId(REQUESTER_ID)).thenReturn(List.of(SPORT_ID)); + when(teamRepository.findTraineesBySportId(SPORT_ID)).thenReturn(List.of(trainee(MEMBER_ID))); when(trainerRepository.findTeamIdByMemberId(REQUESTER_ID)).thenReturn(List.of()); when(transactionRepository.findAllByMemberId(MEMBER_ID)).thenReturn(List.of( txEntity(UUID.randomUUID(), MEMBER_ID, REQUESTER_ID, 100), @@ -491,8 +493,8 @@ void getMemberBalanceAsAdminReturnsSum() { @Test void getMemberBalanceAsDirectorOfMemberReturnsSum() { when(memberRepository.findById(MEMBER_ID)).thenReturn(Optional.of(new MemberEntity())); - when(directorRepository.findSportNamesByMemberId(REQUESTER_ID)).thenReturn(List.of("tennis")); - when(teamRepository.findTraineesBySportName("tennis")).thenReturn(List.of(trainee(MEMBER_ID))); + when(directorRepository.findSportIdsByMemberId(REQUESTER_ID)).thenReturn(List.of(SPORT_ID)); + when(teamRepository.findTraineesBySportId(SPORT_ID)).thenReturn(List.of(trainee(MEMBER_ID))); when(transactionRepository.findAllByMemberId(MEMBER_ID)).thenReturn(List.of( txEntity(UUID.randomUUID(), MEMBER_ID, REQUESTER_ID, 42))); @@ -502,7 +504,7 @@ void getMemberBalanceAsDirectorOfMemberReturnsSum() { @Test void getMemberBalanceAsUnrelatedUserThrowsForbidden() { when(memberRepository.findById(MEMBER_ID)).thenReturn(Optional.of(new MemberEntity())); - when(directorRepository.findSportNamesByMemberId(REQUESTER_ID)).thenReturn(List.of()); + when(directorRepository.findSportIdsByMemberId(REQUESTER_ID)).thenReturn(List.of()); when(trainerRepository.findTeamIdByMemberId(REQUESTER_ID)).thenReturn(List.of()); assertThrows(ForbiddenException.class, () -> service.getMemberBalance(MEMBER_ID, REQUESTER_ID, false)); From 3bafff75e85779f43efe37e68750796a7a2cd789 Mon Sep 17 00:00:00 2001 From: f-s-h Date: Wed, 1 Jul 2026 09:37:27 +0200 Subject: [PATCH 14/16] Fixed Member Service --- .../memberservice/config/SecurityConfig.java | 2 +- .../controller/DashboardController.java | 34 ----- .../controller/MemberController.java | 60 +++++---- .../controller/DashboardControllerTest.java | 83 ------------ .../controller/MemberControllerTest.java | 119 +++++++++++++----- 5 files changed, 124 insertions(+), 174 deletions(-) delete mode 100644 services/spring-member/src/main/java/tum/devoops/memberservice/controller/DashboardController.java delete mode 100644 services/spring-member/src/test/java/tum/devoops/memberservice/controller/DashboardControllerTest.java diff --git a/services/spring-member/src/main/java/tum/devoops/memberservice/config/SecurityConfig.java b/services/spring-member/src/main/java/tum/devoops/memberservice/config/SecurityConfig.java index 5a5d6d3..32473e5 100644 --- a/services/spring-member/src/main/java/tum/devoops/memberservice/config/SecurityConfig.java +++ b/services/spring-member/src/main/java/tum/devoops/memberservice/config/SecurityConfig.java @@ -17,7 +17,7 @@ @Configuration @EnableWebSecurity -@EnableMethodSecurity +@EnableMethodSecurity(proxyTargetClass = true) public class SecurityConfig { @Bean diff --git a/services/spring-member/src/main/java/tum/devoops/memberservice/controller/DashboardController.java b/services/spring-member/src/main/java/tum/devoops/memberservice/controller/DashboardController.java deleted file mode 100644 index bf3f08f..0000000 --- a/services/spring-member/src/main/java/tum/devoops/memberservice/controller/DashboardController.java +++ /dev/null @@ -1,34 +0,0 @@ -package tum.devoops.memberservice.controller; - -import java.util.UUID; - -import org.springframework.http.ResponseEntity; -import org.springframework.security.access.prepost.PreAuthorize; -import org.springframework.security.core.Authentication; -import org.springframework.security.core.context.SecurityContextHolder; -import org.springframework.security.oauth2.jwt.Jwt; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.RestController; - -import tum.devoops.memberservice.model.Dashboard; -import tum.devoops.memberservice.service.DashboardService; - -@RestController -public class DashboardController { - - private final DashboardService dashboardService; - - public DashboardController(DashboardService dashboardService) { - this.dashboardService = dashboardService; - } - - @PreAuthorize("hasAnyRole('member', 'admin')") - @GetMapping("/dashboard") - public ResponseEntity getDashboard() { - Authentication auth = SecurityContextHolder.getContext().getAuthentication(); - UUID requesterId = UUID.fromString(((Jwt) auth.getPrincipal()).getSubject()); - boolean isAdmin = auth.getAuthorities().stream() - .anyMatch(a -> "ROLE_admin".equals(a.getAuthority())); - return ResponseEntity.ok(dashboardService.getDashboard(requesterId, isAdmin)); - } -} diff --git a/services/spring-member/src/main/java/tum/devoops/memberservice/controller/MemberController.java b/services/spring-member/src/main/java/tum/devoops/memberservice/controller/MemberController.java index 21c61d5..003c88e 100644 --- a/services/spring-member/src/main/java/tum/devoops/memberservice/controller/MemberController.java +++ b/services/spring-member/src/main/java/tum/devoops/memberservice/controller/MemberController.java @@ -3,19 +3,17 @@ import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.security.access.prepost.PreAuthorize; -import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.security.oauth2.jwt.Jwt; -import org.springframework.web.bind.annotation.DeleteMapping; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PatchMapping; -import org.springframework.web.bind.annotation.PathVariable; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RestController; +import tum.devoops.memberservice.api.MembersApi; +import tum.devoops.memberservice.model.Dashboard; import tum.devoops.memberservice.model.Member; import tum.devoops.memberservice.model.MemberCreate; import tum.devoops.memberservice.model.MemberPartialUpdate; import tum.devoops.memberservice.model.MemberSummary; +import tum.devoops.memberservice.service.DashboardService; import tum.devoops.memberservice.service.MemberService; import jakarta.validation.Valid; @@ -25,32 +23,34 @@ import java.util.UUID; @RestController -public class MemberController { +public class MemberController implements MembersApi { private final MemberService memberService; + private final DashboardService dashboardService; - public MemberController(MemberService memberService) { + public MemberController(MemberService memberService, DashboardService dashboardService) { this.memberService = memberService; + this.dashboardService = dashboardService; } + @Override @PreAuthorize("hasAnyRole('member', 'admin')") - @GetMapping("/") public ResponseEntity> getAllMembers() { return ResponseEntity.ok(memberService.getAllMembers()); } + @Override @PreAuthorize("hasAnyRole('member', 'admin')") - @GetMapping("/{id}") - public ResponseEntity getMemberDetails(@PathVariable UUID id) { + public ResponseEntity getMemberDetails(UUID id) { Optional memberOptional = memberService.getMemberById(id); return memberOptional.map(ResponseEntity::ok).orElse(ResponseEntity.notFound().build()); } + @Override @PreAuthorize("hasRole('admin')") - @PostMapping("/") - public ResponseEntity createMember(@Valid @RequestBody MemberCreate memberCreate, @AuthenticationPrincipal Jwt jwt) { + public ResponseEntity createMember(@Valid MemberCreate memberCreate) { try { - Optional optionalMember = memberService.createMember(memberCreate, jwt.getTokenValue()); + Optional optionalMember = memberService.createMember(memberCreate, currentJwt().getTokenValue()); if (optionalMember.isEmpty()) { return ResponseEntity.badRequest().build(); } @@ -61,24 +61,36 @@ public ResponseEntity createMember(@Valid @RequestBody MemberCreate memb } } + @Override @PreAuthorize("hasRole('admin') or hasRole('member') and #id.toString() == authentication.name") - @PatchMapping("/{id}") - public ResponseEntity updateMemberDetails( - @PathVariable UUID id, - @Valid @RequestBody MemberPartialUpdate memberPartialUpdate, - @AuthenticationPrincipal Jwt jwt) { + public ResponseEntity updateMemberDetails(UUID id, @Valid MemberPartialUpdate memberPartialUpdate) { try { - Optional updated = memberService.updateMember(id, memberPartialUpdate, jwt.getTokenValue()); + Optional updated = memberService.updateMember(id, memberPartialUpdate, currentJwt().getTokenValue()); return updated.map(ResponseEntity::ok).orElse(ResponseEntity.notFound().build()); } catch (IllegalStateException e) { return ResponseEntity.status(HttpStatus.CONFLICT).build(); } } + @Override @PreAuthorize("hasRole('admin')") - @DeleteMapping("/{id}") - public ResponseEntity deleteMember(@PathVariable UUID id, @AuthenticationPrincipal Jwt jwt) { - boolean deleted = memberService.deleteMember(id, jwt.getTokenValue()); + public ResponseEntity deleteMember(UUID id) { + boolean deleted = memberService.deleteMember(id, currentJwt().getTokenValue()); return deleted ? ResponseEntity.noContent().build() : ResponseEntity.notFound().build(); } + + @Override + @PreAuthorize("hasAnyRole('member', 'admin')") + public ResponseEntity getDashboard() { + Authentication auth = SecurityContextHolder.getContext().getAuthentication(); + UUID requesterId = UUID.fromString(((Jwt) auth.getPrincipal()).getSubject()); + boolean isAdmin = auth.getAuthorities().stream() + .anyMatch(a -> "ROLE_admin".equals(a.getAuthority())); + return ResponseEntity.ok(dashboardService.getDashboard(requesterId, isAdmin)); + } + + private static Jwt currentJwt() { + Authentication auth = SecurityContextHolder.getContext().getAuthentication(); + return (Jwt) auth.getPrincipal(); + } } diff --git a/services/spring-member/src/test/java/tum/devoops/memberservice/controller/DashboardControllerTest.java b/services/spring-member/src/test/java/tum/devoops/memberservice/controller/DashboardControllerTest.java deleted file mode 100644 index 142bd41..0000000 --- a/services/spring-member/src/test/java/tum/devoops/memberservice/controller/DashboardControllerTest.java +++ /dev/null @@ -1,83 +0,0 @@ -package tum.devoops.memberservice.controller; - -import java.util.List; -import java.util.UUID; - -import org.junit.jupiter.api.Test; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; -import org.springframework.context.annotation.Import; -import org.springframework.security.core.authority.SimpleGrantedAuthority; -import org.springframework.security.test.context.support.WithAnonymousUser; -import org.springframework.test.context.bean.override.mockito.MockitoBean; -import org.springframework.test.web.servlet.MockMvc; - -import tum.devoops.memberservice.config.SecurityConfig; -import tum.devoops.memberservice.model.AdminDashboard; -import tum.devoops.memberservice.model.TraineeDashboard; -import tum.devoops.memberservice.service.DashboardService; - -import static org.mockito.Mockito.when; -import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.jwt; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; - -@WebMvcTest(DashboardController.class) -@Import(SecurityConfig.class) -public class DashboardControllerTest { - - @Autowired - private MockMvc mockMvc; - - @MockitoBean - private DashboardService dashboardService; - - private static final UUID REQUESTER_ID = UUID.randomUUID(); - - @Test - void getDashboard_returns200WithTraineeShape_forMember() throws Exception { - TraineeDashboard dashboard = new TraineeDashboard("trainee", 1500, null, 2, List.of(), List.of()); - when(dashboardService.getDashboard(REQUESTER_ID, false)).thenReturn(dashboard); - - mockMvc.perform(get("/dashboard") - .with(jwt() - .jwt(j -> j.subject(REQUESTER_ID.toString())) - .authorities(new SimpleGrantedAuthority("ROLE_member")))) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.role").value("trainee")) - .andExpect(jsonPath("$.balance_cents").value(1500)) - .andExpect(jsonPath("$.upcoming_events").value(2)); - } - - @Test - void getDashboard_returns200WithAdminShape_forAdmin() throws Exception { - AdminDashboard dashboard = new AdminDashboard("admin", 42, 3, 7, 2, 5, 99000, 4); - when(dashboardService.getDashboard(REQUESTER_ID, true)).thenReturn(dashboard); - - mockMvc.perform(get("/dashboard") - .with(jwt() - .jwt(j -> j.subject(REQUESTER_ID.toString())) - .authorities(new SimpleGrantedAuthority("ROLE_admin")))) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.role").value("admin")) - .andExpect(jsonPath("$.total_members").value(42)) - .andExpect(jsonPath("$.total_balance_cents").value(99000)); - } - - @Test - void getDashboard_returns403_forDisallowedRole() throws Exception { - mockMvc.perform(get("/dashboard") - .with(jwt() - .jwt(j -> j.subject(REQUESTER_ID.toString())) - .authorities(new SimpleGrantedAuthority("ROLE_guest")))) - .andExpect(status().isForbidden()); - } - - @Test - @WithAnonymousUser - void getDashboard_returns401_whenAnonymous() throws Exception { - mockMvc.perform(get("/dashboard")) - .andExpect(status().isUnauthorized()); - } -} diff --git a/services/spring-member/src/test/java/tum/devoops/memberservice/controller/MemberControllerTest.java b/services/spring-member/src/test/java/tum/devoops/memberservice/controller/MemberControllerTest.java index 66f91b0..3054562 100644 --- a/services/spring-member/src/test/java/tum/devoops/memberservice/controller/MemberControllerTest.java +++ b/services/spring-member/src/test/java/tum/devoops/memberservice/controller/MemberControllerTest.java @@ -13,10 +13,13 @@ import org.springframework.test.context.bean.override.mockito.MockitoBean; import org.springframework.test.web.servlet.MockMvc; import tum.devoops.memberservice.config.SecurityConfig; +import tum.devoops.memberservice.model.AdminDashboard; import tum.devoops.memberservice.model.Member; import tum.devoops.memberservice.model.MemberCreate; import tum.devoops.memberservice.model.MemberPartialUpdate; import tum.devoops.memberservice.model.MemberSummary; +import tum.devoops.memberservice.model.TraineeDashboard; +import tum.devoops.memberservice.service.DashboardService; import tum.devoops.memberservice.service.MemberService; import java.time.LocalDate; @@ -34,6 +37,7 @@ import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @WebMvcTest(MemberController.class) @@ -49,6 +53,9 @@ public class MemberControllerTest { @MockitoBean private MemberService memberService; + @MockitoBean + private DashboardService dashboardService; + private UUID id; private MemberSummary memberSummary; private MemberSummary memberSummary1; @@ -113,7 +120,7 @@ void setUp() { @WithMockUser(roles = "member") void getMembersAllowedForMember() throws Exception { when(memberService.getAllMembers()).thenReturn(List.of()); - mockMvc.perform(get("/")) + mockMvc.perform(get("/members")) .andExpect(status().isOk()); } @@ -121,21 +128,21 @@ void getMembersAllowedForMember() throws Exception { @WithMockUser(roles = "admin") void getMembersAllowedForAdmin() throws Exception { when(memberService.getAllMembers()).thenReturn(List.of()); - mockMvc.perform(get("/")) + mockMvc.perform(get("/members")) .andExpect(status().isOk()); } @Test @WithMockUser(roles = "guest") void getMembersForbiddenForWrongRole() throws Exception { - mockMvc.perform(get("/")) + mockMvc.perform(get("/members")) .andExpect(status().isForbidden()); } @Test @WithAnonymousUser void getMembersUnauthorizedForAnonymous() throws Exception { - mockMvc.perform(get("/")) + mockMvc.perform(get("/members")) .andExpect(status().isUnauthorized()); } @@ -143,7 +150,7 @@ void getMembersUnauthorizedForAnonymous() throws Exception { @WithMockUser(roles = "member") void getMembersContentType() throws Exception { when(memberService.getAllMembers()).thenReturn(List.of()); - mockMvc.perform(get("/")) + mockMvc.perform(get("/members")) .andExpect(status().isOk()) .andExpect(content().contentType(MediaType.APPLICATION_JSON)); } @@ -152,7 +159,7 @@ void getMembersContentType() throws Exception { @WithMockUser(roles = "member") void getMembersEmptyList() throws Exception { when(memberService.getAllMembers()).thenReturn(List.of()); - mockMvc.perform(get("/")) + mockMvc.perform(get("/members")) .andExpect(status().isOk()) .andExpect(content().json("[]")); } @@ -162,7 +169,7 @@ void getMembersEmptyList() throws Exception { void getMemberNonEmptyList() throws Exception { List list = List.of(memberSummary, memberSummary1); when(memberService.getAllMembers()).thenReturn(list); - mockMvc.perform(get("/")) + mockMvc.perform(get("/members")) .andExpect(status().isOk()) .andExpect(content().json(objectMapper.writeValueAsString(list))); } @@ -173,7 +180,7 @@ void getMemberNonEmptyList() throws Exception { @WithMockUser(roles = "member") void getMemberDetailsAllowedForMember() throws Exception { when(memberService.getMemberById(id)).thenReturn(Optional.of(member)); - mockMvc.perform(get("/{id}", id)) + mockMvc.perform(get("/members/{id}", id)) .andExpect(status().isOk()); } @@ -181,21 +188,21 @@ void getMemberDetailsAllowedForMember() throws Exception { @WithMockUser(roles = "admin") void getMemberDetailsAllowedForAdmin() throws Exception { when(memberService.getMemberById(id)).thenReturn(Optional.of(member)); - mockMvc.perform(get("/{id}", id)) + mockMvc.perform(get("/members/{id}", id)) .andExpect(status().isOk()); } @Test @WithMockUser(roles = "guest") void getMemberDetailsForbiddenForWrongRole() throws Exception { - mockMvc.perform(get("/{id}", id)) + mockMvc.perform(get("/members/{id}", id)) .andExpect(status().isForbidden()); } @Test @WithAnonymousUser void getMemberDetailsUnauthorizedForAnonymous() throws Exception { - mockMvc.perform(get("/{id}", id)) + mockMvc.perform(get("/members/{id}", id)) .andExpect(status().isUnauthorized()); } @@ -203,7 +210,7 @@ void getMemberDetailsUnauthorizedForAnonymous() throws Exception { @WithMockUser(roles = "member") void getMemberDetailsContentType() throws Exception { when(memberService.getMemberById(id)).thenReturn(Optional.of(member)); - mockMvc.perform(get("/{id}", id)) + mockMvc.perform(get("/members/{id}", id)) .andExpect(status().isOk()) .andExpect(content().contentType(MediaType.APPLICATION_JSON)); } @@ -212,7 +219,7 @@ void getMemberDetailsContentType() throws Exception { @WithMockUser(roles = "member") void getMemberDetailsReturnsCorrectMember() throws Exception { when(memberService.getMemberById(id)).thenReturn(Optional.of(member)); - mockMvc.perform(get("/{id}", id)) + mockMvc.perform(get("/members/{id}", id)) .andExpect(status().isOk()) .andExpect(content().json(objectMapper.writeValueAsString(member))); } @@ -222,7 +229,7 @@ void getMemberDetailsReturnsCorrectMember() throws Exception { void getMemberDetailsReturnsNotFound() throws Exception { UUID randomId = UUID.randomUUID(); when(memberService.getMemberById(randomId)).thenReturn(Optional.empty()); - mockMvc.perform(get("/{id}", randomId)) + mockMvc.perform(get("/members/{id}", randomId)) .andExpect(status().isNotFound()); } @@ -232,7 +239,7 @@ void getMemberDetailsReturnsNotFound() throws Exception { void createMemberAllowedForAdmin() throws Exception { when(memberService.createMember(eq(memberCreate), anyString())).thenReturn(Optional.of(member)); - mockMvc.perform(post("/") + mockMvc.perform(post("/members") .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(memberCreate)) .with(jwt() @@ -246,7 +253,7 @@ void createMemberAllowedForAdmin() throws Exception { @Test @WithMockUser(roles = "member") void createMemberForbiddenForMember() throws Exception { - mockMvc.perform(post("/") + mockMvc.perform(post("/members") .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(memberCreate)) ) @@ -256,7 +263,7 @@ void createMemberForbiddenForMember() throws Exception { @Test @WithAnonymousUser void createMemberUnauthorizedForAnonymous() throws Exception { - mockMvc.perform(post("/") + mockMvc.perform(post("/members") .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(memberCreate)) ) @@ -267,7 +274,7 @@ void createMemberUnauthorizedForAnonymous() throws Exception { void createMemberReturnsBadRequestOnKeycloakFailure() throws Exception { when(memberService.createMember(any(), anyString())).thenReturn(Optional.empty()); - mockMvc.perform(post("/") + mockMvc.perform(post("/members") .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(memberCreate)) .with(jwt() @@ -282,7 +289,7 @@ void createMemberReturnsConflictOnEmailConflict() throws Exception { when(memberService.createMember(any(), anyString())) .thenThrow(new IllegalStateException("Email already in use")); - mockMvc.perform(post("/") + mockMvc.perform(post("/members") .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(memberCreate)) .with(jwt() @@ -298,7 +305,7 @@ void createMemberReturnsConflictOnEmailConflict() throws Exception { void updateMemberAllowedForAdmin() throws Exception { when(memberService.updateMember(eq(id), any(MemberPartialUpdate.class), anyString())).thenReturn(Optional.of(updatedMember)); - mockMvc.perform(patch("/{id}", id) + mockMvc.perform(patch("/members/{id}", id) .with(jwt() .jwt(j -> j.subject(id.toString())) .authorities(new SimpleGrantedAuthority("ROLE_admin")) @@ -313,7 +320,7 @@ void updateMemberAllowedForAdmin() throws Exception { @Test void updateMemberForbiddenForMemberOtherId() throws Exception { UUID randomId = UUID.randomUUID(); - mockMvc.perform(patch("/{id}", id) + mockMvc.perform(patch("/members/{id}", id) .with(jwt() .jwt(j -> j.subject(randomId.toString())) .authorities(new SimpleGrantedAuthority("ROLE_member")) @@ -328,7 +335,7 @@ void updateMemberForbiddenForMemberOtherId() throws Exception { void updateMemberAllowedForMemberSameId() throws Exception { when(memberService.updateMember(eq(id), any(MemberPartialUpdate.class), anyString())).thenReturn(Optional.of(updatedMember)); - mockMvc.perform(patch("/{id}", id) + mockMvc.perform(patch("/members/{id}", id) .with(jwt() .jwt(j -> j.subject(id.toString())) .authorities(new SimpleGrantedAuthority("ROLE_member")) @@ -342,7 +349,7 @@ void updateMemberAllowedForMemberSameId() throws Exception { @Test void updateMemberForbiddenForGuestOtherId() throws Exception { UUID randomId = UUID.randomUUID(); - mockMvc.perform(patch("/{id}", id) + mockMvc.perform(patch("/members/{id}", id) .with(jwt() .jwt(j -> j.subject(randomId.toString())) .authorities(new SimpleGrantedAuthority("ROLE_guest")) @@ -355,7 +362,7 @@ void updateMemberForbiddenForGuestOtherId() throws Exception { @Test void updateMemberForbiddenForGuestSameId() throws Exception { - mockMvc.perform(patch("/{id}", id) + mockMvc.perform(patch("/members/{id}", id) .with(jwt() .jwt(j -> j.subject(id.toString())) .authorities(new SimpleGrantedAuthority("ROLE_guest")) @@ -369,7 +376,7 @@ void updateMemberForbiddenForGuestSameId() throws Exception { @Test @WithAnonymousUser void updateMemberUnauthorizedForAnonymous() throws Exception { - mockMvc.perform(patch("/{id}", id) + mockMvc.perform(patch("/members/{id}", id) .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(memberPartialUpdate)) ) @@ -380,7 +387,7 @@ void updateMemberUnauthorizedForAnonymous() throws Exception { void updateMemberReturnsNotFoundForMissingMember() throws Exception { when(memberService.updateMember(eq(id), any(MemberPartialUpdate.class), anyString())).thenReturn(Optional.empty()); - mockMvc.perform(patch("/{id}", id) + mockMvc.perform(patch("/members/{id}", id) .with(jwt() .jwt(j -> j.subject(id.toString())) .authorities(new SimpleGrantedAuthority("ROLE_admin")) @@ -396,7 +403,7 @@ void updateMemberReturnsConflictOnEmailConflict() throws Exception { when(memberService.updateMember(eq(id), any(MemberPartialUpdate.class), anyString())) .thenThrow(new IllegalStateException("Email already in use")); - mockMvc.perform(patch("/{id}", id) + mockMvc.perform(patch("/members/{id}", id) .with(jwt() .jwt(j -> j.subject(id.toString())) .authorities(new SimpleGrantedAuthority("ROLE_admin")) @@ -413,7 +420,7 @@ void updateMemberReturnsConflictOnEmailConflict() throws Exception { void deleteMemberAllowedForAdmin() throws Exception { when(memberService.deleteMember(eq(id), anyString())).thenReturn(true); - mockMvc.perform(delete("/{id}", id) + mockMvc.perform(delete("/members/{id}", id) .with(jwt() .jwt(j -> j.subject(id.toString())) .authorities(new SimpleGrantedAuthority("ROLE_admin")) @@ -424,7 +431,7 @@ void deleteMemberAllowedForAdmin() throws Exception { @Test void deleteMemberForbiddenForMember() throws Exception { - mockMvc.perform(delete("/{id}", id) + mockMvc.perform(delete("/members/{id}", id) .with(jwt() .jwt(j -> j.subject(id.toString())) .authorities(new SimpleGrantedAuthority("ROLE_member")) @@ -435,7 +442,7 @@ void deleteMemberForbiddenForMember() throws Exception { @Test void deleteMemberForbiddenForGuest() throws Exception { - mockMvc.perform(delete("/{id}", id) + mockMvc.perform(delete("/members/{id}", id) .with(jwt() .jwt(j -> j.subject(id.toString())) .authorities(new SimpleGrantedAuthority("ROLE_guest")) @@ -447,7 +454,7 @@ void deleteMemberForbiddenForGuest() throws Exception { @Test @WithAnonymousUser void deleteMemberUnauthorizedForAnonymous() throws Exception { - mockMvc.perform(delete("/{id}", id)) + mockMvc.perform(delete("/members/{id}", id)) .andExpect(status().isUnauthorized()); } @@ -455,7 +462,7 @@ void deleteMemberUnauthorizedForAnonymous() throws Exception { void deleteMemberReturnsNotFoundForMissingMember() throws Exception { when(memberService.deleteMember(eq(id), anyString())).thenReturn(false); - mockMvc.perform(delete("/{id}", id) + mockMvc.perform(delete("/members/{id}", id) .with(jwt() .jwt(j -> j.subject(id.toString())) .authorities(new SimpleGrantedAuthority("ROLE_admin")) @@ -463,4 +470,52 @@ void deleteMemberReturnsNotFoundForMissingMember() throws Exception { ) .andExpect(status().isNotFound()); } + + // Test cases for getDashboard() endpoint + + @Test + void getDashboardReturns200WithTraineeShapeForMember() throws Exception { + TraineeDashboard dashboard = new TraineeDashboard("trainee", 1500, null, 2, List.of(), List.of()); + when(dashboardService.getDashboard(id, false)).thenReturn(dashboard); + + mockMvc.perform(get("/members/dashboard") + .with(jwt() + .jwt(j -> j.subject(id.toString())) + .authorities(new SimpleGrantedAuthority("ROLE_member")))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.role").value("trainee")) + .andExpect(jsonPath("$.balance_cents").value(1500)) + .andExpect(jsonPath("$.upcoming_events").value(2)); + } + + @Test + void getDashboardReturns200WithAdminShapeForAdmin() throws Exception { + AdminDashboard dashboard = new AdminDashboard("admin", 42, 3, 7, 2, 5, 99000, 4); + when(dashboardService.getDashboard(id, true)).thenReturn(dashboard); + + mockMvc.perform(get("/members/dashboard") + .with(jwt() + .jwt(j -> j.subject(id.toString())) + .authorities(new SimpleGrantedAuthority("ROLE_admin")))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.role").value("admin")) + .andExpect(jsonPath("$.total_members").value(42)) + .andExpect(jsonPath("$.total_balance_cents").value(99000)); + } + + @Test + void getDashboardReturns403ForDisallowedRole() throws Exception { + mockMvc.perform(get("/members/dashboard") + .with(jwt() + .jwt(j -> j.subject(id.toString())) + .authorities(new SimpleGrantedAuthority("ROLE_guest")))) + .andExpect(status().isForbidden()); + } + + @Test + @WithAnonymousUser + void getDashboardReturns401WhenAnonymous() throws Exception { + mockMvc.perform(get("/members/dashboard")) + .andExpect(status().isUnauthorized()); + } } From 8d0ffce94dfd7c812a9387a9e570b68618aad31e Mon Sep 17 00:00:00 2001 From: f-s-h Date: Wed, 1 Jul 2026 15:23:45 +0200 Subject: [PATCH 15/16] Fixed MemberController and MemberService to adhere to API documentation --- .../controller/MemberController.java | 25 ++------ .../memberservice/service/MemberService.java | 40 ++++++------ .../controller/MemberControllerTest.java | 31 +++++---- .../service/MemberServiceTest.java | 63 +++++++++---------- 4 files changed, 71 insertions(+), 88 deletions(-) diff --git a/services/spring-member/src/main/java/tum/devoops/memberservice/controller/MemberController.java b/services/spring-member/src/main/java/tum/devoops/memberservice/controller/MemberController.java index 003c88e..c958095 100644 --- a/services/spring-member/src/main/java/tum/devoops/memberservice/controller/MemberController.java +++ b/services/spring-member/src/main/java/tum/devoops/memberservice/controller/MemberController.java @@ -1,6 +1,5 @@ package tum.devoops.memberservice.controller; -import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.core.Authentication; @@ -49,34 +48,22 @@ public ResponseEntity getMemberDetails(UUID id) { @Override @PreAuthorize("hasRole('admin')") public ResponseEntity createMember(@Valid MemberCreate memberCreate) { - try { - Optional optionalMember = memberService.createMember(memberCreate, currentJwt().getTokenValue()); - if (optionalMember.isEmpty()) { - return ResponseEntity.badRequest().build(); - } - Member member = optionalMember.get(); - return ResponseEntity.created(URI.create("/" + member.getId())).body(member); - } catch (IllegalStateException e) { - return ResponseEntity.status(HttpStatus.CONFLICT).build(); - } + Member member = memberService.createMember(memberCreate, currentJwt().getTokenValue()); + return ResponseEntity.created(URI.create("/" + member.getId())).body(member); } @Override @PreAuthorize("hasRole('admin') or hasRole('member') and #id.toString() == authentication.name") public ResponseEntity updateMemberDetails(UUID id, @Valid MemberPartialUpdate memberPartialUpdate) { - try { - Optional updated = memberService.updateMember(id, memberPartialUpdate, currentJwt().getTokenValue()); - return updated.map(ResponseEntity::ok).orElse(ResponseEntity.notFound().build()); - } catch (IllegalStateException e) { - return ResponseEntity.status(HttpStatus.CONFLICT).build(); - } + Member updated = memberService.updateMember(id, memberPartialUpdate, currentJwt().getTokenValue()); + return ResponseEntity.ok(updated); } @Override @PreAuthorize("hasRole('admin')") public ResponseEntity deleteMember(UUID id) { - boolean deleted = memberService.deleteMember(id, currentJwt().getTokenValue()); - return deleted ? ResponseEntity.noContent().build() : ResponseEntity.notFound().build(); + memberService.deleteMember(id, currentJwt().getTokenValue()); + return ResponseEntity.noContent().build(); } @Override diff --git a/services/spring-member/src/main/java/tum/devoops/memberservice/service/MemberService.java b/services/spring-member/src/main/java/tum/devoops/memberservice/service/MemberService.java index b1ea8ec..ebfc4a2 100644 --- a/services/spring-member/src/main/java/tum/devoops/memberservice/service/MemberService.java +++ b/services/spring-member/src/main/java/tum/devoops/memberservice/service/MemberService.java @@ -4,6 +4,9 @@ import org.springframework.stereotype.Service; import tum.devoops.memberservice.converter.MemberConverter; import tum.devoops.memberservice.entity.MemberEntity; +import tum.devoops.memberservice.exception.BadRequestException; +import tum.devoops.memberservice.exception.ConflictException; +import tum.devoops.memberservice.exception.NotFoundException; import tum.devoops.memberservice.model.Member; import tum.devoops.memberservice.model.MemberCreate; import tum.devoops.memberservice.model.MemberPartialUpdate; @@ -39,37 +42,33 @@ public Optional getMemberById(UUID id) { .map(MemberConverter::convertMemberEntityToMember); } - public Optional createMember(MemberCreate memberCreate, String bearerToken) { + public Member createMember(MemberCreate memberCreate, String bearerToken) { if (memberRepository.findByEmail(memberCreate.getEmail()).isPresent()) { - throw new IllegalStateException("Email already in use by another member"); + throw new ConflictException("Email already in use by another member"); } UUID id; try { id = keycloakService.createUser(memberCreate, bearerToken); } catch (Exception e) { - return Optional.empty(); + throw new BadRequestException("Failed to create member: " + e.getMessage()); } MemberEntity memberEntity = MemberConverter.convertMemberCreateToMemberEntity(memberCreate, id); memberEntity = memberRepository.save(memberEntity); - return Optional.of(MemberConverter.convertMemberEntityToMember(memberEntity)); + return MemberConverter.convertMemberEntityToMember(memberEntity); } - public Optional updateMember(UUID memberId, MemberPartialUpdate update, String bearerToken) { - Optional optionalEntity = memberRepository.findById(memberId); - if (optionalEntity.isEmpty()) { - return Optional.empty(); - } - - MemberEntity entity = optionalEntity.get(); + public Member updateMember(UUID memberId, MemberPartialUpdate update, String bearerToken) { + MemberEntity entity = memberRepository.findById(memberId) + .orElseThrow(() -> new NotFoundException("Member not found: " + memberId)); if (update.getEmail() != null) { memberRepository.findByEmail(update.getEmail()) .filter(existing -> !existing.getId().equals(memberId)) .ifPresent(e -> { - throw new IllegalStateException("Email already in use by another member"); + throw new ConflictException("Email already in use by another member"); }); } @@ -78,26 +77,23 @@ public Optional updateMember(UUID memberId, MemberPartialUpdate update, try { keycloakService.updateUser(MemberConverter.convertMemberEntityToMember(entity), bearerToken); } catch (Exception e) { - return Optional.empty(); + throw new BadRequestException("Failed to update member: " + e.getMessage()); } MemberEntity saved = memberRepository.save(entity); - return Optional.of(MemberConverter.convertMemberEntityToMember(saved)); + return MemberConverter.convertMemberEntityToMember(saved); } - public boolean deleteMember(UUID id, String bearerToken) { - Optional optionalMemberEntity = memberRepository.findById(id); - if (optionalMemberEntity.isEmpty()) { - return false; - } + public void deleteMember(UUID id, String bearerToken) { + MemberEntity entity = memberRepository.findById(id) + .orElseThrow(() -> new NotFoundException("Member not found: " + id)); try { keycloakService.deleteUser(id, bearerToken); } catch (Exception e) { - return false; + throw new BadRequestException("Failed to delete member: " + e.getMessage()); } - memberRepository.delete(optionalMemberEntity.get()); - return true; + memberRepository.delete(entity); } } diff --git a/services/spring-member/src/test/java/tum/devoops/memberservice/controller/MemberControllerTest.java b/services/spring-member/src/test/java/tum/devoops/memberservice/controller/MemberControllerTest.java index 3054562..333ccb5 100644 --- a/services/spring-member/src/test/java/tum/devoops/memberservice/controller/MemberControllerTest.java +++ b/services/spring-member/src/test/java/tum/devoops/memberservice/controller/MemberControllerTest.java @@ -13,6 +13,9 @@ import org.springframework.test.context.bean.override.mockito.MockitoBean; import org.springframework.test.web.servlet.MockMvc; import tum.devoops.memberservice.config.SecurityConfig; +import tum.devoops.memberservice.exception.BadRequestException; +import tum.devoops.memberservice.exception.ConflictException; +import tum.devoops.memberservice.exception.NotFoundException; import tum.devoops.memberservice.model.AdminDashboard; import tum.devoops.memberservice.model.Member; import tum.devoops.memberservice.model.MemberCreate; @@ -30,6 +33,7 @@ import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.when; import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.jwt; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; @@ -237,7 +241,7 @@ void getMemberDetailsReturnsNotFound() throws Exception { @Test void createMemberAllowedForAdmin() throws Exception { - when(memberService.createMember(eq(memberCreate), anyString())).thenReturn(Optional.of(member)); + when(memberService.createMember(eq(memberCreate), anyString())).thenReturn(member); mockMvc.perform(post("/members") .contentType(MediaType.APPLICATION_JSON) @@ -272,7 +276,8 @@ void createMemberUnauthorizedForAnonymous() throws Exception { @Test void createMemberReturnsBadRequestOnKeycloakFailure() throws Exception { - when(memberService.createMember(any(), anyString())).thenReturn(Optional.empty()); + when(memberService.createMember(any(), anyString())) + .thenThrow(new BadRequestException("Failed to create member: keycloak down")); mockMvc.perform(post("/members") .contentType(MediaType.APPLICATION_JSON) @@ -281,13 +286,14 @@ void createMemberReturnsBadRequestOnKeycloakFailure() throws Exception { .jwt(j -> j.tokenValue(mockToken)) .authorities(new SimpleGrantedAuthority("ROLE_admin")) )) - .andExpect(status().isBadRequest()); + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.message").exists()); } @Test void createMemberReturnsConflictOnEmailConflict() throws Exception { when(memberService.createMember(any(), anyString())) - .thenThrow(new IllegalStateException("Email already in use")); + .thenThrow(new ConflictException("Email already in use")); mockMvc.perform(post("/members") .contentType(MediaType.APPLICATION_JSON) @@ -296,14 +302,15 @@ void createMemberReturnsConflictOnEmailConflict() throws Exception { .jwt(j -> j.tokenValue(mockToken)) .authorities(new SimpleGrantedAuthority("ROLE_admin")) )) - .andExpect(status().isConflict()); + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.message").exists()); } // Test cases for updateMemberDetails() endpoint @Test void updateMemberAllowedForAdmin() throws Exception { - when(memberService.updateMember(eq(id), any(MemberPartialUpdate.class), anyString())).thenReturn(Optional.of(updatedMember)); + when(memberService.updateMember(eq(id), any(MemberPartialUpdate.class), anyString())).thenReturn(updatedMember); mockMvc.perform(patch("/members/{id}", id) .with(jwt() @@ -333,7 +340,7 @@ void updateMemberForbiddenForMemberOtherId() throws Exception { @Test void updateMemberAllowedForMemberSameId() throws Exception { - when(memberService.updateMember(eq(id), any(MemberPartialUpdate.class), anyString())).thenReturn(Optional.of(updatedMember)); + when(memberService.updateMember(eq(id), any(MemberPartialUpdate.class), anyString())).thenReturn(updatedMember); mockMvc.perform(patch("/members/{id}", id) .with(jwt() @@ -385,7 +392,8 @@ void updateMemberUnauthorizedForAnonymous() throws Exception { @Test void updateMemberReturnsNotFoundForMissingMember() throws Exception { - when(memberService.updateMember(eq(id), any(MemberPartialUpdate.class), anyString())).thenReturn(Optional.empty()); + when(memberService.updateMember(eq(id), any(MemberPartialUpdate.class), anyString())) + .thenThrow(new NotFoundException("Member not found: " + id)); mockMvc.perform(patch("/members/{id}", id) .with(jwt() @@ -401,7 +409,7 @@ void updateMemberReturnsNotFoundForMissingMember() throws Exception { @Test void updateMemberReturnsConflictOnEmailConflict() throws Exception { when(memberService.updateMember(eq(id), any(MemberPartialUpdate.class), anyString())) - .thenThrow(new IllegalStateException("Email already in use")); + .thenThrow(new ConflictException("Email already in use")); mockMvc.perform(patch("/members/{id}", id) .with(jwt() @@ -418,8 +426,6 @@ void updateMemberReturnsConflictOnEmailConflict() throws Exception { @Test void deleteMemberAllowedForAdmin() throws Exception { - when(memberService.deleteMember(eq(id), anyString())).thenReturn(true); - mockMvc.perform(delete("/members/{id}", id) .with(jwt() .jwt(j -> j.subject(id.toString())) @@ -460,7 +466,8 @@ void deleteMemberUnauthorizedForAnonymous() throws Exception { @Test void deleteMemberReturnsNotFoundForMissingMember() throws Exception { - when(memberService.deleteMember(eq(id), anyString())).thenReturn(false); + doThrow(new NotFoundException("Member not found: " + id)) + .when(memberService).deleteMember(eq(id), anyString()); mockMvc.perform(delete("/members/{id}", id) .with(jwt() diff --git a/services/spring-member/src/test/java/tum/devoops/memberservice/service/MemberServiceTest.java b/services/spring-member/src/test/java/tum/devoops/memberservice/service/MemberServiceTest.java index bb2ddf2..cc940f5 100644 --- a/services/spring-member/src/test/java/tum/devoops/memberservice/service/MemberServiceTest.java +++ b/services/spring-member/src/test/java/tum/devoops/memberservice/service/MemberServiceTest.java @@ -7,6 +7,9 @@ import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import tum.devoops.memberservice.entity.MemberEntity; +import tum.devoops.memberservice.exception.BadRequestException; +import tum.devoops.memberservice.exception.ConflictException; +import tum.devoops.memberservice.exception.NotFoundException; import tum.devoops.memberservice.model.Member; import tum.devoops.memberservice.model.MemberCreate; import tum.devoops.memberservice.model.MemberPartialUpdate; @@ -19,7 +22,6 @@ import java.util.UUID; import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; @@ -171,26 +173,25 @@ void getMemberByIdReturnsEmptyWhenNotFound() { // Test cases for createMember() - // Verifies that creation throws when a member with the same email already exists + // Verifies that creation throws a ConflictException when a member with the same email already exists @Test void createMemberThrowsWhenEmailExists() { when(memberRepository.findByEmail("email@email.com")).thenReturn(Optional.of(memberEntity)); - assertThrows(IllegalStateException.class, () -> memberService.createMember(memberCreate, TOKEN)); + assertThrows(ConflictException.class, () -> memberService.createMember(memberCreate, TOKEN)); verifyNoInteractions(keycloakService); verify(memberRepository, never()).save(any()); } - // Verifies that creation is rejected and nothing is persisted when Keycloak fails + // Verifies that creation throws a BadRequestException and nothing is persisted when Keycloak fails @Test - void createMemberReturnsEmptyWhenKeycloakThrows() throws Exception { + void createMemberThrowsBadRequestWhenKeycloakThrows() throws Exception { when(memberRepository.findByEmail("email@email.com")).thenReturn(Optional.empty()); when(keycloakService.createUser(memberCreate, TOKEN)).thenThrow(new RuntimeException("keycloak down")); - Optional result = memberService.createMember(memberCreate, TOKEN); + assertThrows(BadRequestException.class, () -> memberService.createMember(memberCreate, TOKEN)); - assertTrue(result.isEmpty()); verify(memberRepository, never()).save(any()); } @@ -201,23 +202,21 @@ void createMemberReturnsMemberOnSuccess() throws Exception { when(keycloakService.createUser(memberCreate, TOKEN)).thenReturn(id); when(memberRepository.save(any(MemberEntity.class))).thenReturn(memberEntity); - Optional result = memberService.createMember(memberCreate, TOKEN); + Member result = memberService.createMember(memberCreate, TOKEN); - assertTrue(result.isPresent()); - assertEquals(member, result.get()); + assertEquals(member, result); verify(memberRepository).save(any(MemberEntity.class)); } // Test cases for updateMember() - // Verifies that update is rejected when the member does not exist + // Verifies that update throws a NotFoundException when the member does not exist @Test - void updateMemberReturnsEmptyWhenMemberNotFound() { + void updateMemberThrowsNotFoundWhenMemberNotFound() { when(memberRepository.findById(id)).thenReturn(Optional.empty()); - Optional result = memberService.updateMember(id, partialUpdate, TOKEN); + assertThrows(NotFoundException.class, () -> memberService.updateMember(id, partialUpdate, TOKEN)); - assertTrue(result.isEmpty()); verifyNoInteractions(keycloakService); verify(memberRepository, never()).save(any()); } @@ -232,7 +231,7 @@ void updateMemberThrowsWhenEmailTakenByOther() { when(memberRepository.findById(id)).thenReturn(Optional.of(memberEntity)); when(memberRepository.findByEmail("email@email.com")).thenReturn(Optional.of(otherMember)); - assertThrows(IllegalStateException.class, () -> memberService.updateMember(id, partialUpdate, TOKEN)); + assertThrows(ConflictException.class, () -> memberService.updateMember(id, partialUpdate, TOKEN)); verifyNoInteractions(keycloakService); verify(memberRepository, never()).save(any()); @@ -245,10 +244,9 @@ void updateMemberReturnsMemberWhenEmailBelongsToSameMember() { when(memberRepository.findByEmail("email@email.com")).thenReturn(Optional.of(memberEntity)); when(memberRepository.save(any(MemberEntity.class))).thenReturn(memberEntity); - Optional result = memberService.updateMember(id, partialUpdate, TOKEN); + Member result = memberService.updateMember(id, partialUpdate, TOKEN); - assertTrue(result.isPresent()); - assertEquals(member, result.get()); + assertEquals(member, result); verify(memberRepository).save(any(MemberEntity.class)); } @@ -259,49 +257,45 @@ void updateMemberReturnsMemberWhenEmailUnused() { when(memberRepository.findByEmail("email@email.com")).thenReturn(Optional.empty()); when(memberRepository.save(any(MemberEntity.class))).thenReturn(memberEntity); - Optional result = memberService.updateMember(id, partialUpdate, TOKEN); + Member result = memberService.updateMember(id, partialUpdate, TOKEN); - assertTrue(result.isPresent()); - assertEquals(member, result.get()); + assertEquals(member, result); verify(memberRepository).save(any(MemberEntity.class)); } - // Verifies that an update is rejected and nothing is persisted when Keycloak fails + // Verifies that an update throws a BadRequestException and nothing is persisted when Keycloak fails @Test - void updateMemberReturnsEmptyWhenKeycloakThrows() { + void updateMemberThrowsBadRequestWhenKeycloakThrows() { when(memberRepository.findById(id)).thenReturn(Optional.of(memberEntity)); when(memberRepository.findByEmail("email@email.com")).thenReturn(Optional.empty()); doThrow(new RuntimeException("keycloak down")).when(keycloakService).updateUser(any(), any()); - Optional result = memberService.updateMember(id, partialUpdate, TOKEN); + assertThrows(BadRequestException.class, () -> memberService.updateMember(id, partialUpdate, TOKEN)); - assertTrue(result.isEmpty()); verify(memberRepository, never()).save(any()); } // Test cases for deleteMember() - // Verifies that deletion fails when the member does not exist in the repository + // Verifies that deletion throws a NotFoundException when the member does not exist in the repository @Test - void deleteMemberReturnsFalseWhenMemberNotFound() { + void deleteMemberThrowsNotFoundWhenMemberNotFound() { when(memberRepository.findById(id)).thenReturn(Optional.empty()); - boolean result = memberService.deleteMember(id, TOKEN); + assertThrows(NotFoundException.class, () -> memberService.deleteMember(id, TOKEN)); - assertFalse(result); verifyNoInteractions(keycloakService); verify(memberRepository, never()).delete(any()); } - // Verifies that deletion fails and nothing is removed when Keycloak fails + // Verifies that deletion throws a BadRequestException and nothing is removed when Keycloak fails @Test - void deleteMemberReturnsFalseWhenKeycloakThrows() { + void deleteMemberThrowsBadRequestWhenKeycloakThrows() { when(memberRepository.findById(id)).thenReturn(Optional.of(memberEntity)); doThrow(new RuntimeException("keycloak down")).when(keycloakService).deleteUser(id, TOKEN); - boolean result = memberService.deleteMember(id, TOKEN); + assertThrows(BadRequestException.class, () -> memberService.deleteMember(id, TOKEN)); - assertFalse(result); verify(memberRepository, never()).delete(any()); } @@ -310,9 +304,8 @@ void deleteMemberReturnsFalseWhenKeycloakThrows() { void deleteMemberReturnsTrueOnSuccess() { when(memberRepository.findById(id)).thenReturn(Optional.of(memberEntity)); - boolean result = memberService.deleteMember(id, TOKEN); + memberService.deleteMember(id, TOKEN); - assertTrue(result); verify(memberRepository).delete(memberEntity); } } From 6bd9dcbf8800f44c0545922d445e3998518dd315 Mon Sep 17 00:00:00 2001 From: f-s-h Date: Wed, 1 Jul 2026 15:26:20 +0200 Subject: [PATCH 16/16] fix: add missing exception package files MemberService referenced these classes but they were never committed, breaking CI compilation. Co-Authored-By: Claude Sonnet 5 --- .../exception/BadRequestException.java | 7 +++ .../exception/ConflictException.java | 7 +++ .../exception/ForbiddenException.java | 7 +++ .../exception/GlobalExceptionHandler.java | 61 +++++++++++++++++++ .../exception/NotFoundException.java | 7 +++ 5 files changed, 89 insertions(+) create mode 100644 services/spring-member/src/main/java/tum/devoops/memberservice/exception/BadRequestException.java create mode 100644 services/spring-member/src/main/java/tum/devoops/memberservice/exception/ConflictException.java create mode 100644 services/spring-member/src/main/java/tum/devoops/memberservice/exception/ForbiddenException.java create mode 100644 services/spring-member/src/main/java/tum/devoops/memberservice/exception/GlobalExceptionHandler.java create mode 100644 services/spring-member/src/main/java/tum/devoops/memberservice/exception/NotFoundException.java diff --git a/services/spring-member/src/main/java/tum/devoops/memberservice/exception/BadRequestException.java b/services/spring-member/src/main/java/tum/devoops/memberservice/exception/BadRequestException.java new file mode 100644 index 0000000..0b42648 --- /dev/null +++ b/services/spring-member/src/main/java/tum/devoops/memberservice/exception/BadRequestException.java @@ -0,0 +1,7 @@ +package tum.devoops.memberservice.exception; + +public class BadRequestException extends RuntimeException { + public BadRequestException(String message) { + super(message); + } +} diff --git a/services/spring-member/src/main/java/tum/devoops/memberservice/exception/ConflictException.java b/services/spring-member/src/main/java/tum/devoops/memberservice/exception/ConflictException.java new file mode 100644 index 0000000..dc7bebc --- /dev/null +++ b/services/spring-member/src/main/java/tum/devoops/memberservice/exception/ConflictException.java @@ -0,0 +1,7 @@ +package tum.devoops.memberservice.exception; + +public class ConflictException extends RuntimeException { + public ConflictException(String message) { + super(message); + } +} diff --git a/services/spring-member/src/main/java/tum/devoops/memberservice/exception/ForbiddenException.java b/services/spring-member/src/main/java/tum/devoops/memberservice/exception/ForbiddenException.java new file mode 100644 index 0000000..5e46d74 --- /dev/null +++ b/services/spring-member/src/main/java/tum/devoops/memberservice/exception/ForbiddenException.java @@ -0,0 +1,7 @@ +package tum.devoops.memberservice.exception; + +public class ForbiddenException extends RuntimeException { + public ForbiddenException(String message) { + super(message); + } +} diff --git a/services/spring-member/src/main/java/tum/devoops/memberservice/exception/GlobalExceptionHandler.java b/services/spring-member/src/main/java/tum/devoops/memberservice/exception/GlobalExceptionHandler.java new file mode 100644 index 0000000..a3f6502 --- /dev/null +++ b/services/spring-member/src/main/java/tum/devoops/memberservice/exception/GlobalExceptionHandler.java @@ -0,0 +1,61 @@ +package tum.devoops.memberservice.exception; + +import org.springframework.http.HttpStatus; +import org.springframework.validation.FieldError; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +import tum.devoops.memberservice.model.BadRequestResponse; +import tum.devoops.memberservice.model.ErrorResponse; + +@RestControllerAdvice +public class GlobalExceptionHandler { + + @ExceptionHandler(NotFoundException.class) + @ResponseStatus(HttpStatus.NOT_FOUND) + public ErrorResponse handleNotFound(NotFoundException ex) { + return new ErrorResponse().message(ex.getMessage()); + } + + @ExceptionHandler(ForbiddenException.class) + @ResponseStatus(HttpStatus.FORBIDDEN) + public ErrorResponse handleForbidden(ForbiddenException ex) { + return new ErrorResponse().message(ex.getMessage()); + } + + @ExceptionHandler(BadRequestException.class) + @ResponseStatus(HttpStatus.BAD_REQUEST) + public BadRequestResponse handleBadRequest(BadRequestException ex) { + return new BadRequestResponse().message(ex.getMessage()); + } + + @ExceptionHandler(ConflictException.class) + @ResponseStatus(HttpStatus.CONFLICT) + public ErrorResponse handleConflict(ConflictException ex) { + return new ErrorResponse().message(ex.getMessage()); + } + + // Kept for backwards compatibility with call sites that still signal a conflict via the + // built-in IllegalStateException instead of the dedicated ConflictException. + @ExceptionHandler(IllegalStateException.class) + @ResponseStatus(HttpStatus.CONFLICT) + public ErrorResponse handleIllegalState(IllegalStateException ex) { + return new ErrorResponse().message(ex.getMessage()); + } + + // Without this handler, a failed @Valid check on a request body (e.g. a missing + // required field) falls through to Spring's default resolver, which sets the 400 + // status but writes no response body at all. + @ExceptionHandler(MethodArgumentNotValidException.class) + @ResponseStatus(HttpStatus.BAD_REQUEST) + public BadRequestResponse handleValidation(MethodArgumentNotValidException ex) { + BadRequestResponse response = new BadRequestResponse().message("Validation failed"); + for (FieldError fieldError : ex.getBindingResult().getFieldErrors()) { + response.addErrorsItem(new ErrorResponse( + fieldError.getField() + ": " + fieldError.getDefaultMessage())); + } + return response; + } +} diff --git a/services/spring-member/src/main/java/tum/devoops/memberservice/exception/NotFoundException.java b/services/spring-member/src/main/java/tum/devoops/memberservice/exception/NotFoundException.java new file mode 100644 index 0000000..ab8acc4 --- /dev/null +++ b/services/spring-member/src/main/java/tum/devoops/memberservice/exception/NotFoundException.java @@ -0,0 +1,7 @@ +package tum.devoops.memberservice.exception; + +public class NotFoundException extends RuntimeException { + public NotFoundException(String message) { + super(message); + } +}