From 0e1f70aaf1f7ade83ecd0f6f646890b85eed10a7 Mon Sep 17 00:00:00 2001 From: Christoph Heiss Date: Sun, 26 May 2024 07:50:32 +0000 Subject: [PATCH] feat(#14): add button to copy tournament team self-registration link --- backend/pom.xml | 4 - .../backend/endpoint/TournamentEndpoint.java | 42 +++++-- ...mDto.java => TournamentCreateTeamDto.java} | 8 +- .../endpoint/dto/TournamentListDto.java | 58 +++------ ...a => TournamentSignupTeamResponseDto.java} | 5 +- .../GlobalExceptionHandler.java | 13 ++ .../endpoint/mapper/TournamentMapper.java | 1 + .../groupphase/backend/entity/Tournament.java | 69 ++++------- .../BadTournamentSignupTokenException.java | 4 + .../backend/service/TournamentService.java | 5 +- .../service/impl/TournamentServiceImpl.java | 13 +- .../BeerPongTableEndpointTest.java | 36 +++--- .../TournamentEndpointTest.java | 115 ++++++++++++++---- .../unittests/BeerPongTableServiceTest.java | 56 +++++---- .../unittests/TournamentServiceTest.java | 25 ++-- e2e/cypress/e2e/tournaments.cy.js | 14 +++ e2e/cypress/support/commands.js | 8 ++ .../.openapi-generator/FILES | 4 +- .../api/tournamentEndpoint.service.ts | 37 ++++-- frontend/openapi-generated/model/models.ts | 4 +- .../model/tournamentCreateTeamDto.ts | 17 +++ .../model/tournamentListDto.ts | 1 + .../model/tournamentSignupTeamResponseDto.ts | 27 ++++ frontend/src/app/app-routing.module.ts | 10 +- frontend/src/app/app.module.ts | 4 + .../copy-link-dialog.component.html | 16 +++ .../copy-link-dialog.component.scss | 11 ++ .../copy-link-dialog.component.spec.ts | 22 ++++ .../copy-link-dialog.component.ts | 32 +++++ .../components/sidebar/sidebar.component.html | 2 +- .../team-signup/team-signup.component.ts | 38 +++--- .../tournament-card.component.html | 23 ++-- .../tournament-card.component.scss | 9 +- .../tournament-card.component.ts | 15 ++- .../src/app/services/copy-link.service.ts | 25 ++++ .../delete-confirmation.service.spec.ts | 16 --- 36 files changed, 528 insertions(+), 261 deletions(-) rename backend/src/main/java/at/ac/tuwien/sepr/groupphase/backend/endpoint/dto/{CreateTeamDto.java => TournamentCreateTeamDto.java} (59%) rename backend/src/main/java/at/ac/tuwien/sepr/groupphase/backend/endpoint/dto/{SignupTeamResponseDto.java => TournamentSignupTeamResponseDto.java} (67%) create mode 100644 backend/src/main/java/at/ac/tuwien/sepr/groupphase/backend/exception/BadTournamentSignupTokenException.java create mode 100644 frontend/openapi-generated/model/tournamentCreateTeamDto.ts create mode 100644 frontend/openapi-generated/model/tournamentSignupTeamResponseDto.ts create mode 100644 frontend/src/app/components/copy-link-dialog/copy-link-dialog.component.html create mode 100644 frontend/src/app/components/copy-link-dialog/copy-link-dialog.component.scss create mode 100644 frontend/src/app/components/copy-link-dialog/copy-link-dialog.component.spec.ts create mode 100644 frontend/src/app/components/copy-link-dialog/copy-link-dialog.component.ts create mode 100644 frontend/src/app/services/copy-link.service.ts delete mode 100644 frontend/src/app/services/delete-confirmation.service.spec.ts diff --git a/backend/pom.xml b/backend/pom.xml index 8e26a58..7e00597 100644 --- a/backend/pom.xml +++ b/backend/pom.xml @@ -62,10 +62,6 @@ org.springframework.boot spring-boot-starter-actuator - - org.springframework.boot - spring-boot-starter-test - com.h2database h2 diff --git a/backend/src/main/java/at/ac/tuwien/sepr/groupphase/backend/endpoint/TournamentEndpoint.java b/backend/src/main/java/at/ac/tuwien/sepr/groupphase/backend/endpoint/TournamentEndpoint.java index d8e0f7c..1f58d4d 100644 --- a/backend/src/main/java/at/ac/tuwien/sepr/groupphase/backend/endpoint/TournamentEndpoint.java +++ b/backend/src/main/java/at/ac/tuwien/sepr/groupphase/backend/endpoint/TournamentEndpoint.java @@ -2,15 +2,18 @@ package at.ac.tuwien.sepr.groupphase.backend.endpoint; import java.lang.invoke.MethodHandles; import java.util.List; +import java.util.Optional; +import java.util.UUID; -import at.ac.tuwien.sepr.groupphase.backend.endpoint.dto.CreateTeamDto; import at.ac.tuwien.sepr.groupphase.backend.endpoint.dto.CreateTournamentDto; -import at.ac.tuwien.sepr.groupphase.backend.endpoint.dto.SignupTeamResponseDto; import at.ac.tuwien.sepr.groupphase.backend.endpoint.dto.TeamDto; +import at.ac.tuwien.sepr.groupphase.backend.endpoint.dto.TournamentCreateTeamDto; import at.ac.tuwien.sepr.groupphase.backend.endpoint.dto.TournamentDto; import at.ac.tuwien.sepr.groupphase.backend.endpoint.dto.TournamentListDto; import at.ac.tuwien.sepr.groupphase.backend.endpoint.dto.TournamentQualificationMatchDto; +import at.ac.tuwien.sepr.groupphase.backend.endpoint.dto.TournamentSignupTeamResponseDto; import at.ac.tuwien.sepr.groupphase.backend.endpoint.mapper.TeamMapper; +import at.ac.tuwien.sepr.groupphase.backend.exception.BadTournamentSignupTokenException; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.http.HttpStatus; @@ -35,6 +38,7 @@ 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.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.ResponseStatus; import org.springframework.web.bind.annotation.RestController; @@ -133,8 +137,6 @@ public class TournamentEndpoint { } // region Team - - @Secured("ROLE_USER") @ResponseStatus(HttpStatus.OK) @GetMapping(value = "{id}/teams") @@ -168,20 +170,36 @@ public class TournamentEndpoint { return ResponseEntity.noContent().build(); } - @PermitAll @ResponseStatus(HttpStatus.OK) // No location header, thus 200 @PostMapping("{tournamentId}/teams") @Operation(summary = "Create a new team") - public ResponseEntity signupTeamForTournament( + public ResponseEntity signupTeamForTournament( @PathVariable("tournamentId") long tournamentId, - @Valid @RequestBody CreateTeamDto messageDto) { - LOG.info("POST {} body: {}", BASE_ENDPOINT, messageDto); - final var signupResult = tournamentService.signupTeamForTournament(tournamentId, messageDto.name()); - if (signupResult != SignupTeamResult.SUCCESS) { - return ResponseEntity.badRequest().body(new SignupTeamResponseDto(signupResult)); + @RequestParam("token") Optional selfRegistrationToken, + @Valid @RequestBody TournamentCreateTeamDto createTeamDto + ) { + LOG.info("POST {}/{}/teams?token={}", BASE_ENDPOINT, tournamentId, selfRegistrationToken); + LOG.debug("request body: {}", createTeamDto); + + // Explicitly use an `Optional<>` and check it here, so we can return + // the appropriate error + // Otherwise, Spring Boot would just return a 400. + if (selfRegistrationToken.isEmpty()) { + throw new BadTournamentSignupTokenException(); } - return ResponseEntity.ok(new SignupTeamResponseDto(SignupTeamResult.SUCCESS)); + + final var signupResult = tournamentService.signupTeamForTournament( + tournamentId, selfRegistrationToken.get(), createTeamDto.name() + ); + + if (signupResult != SignupTeamResult.SUCCESS) { + return ResponseEntity.badRequest() + .body(new TournamentSignupTeamResponseDto(signupResult)); + } + + return ResponseEntity + .ok(new TournamentSignupTeamResponseDto(SignupTeamResult.SUCCESS)); } // endregion team } \ No newline at end of file diff --git a/backend/src/main/java/at/ac/tuwien/sepr/groupphase/backend/endpoint/dto/CreateTeamDto.java b/backend/src/main/java/at/ac/tuwien/sepr/groupphase/backend/endpoint/dto/TournamentCreateTeamDto.java similarity index 59% rename from backend/src/main/java/at/ac/tuwien/sepr/groupphase/backend/endpoint/dto/CreateTeamDto.java rename to backend/src/main/java/at/ac/tuwien/sepr/groupphase/backend/endpoint/dto/TournamentCreateTeamDto.java index 7421dc4..b43cc8c 100644 --- a/backend/src/main/java/at/ac/tuwien/sepr/groupphase/backend/endpoint/dto/CreateTeamDto.java +++ b/backend/src/main/java/at/ac/tuwien/sepr/groupphase/backend/endpoint/dto/TournamentCreateTeamDto.java @@ -3,5 +3,9 @@ package at.ac.tuwien.sepr.groupphase.backend.endpoint.dto; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.Size; -public record CreateTeamDto(@NotBlank @Size(min = 3, max = 20) String name) { -} \ No newline at end of file +public record TournamentCreateTeamDto( + @NotBlank + @Size(min = 3, max = 20) + String name +) { +} diff --git a/backend/src/main/java/at/ac/tuwien/sepr/groupphase/backend/endpoint/dto/TournamentListDto.java b/backend/src/main/java/at/ac/tuwien/sepr/groupphase/backend/endpoint/dto/TournamentListDto.java index 1747c75..358eb6d 100644 --- a/backend/src/main/java/at/ac/tuwien/sepr/groupphase/backend/endpoint/dto/TournamentListDto.java +++ b/backend/src/main/java/at/ac/tuwien/sepr/groupphase/backend/endpoint/dto/TournamentListDto.java @@ -3,11 +3,16 @@ package at.ac.tuwien.sepr.groupphase.backend.endpoint.dto; import com.fasterxml.jackson.annotation.JsonFormat; import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Size; +import lombok.Getter; +import lombok.Setter; + import java.time.LocalDateTime; import java.util.Objects; +import java.util.UUID; +@Setter +@Getter public class TournamentListDto { - @NotNull(message = "ID must not be null") private Long id; @@ -25,51 +30,12 @@ public class TournamentListDto { @Size(max = 1024) private String description; - // Getters - public Long getId() { - return id; - } - - public String getName() { - return name; - } - - public LocalDateTime getRegistrationEnd() { - return registrationEnd; - } - - public Long getMaxParticipants() { - return maxParticipants; - } - - public String getDescription() { - return description; - } - - // Setters - public void setId(Long id) { - this.id = id; - } - - public void setName(String name) { - this.name = name; - } - - public void setRegistrationEnd(LocalDateTime registrationEnd) { - this.registrationEnd = registrationEnd; - } - - public void setMaxParticipants(Long maxParticipants) { - this.maxParticipants = maxParticipants; - } - - public void setDescription(String description) { - this.description = description; - } + @NotNull + private UUID selfRegistrationToken; @Override public int hashCode() { - return Objects.hash(id, name, registrationEnd, maxParticipants, description); + return Objects.hash(id, name, registrationEnd, maxParticipants, description, selfRegistrationToken); } @Override @@ -85,7 +51,8 @@ public class TournamentListDto { && Objects.equals(name, that.name) && Objects.equals(registrationEnd, that.registrationEnd) && Objects.equals(maxParticipants, that.maxParticipants) - && Objects.equals(description, that.description); + && Objects.equals(description, that.description) + && Objects.equals(selfRegistrationToken, that.selfRegistrationToken); } @Override @@ -102,6 +69,9 @@ public class TournamentListDto { + ", description='" + description + '\'' + + ", selfRegistrationToken='" + + selfRegistrationToken + + '\'' + '}'; } } \ No newline at end of file diff --git a/backend/src/main/java/at/ac/tuwien/sepr/groupphase/backend/endpoint/dto/SignupTeamResponseDto.java b/backend/src/main/java/at/ac/tuwien/sepr/groupphase/backend/endpoint/dto/TournamentSignupTeamResponseDto.java similarity index 67% rename from backend/src/main/java/at/ac/tuwien/sepr/groupphase/backend/endpoint/dto/SignupTeamResponseDto.java rename to backend/src/main/java/at/ac/tuwien/sepr/groupphase/backend/endpoint/dto/TournamentSignupTeamResponseDto.java index aae223b..5a59041 100644 --- a/backend/src/main/java/at/ac/tuwien/sepr/groupphase/backend/endpoint/dto/SignupTeamResponseDto.java +++ b/backend/src/main/java/at/ac/tuwien/sepr/groupphase/backend/endpoint/dto/TournamentSignupTeamResponseDto.java @@ -1,10 +1,11 @@ package at.ac.tuwien.sepr.groupphase.backend.endpoint.dto; - import at.ac.tuwien.sepr.groupphase.backend.entity.Tournament; /** * Response for team signup. Compared to enum, has openapi-generator support. */ -public record SignupTeamResponseDto(Tournament.SignupTeamResult signupTeamResult) { +public record TournamentSignupTeamResponseDto( + Tournament.SignupTeamResult signupTeamResult +) { } diff --git a/backend/src/main/java/at/ac/tuwien/sepr/groupphase/backend/endpoint/exceptionhandler/GlobalExceptionHandler.java b/backend/src/main/java/at/ac/tuwien/sepr/groupphase/backend/endpoint/exceptionhandler/GlobalExceptionHandler.java index 99a62da..a3d3842 100644 --- a/backend/src/main/java/at/ac/tuwien/sepr/groupphase/backend/endpoint/exceptionhandler/GlobalExceptionHandler.java +++ b/backend/src/main/java/at/ac/tuwien/sepr/groupphase/backend/endpoint/exceptionhandler/GlobalExceptionHandler.java @@ -1,5 +1,6 @@ package at.ac.tuwien.sepr.groupphase.backend.endpoint.exceptionhandler; +import at.ac.tuwien.sepr.groupphase.backend.exception.BadTournamentSignupTokenException; import at.ac.tuwien.sepr.groupphase.backend.exception.NotFoundException; import at.ac.tuwien.sepr.groupphase.backend.exception.PreconditionFailedException; @@ -103,4 +104,16 @@ public class GlobalExceptionHandler extends ResponseEntityExceptionHandler { HttpStatus.CONFLICT, request); } + + @ExceptionHandler({BadTournamentSignupTokenException.class}) + protected ResponseEntity handleBadTournamentSignupTokenException(RuntimeException ex, WebRequest request) { + LOGGER.debug(ex.getMessage()); + + return handleExceptionInternal( + ex, + "self registration token missing or incorrect", + new HttpHeaders(), + HttpStatus.UNAUTHORIZED, + request); + } } diff --git a/backend/src/main/java/at/ac/tuwien/sepr/groupphase/backend/endpoint/mapper/TournamentMapper.java b/backend/src/main/java/at/ac/tuwien/sepr/groupphase/backend/endpoint/mapper/TournamentMapper.java index 0a479c7..9f4798a 100644 --- a/backend/src/main/java/at/ac/tuwien/sepr/groupphase/backend/endpoint/mapper/TournamentMapper.java +++ b/backend/src/main/java/at/ac/tuwien/sepr/groupphase/backend/endpoint/mapper/TournamentMapper.java @@ -8,6 +8,7 @@ import org.mapstruct.Mapper; import org.mapstruct.Mapping; import java.util.List; + import at.ac.tuwien.sepr.groupphase.backend.endpoint.dto.TournamentQualificationMatchDto; import at.ac.tuwien.sepr.groupphase.backend.endpoint.dto.TournamentQualificationMatchParticipantDto; import at.ac.tuwien.sepr.groupphase.backend.entity.QualificationMatch; diff --git a/backend/src/main/java/at/ac/tuwien/sepr/groupphase/backend/entity/Tournament.java b/backend/src/main/java/at/ac/tuwien/sepr/groupphase/backend/entity/Tournament.java index 45f103a..3c40e87 100644 --- a/backend/src/main/java/at/ac/tuwien/sepr/groupphase/backend/entity/Tournament.java +++ b/backend/src/main/java/at/ac/tuwien/sepr/groupphase/backend/entity/Tournament.java @@ -1,6 +1,7 @@ package at.ac.tuwien.sepr.groupphase.backend.entity; import jakarta.persistence.CascadeType; +import jakarta.persistence.Column; import jakarta.persistence.Entity; import jakarta.persistence.GeneratedValue; import jakarta.persistence.Id; @@ -14,6 +15,7 @@ import java.time.LocalDateTime; import java.util.Collections; import java.util.LinkedList; import java.util.List; +import java.util.UUID; import org.hibernate.annotations.OnDelete; import org.hibernate.annotations.OnDeleteAction; @@ -21,24 +23,38 @@ import org.hibernate.annotations.OnDeleteAction; /** * The tournament entity. * Owns the relation with teams for consistency reasons. - * + * */ @Entity @Getter(value = AccessLevel.PUBLIC) @Setter(value = AccessLevel.PRIVATE) public class Tournament { + @Getter @Id @GeneratedValue private Long id; + @Setter + @Getter private String name; + @Setter + @Getter private LocalDateTime registrationEnd; + @Getter private Long maxParticipants; + @Setter + @Getter private String description; + @Getter + @Column(nullable = false, updatable = false) + private UUID selfRegistrationToken; + @Setter + @Getter @ManyToOne private ApplicationUser organizer; @OneToMany(mappedBy = Team_.TOURNAMENT, cascade = CascadeType.ALL, orphanRemoval = true) @OnDelete(action = OnDeleteAction.CASCADE) private List teams = new LinkedList<>(); + @Getter @OneToMany(mappedBy = BeerPongTable_.TOURNAMENT, cascade = CascadeType.ALL, orphanRemoval = true) @OnDelete(action = OnDeleteAction.CASCADE) private List tables = new LinkedList<>(); @@ -59,31 +75,18 @@ public class Tournament { this.maxParticipants = maxParticipants; this.description = description; this.organizer = organizer; + this.selfRegistrationToken = UUID.randomUUID(); } /** * This object should be able to enforce its validity. It is not able to do that - * if it isn't even initialized + * if it isn't even initialized. + * Unfortunately required due to Spring Boot doing some things automagically in + * the background and needs this ... */ @Deprecated public Tournament() { - - } - - public Long getId() { - return id; - } - - public String getName() { - return name; - } - - public void setName(String name) { - this.name = name; - } - - public Long getMaxParticipants() { - return maxParticipants; + this.selfRegistrationToken = UUID.randomUUID(); } // requires careful validation if updated retrospectively, please just use @@ -93,30 +96,6 @@ public class Tournament { this.maxParticipants = maxParticipants; } - public String getDescription() { - return description; - } - - public void setDescription(String description) { - this.description = description; - } - - public ApplicationUser getOrganizer() { - return organizer; - } - - public void setOrganizer(ApplicationUser organizer) { - this.organizer = organizer; - } - - public LocalDateTime getRegistrationEnd() { - return registrationEnd; - } - - public void setRegistrationEnd(LocalDateTime registrationEnd) { - this.registrationEnd = registrationEnd; - } - public List getTeams() { return Collections.unmodifiableList(teams); } @@ -156,8 +135,4 @@ public class Tournament { teams.add(team); return SignupTeamResult.SUCCESS; } - - public List getTables() { - return tables; - } } diff --git a/backend/src/main/java/at/ac/tuwien/sepr/groupphase/backend/exception/BadTournamentSignupTokenException.java b/backend/src/main/java/at/ac/tuwien/sepr/groupphase/backend/exception/BadTournamentSignupTokenException.java new file mode 100644 index 0000000..bb550a0 --- /dev/null +++ b/backend/src/main/java/at/ac/tuwien/sepr/groupphase/backend/exception/BadTournamentSignupTokenException.java @@ -0,0 +1,4 @@ +package at.ac.tuwien.sepr.groupphase.backend.exception; + +public class BadTournamentSignupTokenException extends RuntimeException { +} diff --git a/backend/src/main/java/at/ac/tuwien/sepr/groupphase/backend/service/TournamentService.java b/backend/src/main/java/at/ac/tuwien/sepr/groupphase/backend/service/TournamentService.java index 2b1acd1..8a1febf 100644 --- a/backend/src/main/java/at/ac/tuwien/sepr/groupphase/backend/service/TournamentService.java +++ b/backend/src/main/java/at/ac/tuwien/sepr/groupphase/backend/service/TournamentService.java @@ -2,6 +2,7 @@ package at.ac.tuwien.sepr.groupphase.backend.service; import java.util.Collection; import java.util.List; +import java.util.UUID; import at.ac.tuwien.sepr.groupphase.backend.entity.Team; import org.springframework.security.access.AccessDeniedException; @@ -66,7 +67,9 @@ public interface TournamentService { * * @return created tournament entity */ - SignupTeamResult signupTeamForTournament(long tournamentId, String name); + SignupTeamResult signupTeamForTournament( + long tournamentId, UUID selfRegistrationToken, String name + ); /** * Find a single tournament entity by id. diff --git a/backend/src/main/java/at/ac/tuwien/sepr/groupphase/backend/service/impl/TournamentServiceImpl.java b/backend/src/main/java/at/ac/tuwien/sepr/groupphase/backend/service/impl/TournamentServiceImpl.java index f5e4ca7..1bd1cd9 100644 --- a/backend/src/main/java/at/ac/tuwien/sepr/groupphase/backend/service/impl/TournamentServiceImpl.java +++ b/backend/src/main/java/at/ac/tuwien/sepr/groupphase/backend/service/impl/TournamentServiceImpl.java @@ -5,10 +5,13 @@ import java.util.ArrayList; import java.util.Collection; import java.util.Collections; import java.util.List; +import java.util.Objects; +import java.util.UUID; import java.util.stream.Collectors; import at.ac.tuwien.sepr.groupphase.backend.entity.Team; import at.ac.tuwien.sepr.groupphase.backend.exception.TournamentAlreadyStartedException; +import at.ac.tuwien.sepr.groupphase.backend.exception.BadTournamentSignupTokenException; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.security.access.AccessDeniedException; @@ -143,12 +146,18 @@ public class TournamentServiceImpl implements TournamentService { @Override @Transactional - public SignupTeamResult signupTeamForTournament(long tournamentId, String name) { + public SignupTeamResult signupTeamForTournament( + long tournamentId, UUID selfRegistrationToken, String name + ) { LOGGER.debug("Create new team {} for tournament {}", name, tournamentId); final var tournament = tournamentRepository.findById(tournamentId) .orElseThrow(() -> new IllegalArgumentException("Tournament not found")); + if (!Objects.equals(tournament.getSelfRegistrationToken(), selfRegistrationToken)) { + throw new BadTournamentSignupTokenException(); + } + final var newTeam = new Team(name, tournament); var result = tournament.signupTeam(newTeam); if (result != SignupTeamResult.SUCCESS) { @@ -289,7 +298,7 @@ public class TournamentServiceImpl implements TournamentService { if (!tournament.getId().equals(tournamentId)) { throw new NotFoundException("Team not found in tournament"); } - var test = tournament.getQualificationMatches(); + if ((long) tournament.getQualificationMatches().size() > 0) { throw new TournamentAlreadyStartedException(); } diff --git a/backend/src/test/java/at/ac/tuwien/sepr/groupphase/backend/integrationtest/BeerPongTableEndpointTest.java b/backend/src/test/java/at/ac/tuwien/sepr/groupphase/backend/integrationtest/BeerPongTableEndpointTest.java index 0051858..c1f9a22 100644 --- a/backend/src/test/java/at/ac/tuwien/sepr/groupphase/backend/integrationtest/BeerPongTableEndpointTest.java +++ b/backend/src/test/java/at/ac/tuwien/sepr/groupphase/backend/integrationtest/BeerPongTableEndpointTest.java @@ -106,10 +106,10 @@ public class BeerPongTableEndpointTest extends TestUserData implements TestData var user = new ApplicationUser("TestUser", "Password", false); userRepository.save(user); - var tournament = new Tournament(); - tournament.setName("TEST_TOURNAMENT"); - tournament.setRegistrationEnd(LocalDateTime.now().plusDays(1)); - tournament.setMaxParticipants(64l); + var tournament = new Tournament( + "TEST_TOURNAMENT", LocalDateTime.now().plusDays(1), 64L, + "testdescription", user + ); tournament = tournamentService.create(tournament, user.getUsername()); var tableDto = new CreateBeerPongTableDto(); @@ -142,10 +142,10 @@ public class BeerPongTableEndpointTest extends TestUserData implements TestData var user = new ApplicationUser("TestUser", "Password", false); userRepository.save(user); - var tournament = new Tournament(); - tournament.setName("TEST_TOURNAMENT"); - tournament.setRegistrationEnd(LocalDateTime.now().plusDays(1)); - tournament.setMaxParticipants(64l); + var tournament = new Tournament( + "TEST_TOURNAMENT", LocalDateTime.now().plusDays(1), 64L, + "testdescription", user + ); tournament = tournamentService.create(tournament, user.getUsername()); var tableDto = new CreateBeerPongTableDto(); @@ -185,11 +185,11 @@ public class BeerPongTableEndpointTest extends TestUserData implements TestData @Test public void updateBeerPongTableForExistingTournamentThatWasCreatedByTheCurrentUser() throws Exception { // setup - var tournament = new Tournament(); - tournament.setName("TEST_TOURNAMENT"); - tournament.setRegistrationEnd(LocalDateTime.now().plusDays(1)); - tournament.setMaxParticipants(64l); - tournament = tournamentService.create(tournament, TestDataGenerator.TEST_USER); + var tournament = new Tournament( + "TEST_TOURNAMENT", LocalDateTime.now().plusDays(1), 64L, + "testdescription", userRepository.findByUsername(TEST_USER) + ); + tournament = tournamentService.create(tournament, TEST_USER); var beerPongTable = new BeerPongTable("TEST"); beerPongTable.setTournament(tournament); @@ -221,11 +221,11 @@ public class BeerPongTableEndpointTest extends TestUserData implements TestData @Test public void updateBeerPongTableForExistingTournamentThatWasntCreatedByTheCurrentUser() throws Exception { // setup - var tournament = new Tournament(); - tournament.setName("TEST_TOURNAMENT"); - tournament.setRegistrationEnd(LocalDateTime.now().plusDays(1)); - tournament.setMaxParticipants(64l); - tournament = tournamentService.create(tournament, TestDataGenerator.TEST_USER); + var tournament = new Tournament( + "TEST_TOURNAMENT", LocalDateTime.now().plusDays(1), 64L, + "testdescription", userRepository.findByUsername(TEST_USER) + ); + tournament = tournamentService.create(tournament, TEST_USER); var beerPongTable = new BeerPongTable("TEST"); beerPongTable.setTournament(tournament); diff --git a/backend/src/test/java/at/ac/tuwien/sepr/groupphase/backend/integrationtest/TournamentEndpointTest.java b/backend/src/test/java/at/ac/tuwien/sepr/groupphase/backend/integrationtest/TournamentEndpointTest.java index dfe0f18..1b67716 100644 --- a/backend/src/test/java/at/ac/tuwien/sepr/groupphase/backend/integrationtest/TournamentEndpointTest.java +++ b/backend/src/test/java/at/ac/tuwien/sepr/groupphase/backend/integrationtest/TournamentEndpointTest.java @@ -5,6 +5,7 @@ import at.ac.tuwien.sepr.groupphase.backend.basetest.TestUserData; import at.ac.tuwien.sepr.groupphase.backend.config.properties.SecurityProperties; import at.ac.tuwien.sepr.groupphase.backend.datagenerator.TestDataGenerator; import at.ac.tuwien.sepr.groupphase.backend.endpoint.dto.CreateTournamentDto; +import at.ac.tuwien.sepr.groupphase.backend.endpoint.dto.TournamentCreateTeamDto; import at.ac.tuwien.sepr.groupphase.backend.endpoint.dto.TournamentDto; import at.ac.tuwien.sepr.groupphase.backend.endpoint.dto.TournamentListDto; import at.ac.tuwien.sepr.groupphase.backend.entity.ApplicationUser; @@ -18,6 +19,7 @@ import at.ac.tuwien.sepr.groupphase.backend.repository.UserRepository; import at.ac.tuwien.sepr.groupphase.backend.security.JwtTokenizer; import com.fasterxml.jackson.databind.ObjectMapper; +import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.api.Assertions.*; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; @@ -126,12 +128,10 @@ public class TournamentEndpointTest extends TestUserData implements TestData { @Test public void successfullyGetTournaments() throws Exception { // setup - var tournament = new Tournament(); - tournament.setName("TOURNAMENT_WITHOUT_TEAMS"); - tournament.setMaxParticipants(64l); - tournament.setOrganizer(userRepository.findByUsername(TestDataGenerator.TEST_USER)); - tournament.setRegistrationEnd(LocalDateTime.now().plusDays(1)); - + var tournament = new Tournament( + "TEST_TOURNAMENT", LocalDateTime.now().plusDays(1), 64L, + "testdescription", userRepository.findByUsername(TEST_USER) + ); tournamentRepository.saveAndFlush(tournament); var mvcResult = this.mockMvc.perform(get(TOURNAMENT_BASE_URI) @@ -186,12 +186,10 @@ public class TournamentEndpointTest extends TestUserData implements TestData { @Test public void generateQualificationMatchesForTournamentWithEnoughTeams() throws Exception { // setup - var tournament = new Tournament(); - tournament.setName("TOURNAMENT_WITH_TEAMS"); - tournament.setMaxParticipants(64l); - tournament.setOrganizer(userRepository.findByUsername(TestDataGenerator.TEST_USER)); - tournament.setRegistrationEnd(LocalDateTime.now().plusDays(1)); - + var tournament = new Tournament( + "TOURNAMENT_WITHOUT_TEAMS", LocalDateTime.now().plusDays(1), 64L, + "testdescription", userRepository.findByUsername(TEST_USER) + ); var numberOfTeams = 16; for (int i = 0; i < numberOfTeams; i++) { @@ -234,12 +232,10 @@ public class TournamentEndpointTest extends TestUserData implements TestData { @Test public void generateQualificationMatchesForTournamentWithoutEnoughTeams() throws Exception { // setup - var tournament = new Tournament(); - tournament.setName("TOURNAMENT_WITHOUT_TEAMS"); - tournament.setMaxParticipants(64l); - tournament.setOrganizer(userRepository.findByUsername(TestDataGenerator.TEST_USER)); - tournament.setRegistrationEnd(LocalDateTime.now().plusDays(1)); - + var tournament = new Tournament( + "TOURNAMENT_WITHOUT_TEAMS", LocalDateTime.now().plusDays(1), 64L, + "testdescription", userRepository.findByUsername(TEST_USER) + ); tournamentRepository.saveAndFlush(tournament); var mvcResult = this.mockMvc @@ -260,12 +256,10 @@ public class TournamentEndpointTest extends TestUserData implements TestData { @Test public void generateQualificationMatchesForTournamentFromAnotherOrganizerWhenItIsntAllowed() throws Exception { // setup - var tournament = new Tournament(); - tournament.setName("TOURNAMENT_WITHOUT_TEAMS"); - tournament.setMaxParticipants(64l); - tournament.setOrganizer(userRepository.findByUsername(TestDataGenerator.TEST_USER)); - tournament.setRegistrationEnd(LocalDateTime.now().plusDays(1)); - + var tournament = new Tournament( + "TOURNAMENT_WITHOUT_TEAMS", LocalDateTime.now().plusDays(1), 64L, + "testdescription", userRepository.findByUsername(TEST_USER) + ); tournamentRepository.saveAndFlush(tournament); var mvcResult = this.mockMvc @@ -514,4 +508,77 @@ public class TournamentEndpointTest extends TestUserData implements TestData { .andReturn(); }); } + + @Test + public void cannotSelfSignupTeamForTournamentWithMissingToken() throws Exception { + final var tournament = new Tournament( + "FOOBAR", LocalDateTime.now().plusDays(5), 64L, "test tournament", + userRepository.findByUsername(TestDataGenerator.TEST_USER)); + + tournamentRepository.saveAndFlush(tournament); + + var mvcResult = this.mockMvc.perform(get(TOURNAMENT_BASE_URI) + .header(securityProperties.getAuthHeader(), jwtTokenizer.getAuthToken(TEST_USER, TEST_USER_ROLES)) + .contentType(MediaType.APPLICATION_JSON)) + .andReturn(); + MockHttpServletResponse response = mvcResult.getResponse(); + + final var tournaments = objectMapper.readValue(response.getContentAsString(), TournamentListDto[].class); + + assertThat(tournaments) + .isNotNull() + .hasSize(1) + .extracting("name") + .containsExactly(tournament.getName()); + + final var listDto = tournaments[0]; + final var createDto = new TournamentCreateTeamDto("baz"); + + mvcResult = this.mockMvc.perform(post(TOURNAMENT_BASE_URI + "/{tournamentId}/teams", listDto.getId()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(createDto))) + .andDo(print()) + .andReturn(); + + response = mvcResult.getResponse(); + assertEquals(response.getStatus(), HttpStatus.UNAUTHORIZED.value()); + assertEquals(response.getContentAsString(), "self registration token missing or incorrect"); + } + + @Test + public void cannotSelfSignupTeamForTournamentWithInvalidToken() throws Exception { + final var tournament = new Tournament( + "FOOBAR", LocalDateTime.now().plusDays(5), 64L, "test tournament", + userRepository.findByUsername(TestDataGenerator.TEST_USER)); + + tournamentRepository.saveAndFlush(tournament); + + var mvcResult = this.mockMvc.perform(get(TOURNAMENT_BASE_URI) + .header(securityProperties.getAuthHeader(), jwtTokenizer.getAuthToken(TEST_USER, TEST_USER_ROLES)) + .contentType(MediaType.APPLICATION_JSON)) + .andReturn(); + MockHttpServletResponse response = mvcResult.getResponse(); + + final var tournaments = objectMapper.readValue(response.getContentAsString(), TournamentListDto[].class); + + assertThat(tournaments) + .isNotNull() + .hasSize(1) + .extracting("name") + .containsExactly(tournament.getName()); + + final var listDto = tournaments[0]; + final var createDto = new TournamentCreateTeamDto("baz"); + + mvcResult = this.mockMvc.perform(post(TOURNAMENT_BASE_URI + "/{tournamentId}/teams", listDto.getId()) + .param("token", "11111111-2222-3333-4444-555555555555") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(createDto))) + .andDo(print()) + .andReturn(); + + response = mvcResult.getResponse(); + assertEquals(response.getStatus(), HttpStatus.UNAUTHORIZED.value()); + assertEquals(response.getContentAsString(), "self registration token missing or incorrect"); + } } diff --git a/backend/src/test/java/at/ac/tuwien/sepr/groupphase/backend/unittests/BeerPongTableServiceTest.java b/backend/src/test/java/at/ac/tuwien/sepr/groupphase/backend/unittests/BeerPongTableServiceTest.java index 7b935c7..ad13d85 100644 --- a/backend/src/test/java/at/ac/tuwien/sepr/groupphase/backend/unittests/BeerPongTableServiceTest.java +++ b/backend/src/test/java/at/ac/tuwien/sepr/groupphase/backend/unittests/BeerPongTableServiceTest.java @@ -8,6 +8,8 @@ import static org.junit.jupiter.api.Assertions.assertThrows; import java.time.LocalDateTime; +import at.ac.tuwien.sepr.groupphase.backend.basetest.TestData; +import at.ac.tuwien.sepr.groupphase.backend.repository.UserRepository; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; @@ -29,10 +31,12 @@ import at.ac.tuwien.sepr.groupphase.backend.service.TournamentService; @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) @ActiveProfiles("test") @DirtiesContext(classMode = DirtiesContext.ClassMode.BEFORE_EACH_TEST_METHOD) -public class BeerPongTableServiceTest extends TestUserData { +public class BeerPongTableServiceTest extends TestUserData implements TestData { @Autowired private TournamentService tournamentService; @Autowired + private UserRepository userRepository; + @Autowired private BeerPongTableService beerPongTableService; @Autowired private BeerPongTableRepository beerPongTableRepository; @@ -40,11 +44,11 @@ public class BeerPongTableServiceTest extends TestUserData { @Test public void getSingleBeerPongTableById() { // setup - var tournament = new Tournament(); - tournament.setName("TEST_TOURNAMENT"); - tournament.setRegistrationEnd(LocalDateTime.now().plusDays(1)); - tournament.setMaxParticipants(64l); - tournament = tournamentService.create(tournament, TestDataGenerator.TEST_USER); + var tournament = new Tournament( + "TEST_TOURNAMENT", LocalDateTime.now().plusDays(1), 64L, + "testdescription", userRepository.findByUsername(TEST_USER) + ); + tournament = tournamentService.create(tournament, TEST_USER); var beerPongTable = new BeerPongTable("TEST"); beerPongTable.setTournament(tournament); @@ -68,11 +72,11 @@ public class BeerPongTableServiceTest extends TestUserData { @Test public void createNewBeerPongTableForExistingTournamentThatWasCreatedByTheCurrentUser() { // setup - var tournament = new Tournament(); - tournament.setName("TEST_TOURNAMENT"); - tournament.setRegistrationEnd(LocalDateTime.now().plusDays(1)); - tournament.setMaxParticipants(64l); - tournament = tournamentService.create(tournament, TestDataGenerator.TEST_USER); + var tournament = new Tournament( + "TEST_TOURNAMENT", LocalDateTime.now().plusDays(1), 64L, + "testdescription", userRepository.findByUsername(TEST_USER) + ); + tournament = tournamentService.create(tournament, TEST_USER); var tableDto = new CreateBeerPongTableDto(); tableDto.setName("TEST_NAME"); @@ -89,11 +93,11 @@ public class BeerPongTableServiceTest extends TestUserData { @Test public void createNewBeerPongTableForExistingTournamentThatWasntCreatedByTheCurrentUser() { // setup - var tournament = new Tournament(); - tournament.setName("TEST_TOURNAMENT"); - tournament.setRegistrationEnd(LocalDateTime.now().plusDays(1)); - tournament.setMaxParticipants(64l); - tournament = tournamentService.create(tournament, TestDataGenerator.TEST_USER); + var tournament = new Tournament( + "TEST_TOURNAMENT", LocalDateTime.now().plusDays(1), 64L, + "testdescription", userRepository.findByUsername(TEST_USER) + ); + tournament = tournamentService.create(tournament, TEST_USER); var tableDto = new CreateBeerPongTableDto(); tableDto.setName("TEST_NAME"); @@ -115,11 +119,11 @@ public class BeerPongTableServiceTest extends TestUserData { @Test public void updateBeerPongTableForExistingTournamentThatWasCreatedByTheCurrentUser() { // setup - var tournament = new Tournament(); - tournament.setName("TEST_TOURNAMENT"); - tournament.setRegistrationEnd(LocalDateTime.now().plusDays(1)); - tournament.setMaxParticipants(64l); - tournament = tournamentService.create(tournament, TestDataGenerator.TEST_USER); + var tournament = new Tournament( + "TEST_TOURNAMENT", LocalDateTime.now().plusDays(1), 64L, + "testdescription", userRepository.findByUsername(TEST_USER) + ); + tournament = tournamentService.create(tournament, TEST_USER); var beerPongTable = new BeerPongTable("TEST"); beerPongTable.setTournament(tournament); @@ -139,11 +143,11 @@ public class BeerPongTableServiceTest extends TestUserData { @Test public void updateBeerPongTableForExistingTournamentThatWasntCreatedByTheCurrentUser() { // setup - var tournament = new Tournament(); - tournament.setName("TEST_TOURNAMENT"); - tournament.setRegistrationEnd(LocalDateTime.now().plusDays(1)); - tournament.setMaxParticipants(64l); - tournament = tournamentService.create(tournament, TestDataGenerator.TEST_USER); + var tournament = new Tournament( + "TEST_TOURNAMENT", LocalDateTime.now().plusDays(1), 64L, + "testdescription", userRepository.findByUsername(TEST_USER) + ); + tournament = tournamentService.create(tournament, TEST_USER); var beerPongTable = new BeerPongTable("TEST"); beerPongTable.setTournament(tournament); diff --git a/backend/src/test/java/at/ac/tuwien/sepr/groupphase/backend/unittests/TournamentServiceTest.java b/backend/src/test/java/at/ac/tuwien/sepr/groupphase/backend/unittests/TournamentServiceTest.java index 2049461..294dbff 100644 --- a/backend/src/test/java/at/ac/tuwien/sepr/groupphase/backend/unittests/TournamentServiceTest.java +++ b/backend/src/test/java/at/ac/tuwien/sepr/groupphase/backend/unittests/TournamentServiceTest.java @@ -53,10 +53,10 @@ public class TournamentServiceTest extends TestUserData implements TestData { @Test public void createNewTournamentWithTestUserAsOrganizer() { - var tournament = new Tournament(); - tournament.setName("TEST_TOURNAMENT"); - tournament.setRegistrationEnd(LocalDateTime.now().plusDays(1)); - tournament.setMaxParticipants(64L); + var tournament = new Tournament( + "TEST_TOURNAMENT", LocalDateTime.now().plusDays(1), 64L, + "testdescription", userRepository.findByUsername(TEST_USER) + ); tournament = tournamentService.create(tournament, TestDataGenerator.TEST_USER); assertNotNull(tournament); @@ -152,15 +152,18 @@ public class TournamentServiceTest extends TestUserData implements TestData { } Tournament generateTournamentWithFinishedQualiPhase() { - - var tournament = new Tournament("testname", LocalDateTime.now().plusMinutes(1), 64L, "testdescription", - userRepository.findByUsername(TEST_USER)); - tournamentService - .create(tournament, TEST_USER); + var tournament = new Tournament( + "testname", LocalDateTime.now().plusMinutes(1), 64L, "testdescription", + userRepository.findByUsername(TEST_USER)); + tournamentService.create(tournament, TEST_USER); IntStream.rangeClosed(1, 32).forEach(i -> { - assertEquals(SignupTeamResult.SUCCESS, - tournamentService.signupTeamForTournament(tournament.getId(), "team" + i)); + assertEquals( + SignupTeamResult.SUCCESS, + tournamentService.signupTeamForTournament( + tournament.getId(), tournament.getSelfRegistrationToken(), "team" + i + ) + ); }); tournamentService.generateQualificationMatchesForTournament(tournament.getId(), TEST_USER); diff --git a/e2e/cypress/e2e/tournaments.cy.js b/e2e/cypress/e2e/tournaments.cy.js index fe401a5..2cce6d4 100644 --- a/e2e/cypress/e2e/tournaments.cy.js +++ b/e2e/cypress/e2e/tournaments.cy.js @@ -13,6 +13,20 @@ context('Get Tournaments', () => { cy.get('[data-cy="tournaments-list"]').should('exist'); cy.get('[data-cy="tournaments-list-item"]').should('have.length.at.least', 1); }); + + it('successfully copied team self-registration link', () => { + cy.fixture('settings').then(settings => { + cy.intercept('GET', '/api/v1/tournaments', { fixture: 'tournaments.json' }).as( + 'getTournaments', + ); + cy.visit('/#/tournaments'); + cy.wait('@getTournaments'); + cy.get('[data-cy="open-copy-team-self-reg-link-dialog"]').first().click(); + + cy.get('[data-cy="copy-button"]').first().click(); + cy.assertClipboardContents(`http://${settings.baseUrl}/#/tournaments/1/signup`); + }); + }); }); context('Delete Tournament', () => { diff --git a/e2e/cypress/support/commands.js b/e2e/cypress/support/commands.js index 62ed5ba..bc7856d 100644 --- a/e2e/cypress/support/commands.js +++ b/e2e/cypress/support/commands.js @@ -82,3 +82,11 @@ Cypress.Commands.add('fillTournamentCreateFormWithInvalidData', () => { cy.contains('At least 16 participants are required.'); }); }); + +Cypress.Commands.add('assertClipboardContents', value => { + cy.window().then(window => { + window.navigator.clipboard.readText().then(text => { + expect(text).to.eq(value); + }); + }); +}); diff --git a/frontend/openapi-generated/.openapi-generator/FILES b/frontend/openapi-generated/.openapi-generator/FILES index f0dac5e..238399f 100644 --- a/frontend/openapi-generated/.openapi-generator/FILES +++ b/frontend/openapi-generated/.openapi-generator/FILES @@ -14,18 +14,18 @@ git_push.sh index.ts model/beerPongTableDto.ts model/createBeerPongTableDto.ts -model/createTeamDto.ts model/createTournamentDto.ts model/detailedMessageDto.ts model/messageInquiryDto.ts model/models.ts -model/signupTeamResponseDto.ts model/simpleMessageDto.ts model/teamDto.ts +model/tournamentCreateTeamDto.ts model/tournamentDto.ts model/tournamentListDto.ts model/tournamentQualificationMatchDto.ts model/tournamentQualificationMatchParticipantDto.ts +model/tournamentSignupTeamResponseDto.ts model/updateBeerPongTableDto.ts model/userDetailDto.ts model/userLoginDto.ts diff --git a/frontend/openapi-generated/api/tournamentEndpoint.service.ts b/frontend/openapi-generated/api/tournamentEndpoint.service.ts index 793cf0c..b00b83d 100644 --- a/frontend/openapi-generated/api/tournamentEndpoint.service.ts +++ b/frontend/openapi-generated/api/tournamentEndpoint.service.ts @@ -18,20 +18,20 @@ import { HttpClient, HttpHeaders, HttpParams, import { CustomHttpParameterCodec } from '../encoder'; import { Observable } from 'rxjs'; -// @ts-ignore -import { CreateTeamDto } from '../model/createTeamDto'; // @ts-ignore import { CreateTournamentDto } from '../model/createTournamentDto'; // @ts-ignore -import { SignupTeamResponseDto } from '../model/signupTeamResponseDto'; -// @ts-ignore import { TeamDto } from '../model/teamDto'; // @ts-ignore +import { TournamentCreateTeamDto } from '../model/tournamentCreateTeamDto'; +// @ts-ignore import { TournamentDto } from '../model/tournamentDto'; // @ts-ignore import { TournamentListDto } from '../model/tournamentListDto'; // @ts-ignore import { TournamentQualificationMatchDto } from '../model/tournamentQualificationMatchDto'; +// @ts-ignore +import { TournamentSignupTeamResponseDto } from '../model/tournamentSignupTeamResponseDto'; // @ts-ignore import { BASE_PATH, COLLECTION_FORMATS } from '../variables'; @@ -565,19 +565,29 @@ export class TournamentEndpointService { /** * Create a new team * @param tournamentId - * @param createTeamDto + * @param token + * @param tournamentCreateTeamDto * @param observe set whether or not to return the data Observable as the body, response or events. defaults to returning the body. * @param reportProgress flag to report request and response progress. */ - public signupTeamForTournament(tournamentId: number, createTeamDto: CreateTeamDto, observe?: 'body', reportProgress?: boolean, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable; - public signupTeamForTournament(tournamentId: number, createTeamDto: CreateTeamDto, observe?: 'response', reportProgress?: boolean, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable>; - public signupTeamForTournament(tournamentId: number, createTeamDto: CreateTeamDto, observe?: 'events', reportProgress?: boolean, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable>; - public signupTeamForTournament(tournamentId: number, createTeamDto: CreateTeamDto, observe: any = 'body', reportProgress: boolean = false, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable { + public signupTeamForTournament(tournamentId: number, token: string, tournamentCreateTeamDto: TournamentCreateTeamDto, observe?: 'body', reportProgress?: boolean, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable; + public signupTeamForTournament(tournamentId: number, token: string, tournamentCreateTeamDto: TournamentCreateTeamDto, observe?: 'response', reportProgress?: boolean, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable>; + public signupTeamForTournament(tournamentId: number, token: string, tournamentCreateTeamDto: TournamentCreateTeamDto, observe?: 'events', reportProgress?: boolean, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable>; + public signupTeamForTournament(tournamentId: number, token: string, tournamentCreateTeamDto: TournamentCreateTeamDto, observe: any = 'body', reportProgress: boolean = false, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable { if (tournamentId === null || tournamentId === undefined) { throw new Error('Required parameter tournamentId was null or undefined when calling signupTeamForTournament.'); } - if (createTeamDto === null || createTeamDto === undefined) { - throw new Error('Required parameter createTeamDto was null or undefined when calling signupTeamForTournament.'); + if (token === null || token === undefined) { + throw new Error('Required parameter token was null or undefined when calling signupTeamForTournament.'); + } + if (tournamentCreateTeamDto === null || tournamentCreateTeamDto === undefined) { + throw new Error('Required parameter tournamentCreateTeamDto was null or undefined when calling signupTeamForTournament.'); + } + + let localVarQueryParameters = new HttpParams({encoder: this.encoder}); + if (token !== undefined && token !== null) { + localVarQueryParameters = this.addToHttpParams(localVarQueryParameters, + token, 'token'); } let localVarHeaders = this.defaultHeaders; @@ -626,10 +636,11 @@ export class TournamentEndpointService { } let localVarPath = `/api/v1/tournaments/${this.configuration.encodeParam({name: "tournamentId", value: tournamentId, in: "path", style: "simple", explode: false, dataType: "number", dataFormat: "int64"})}/teams`; - return this.httpClient.request('post', `${this.configuration.basePath}${localVarPath}`, + return this.httpClient.request('post', `${this.configuration.basePath}${localVarPath}`, { context: localVarHttpContext, - body: createTeamDto, + body: tournamentCreateTeamDto, + params: localVarQueryParameters, responseType: responseType_, withCredentials: this.configuration.withCredentials, headers: localVarHeaders, diff --git a/frontend/openapi-generated/model/models.ts b/frontend/openapi-generated/model/models.ts index 84300c8..5b1f619 100644 --- a/frontend/openapi-generated/model/models.ts +++ b/frontend/openapi-generated/model/models.ts @@ -1,16 +1,16 @@ export * from './beerPongTableDto'; export * from './createBeerPongTableDto'; -export * from './createTeamDto'; export * from './createTournamentDto'; export * from './detailedMessageDto'; export * from './messageInquiryDto'; -export * from './signupTeamResponseDto'; export * from './simpleMessageDto'; export * from './teamDto'; +export * from './tournamentCreateTeamDto'; export * from './tournamentDto'; export * from './tournamentListDto'; export * from './tournamentQualificationMatchDto'; export * from './tournamentQualificationMatchParticipantDto'; +export * from './tournamentSignupTeamResponseDto'; export * from './updateBeerPongTableDto'; export * from './userDetailDto'; export * from './userLoginDto'; diff --git a/frontend/openapi-generated/model/tournamentCreateTeamDto.ts b/frontend/openapi-generated/model/tournamentCreateTeamDto.ts new file mode 100644 index 0000000..ee2eae8 --- /dev/null +++ b/frontend/openapi-generated/model/tournamentCreateTeamDto.ts @@ -0,0 +1,17 @@ +/** + * OpenAPI definition + * No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator) + * + * The version of the OpenAPI document: v0 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + + +export interface TournamentCreateTeamDto { + name: string; +} + diff --git a/frontend/openapi-generated/model/tournamentListDto.ts b/frontend/openapi-generated/model/tournamentListDto.ts index 6a50e77..3856c32 100644 --- a/frontend/openapi-generated/model/tournamentListDto.ts +++ b/frontend/openapi-generated/model/tournamentListDto.ts @@ -17,5 +17,6 @@ export interface TournamentListDto { registrationEnd: string; maxParticipants: number; description?: string; + selfRegistrationToken: string; } diff --git a/frontend/openapi-generated/model/tournamentSignupTeamResponseDto.ts b/frontend/openapi-generated/model/tournamentSignupTeamResponseDto.ts new file mode 100644 index 0000000..994fddf --- /dev/null +++ b/frontend/openapi-generated/model/tournamentSignupTeamResponseDto.ts @@ -0,0 +1,27 @@ +/** + * OpenAPI definition + * No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator) + * + * The version of the OpenAPI document: v0 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + + +export interface TournamentSignupTeamResponseDto { + signupTeamResult?: TournamentSignupTeamResponseDto.SignupTeamResultEnum; +} +export namespace TournamentSignupTeamResponseDto { + export type SignupTeamResultEnum = 'SUCCESS' | 'REGISTRATION_CLOSED' | 'MAX_PARTICIPANTS_REACHED' | 'TEAM_ALREADY_EXISTS'; + export const SignupTeamResultEnum = { + Success: 'SUCCESS' as SignupTeamResultEnum, + RegistrationClosed: 'REGISTRATION_CLOSED' as SignupTeamResultEnum, + MaxParticipantsReached: 'MAX_PARTICIPANTS_REACHED' as SignupTeamResultEnum, + TeamAlreadyExists: 'TEAM_ALREADY_EXISTS' as SignupTeamResultEnum + }; +} + + diff --git a/frontend/src/app/app-routing.module.ts b/frontend/src/app/app-routing.module.ts index d2a7778..80248cf 100644 --- a/frontend/src/app/app-routing.module.ts +++ b/frontend/src/app/app-routing.module.ts @@ -26,6 +26,11 @@ const routes: Routes = [ { path: 'details/:username', component: UserDetailComponent }, ], }, + { + path: '', + component: MainLayoutComponent, + children: [{ path: 'tournaments/:tournamentId/signup', component: TeamSignupComponent }], + }, { path: '', component: MainLayoutComponent, @@ -40,10 +45,7 @@ const routes: Routes = [ { path: 'create', component: TournamentCreateComponent }, { path: ':tournamentId', - children: [ - { path: 'signup', component: TeamSignupComponent }, - { path: 'teams', component: TournamentTeamsComponent }, - ], + children: [{ path: 'teams', component: TournamentTeamsComponent }], }, ], }, diff --git a/frontend/src/app/app.module.ts b/frontend/src/app/app.module.ts index 2584748..da4554f 100644 --- a/frontend/src/app/app.module.ts +++ b/frontend/src/app/app.module.ts @@ -27,6 +27,7 @@ import { MainLayoutComponent } from './layouts/main-layout/main-layout.component import { MatListItem, MatNavList } from '@angular/material/list'; import { MatDivider } from '@angular/material/divider'; import { SidebarComponent } from './components/sidebar/sidebar.component'; +import { ClipboardModule } from '@angular/cdk/clipboard'; import localeDe from '@angular/common/locales/de'; import { registerLocaleData } from '@angular/common'; registerLocaleData(localeDe); @@ -36,6 +37,7 @@ import { CommonModule } from '@angular/common'; import { TeamSignupComponent } from './components/tournament/team-signup/team-signup.component'; import { TournamentsComponent } from './components/tournament/tournaments/tournaments.component'; import { TournamentCardComponent } from './components/tournament/tournament-card/tournament-card.component'; +import { CopyLinkDialogComponent } from './components/copy-link-dialog/copy-link-dialog.component'; @NgModule({ declarations: [ @@ -56,6 +58,7 @@ import { TournamentCardComponent } from './components/tournament/tournament-card UpdateUserComponent, TeamSignupComponent, ConfirmDialogComponent, + CopyLinkDialogComponent, ], imports: [ AppRoutingModule, @@ -79,6 +82,7 @@ import { TournamentCardComponent } from './components/tournament/tournament-card MatButtonModule, MatIconModule, CommonModule, + ClipboardModule, ], providers: [ httpInterceptorProviders, diff --git a/frontend/src/app/components/copy-link-dialog/copy-link-dialog.component.html b/frontend/src/app/components/copy-link-dialog/copy-link-dialog.component.html new file mode 100644 index 0000000..20b7a2a --- /dev/null +++ b/frontend/src/app/components/copy-link-dialog/copy-link-dialog.component.html @@ -0,0 +1,16 @@ +

Copy {{ title }}

+ + + + + + diff --git a/frontend/src/app/components/copy-link-dialog/copy-link-dialog.component.scss b/frontend/src/app/components/copy-link-dialog/copy-link-dialog.component.scss new file mode 100644 index 0000000..98e9fdd --- /dev/null +++ b/frontend/src/app/components/copy-link-dialog/copy-link-dialog.component.scss @@ -0,0 +1,11 @@ +mat-dialog-content { + display: flex; +} + +mat-form-field { + flex-grow: 1; +} + +button { + margin-left: 16px; +} diff --git a/frontend/src/app/components/copy-link-dialog/copy-link-dialog.component.spec.ts b/frontend/src/app/components/copy-link-dialog/copy-link-dialog.component.spec.ts new file mode 100644 index 0000000..10b2fed --- /dev/null +++ b/frontend/src/app/components/copy-link-dialog/copy-link-dialog.component.spec.ts @@ -0,0 +1,22 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { CopyLinkDialogComponent } from './copy-link-dialog.component'; + +describe('CopyLinkDialogComponent', () => { + let component: CopyLinkDialogComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [CopyLinkDialogComponent], + }).compileComponents(); + + fixture = TestBed.createComponent(CopyLinkDialogComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/frontend/src/app/components/copy-link-dialog/copy-link-dialog.component.ts b/frontend/src/app/components/copy-link-dialog/copy-link-dialog.component.ts new file mode 100644 index 0000000..3a2e91d --- /dev/null +++ b/frontend/src/app/components/copy-link-dialog/copy-link-dialog.component.ts @@ -0,0 +1,32 @@ +import { Component, Inject } from '@angular/core'; +import { MAT_DIALOG_DATA } from '@angular/material/dialog'; +import { MatSnackBar } from '@angular/material/snack-bar'; + +export interface CopyLinkDialogData { + title: string; + link: string; +} + +@Component({ + selector: 'app-copy-link-dialog', + templateUrl: './copy-link-dialog.component.html', + styleUrl: './copy-link-dialog.component.scss', +}) +export class CopyLinkDialogComponent { + title: string; + link: string; + + constructor( + @Inject(MAT_DIALOG_DATA) data: CopyLinkDialogData, + private snackBar: MatSnackBar, + ) { + this.title = data.title; + this.link = data.link; + } + + showNotification(): void { + this.snackBar.open('Copied link to clipboard!', 'OK', { + duration: 2500, + }); + } +} diff --git a/frontend/src/app/components/sidebar/sidebar.component.html b/frontend/src/app/components/sidebar/sidebar.component.html index 3eb6c9b..944e670 100644 --- a/frontend/src/app/components/sidebar/sidebar.component.html +++ b/frontend/src/app/components/sidebar/sidebar.component.html @@ -1,6 +1,6 @@ -

Beer Brawl

+

Beer Brawl

diff --git a/frontend/src/app/components/tournament/team-signup/team-signup.component.ts b/frontend/src/app/components/tournament/team-signup/team-signup.component.ts index 14eb862..47370df 100644 --- a/frontend/src/app/components/tournament/team-signup/team-signup.component.ts +++ b/frontend/src/app/components/tournament/team-signup/team-signup.component.ts @@ -1,10 +1,11 @@ -import { ChangeDetectionStrategy, Component } from '@angular/core'; -import { TournamentEndpointService } from '../../../../../openapi-generated/api/tournamentEndpoint.service'; +import { Component } from '@angular/core'; import { ActivatedRoute } from '@angular/router'; import { FormControl, FormGroup, Validators } from '@angular/forms'; -import { SignupTeamResponseDto, TournamentDto } from 'openapi-generated'; -import { catchError, tap, firstValueFrom, of } from 'rxjs'; -import { extend } from 'lodash'; +import { TournamentDto, TournamentSignupTeamResponseDto } from 'openapi-generated'; +import { catchError, of } from 'rxjs'; +import { MatSnackBar } from '@angular/material/snack-bar'; +import { TournamentEndpointService } from '@api'; +import { HttpErrorResponse } from '@angular/common/http'; @Component({ selector: 'app-team-signup', @@ -13,6 +14,8 @@ import { extend } from 'lodash'; }) export class TeamSignupComponent { tournament: TournamentDto | undefined; + token: string | null | undefined; + success = false; nameFormControl = new FormControl('', [ Validators.minLength(3), @@ -33,41 +36,48 @@ export class TeamSignupComponent { constructor( private tournamentService: TournamentEndpointService, private route: ActivatedRoute, + private snackBar: MatSnackBar, ) { const tournamentId = Number(this.route.snapshot.paramMap.get('tournamentId')); + const token = this.route.snapshot.queryParamMap.get('token'); this.tournamentService.get(tournamentId).subscribe(tournament => { this.tournament = tournament; + this.token = token; }); } async createTeam() { const request = this.tournamentService.signupTeamForTournament( this.tournament?.id!, + this.token!, { name: this.newTeamNameForm.controls.name.value! }, 'response', ); request .pipe( - catchError(e => { - if (e.status !== 400) { + catchError((e: HttpErrorResponse) => { + if (!Object.values(TournamentSignupTeamResponseDto).includes(e.error)) { + const errorMessage = + typeof e.error === 'string' ? e.error : 'Failed to register team; invalid request'; + this.snackBar.open(errorMessage, 'Dismiss', { + duration: 5000, + }); return []; } - const response = e.error as SignupTeamResponseDto; + + const response = e.error as TournamentSignupTeamResponseDto; switch (response.signupTeamResult) { - case SignupTeamResponseDto.SignupTeamResultEnum.TeamAlreadyExists: + case TournamentSignupTeamResponseDto.SignupTeamResultEnum.TeamAlreadyExists: this.nameFormControl.setErrors({ teamAlreadyExists: true }); return of(); - case SignupTeamResponseDto.SignupTeamResultEnum.MaxParticipantsReached: + case TournamentSignupTeamResponseDto.SignupTeamResultEnum.MaxParticipantsReached: this.nameFormControl.setErrors({ maxParticipantsReached: true }); return of(); default: return []; } - - this.nameFormControl.setErrors({ nameAlreadyTaken: true }); - return of(); }), ) - .subscribe(response => (this.success = true)); + .subscribe(() => (this.success = true)); } } diff --git a/frontend/src/app/components/tournament/tournament-card/tournament-card.component.html b/frontend/src/app/components/tournament/tournament-card/tournament-card.component.html index 6e74f96..ebcc785 100644 --- a/frontend/src/app/components/tournament/tournament-card/tournament-card.component.html +++ b/frontend/src/app/components/tournament/tournament-card/tournament-card.component.html @@ -6,19 +6,26 @@ + diff --git a/frontend/src/app/components/tournament/tournament-card/tournament-card.component.scss b/frontend/src/app/components/tournament/tournament-card/tournament-card.component.scss index 40321b8..c79e16d 100644 --- a/frontend/src/app/components/tournament/tournament-card/tournament-card.component.scss +++ b/frontend/src/app/components/tournament/tournament-card/tournament-card.component.scss @@ -33,6 +33,7 @@ .card-title { font-size: 1.25rem; /* Larger font size for the title */ font-weight: 500; /* Medium font weight for better readability */ + flex-grow: 1; } .card-footer { @@ -44,14 +45,10 @@ .card-date { font-size: 0.8rem; /* Default font size for the date */ color: #757575; /* Subtle color for the date */ + flex-grow: 1; } -.card-icons { - display: flex; - gap: 0.5rem; -} - -.card-icons button { +button { color: #757575; /* Default color for icons */ } diff --git a/frontend/src/app/components/tournament/tournament-card/tournament-card.component.ts b/frontend/src/app/components/tournament/tournament-card/tournament-card.component.ts index 35bfe8c..049c869 100644 --- a/frontend/src/app/components/tournament/tournament-card/tournament-card.component.ts +++ b/frontend/src/app/components/tournament/tournament-card/tournament-card.component.ts @@ -1,14 +1,15 @@ import { Component, Input, Output, EventEmitter } from '@angular/core'; -import { MatDialog } from '@angular/material/dialog'; import { TournamentEndpointService, TournamentListDto } from '@api'; import { MatSnackBar } from '@angular/material/snack-bar'; import { ConfirmationService } from '../../../services/confirmation.service'; -import { DatePipe } from '@angular/common'; +import { Location, LocationStrategy, PathLocationStrategy } from '@angular/common'; +import { CopyLinkService } from 'src/app/services/copy-link.service'; @Component({ selector: 'app-tournament-card', templateUrl: './tournament-card.component.html', styleUrls: ['./tournament-card.component.scss'], + providers: [Location, { provide: LocationStrategy, useClass: PathLocationStrategy }], }) export class TournamentCardComponent { @Input() tournament: TournamentListDto | undefined; @@ -18,6 +19,8 @@ export class TournamentCardComponent { private tournamentService: TournamentEndpointService, private snackBar: MatSnackBar, private confirmationService: ConfirmationService, + private copyLinkService: CopyLinkService, + private location: Location, ) {} async openConfirmDialog(): Promise { @@ -44,4 +47,12 @@ export class TournamentCardComponent { }); } } + + openCopyTeamRegistrationLink(): void { + const link = this.location.prepareExternalUrl( + `#/tournaments/${this.tournament?.id}/signup?token=${this.tournament?.selfRegistrationToken}`, + ); + + this.copyLinkService.openDialog({ title: 'Team self-registration link', link }); + } } diff --git a/frontend/src/app/services/copy-link.service.ts b/frontend/src/app/services/copy-link.service.ts new file mode 100644 index 0000000..7e17aa0 --- /dev/null +++ b/frontend/src/app/services/copy-link.service.ts @@ -0,0 +1,25 @@ +import { inject, Injectable } from '@angular/core'; +import { MatDialog } from '@angular/material/dialog'; +import { + CopyLinkDialogComponent, + CopyLinkDialogData, +} from '../components/copy-link-dialog/copy-link-dialog.component'; + +@Injectable({ + providedIn: 'root', +}) +export class CopyLinkService { + matDialog = inject(MatDialog); + + /* + * Opens a dialog and prompts the user to copy a given link. + * @param data dialog data to use + */ + openDialog(data: CopyLinkDialogData): void { + this.matDialog.open(CopyLinkDialogComponent, { + width: '750px', + maxWidth: '900px', + data, + }); + } +} diff --git a/frontend/src/app/services/delete-confirmation.service.spec.ts b/frontend/src/app/services/delete-confirmation.service.spec.ts deleted file mode 100644 index 45f5ea2..0000000 --- a/frontend/src/app/services/delete-confirmation.service.spec.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { TestBed } from '@angular/core/testing'; - -import { ConfirmationService } from './confirmation.service'; - -describe('DeleteConfirmationService', () => { - let service: ConfirmationService; - - beforeEach(() => { - TestBed.configureTestingModule({}); - service = TestBed.inject(ConfirmationService); - }); - - it('should be created', () => { - expect(service).toBeTruthy(); - }); -});