diff --git a/README.md b/README.md index ecb149b..bbd2fe1 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,18 @@ # Scavenger Hunt API -REST API to support a community scavenger hunt app. \ No newline at end of file +REST API to support a community scavenger hunt app. + +## Environment variables +* `DB_PASSWORD` Password for the database +* `DB_URL` JDBC URL for the database +* `DB_USER` Username for the database +* `JWT_SECRET` Secret pass for the JWT + +## TODO: +### User Endpoints +* upload photo for hunt item POST `/hunt/{huntId}/team/{teamId}/item/{itemId}/photo` - body: image binary +* delete photo for hunt item DELETE `/hunt/{huntId}/team/{teamId}/item/{itemId}/photo/{photoId}` +* list hunt teams with scores for hunt `GET /lead/hunt/{huntId}/team` +* list hunters with scores for hunt GET `/lead/hunt/{huntId}/hunter` +### Admin Endpoints +* approve photo for hunt item POST `/admin/hunt/{huntId}/team/{teamId}/item/{itemId}/photo/{photoId}` - body: approval status \ No newline at end of file diff --git a/build.gradle.kts b/build.gradle.kts index dc00826..6db3bbd 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,9 +1,9 @@ plugins { - kotlin("jvm") version "2.2.21" - kotlin("plugin.spring") version "2.2.21" - id("org.springframework.boot") version "4.0.0" + kotlin("jvm") version "2.3.21" + kotlin("plugin.spring") version "2.3.21" + id("org.springframework.boot") version "4.0.6" id("io.spring.dependency-management") version "1.1.7" - kotlin("plugin.jpa") version "2.2.21" + kotlin("plugin.jpa") version "2.3.21" } group = "net.halfbinary" @@ -27,18 +27,25 @@ repositories { } dependencies { - val mysqlConnectorJ = "9.5.0" + val mariaDriver = "3.5.8" val commonsValidator = "1.10.1" val jakartaValidation = "3.1.1" + val jsonWebToken = "0.13.0" + val springdocUi = "3.0.3" implementation("org.springframework.boot:spring-boot-starter-actuator") implementation("org.springframework.boot:spring-boot-starter-web") implementation("org.springframework.boot:spring-boot-starter-data-jpa") implementation("org.springframework.boot:spring-boot-starter-validation") - implementation("jakarta.validation:jakarta.validation-api:${jakartaValidation}") - implementation("com.mysql:mysql-connector-j:${mysqlConnectorJ}") + implementation("org.springframework.boot:spring-boot-starter-security") + implementation("jakarta.validation:jakarta.validation-api:$jakartaValidation") + implementation("org.mariadb.jdbc:mariadb-java-client:${mariaDriver}") implementation("com.fasterxml.jackson.module:jackson-module-kotlin") implementation("org.jetbrains.kotlin:kotlin-reflect") - implementation("commons-validator:commons-validator:${commonsValidator}") + implementation("commons-validator:commons-validator:$commonsValidator") + implementation("io.jsonwebtoken:jjwt-api:$jsonWebToken") + implementation("io.jsonwebtoken:jjwt-impl:$jsonWebToken") + implementation("io.jsonwebtoken:jjwt-jackson:$jsonWebToken") + implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:$springdocUi") developmentOnly("org.springframework.boot:spring-boot-devtools") annotationProcessor("org.springframework.boot:spring-boot-configuration-processor") testImplementation("org.springframework.boot:spring-boot-starter-actuator-test") diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar index f8e1ee3..d997cfc 100644 Binary files a/gradle/wrapper/gradle-wrapper.jar and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 23449a2..dbc3ce4 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-9.2.1-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.4.0-bin.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME diff --git a/gradlew b/gradlew index adff685..0262dcb 100644 --- a/gradlew +++ b/gradlew @@ -57,7 +57,7 @@ # Darwin, MinGW, and NonStop. # # (3) This script is generated from the Groovy template -# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# https://github.com/gradle/gradle/blob/b631911858264c0b6e4d6603d677ff5218766cee/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt # within the Gradle project. # # You can find Gradle at https://github.com/gradle/gradle/. diff --git a/src/main/kotlin/net/halfbinary/scavengerhuntapi/config/AuthEntrypointJwt.kt b/src/main/kotlin/net/halfbinary/scavengerhuntapi/config/AuthEntrypointJwt.kt new file mode 100644 index 0000000..a6c2855 --- /dev/null +++ b/src/main/kotlin/net/halfbinary/scavengerhuntapi/config/AuthEntrypointJwt.kt @@ -0,0 +1,18 @@ +package net.halfbinary.scavengerhuntapi.config + +import jakarta.servlet.http.HttpServletRequest +import jakarta.servlet.http.HttpServletResponse +import org.springframework.security.core.AuthenticationException +import org.springframework.security.web.AuthenticationEntryPoint +import org.springframework.stereotype.Component + +@Component +class AuthEntrypointJwt: AuthenticationEntryPoint { + override fun commence( + request: HttpServletRequest, + response: HttpServletResponse, + authException: AuthenticationException + ) { + response.sendError(HttpServletResponse.SC_UNAUTHORIZED, authException.message) + } +} \ No newline at end of file diff --git a/src/main/kotlin/net/halfbinary/scavengerhuntapi/config/AuthTokenFilter.kt b/src/main/kotlin/net/halfbinary/scavengerhuntapi/config/AuthTokenFilter.kt new file mode 100644 index 0000000..f714cf6 --- /dev/null +++ b/src/main/kotlin/net/halfbinary/scavengerhuntapi/config/AuthTokenFilter.kt @@ -0,0 +1,51 @@ +package net.halfbinary.scavengerhuntapi.config + +import jakarta.servlet.FilterChain +import jakarta.servlet.http.HttpServletRequest +import jakarta.servlet.http.HttpServletResponse +import net.halfbinary.scavengerhuntapi.service.HunterDetailsService +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken +import org.springframework.security.core.context.SecurityContextHolder +import org.springframework.security.core.userdetails.UserDetails +import org.springframework.security.web.authentication.WebAuthenticationDetailsSource +import org.springframework.stereotype.Component +import org.springframework.web.filter.OncePerRequestFilter + + +@Component +class AuthTokenFilter(private val jwtUtils: JwtUtil, private val hunterDetailsService: HunterDetailsService): OncePerRequestFilter() { + override fun doFilterInternal( + request: HttpServletRequest, + response: HttpServletResponse, + filterChain: FilterChain + ) { + try { + val jwt: String? = parseJwt(request) + if (jwt != null && jwtUtils.validateJwtToken(jwt)) { + val username = jwtUtils.getUsernameFromToken(jwt) + val userDetails: UserDetails = hunterDetailsService.loadUserByUsername(username) + val authentication = + UsernamePasswordAuthenticationToken( + userDetails, + null, + userDetails.authorities + ) + authentication.details = WebAuthenticationDetailsSource().buildDetails(request) + val context = SecurityContextHolder.createEmptyContext() + context.authentication = authentication + SecurityContextHolder.setContext(context) + } + } catch (e: Exception) { + println("Cannot set user authentication: $e") + } + filterChain.doFilter(request, response) + } + + private fun parseJwt(request: HttpServletRequest): String? { + val headerAuth = request.getHeader("Authorization") + if (headerAuth != null && headerAuth.startsWith("Bearer ")) { + return headerAuth.substring(7) + } + return null + } +} \ No newline at end of file diff --git a/src/main/kotlin/net/halfbinary/scavengerhuntapi/config/JwtUtil.kt b/src/main/kotlin/net/halfbinary/scavengerhuntapi/config/JwtUtil.kt new file mode 100644 index 0000000..3886bd5 --- /dev/null +++ b/src/main/kotlin/net/halfbinary/scavengerhuntapi/config/JwtUtil.kt @@ -0,0 +1,63 @@ +package net.halfbinary.scavengerhuntapi.config + +import io.jsonwebtoken.JwtException +import io.jsonwebtoken.Jwts +import jakarta.annotation.PostConstruct +import org.springframework.beans.factory.annotation.Value +import org.springframework.stereotype.Component +import java.util.Date +import javax.crypto.SecretKey +import javax.crypto.spec.SecretKeySpec + +@Component +class JwtUtil { + @Value($$"${jwt.secret}") + private val jwtSecret: String = "" + + @Value($$"${jwt.expiration}") + private val jwtExpirationMs = 0 + + private var key: SecretKey? = null + + // Initializes the key after the class is instantiated and the jwtSecret is injected, + // preventing the repeated creation of the key and enhancing performance + @PostConstruct + fun init() { + this.key = SecretKeySpec(jwtSecret.toByteArray(Charsets.UTF_8), "HmacSHA256") + } + + // Generate JWT token + fun generateToken(email: String): String { + return Jwts.builder() + .subject(email) + .issuedAt(Date()) + .expiration(Date(System.currentTimeMillis() + jwtExpirationMs)) + .signWith(key) + .compact() + } + + // Get username from JWT token + fun getUsernameFromToken(token: String): String { + return Jwts.parser() + .verifyWith(key) + .build() + .parseSignedClaims(token) + .payload + .subject + } + + // Validate JWT token + fun validateJwtToken(token: String?): Boolean { + try { + Jwts.parser().verifyWith(key).build().parseSignedClaims(token) + return true + } catch (e: SecurityException) { + println("Invalid JWT signature: " + e.message) + } catch (e: JwtException) { + println("Invalid JWT token: " + e.message) + } catch (e: IllegalArgumentException) { + println("JWT claims string is empty: " + e.message) + } + return false + } +} \ No newline at end of file diff --git a/src/main/kotlin/net/halfbinary/scavengerhuntapi/config/SecurityConfig.kt b/src/main/kotlin/net/halfbinary/scavengerhuntapi/config/SecurityConfig.kt new file mode 100644 index 0000000..42b70b5 --- /dev/null +++ b/src/main/kotlin/net/halfbinary/scavengerhuntapi/config/SecurityConfig.kt @@ -0,0 +1,82 @@ +package net.halfbinary.scavengerhuntapi.config + +import org.springframework.boot.web.servlet.FilterRegistrationBean +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.security.authentication.AuthenticationManager +import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration +import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity +import org.springframework.security.config.annotation.web.builders.HttpSecurity +import org.springframework.security.config.annotation.web.configurers.CorsConfigurer +import org.springframework.security.config.annotation.web.configurers.CsrfConfigurer +import org.springframework.security.config.annotation.web.configurers.ExceptionHandlingConfigurer +import org.springframework.security.config.annotation.web.configurers.SessionManagementConfigurer +import org.springframework.security.config.http.SessionCreationPolicy +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder +import org.springframework.security.crypto.password.PasswordEncoder +import org.springframework.security.web.SecurityFilterChain +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter + + +@Configuration +@EnableMethodSecurity +class SecurityConfig(private val authEntrypointJwt: AuthEntrypointJwt, + private val authTokenFilter: AuthTokenFilter) { + + @Bean + fun authenticationJwtTokenFilter(): AuthTokenFilter { + return authTokenFilter + } + + @Bean + fun authTokenFilterRegistration(): FilterRegistrationBean { + val registration = FilterRegistrationBean(authTokenFilter) + registration.isEnabled = false + return registration + } + + @Bean + @Throws(Exception::class) + fun authenticationManager( + authenticationConfiguration: AuthenticationConfiguration + ): AuthenticationManager? { + return authenticationConfiguration.getAuthenticationManager() + } + + @Bean + fun passwordEncoder(): PasswordEncoder { + return BCryptPasswordEncoder() + } + + @Bean + @Throws(Exception::class) + fun securityFilterChain(http: HttpSecurity): SecurityFilterChain? { + // Updated configuration for Spring Security 6.x + http + .csrf { csrf: CsrfConfigurer -> csrf.disable() } // Disable CSRF + .cors { cors: CorsConfigurer -> cors.disable() } // Disable CORS (or configure if needed) + .exceptionHandling { exceptionHandling: ExceptionHandlingConfigurer -> + exceptionHandling.authenticationEntryPoint( + authEntrypointJwt + ) + } + .sessionManagement { sessionManagement: SessionManagementConfigurer -> + sessionManagement.sessionCreationPolicy( + SessionCreationPolicy.STATELESS + ) + } + .authorizeHttpRequests { authorizeRequests -> + authorizeRequests + .requestMatchers("/auth/**", "/signup", "/docs/**") + .permitAll() + .anyRequest().authenticated() + } + + // Add the JWT Token filter before the UsernamePasswordAuthenticationFilter + http.addFilterBefore( + authenticationJwtTokenFilter(), + UsernamePasswordAuthenticationFilter::class.java + ) + return http.build() + } +} \ No newline at end of file diff --git a/src/main/kotlin/net/halfbinary/scavengerhuntapi/controller/AuthController.kt b/src/main/kotlin/net/halfbinary/scavengerhuntapi/controller/AuthController.kt new file mode 100644 index 0000000..92c7cb2 --- /dev/null +++ b/src/main/kotlin/net/halfbinary/scavengerhuntapi/controller/AuthController.kt @@ -0,0 +1,54 @@ +package net.halfbinary.scavengerhuntapi.controller + +import jakarta.servlet.http.HttpServletResponse +import jakarta.validation.Valid +import net.halfbinary.scavengerhuntapi.config.JwtUtil +import net.halfbinary.scavengerhuntapi.model.converter.toDomain +import net.halfbinary.scavengerhuntapi.model.converter.toRefreshResponse +import net.halfbinary.scavengerhuntapi.model.request.LoginRequest +import net.halfbinary.scavengerhuntapi.model.request.LogoutRequest +import net.halfbinary.scavengerhuntapi.model.request.RefreshRequest +import net.halfbinary.scavengerhuntapi.model.response.LoginResponse +import net.halfbinary.scavengerhuntapi.model.response.RefreshResponse +import net.halfbinary.scavengerhuntapi.service.LoginService +import net.halfbinary.scavengerhuntapi.service.RefreshTokenService +import org.springframework.http.ResponseEntity +import org.springframework.security.core.authority.SimpleGrantedAuthority +import org.springframework.security.core.userdetails.User +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.RestController +import java.util.Collections + + +@RestController +@RequestMapping("/auth") +class AuthController(private val loginService: LoginService, private val jwtUtils: JwtUtil, private val refreshTokenService: RefreshTokenService) { + @PostMapping("/login") + fun login(@Valid @RequestBody body: LoginRequest, response: HttpServletResponse): ResponseEntity { + val result = loginService.login(body.toDomain()) + val hunterAuthorities = + if (result.isAdmin) { + SimpleGrantedAuthority("ROLE_ADMIN") + } else { + SimpleGrantedAuthority("ROLE_USER") + } + val user = User(result.email, result.password, Collections.singleton(hunterAuthorities)) + val accessToken = jwtUtils.generateToken(result.email) + val refreshToken = refreshTokenService.generateRefreshToken(result.email) + val loginResponse = LoginResponse(accessToken, refreshToken) + return ResponseEntity.ok(loginResponse) + } + + @PostMapping("/refresh") + fun refresh(@RequestBody body: RefreshRequest): ResponseEntity { + return ResponseEntity.ok(refreshTokenService.getAccessToken(body.refreshToken).toRefreshResponse()) + } + + @PostMapping("/logout") + fun logout(@RequestBody body: LogoutRequest, response: HttpServletResponse): ResponseEntity { + refreshTokenService.removeToken(body.refreshToken) + return ResponseEntity.ok().build() + } +} \ No newline at end of file diff --git a/src/main/kotlin/net/halfbinary/scavengerhuntapi/controller/HuntController.kt b/src/main/kotlin/net/halfbinary/scavengerhuntapi/controller/HuntController.kt index 7314ecd..dbd54de 100644 --- a/src/main/kotlin/net/halfbinary/scavengerhuntapi/controller/HuntController.kt +++ b/src/main/kotlin/net/halfbinary/scavengerhuntapi/controller/HuntController.kt @@ -1,5 +1,7 @@ package net.halfbinary.scavengerhuntapi.controller +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.tags.Tag import jakarta.validation.Valid import net.halfbinary.scavengerhuntapi.model.HuntId import net.halfbinary.scavengerhuntapi.model.HunterId @@ -9,30 +11,61 @@ import net.halfbinary.scavengerhuntapi.model.request.HuntCreateRequest import net.halfbinary.scavengerhuntapi.model.request.HuntStatus import net.halfbinary.scavengerhuntapi.model.response.HuntResponse import net.halfbinary.scavengerhuntapi.service.HuntService +import net.halfbinary.scavengerhuntapi.service.HunterService import org.springframework.http.ResponseEntity +import org.springframework.security.access.prepost.PreAuthorize +import org.springframework.security.core.Authentication import org.springframework.web.bind.annotation.* +import java.time.LocalDateTime @RestController @RequestMapping("hunt") -class HuntController(private val huntService: HuntService) { +class HuntController(private val huntService: HuntService, private val hunterService: HunterService) { @GetMapping("/{id}") + @Operation(summary = "Gets the specified hunt information") fun getHunt(@PathVariable("id") huntId: HuntId): ResponseEntity { return ResponseEntity.ok(huntService.getHunt(huntId).toResponse()) } - + @PreAuthorize("hasRole('ADMIN')") + @Tag(name = "Admin") @GetMapping() + @Operation(summary = "Gets all Hunts") fun getAllHunts(@RequestParam status: HuntStatus?): ResponseEntity> { return ResponseEntity.ok(huntService.getAllHunts(status).map { it.toResponse() }) } + @GetMapping("/ongoing") + @Operation(summary = "Gets list of all currently running Hunts (filtered by the calling hunter)") + fun getOngoingHunts(authentication: Authentication, @RequestParam status: HuntStatus?): ResponseEntity> { + val email = authentication.name + val isAdmin = hunterService.getHunterByEmail(email).isAdmin + return if(isAdmin) { + ResponseEntity.ok(huntService.getAllHunts(HuntStatus.ONGOING).map { it.toResponse() }) + } else { + ResponseEntity.ok(huntService.getHuntsByEmail(email, status).map { it.toResponse() }) + } + } + + @GetMapping("/unstarted") + @Operation(summary = "Gets list of all upcoming Hunts") + fun getUnstartedHunts(): ResponseEntity> { + return ResponseEntity.ok(huntService.getAllHunts(HuntStatus.UNSTARTED).map { it.toResponse() }) + } + + @PreAuthorize("hasRole('ADMIN')") + @Tag(name = "Admin") @PostMapping() + @Operation(summary = "Creates a new Hunt") fun createHunt(@Valid @RequestBody huntRequest: HuntCreateRequest): ResponseEntity { return ResponseEntity.ok(huntService.createHunt(huntRequest.toDomain()).toResponse()) } + @PreAuthorize("hasRole('ADMIN')") + @Tag(name = "Admin") @GetMapping("/hunter/{hunterId}") - fun getHuntsByHunter(@PathVariable("hunterId") hunterId: HunterId): ResponseEntity> { + @Operation(summary = "Lists all Hunts for specified Hunter") + fun getHuntsByHunter(@PathVariable hunterId: HunterId): ResponseEntity> { return ResponseEntity.ok(huntService.getHuntsByHunter(hunterId).map { it.toResponse() }) } diff --git a/src/main/kotlin/net/halfbinary/scavengerhuntapi/controller/ItemController.kt b/src/main/kotlin/net/halfbinary/scavengerhuntapi/controller/ItemController.kt new file mode 100644 index 0000000..1549ffd --- /dev/null +++ b/src/main/kotlin/net/halfbinary/scavengerhuntapi/controller/ItemController.kt @@ -0,0 +1,41 @@ +package net.halfbinary.scavengerhuntapi.controller + +import io.swagger.v3.oas.annotations.Operation +import jakarta.validation.Valid +import net.halfbinary.scavengerhuntapi.model.HuntId +import net.halfbinary.scavengerhuntapi.model.HunterId +import net.halfbinary.scavengerhuntapi.model.ItemId +import net.halfbinary.scavengerhuntapi.model.TeamHuntId +import net.halfbinary.scavengerhuntapi.model.converter.toDomain +import net.halfbinary.scavengerhuntapi.model.converter.toResponse +import net.halfbinary.scavengerhuntapi.model.request.HuntCreateRequest +import net.halfbinary.scavengerhuntapi.model.request.HuntStatus +import net.halfbinary.scavengerhuntapi.model.request.ItemRequest +import net.halfbinary.scavengerhuntapi.model.response.HuntResponse +import net.halfbinary.scavengerhuntapi.model.response.ItemResponse +import net.halfbinary.scavengerhuntapi.service.HuntService +import org.springframework.http.ResponseEntity +import org.springframework.security.access.prepost.PreAuthorize +import org.springframework.web.bind.annotation.* + +@RestController +@RequestMapping("hunt/{huntId}/item") +class ItemController(private val huntService: HuntService) { + + @GetMapping + fun getItemsForHunt(@PathVariable huntId: HuntId): ResponseEntity> { + TODO() + } + + @GetMapping("/{itemId}") + fun getItem(@PathVariable huntId: HuntId, @PathVariable itemId: ItemId): ResponseEntity { + TODO() + } + + @PostMapping + @Operation(summary = "Adds new Item to specified Hunt") + fun addItemToHunt(@PathVariable huntId: HuntId, @Valid @RequestBody body: ItemRequest) { + TODO() + } + +} \ No newline at end of file diff --git a/src/main/kotlin/net/halfbinary/scavengerhuntapi/controller/LoginController.kt b/src/main/kotlin/net/halfbinary/scavengerhuntapi/controller/LoginController.kt deleted file mode 100644 index a021c45..0000000 --- a/src/main/kotlin/net/halfbinary/scavengerhuntapi/controller/LoginController.kt +++ /dev/null @@ -1,36 +0,0 @@ -package net.halfbinary.scavengerhuntapi.controller - -import jakarta.servlet.http.Cookie -import jakarta.servlet.http.HttpServletResponse -import jakarta.validation.Valid -import net.halfbinary.scavengerhuntapi.model.converter.toDomain -import net.halfbinary.scavengerhuntapi.model.converter.toLoginResponse -import net.halfbinary.scavengerhuntapi.model.request.LoginRequest -import net.halfbinary.scavengerhuntapi.model.response.LoginResponse -import net.halfbinary.scavengerhuntapi.service.LoginService -import org.springframework.http.ResponseEntity -import org.springframework.web.bind.annotation.PostMapping -import org.springframework.web.bind.annotation.RequestBody -import org.springframework.web.bind.annotation.RestController -import java.net.URLEncoder - - -@RestController -class LoginController(private val loginService: LoginService) { - @PostMapping("/login") - fun login(@Valid @RequestBody body: LoginRequest, response: HttpServletResponse): ResponseEntity { - val result = loginService.login(body.toDomain()) - val creds = "${result.email}|${result.name}" - val encodedCreds = URLEncoder.encode(creds, "UTF-8") - response.addCookie(Cookie("creds", encodedCreds)) - return ResponseEntity.ok(result.toLoginResponse()) - } - - @PostMapping("/logout") - fun logout(response: HttpServletResponse): ResponseEntity { - val cookie = Cookie("creds", null) - cookie.maxAge = 0 - response.addCookie(cookie) - return ResponseEntity.ok("OK") - } -} \ No newline at end of file diff --git a/src/main/kotlin/net/halfbinary/scavengerhuntapi/controller/TeamController.kt b/src/main/kotlin/net/halfbinary/scavengerhuntapi/controller/TeamController.kt new file mode 100644 index 0000000..66a2fd2 --- /dev/null +++ b/src/main/kotlin/net/halfbinary/scavengerhuntapi/controller/TeamController.kt @@ -0,0 +1,64 @@ +package net.halfbinary.scavengerhuntapi.controller + +import io.swagger.v3.oas.annotations.Operation +import jakarta.validation.Valid +import net.halfbinary.scavengerhuntapi.model.HuntId +import net.halfbinary.scavengerhuntapi.model.ItemId +import net.halfbinary.scavengerhuntapi.model.TeamId +import net.halfbinary.scavengerhuntapi.model.converter.toResponse +import net.halfbinary.scavengerhuntapi.model.request.TeamRequest +import net.halfbinary.scavengerhuntapi.model.response.PhotoResponse +import net.halfbinary.scavengerhuntapi.model.response.TeamItemResponse +import net.halfbinary.scavengerhuntapi.model.response.TeamResponse +import net.halfbinary.scavengerhuntapi.service.TeamService +import org.springframework.http.ResponseEntity +import org.springframework.security.core.Authentication +import org.springframework.web.bind.annotation.GetMapping +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.RestController + +@RestController +@RequestMapping("hunt/{huntId}/team") +class TeamController(private val teamService: TeamService) { + @GetMapping + @Operation(summary = "List all teams for the specified hunt") + fun listHuntTeams(@PathVariable huntId: HuntId): ResponseEntity> { + return ResponseEntity.ok(teamService.getListOfTeamsForHunt(huntId).map { it.toResponse()}) + } + + @PostMapping + @Operation(summary = "Create a new team for the specified hunt") + fun createHuntTeam(@PathVariable huntId: HuntId, @Valid @RequestBody team: TeamRequest) { + val teamResponse = teamService.createTeam(team.name) + teamService.addTeamToHunt(huntId, teamResponse.id) + } + + @GetMapping("/{teamId}") + fun getTeam(@PathVariable huntId: HuntId, @PathVariable teamId: TeamId): ResponseEntity { + return ResponseEntity.ok(teamService.getTeamFromHunt(huntId, teamId).toResponse()) + } + + @PostMapping("/{teamId}") + fun joinTeamForHunt(@PathVariable huntId: HuntId, @PathVariable teamId: TeamId, authentication: Authentication) { + teamService.joinTeam(teamId, authentication.name) + } + + @GetMapping("/{teamId}/item/{itemId}") + fun getItemsForTeam(@PathVariable huntId: HuntId, + @PathVariable teamId: TeamId, + @PathVariable itemId: ItemId): ResponseEntity { + TODO() + } + + @GetMapping("/{teamId}/item/{itemId}/photo") + fun getPhotosForTeam(@PathVariable huntId: HuntId, + @PathVariable teamId: TeamId, + @PathVariable itemId: ItemId): ResponseEntity { + TODO() + } + + +} \ No newline at end of file diff --git a/src/main/kotlin/net/halfbinary/scavengerhuntapi/error/ExceptionHandler.kt b/src/main/kotlin/net/halfbinary/scavengerhuntapi/error/ExceptionHandler.kt index ed93cd5..a4c3397 100644 --- a/src/main/kotlin/net/halfbinary/scavengerhuntapi/error/ExceptionHandler.kt +++ b/src/main/kotlin/net/halfbinary/scavengerhuntapi/error/ExceptionHandler.kt @@ -4,6 +4,7 @@ import net.halfbinary.scavengerhuntapi.error.exception.InvalidEmailException import net.halfbinary.scavengerhuntapi.error.exception.LoginFailedException import net.halfbinary.scavengerhuntapi.error.exception.NotFoundException import net.halfbinary.scavengerhuntapi.error.exception.PreexistingAccountException +import org.slf4j.LoggerFactory import org.springframework.http.HttpStatus import org.springframework.http.converter.HttpMessageNotReadableException import org.springframework.validation.FieldError @@ -15,7 +16,9 @@ import org.springframework.web.bind.annotation.RestControllerAdvice @RestControllerAdvice class ExceptionHandler { - + companion object { + private val log = LoggerFactory.getLogger(net.halfbinary.scavengerhuntapi.error.ExceptionHandler::class.java) + } @ExceptionHandler(PreexistingAccountException::class) @ResponseStatus(HttpStatus.CONFLICT) fun preexistingAccountException(e: PreexistingAccountException): String? { @@ -42,8 +45,16 @@ class ExceptionHandler { @ExceptionHandler(HttpMessageNotReadableException::class) @ResponseStatus(HttpStatus.BAD_REQUEST) - fun httpMessageNotReadableException(e: HttpMessageNotReadableException): String? { - return e.message + fun httpMessageNotReadableException(e: HttpMessageNotReadableException): Map { + if (e.message?.contains("body is missing")?:false) { + return simpleMap("body","Body is missing") + } + if (e.message?.contains("parameter")?:false) { + val missingParameter = e.message?.split("parameter ")[1] + return simpleMap(missingParameter?:"","Missing required parameter $missingParameter") + } + log.debug("JSON parsing issue", e) + return simpleMap("body", "Parsing error") } @ExceptionHandler(MethodArgumentNotValidException::class) @@ -56,4 +67,8 @@ class ExceptionHandler { ) } } + + private fun simpleMap(key: String, value: String?): Map { + return mapOf(Pair(key, value)) + } } \ No newline at end of file diff --git a/src/main/kotlin/net/halfbinary/scavengerhuntapi/error/exception/ExpiredRefreshTokenException.kt b/src/main/kotlin/net/halfbinary/scavengerhuntapi/error/exception/ExpiredRefreshTokenException.kt new file mode 100644 index 0000000..1902456 --- /dev/null +++ b/src/main/kotlin/net/halfbinary/scavengerhuntapi/error/exception/ExpiredRefreshTokenException.kt @@ -0,0 +1,5 @@ +package net.halfbinary.scavengerhuntapi.error.exception + +import net.halfbinary.scavengerhuntapi.model.RefreshId + +class ExpiredRefreshTokenException(token: RefreshId): RuntimeException("The refresh token $token is expired.") \ No newline at end of file diff --git a/src/main/kotlin/net/halfbinary/scavengerhuntapi/error/exception/InvalidRefreshTokenException.kt b/src/main/kotlin/net/halfbinary/scavengerhuntapi/error/exception/InvalidRefreshTokenException.kt new file mode 100644 index 0000000..9f2e1cb --- /dev/null +++ b/src/main/kotlin/net/halfbinary/scavengerhuntapi/error/exception/InvalidRefreshTokenException.kt @@ -0,0 +1,5 @@ +package net.halfbinary.scavengerhuntapi.error.exception + +import net.halfbinary.scavengerhuntapi.model.RefreshId + +class InvalidRefreshTokenException(token: RefreshId): RuntimeException("The refresh token $token is not valid.") \ No newline at end of file diff --git a/src/main/kotlin/net/halfbinary/scavengerhuntapi/model/FoundStatus.kt b/src/main/kotlin/net/halfbinary/scavengerhuntapi/model/FoundStatus.kt index 79caaa3..7e80c2c 100644 --- a/src/main/kotlin/net/halfbinary/scavengerhuntapi/model/FoundStatus.kt +++ b/src/main/kotlin/net/halfbinary/scavengerhuntapi/model/FoundStatus.kt @@ -1,6 +1,7 @@ package net.halfbinary.scavengerhuntapi.model enum class FoundStatus { + NOT_FOUND, SUBMITTED, APPROVED, REJECTED, diff --git a/src/main/kotlin/net/halfbinary/scavengerhuntapi/model/TypeAlias.kt b/src/main/kotlin/net/halfbinary/scavengerhuntapi/model/TypeAlias.kt index b944653..14dcd6e 100644 --- a/src/main/kotlin/net/halfbinary/scavengerhuntapi/model/TypeAlias.kt +++ b/src/main/kotlin/net/halfbinary/scavengerhuntapi/model/TypeAlias.kt @@ -6,4 +6,7 @@ typealias FoundId = UUID typealias HuntId = UUID typealias HunterId = UUID typealias ItemId = UUID -typealias TeamId = UUID \ No newline at end of file +typealias TeamId = UUID +typealias RefreshId = UUID +typealias TeamHuntId = UUID +typealias PhotoId = UUID \ No newline at end of file diff --git a/src/main/kotlin/net/halfbinary/scavengerhuntapi/model/converter/HunterConverter.kt b/src/main/kotlin/net/halfbinary/scavengerhuntapi/model/converter/HunterConverter.kt index eb189b2..c82ac14 100644 --- a/src/main/kotlin/net/halfbinary/scavengerhuntapi/model/converter/HunterConverter.kt +++ b/src/main/kotlin/net/halfbinary/scavengerhuntapi/model/converter/HunterConverter.kt @@ -3,7 +3,6 @@ package net.halfbinary.scavengerhuntapi.model.converter import net.halfbinary.scavengerhuntapi.model.domain.Hunter import net.halfbinary.scavengerhuntapi.model.record.HunterRecord import net.halfbinary.scavengerhuntapi.model.request.HunterSignupRequest -import net.halfbinary.scavengerhuntapi.model.response.LoginResponse fun HunterSignupRequest.toDomain(): Hunter { return Hunter( @@ -20,8 +19,4 @@ fun Hunter.toRecord(): HunterRecord { fun HunterRecord.toDomain(): Hunter { return Hunter(id, email, name, password, isAdmin) -} - -fun Hunter.toLoginResponse(): LoginResponse { - return LoginResponse(email, name) } \ No newline at end of file diff --git a/src/main/kotlin/net/halfbinary/scavengerhuntapi/model/converter/RefreshConverter.kt b/src/main/kotlin/net/halfbinary/scavengerhuntapi/model/converter/RefreshConverter.kt new file mode 100644 index 0000000..f8bb9fc --- /dev/null +++ b/src/main/kotlin/net/halfbinary/scavengerhuntapi/model/converter/RefreshConverter.kt @@ -0,0 +1,7 @@ +package net.halfbinary.scavengerhuntapi.model.converter + +import net.halfbinary.scavengerhuntapi.model.response.RefreshResponse + +fun String.toRefreshResponse(): RefreshResponse { + return RefreshResponse(this) +} \ No newline at end of file diff --git a/src/main/kotlin/net/halfbinary/scavengerhuntapi/model/converter/TeamConverter.kt b/src/main/kotlin/net/halfbinary/scavengerhuntapi/model/converter/TeamConverter.kt new file mode 100644 index 0000000..f0fe2f5 --- /dev/null +++ b/src/main/kotlin/net/halfbinary/scavengerhuntapi/model/converter/TeamConverter.kt @@ -0,0 +1,22 @@ +package net.halfbinary.scavengerhuntapi.model.converter + +import net.halfbinary.scavengerhuntapi.model.domain.Team +import net.halfbinary.scavengerhuntapi.model.record.TeamRecord +import net.halfbinary.scavengerhuntapi.model.request.TeamRequest +import net.halfbinary.scavengerhuntapi.model.response.TeamResponse + +fun TeamRequest.toDomain(): Team { + return Team(name = name) +} + +fun Team.toRecord(): TeamRecord { + return TeamRecord(id, name) +} + +fun TeamRecord.toDomain(): Team { + return Team(id, name) +} + +fun Team.toResponse(): TeamResponse { + return TeamResponse(id, name) +} \ No newline at end of file diff --git a/src/main/kotlin/net/halfbinary/scavengerhuntapi/model/converter/TeamHuntConverter.kt b/src/main/kotlin/net/halfbinary/scavengerhuntapi/model/converter/TeamHuntConverter.kt new file mode 100644 index 0000000..00d2624 --- /dev/null +++ b/src/main/kotlin/net/halfbinary/scavengerhuntapi/model/converter/TeamHuntConverter.kt @@ -0,0 +1,16 @@ +package net.halfbinary.scavengerhuntapi.model.converter + +import net.halfbinary.scavengerhuntapi.model.domain.Team +import net.halfbinary.scavengerhuntapi.model.domain.TeamHunt +import net.halfbinary.scavengerhuntapi.model.record.TeamHuntRecord +import net.halfbinary.scavengerhuntapi.model.record.TeamRecord +import net.halfbinary.scavengerhuntapi.model.request.TeamRequest +import net.halfbinary.scavengerhuntapi.model.response.TeamResponse + +fun TeamHunt.toRecord(): TeamHuntRecord { + return TeamHuntRecord(id, teamId, huntId) +} + +fun TeamHuntRecord.toDomain(): TeamHunt { + return TeamHunt(id, teamId, huntId) +} \ No newline at end of file diff --git a/src/main/kotlin/net/halfbinary/scavengerhuntapi/model/domain/Team.kt b/src/main/kotlin/net/halfbinary/scavengerhuntapi/model/domain/Team.kt new file mode 100644 index 0000000..dc620f9 --- /dev/null +++ b/src/main/kotlin/net/halfbinary/scavengerhuntapi/model/domain/Team.kt @@ -0,0 +1,9 @@ +package net.halfbinary.scavengerhuntapi.model.domain + +import net.halfbinary.scavengerhuntapi.model.TeamId +import java.util.UUID + +data class Team( + val id: TeamId = UUID.randomUUID(), + val name: String +) diff --git a/src/main/kotlin/net/halfbinary/scavengerhuntapi/model/domain/TeamHunt.kt b/src/main/kotlin/net/halfbinary/scavengerhuntapi/model/domain/TeamHunt.kt new file mode 100644 index 0000000..6b402c3 --- /dev/null +++ b/src/main/kotlin/net/halfbinary/scavengerhuntapi/model/domain/TeamHunt.kt @@ -0,0 +1,12 @@ +package net.halfbinary.scavengerhuntapi.model.domain + +import net.halfbinary.scavengerhuntapi.model.HuntId +import net.halfbinary.scavengerhuntapi.model.TeamHuntId +import net.halfbinary.scavengerhuntapi.model.TeamId +import java.util.UUID + +data class TeamHunt( + val id: TeamHuntId = TeamHuntId.randomUUID(), + val teamId: TeamId, + val huntId: HuntId +) diff --git a/src/main/kotlin/net/halfbinary/scavengerhuntapi/model/record/RefreshTokenRecord.kt b/src/main/kotlin/net/halfbinary/scavengerhuntapi/model/record/RefreshTokenRecord.kt new file mode 100644 index 0000000..ce492bc --- /dev/null +++ b/src/main/kotlin/net/halfbinary/scavengerhuntapi/model/record/RefreshTokenRecord.kt @@ -0,0 +1,16 @@ +package net.halfbinary.scavengerhuntapi.model.record + +import jakarta.persistence.Entity +import jakarta.persistence.Id +import jakarta.persistence.Table +import net.halfbinary.scavengerhuntapi.model.RefreshId +import java.time.LocalDateTime + +@Entity +@Table(name = "refresh_token") +data class RefreshTokenRecord( + @Id + val token: RefreshId, + val email: String, + val expiryDateTime: LocalDateTime +) diff --git a/src/main/kotlin/net/halfbinary/scavengerhuntapi/model/record/TeamHuntRecord.kt b/src/main/kotlin/net/halfbinary/scavengerhuntapi/model/record/TeamHuntRecord.kt index 32d4204..4f251e3 100644 --- a/src/main/kotlin/net/halfbinary/scavengerhuntapi/model/record/TeamHuntRecord.kt +++ b/src/main/kotlin/net/halfbinary/scavengerhuntapi/model/record/TeamHuntRecord.kt @@ -4,6 +4,7 @@ import jakarta.persistence.Entity import jakarta.persistence.Id import jakarta.persistence.Table import net.halfbinary.scavengerhuntapi.model.HuntId +import net.halfbinary.scavengerhuntapi.model.TeamHuntId import net.halfbinary.scavengerhuntapi.model.TeamId import java.util.* @@ -11,7 +12,7 @@ import java.util.* @Table(name = "team_hunt") data class TeamHuntRecord( @Id - val id: UUID, + val id: TeamHuntId, val teamId: TeamId, val huntId: HuntId ) diff --git a/src/main/kotlin/net/halfbinary/scavengerhuntapi/model/request/ItemRequest.kt b/src/main/kotlin/net/halfbinary/scavengerhuntapi/model/request/ItemRequest.kt new file mode 100644 index 0000000..824aec0 --- /dev/null +++ b/src/main/kotlin/net/halfbinary/scavengerhuntapi/model/request/ItemRequest.kt @@ -0,0 +1,6 @@ +package net.halfbinary.scavengerhuntapi.model.request + +data class ItemRequest( + val name: String, + val points: Int +) diff --git a/src/main/kotlin/net/halfbinary/scavengerhuntapi/model/request/JoinTeamRequest.kt b/src/main/kotlin/net/halfbinary/scavengerhuntapi/model/request/JoinTeamRequest.kt new file mode 100644 index 0000000..89447e4 --- /dev/null +++ b/src/main/kotlin/net/halfbinary/scavengerhuntapi/model/request/JoinTeamRequest.kt @@ -0,0 +1,9 @@ +package net.halfbinary.scavengerhuntapi.model.request + +import jakarta.validation.constraints.NotBlank +import net.halfbinary.scavengerhuntapi.model.TeamId + +data class JoinTeamRequest( + @field:NotBlank + val teamId: TeamId +) diff --git a/src/main/kotlin/net/halfbinary/scavengerhuntapi/model/request/LogoutRequest.kt b/src/main/kotlin/net/halfbinary/scavengerhuntapi/model/request/LogoutRequest.kt new file mode 100644 index 0000000..a5ac952 --- /dev/null +++ b/src/main/kotlin/net/halfbinary/scavengerhuntapi/model/request/LogoutRequest.kt @@ -0,0 +1,9 @@ +package net.halfbinary.scavengerhuntapi.model.request + +import jakarta.validation.constraints.NotBlank +import net.halfbinary.scavengerhuntapi.model.RefreshId + +data class LogoutRequest( + @field:NotBlank(message = "You must provide a refresh token.") + val refreshToken: RefreshId +) diff --git a/src/main/kotlin/net/halfbinary/scavengerhuntapi/model/request/RefreshRequest.kt b/src/main/kotlin/net/halfbinary/scavengerhuntapi/model/request/RefreshRequest.kt new file mode 100644 index 0000000..d9698c1 --- /dev/null +++ b/src/main/kotlin/net/halfbinary/scavengerhuntapi/model/request/RefreshRequest.kt @@ -0,0 +1,9 @@ +package net.halfbinary.scavengerhuntapi.model.request + +import jakarta.validation.constraints.NotBlank +import net.halfbinary.scavengerhuntapi.model.RefreshId + +data class RefreshRequest( + @field:NotBlank(message = "Refresh token cannot be blank") + val refreshToken: RefreshId, +) diff --git a/src/main/kotlin/net/halfbinary/scavengerhuntapi/model/request/TeamRequest.kt b/src/main/kotlin/net/halfbinary/scavengerhuntapi/model/request/TeamRequest.kt new file mode 100644 index 0000000..5fab85e --- /dev/null +++ b/src/main/kotlin/net/halfbinary/scavengerhuntapi/model/request/TeamRequest.kt @@ -0,0 +1,5 @@ +package net.halfbinary.scavengerhuntapi.model.request + +data class TeamRequest( + val name: String +) diff --git a/src/main/kotlin/net/halfbinary/scavengerhuntapi/model/response/ItemResponse.kt b/src/main/kotlin/net/halfbinary/scavengerhuntapi/model/response/ItemResponse.kt new file mode 100644 index 0000000..b684540 --- /dev/null +++ b/src/main/kotlin/net/halfbinary/scavengerhuntapi/model/response/ItemResponse.kt @@ -0,0 +1,9 @@ +package net.halfbinary.scavengerhuntapi.model.response + +import net.halfbinary.scavengerhuntapi.model.ItemId + +data class ItemResponse( + val id: ItemId, + val name: String, + val points: Int +) diff --git a/src/main/kotlin/net/halfbinary/scavengerhuntapi/model/response/LoginResponse.kt b/src/main/kotlin/net/halfbinary/scavengerhuntapi/model/response/LoginResponse.kt index c98dc89..5ce4073 100644 --- a/src/main/kotlin/net/halfbinary/scavengerhuntapi/model/response/LoginResponse.kt +++ b/src/main/kotlin/net/halfbinary/scavengerhuntapi/model/response/LoginResponse.kt @@ -1,6 +1,8 @@ package net.halfbinary.scavengerhuntapi.model.response +import net.halfbinary.scavengerhuntapi.model.RefreshId + data class LoginResponse( - val email: String, - val name: String + val accessToken: String, + val refreshToken: RefreshId ) diff --git a/src/main/kotlin/net/halfbinary/scavengerhuntapi/model/response/PhotoResponse.kt b/src/main/kotlin/net/halfbinary/scavengerhuntapi/model/response/PhotoResponse.kt new file mode 100644 index 0000000..dfb0e68 --- /dev/null +++ b/src/main/kotlin/net/halfbinary/scavengerhuntapi/model/response/PhotoResponse.kt @@ -0,0 +1,10 @@ +package net.halfbinary.scavengerhuntapi.model.response + +import net.halfbinary.scavengerhuntapi.model.PhotoId +import java.time.LocalDateTime + +data class PhotoResponse( + val id: PhotoId, + val hunterName: String, + val photoUploadDateTime: LocalDateTime +) diff --git a/src/main/kotlin/net/halfbinary/scavengerhuntapi/model/response/RefreshResponse.kt b/src/main/kotlin/net/halfbinary/scavengerhuntapi/model/response/RefreshResponse.kt new file mode 100644 index 0000000..8d8a2d2 --- /dev/null +++ b/src/main/kotlin/net/halfbinary/scavengerhuntapi/model/response/RefreshResponse.kt @@ -0,0 +1,5 @@ +package net.halfbinary.scavengerhuntapi.model.response + +data class RefreshResponse( + val accessToken: String +) diff --git a/src/main/kotlin/net/halfbinary/scavengerhuntapi/model/response/TeamItemResponse.kt b/src/main/kotlin/net/halfbinary/scavengerhuntapi/model/response/TeamItemResponse.kt new file mode 100644 index 0000000..ef8ced0 --- /dev/null +++ b/src/main/kotlin/net/halfbinary/scavengerhuntapi/model/response/TeamItemResponse.kt @@ -0,0 +1,11 @@ +package net.halfbinary.scavengerhuntapi.model.response + +import net.halfbinary.scavengerhuntapi.model.FoundStatus +import net.halfbinary.scavengerhuntapi.model.ItemId + +data class TeamItemResponse( + val id: ItemId, + val itemName: String, + val hunterName: String, + val itemFoundStatus: FoundStatus +) diff --git a/src/main/kotlin/net/halfbinary/scavengerhuntapi/model/response/TeamResponse.kt b/src/main/kotlin/net/halfbinary/scavengerhuntapi/model/response/TeamResponse.kt new file mode 100644 index 0000000..3df18ab --- /dev/null +++ b/src/main/kotlin/net/halfbinary/scavengerhuntapi/model/response/TeamResponse.kt @@ -0,0 +1,8 @@ +package net.halfbinary.scavengerhuntapi.model.response + +import net.halfbinary.scavengerhuntapi.model.TeamId + +data class TeamResponse( + val id: TeamId, + val name: String +) diff --git a/src/main/kotlin/net/halfbinary/scavengerhuntapi/repository/HuntRepository.kt b/src/main/kotlin/net/halfbinary/scavengerhuntapi/repository/HuntRepository.kt index a7c8c36..1ccb029 100644 --- a/src/main/kotlin/net/halfbinary/scavengerhuntapi/repository/HuntRepository.kt +++ b/src/main/kotlin/net/halfbinary/scavengerhuntapi/repository/HuntRepository.kt @@ -2,6 +2,7 @@ package net.halfbinary.scavengerhuntapi.repository import net.halfbinary.scavengerhuntapi.model.HuntId import net.halfbinary.scavengerhuntapi.model.HunterId +import net.halfbinary.scavengerhuntapi.model.TeamId import net.halfbinary.scavengerhuntapi.model.record.HuntRecord import org.springframework.data.jpa.repository.JpaRepository import org.springframework.data.jpa.repository.Query @@ -23,6 +24,17 @@ interface HuntRepository : JpaRepository { """, nativeQuery = true) fun findAllOngoingByHunter(hunterId: HunterId): List + @Query(""" + SELECT h.* + FROM hunter u + INNER JOIN hunter_team ht ON u.id = ht.hunter_id + INNER JOIN team t ON ht.team_id = t.id + INNER JOIN team_hunt th ON t.id = th.team_id + INNER JOIN hunt h ON th.hunt_id = h.id + WHERE u.email = :email + """, nativeQuery = true) + fun findHuntsByEmail(email: String): List + @Query(""" SELECT h.* FROM hunt h diff --git a/src/main/kotlin/net/halfbinary/scavengerhuntapi/repository/HunterTeamRepository.kt b/src/main/kotlin/net/halfbinary/scavengerhuntapi/repository/HunterTeamRepository.kt new file mode 100644 index 0000000..46b375b --- /dev/null +++ b/src/main/kotlin/net/halfbinary/scavengerhuntapi/repository/HunterTeamRepository.kt @@ -0,0 +1,9 @@ +package net.halfbinary.scavengerhuntapi.repository + +import net.halfbinary.scavengerhuntapi.model.record.HunterTeamRecord +import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.stereotype.Repository +import java.util.UUID + +@Repository +interface HunterTeamRepository : JpaRepository diff --git a/src/main/kotlin/net/halfbinary/scavengerhuntapi/repository/RefreshTokenRepository.kt b/src/main/kotlin/net/halfbinary/scavengerhuntapi/repository/RefreshTokenRepository.kt new file mode 100644 index 0000000..15e3d7e --- /dev/null +++ b/src/main/kotlin/net/halfbinary/scavengerhuntapi/repository/RefreshTokenRepository.kt @@ -0,0 +1,7 @@ +package net.halfbinary.scavengerhuntapi.repository + +import net.halfbinary.scavengerhuntapi.model.RefreshId +import net.halfbinary.scavengerhuntapi.model.record.RefreshTokenRecord +import org.springframework.data.jpa.repository.JpaRepository + +interface RefreshTokenRepository: JpaRepository \ No newline at end of file diff --git a/src/main/kotlin/net/halfbinary/scavengerhuntapi/repository/TeamHuntRepository.kt b/src/main/kotlin/net/halfbinary/scavengerhuntapi/repository/TeamHuntRepository.kt new file mode 100644 index 0000000..4fc728b --- /dev/null +++ b/src/main/kotlin/net/halfbinary/scavengerhuntapi/repository/TeamHuntRepository.kt @@ -0,0 +1,28 @@ +package net.halfbinary.scavengerhuntapi.repository + +import net.halfbinary.scavengerhuntapi.model.HuntId +import net.halfbinary.scavengerhuntapi.model.TeamId +import net.halfbinary.scavengerhuntapi.model.record.HuntRecord +import net.halfbinary.scavengerhuntapi.model.record.TeamHuntRecord +import net.halfbinary.scavengerhuntapi.model.record.TeamRecord +import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.data.jpa.repository.Query +import org.springframework.stereotype.Repository +import java.util.* + +@Repository +interface TeamHuntRepository : JpaRepository { + @Query(""" + SELECT h.* FROM hunt h + INNER JOIN team_hunt th ON h.id = th.hunt_id + WHERE th.team_id = :teamId + """, nativeQuery = true) + fun findHuntsByTeamId(teamId: TeamId): List + + @Query(""" + SELECT t.* FROM team t + INNER JOIN team_hunt th ON t.id = th.team_id + WHERE th.hunt_id = :huntId + """, nativeQuery = true) + fun findTeamsByHuntId(huntId: HuntId): List +} \ No newline at end of file diff --git a/src/main/kotlin/net/halfbinary/scavengerhuntapi/repository/TeamRepository.kt b/src/main/kotlin/net/halfbinary/scavengerhuntapi/repository/TeamRepository.kt index 8cf9575..31644d0 100644 --- a/src/main/kotlin/net/halfbinary/scavengerhuntapi/repository/TeamRepository.kt +++ b/src/main/kotlin/net/halfbinary/scavengerhuntapi/repository/TeamRepository.kt @@ -1,5 +1,6 @@ package net.halfbinary.scavengerhuntapi.repository +import net.halfbinary.scavengerhuntapi.model.HuntId import net.halfbinary.scavengerhuntapi.model.TeamId import net.halfbinary.scavengerhuntapi.model.record.TeamRecord import org.springframework.data.jpa.repository.JpaRepository diff --git a/src/main/kotlin/net/halfbinary/scavengerhuntapi/service/HuntService.kt b/src/main/kotlin/net/halfbinary/scavengerhuntapi/service/HuntService.kt index b90534f..14655fa 100644 --- a/src/main/kotlin/net/halfbinary/scavengerhuntapi/service/HuntService.kt +++ b/src/main/kotlin/net/halfbinary/scavengerhuntapi/service/HuntService.kt @@ -10,6 +10,7 @@ import net.halfbinary.scavengerhuntapi.model.request.HuntStatus import net.halfbinary.scavengerhuntapi.repository.HuntRepository import org.springframework.data.repository.findByIdOrNull import org.springframework.stereotype.Service +import java.time.LocalDateTime @Service class HuntService(private val huntRepository: HuntRepository) { @@ -30,6 +31,27 @@ class HuntService(private val huntRepository: HuntRepository) { return huntRepository.findAllOngoingByHunter(hunterId).map { it.toDomain() } } + fun getHuntsByEmail(email: String, status: HuntStatus?): List { + val allHunts = huntRepository.findHuntsByEmail(email) + val filteredHunts = when (status) { + HuntStatus.ONGOING -> { + allHunts + .filter { !it.isTerminated && it.startDateTime < LocalDateTime.now() && it.endDateTime > LocalDateTime.now() } + .toList() + } + HuntStatus.CLOSED -> { + allHunts + .filter { it.isTerminated || it.endDateTime < LocalDateTime.now() } + } + HuntStatus.UNSTARTED -> { + allHunts + .filter { !it.isTerminated && it.startDateTime > LocalDateTime.now() } + } + else -> { allHunts } + } + return filteredHunts.map { it.toDomain() } + } + fun createHunt(hunt: Hunt): Hunt { return huntRepository.save(hunt.toRecord()).toDomain() } diff --git a/src/main/kotlin/net/halfbinary/scavengerhuntapi/service/HunterDetailsService.kt b/src/main/kotlin/net/halfbinary/scavengerhuntapi/service/HunterDetailsService.kt new file mode 100644 index 0000000..bc4e7ea --- /dev/null +++ b/src/main/kotlin/net/halfbinary/scavengerhuntapi/service/HunterDetailsService.kt @@ -0,0 +1,32 @@ +package net.halfbinary.scavengerhuntapi.service + +import net.halfbinary.scavengerhuntapi.repository.HunterRepository +import org.springframework.security.core.authority.SimpleGrantedAuthority +import org.springframework.security.core.userdetails.User +import org.springframework.security.core.userdetails.UserDetails +import org.springframework.security.core.userdetails.UserDetailsService +import org.springframework.security.core.userdetails.UsernameNotFoundException +import org.springframework.stereotype.Service +import java.util.Collections + + +@Service +class HunterDetailsService(private val hunterRepository: HunterRepository): UserDetailsService { + override fun loadUserByUsername(username: String): UserDetails { + hunterRepository.findByEmail(username) + ?.let { hunter -> + val hunterAuthorities = + if (hunter.isAdmin) { + SimpleGrantedAuthority("ROLE_ADMIN") + } else { + SimpleGrantedAuthority("ROLE_USER") + } + return User( + hunter.email, + hunter.password, + Collections.singleton(hunterAuthorities) + ) + } + throw UsernameNotFoundException("User Not Found with username: $username") + } +} \ No newline at end of file diff --git a/src/main/kotlin/net/halfbinary/scavengerhuntapi/service/HunterService.kt b/src/main/kotlin/net/halfbinary/scavengerhuntapi/service/HunterService.kt new file mode 100644 index 0000000..582b12b --- /dev/null +++ b/src/main/kotlin/net/halfbinary/scavengerhuntapi/service/HunterService.kt @@ -0,0 +1,15 @@ +package net.halfbinary.scavengerhuntapi.service + +import net.halfbinary.scavengerhuntapi.error.exception.NotFoundException +import net.halfbinary.scavengerhuntapi.model.converter.toDomain +import net.halfbinary.scavengerhuntapi.model.domain.Hunter +import net.halfbinary.scavengerhuntapi.repository.HunterRepository +import org.springframework.stereotype.Service + +@Service +class HunterService(private val hunterRepository: HunterRepository) { + fun getHunterByEmail(email: String): Hunter { + return hunterRepository.findByEmail(email)?.toDomain() + ?: throw NotFoundException("No hunter with email $email found") + } +} \ No newline at end of file diff --git a/src/main/kotlin/net/halfbinary/scavengerhuntapi/service/RefreshTokenService.kt b/src/main/kotlin/net/halfbinary/scavengerhuntapi/service/RefreshTokenService.kt new file mode 100644 index 0000000..5897a14 --- /dev/null +++ b/src/main/kotlin/net/halfbinary/scavengerhuntapi/service/RefreshTokenService.kt @@ -0,0 +1,49 @@ +package net.halfbinary.scavengerhuntapi.service + +import net.halfbinary.scavengerhuntapi.config.JwtUtil +import net.halfbinary.scavengerhuntapi.error.exception.ExpiredRefreshTokenException +import net.halfbinary.scavengerhuntapi.error.exception.InvalidRefreshTokenException +import net.halfbinary.scavengerhuntapi.model.RefreshId +import net.halfbinary.scavengerhuntapi.model.record.RefreshTokenRecord +import net.halfbinary.scavengerhuntapi.repository.RefreshTokenRepository +import org.slf4j.LoggerFactory +import org.springframework.data.repository.findByIdOrNull +import org.springframework.stereotype.Service +import java.time.LocalDateTime +import java.time.temporal.ChronoUnit + +@Service +class RefreshTokenService(private val refreshTokenRepository: RefreshTokenRepository, private val jwtUtil: JwtUtil) { + + companion object { + private val log = LoggerFactory.getLogger(RefreshTokenService::class.java) + } + + fun getAccessToken(tokenId: RefreshId): String { + return getToken(tokenId)?.let { refreshToken -> + if (isTokenExpired(refreshToken)) { + removeToken(tokenId) + throw ExpiredRefreshTokenException(tokenId) + } else { + jwtUtil.generateToken(refreshToken.email) + } + }?: throw InvalidRefreshTokenException(tokenId) + } + + fun generateRefreshToken(email: String): RefreshId { + return refreshTokenRepository.save(RefreshTokenRecord(RefreshId.randomUUID(), email, LocalDateTime.now().plus(1, ChronoUnit.MONTHS))).token + } + + fun isTokenExpired(token: RefreshTokenRecord): Boolean { + return token.expiryDateTime.isBefore(LocalDateTime.now()) + } + + fun getToken(token: RefreshId): RefreshTokenRecord? { + return refreshTokenRepository.findByIdOrNull(token) + } + + fun removeToken(token: RefreshId) { + log.debug("Removing refresh token: $token") + refreshTokenRepository.deleteById(token) + } +} \ No newline at end of file diff --git a/src/main/kotlin/net/halfbinary/scavengerhuntapi/service/TeamService.kt b/src/main/kotlin/net/halfbinary/scavengerhuntapi/service/TeamService.kt new file mode 100644 index 0000000..664f0f8 --- /dev/null +++ b/src/main/kotlin/net/halfbinary/scavengerhuntapi/service/TeamService.kt @@ -0,0 +1,55 @@ +package net.halfbinary.scavengerhuntapi.service + +import net.halfbinary.scavengerhuntapi.error.exception.NotFoundException +import net.halfbinary.scavengerhuntapi.model.HuntId +import net.halfbinary.scavengerhuntapi.model.TeamId +import net.halfbinary.scavengerhuntapi.model.converter.toDomain +import net.halfbinary.scavengerhuntapi.model.converter.toRecord +import net.halfbinary.scavengerhuntapi.model.domain.Team +import net.halfbinary.scavengerhuntapi.model.domain.TeamHunt +import net.halfbinary.scavengerhuntapi.model.record.HunterTeamRecord +import net.halfbinary.scavengerhuntapi.model.record.TeamHuntRecord +import net.halfbinary.scavengerhuntapi.model.record.TeamRecord +import net.halfbinary.scavengerhuntapi.model.request.TeamRequest +import net.halfbinary.scavengerhuntapi.model.response.TeamResponse +import net.halfbinary.scavengerhuntapi.repository.HunterRepository +import net.halfbinary.scavengerhuntapi.repository.HunterTeamRepository +import net.halfbinary.scavengerhuntapi.repository.TeamHuntRepository +import net.halfbinary.scavengerhuntapi.repository.TeamRepository +import org.springframework.stereotype.Service +import java.util.UUID + +@Service +class TeamService( + private val teamRepository: TeamRepository, + private val teamHuntRepository: TeamHuntRepository, + private val hunterRepository: HunterRepository, + private val hunterTeamRepository: HunterTeamRepository, +) { + fun getListOfTeamsForHunt(huntId: HuntId): List { + return getTeamsForHunt(huntId) + } + + fun createTeam(name: String): Team { + return teamRepository.save(TeamRequest(name).toDomain().toRecord()).toDomain() + } + + fun addTeamToHunt(huntId: HuntId, teamId: TeamId) { + teamHuntRepository.save(TeamHunt(teamId = teamId, huntId = huntId).toRecord()).toDomain() + } + + fun getTeamFromHunt(huntId: HuntId, teamId: TeamId): Team { + return getTeamsForHunt(huntId) + .filter { it.id == teamId } + .elementAt(0) + } + + fun joinTeam(teamId: TeamId, email: String) { + val hunter = hunterRepository.findByEmail(email) ?: throw NotFoundException("No hunter with email $email found") + hunterTeamRepository.save(HunterTeamRecord(UUID.randomUUID(), hunter.id, teamId)) + } + + private fun getTeamsForHunt(huntId: HuntId): List { + return teamHuntRepository.findTeamsByHuntId(huntId).map { it.toDomain() } + } +} \ No newline at end of file diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 5c5b534..c901910 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -4,7 +4,15 @@ spring.jpa.hibernate.ddl-auto=update spring.jpa.properties.hibernate.type.preferred_uuid_jdbc_type=CHAR -spring.datasource.driverClassName=com.mysql.cj.jdbc.Driver +spring.datasource.driverClassName=org.mariadb.jdbc.Driver spring.datasource.url=${DB_URL} spring.datasource.username=${DB_USER} -spring.datasource.password=${DB_PASSWORD} \ No newline at end of file +spring.datasource.password=${DB_PASSWORD} + +jwt.secret=${JWT_SECRET} +jwt.expiration=300000 + +springdoc.api-docs.enabled=true +springdoc.api-docs.path=/docs/api-docs +springdoc.swagger-ui.enabled=true +springdoc.swagger-ui.path=/docs/swagger-ui.html \ No newline at end of file