From da76c57aef19c102b2ba7f32fcdb13725a3ffd54 Mon Sep 17 00:00:00 2001 From: Jaeheon Shim Date: Mon, 13 Apr 2026 21:19:59 -0400 Subject: [PATCH 1/5] Logging (#133) * Add Grafana Loki logback appender * Convert build.gradle.kts and settings.gradle.kts to regular Groovy files * Remove create_backup.sh --- build.gradle | 117 ++++++++++++++++++ build.gradle.kts | 116 ----------------- create_backup.sh | 54 -------- docker-compose.yml | 6 + settings.gradle | 1 + settings.gradle.kts | 1 - .../shared/web/GlobalExceptionHandler.java | 8 ++ src/main/resources/logback-spring.xml | 58 +++++---- 8 files changed, 167 insertions(+), 194 deletions(-) create mode 100644 build.gradle delete mode 100644 build.gradle.kts delete mode 100755 create_backup.sh create mode 100644 settings.gradle delete mode 100644 settings.gradle.kts diff --git a/build.gradle b/build.gradle new file mode 100644 index 00000000..a2c449c6 --- /dev/null +++ b/build.gradle @@ -0,0 +1,117 @@ +plugins { + id 'java' + id 'org.springframework.boot' version '3.5.5' + id 'io.spring.dependency-management' version '1.1.6' + id 'com.diffplug.spotless' version '6.25.0' + id 'checkstyle' +} + +group = 'org.bytefight' +version = '0.0.1' + +java { + toolchain { + languageVersion = JavaLanguageVersion.of(17) + } +} + +repositories { + mavenCentral() +} + +def springCloudGcpVersion = '5.0.0' +def springCloudVersion = '2023.0.0' +def jjwtVersion = '0.11.5' +def testcontainersVersion = '1.18.3' + +configurations { + compileOnly { + extendsFrom annotationProcessor + } +} + +dependencies { + implementation 'org.springframework.boot:spring-boot-starter-web' + implementation 'net.logstash.logback:logstash-logback-encoder:8.0' + implementation 'com.github.loki4j:loki-logback-appender:1.4.1' + implementation 'org.springframework.boot:spring-boot-starter-data-jpa' + implementation 'org.springframework.boot:spring-boot-starter-validation' + implementation 'org.springframework.boot:spring-boot-starter-amqp' + + implementation 'com.github.luben:zstd-jni:1.5.6-9' + + implementation 'org.springframework.boot:spring-boot-starter-security' + implementation 'org.springframework.boot:spring-boot-starter-oauth2-resource-server' + + runtimeOnly 'org.postgresql:postgresql' + runtimeOnly 'com.h2database:h2' + implementation 'org.flywaydb:flyway-core' + implementation 'org.flywaydb:flyway-database-postgresql' + + implementation platform('com.google.cloud:libraries-bom:26.29.0') + implementation 'com.google.cloud:google-cloud-storage' + + implementation platform('org.hibernate.search:hibernate-search-bom:7.0.1.Final') + implementation 'org.hibernate.search:hibernate-search-mapper-orm' + implementation 'org.hibernate.search:hibernate-search-backend-lucene' + + implementation "io.jsonwebtoken:jjwt-api:${jjwtVersion}" + runtimeOnly "io.jsonwebtoken:jjwt-impl:${jjwtVersion}" + runtimeOnly "io.jsonwebtoken:jjwt-jackson:${jjwtVersion}" + + implementation 'me.paulschwarz:spring-dotenv:3.0.0' + implementation 'com.fasterxml.jackson.datatype:jackson-datatype-jsr310' + implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.13' + + compileOnly 'org.projectlombok:lombok' + annotationProcessor 'org.projectlombok:lombok' + + developmentOnly 'org.springframework.boot:spring-boot-devtools' + + testImplementation 'org.springframework.boot:spring-boot-starter-test' + testImplementation 'org.springframework.security:spring-security-test' + testRuntimeOnly 'org.junit.platform:junit-platform-launcher' + + testImplementation 'org.springframework.boot:spring-boot-testcontainers:3.3.5' + testImplementation "org.testcontainers:junit-jupiter:${testcontainersVersion}" + testImplementation "org.testcontainers:postgresql:${testcontainersVersion}" + testImplementation "org.testcontainers:rabbitmq:${testcontainersVersion}" + + testRuntimeOnly 'org.postgresql:postgresql' +} + +dependencyManagement { + imports { + mavenBom "com.google.cloud:spring-cloud-gcp-dependencies:${springCloudGcpVersion}" + mavenBom "org.springframework.cloud:spring-cloud-dependencies:${springCloudVersion}" + } +} + +tasks.withType(Test) { + useJUnitPlatform() +} + +spotless { + java { + googleJavaFormat('1.17.0') + + removeUnusedImports() + + importOrder('', 'java', 'javax', 'org', 'com') + + trimTrailingWhitespace() + endWithNewline() + } +} + +checkstyle { + toolVersion = '10.17.0' + configDirectory = file("${rootDir}/config/checkstyle") +} + +tasks.withType(Checkstyle) { + reports { + xml.required = true + html.required = true + } +} diff --git a/build.gradle.kts b/build.gradle.kts deleted file mode 100644 index 665829e6..00000000 --- a/build.gradle.kts +++ /dev/null @@ -1,116 +0,0 @@ -plugins { - java - id("org.springframework.boot") version "3.5.5" - id("io.spring.dependency-management") version "1.1.6" - id("com.diffplug.spotless") version "6.25.0" - checkstyle -} - -group = "org.bytefight" -version = "0.0.1" - -java { - toolchain { - languageVersion = JavaLanguageVersion.of(17) - } -} - -repositories { - mavenCentral() -} - -val springCloudGcpVersion = "5.0.0" -val springCloudVersion = "2023.0.0" -val jjwtVersion = "0.11.5" -val testcontainersVersion = "1.18.3" - -configurations { - compileOnly { - extendsFrom(annotationProcessor.get()) - } -} - -dependencies { - implementation("org.springframework.boot:spring-boot-starter-web") - implementation("net.logstash.logback:logstash-logback-encoder:8.0") - implementation("org.springframework.boot:spring-boot-starter-data-jpa") - implementation("org.springframework.boot:spring-boot-starter-validation") - implementation("org.springframework.boot:spring-boot-starter-amqp") - - implementation("com.github.luben:zstd-jni:1.5.6-9") - - implementation("org.springframework.boot:spring-boot-starter-security") - implementation("org.springframework.boot:spring-boot-starter-oauth2-resource-server") - - runtimeOnly("org.postgresql:postgresql") - runtimeOnly("com.h2database:h2") - implementation("org.flywaydb:flyway-core") - implementation("org.flywaydb:flyway-database-postgresql") - - implementation(platform("com.google.cloud:libraries-bom:26.29.0")) - implementation("com.google.cloud:google-cloud-storage") - - implementation(platform("org.hibernate.search:hibernate-search-bom:7.0.1.Final")) - implementation("org.hibernate.search:hibernate-search-mapper-orm") - implementation("org.hibernate.search:hibernate-search-backend-lucene") - - implementation("io.jsonwebtoken:jjwt-api:$jjwtVersion") - runtimeOnly("io.jsonwebtoken:jjwt-impl:$jjwtVersion") - runtimeOnly("io.jsonwebtoken:jjwt-jackson:$jjwtVersion") - - implementation("me.paulschwarz:spring-dotenv:3.0.0") - implementation("com.fasterxml.jackson.datatype:jackson-datatype-jsr310") - implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.13") - - compileOnly("org.projectlombok:lombok") - annotationProcessor("org.projectlombok:lombok") - - developmentOnly("org.springframework.boot:spring-boot-devtools") - - testImplementation("org.springframework.boot:spring-boot-starter-test") - testImplementation("org.springframework.security:spring-security-test") - testRuntimeOnly("org.junit.platform:junit-platform-launcher") - - testImplementation("org.springframework.boot:spring-boot-testcontainers:3.3.5") - testImplementation("org.testcontainers:junit-jupiter:$testcontainersVersion") - testImplementation("org.testcontainers:postgresql:$testcontainersVersion") - testImplementation("org.testcontainers:rabbitmq:$testcontainersVersion") - - testRuntimeOnly("org.postgresql:postgresql") -} - -dependencyManagement { - imports { - mavenBom("com.google.cloud:spring-cloud-gcp-dependencies:$springCloudGcpVersion") - mavenBom("org.springframework.cloud:spring-cloud-dependencies:$springCloudVersion") - } -} - -tasks.withType { - useJUnitPlatform() -} - -spotless { - java { - googleJavaFormat("1.17.0") - - removeUnusedImports() - - importOrder("", "java", "javax", "org", "com") - - trimTrailingWhitespace() - endWithNewline() - } -} - -checkstyle { - toolVersion = "10.17.0" - configDirectory.set(file("$rootDir/config/checkstyle")) -} - -tasks.withType { - reports { - xml.required.set(true) - html.required.set(true) - } -} diff --git a/create_backup.sh b/create_backup.sh deleted file mode 100755 index b5464872..00000000 --- a/create_backup.sh +++ /dev/null @@ -1,54 +0,0 @@ -#!/bin/bash -if [ -z "$DB_HOST" ] || [ -z "$DB_PORT" ] || [ -z "$DB_NAME" ] || [ -z "$DB_PASSWORD" ] || [ -z "$DB_USER" ]; then - echo "Error: one or more of the required variables (DB_HOST, DB_PORT, DB_NAME, DB_USER, DB_PASSWORD were not found in the .env file." - exit 1 -fi - -echo "Starting backup process" - -mkdir -p "$BACKUP_DIR" -if [ $? -ne 0 ]; then - echo "Error: could not create backup directory '$BACKUP_DIR'." - exit 1 -fi - -echo "Connection details:" -echo " - Host: $DB_HOST" -echo " - Port: $DB_PORT" -echo " - Database: $DB_NAME" -echo " - User: $DB_USER" - -TIMESTAMP=$(date +"%Y-%m-%d_%H-%M-%S") - -BACKUP_FILE="$BACKUP_DIR/dump_${DB_NAME}_${TIMESTAMP}.dump" - -export PGPASSWORD="$DB_PASSWORD" - -echo "creating dump for database '$DB_NAME'" -pg_dump -h "$DB_HOST" \ - -p "$DB_PORT" \ - -U "$DB_USER" \ - -d "$DB_NAME" \ - -F c \ - -f "$BACKUP_FILE" - -EXIT_CODE=$? - -unset PGPASSWORD - -if [ $EXIT_CODE -ne 0 ]; then - echo "---" - echo "pg_dump failed with exit code $EXIT_CODE." - rm "$BACKUP_FILE" 2>/dev/null - exit 1 -fi - - -echo "Database backup created successfully" -echo "File: $BACKUP_FILE" - -exit 0 - - - - diff --git a/docker-compose.yml b/docker-compose.yml index 85b6ddf5..36519e3b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -20,6 +20,8 @@ services: depends_on: postgres: condition: service_healthy + loki: + condition: service_started env_file: - .env ports: @@ -54,6 +56,10 @@ services: - grafana_data:/var/lib/grafana restart: unless-stopped + loki: + image: grafana/loki:latest + command: -config.file=/etc/loki/local-config.yaml + volumes: db_data: driver: local diff --git a/settings.gradle b/settings.gradle new file mode 100644 index 00000000..fd1da902 --- /dev/null +++ b/settings.gradle @@ -0,0 +1 @@ +rootProject.name = 'BotFightWebServer' diff --git a/settings.gradle.kts b/settings.gradle.kts deleted file mode 100644 index 2e1e3d41..00000000 --- a/settings.gradle.kts +++ /dev/null @@ -1 +0,0 @@ -rootProject.name = "BotFightWebServer" diff --git a/src/main/java/org/bytefight/webserver/shared/web/GlobalExceptionHandler.java b/src/main/java/org/bytefight/webserver/shared/web/GlobalExceptionHandler.java index 97e16a66..0b0fffef 100644 --- a/src/main/java/org/bytefight/webserver/shared/web/GlobalExceptionHandler.java +++ b/src/main/java/org/bytefight/webserver/shared/web/GlobalExceptionHandler.java @@ -6,6 +6,8 @@ import java.util.LinkedHashMap; import java.util.Map; +import lombok.extern.slf4j.Slf4j; + import org.bytefight.webserver.auth.domain.RegistrationException; import org.bytefight.webserver.common.domain.PermissionDeniedException; import org.springframework.http.HttpStatus; @@ -15,6 +17,7 @@ import org.springframework.web.bind.annotation.ResponseStatus; import org.springframework.web.bind.annotation.RestControllerAdvice; +@Slf4j @RestControllerAdvice public class GlobalExceptionHandler { private ProblemDetail problem(HttpStatus status, String title, String detail) { @@ -26,6 +29,7 @@ private ProblemDetail problem(HttpStatus status, String title, String detail) { @ExceptionHandler(MethodArgumentNotValidException.class) ProblemDetail handleValidation(MethodArgumentNotValidException ex) { + log.warn("Validation failed: {}", ex.getMessage()); ProblemDetail pd = ProblemDetail.forStatus(HttpStatus.BAD_REQUEST); pd.setTitle("Validation Failed"); pd.setDetail("One or more fields are invalid."); @@ -42,6 +46,7 @@ ProblemDetail handleValidation(MethodArgumentNotValidException ex) { @ExceptionHandler(ConstraintViolationException.class) @ResponseStatus(HttpStatus.BAD_REQUEST) public ProblemDetail handleConstraintViolation(ConstraintViolationException ex) { + log.warn("Constraint violation: {}", ex.getMessage()); ProblemDetail pd = ProblemDetail.forStatus(HttpStatus.BAD_REQUEST); pd.setTitle("Validation failed"); @@ -56,18 +61,21 @@ public ProblemDetail handleConstraintViolation(ConstraintViolationException ex) @ExceptionHandler(RegistrationException.class) ProblemDetail handleRegistration(RegistrationException ex) { + log.warn("Registration error: {}", ex.getMessage()); return problem(HttpStatus.CONFLICT, "Registration Error", ex.getMessage()); } @ExceptionHandler(IllegalArgumentException.class) @ResponseStatus(HttpStatus.BAD_REQUEST) public ProblemDetail handleIllegalArgument(IllegalArgumentException ex) { + log.warn("Illegal argument: {}", ex.getMessage()); return problem(HttpStatus.BAD_REQUEST, "Bad Request", ex.getMessage()); } @ExceptionHandler(PermissionDeniedException.class) @ResponseStatus(HttpStatus.FORBIDDEN) ProblemDetail handlePermissionDenied(PermissionDeniedException ex) { + log.warn("Permission denied: {}", ex.getMessage()); return problem(HttpStatus.CONFLICT, "Permission Denied", ex.getMessage()); } } diff --git a/src/main/resources/logback-spring.xml b/src/main/resources/logback-spring.xml index 2bf05554..a6369426 100644 --- a/src/main/resources/logback-spring.xml +++ b/src/main/resources/logback-spring.xml @@ -1,32 +1,44 @@ - - - - %d{HH:mm:ss.SSS} %highlight(%-5level) [%thread] %cyan(%logger{36}) - %msg%n - - - - - - + + + %d{HH:mm:ss.SSS} %highlight(%-5level) [%thread] %cyan(%logger{36}) - %msg%n + + + + + - - - - @timestamp - message - logger - thread - level - stack_trace - - {"service":"bytefight-webserver"} - + + + http://loki:3100/loki/api/v1/push + + + + + + { + "level":"%level", + "class":"%logger{36}", + "thread":"%thread", + "message": "%message", + "requestId": "%X{requestId}", + "httpMethod": "%X{httpMethod}", + "httpPath": "%X{httpPath}", + "userId": "%X{userId}" + } + + + + - + + From 5c0ef98ab52570e050fbbdc0d2bfd3c5d1e10134 Mon Sep 17 00:00:00 2001 From: Jaeheon Shim Date: Mon, 13 Apr 2026 21:21:35 -0400 Subject: [PATCH 2/5] Fix dockerfile --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 9b204857..d5db2b39 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,7 +1,7 @@ FROM eclipse-temurin:17-jdk-jammy AS build WORKDIR /app -COPY gradlew settings.gradle.kts build.gradle.kts ./ +COPY gradlew settings.gradle build.gradle ./ COPY gradle ./gradle RUN chmod +x ./gradlew && ./gradlew --no-daemon help From cfe3ef62db5ec991c1524e973acc1e186d327151 Mon Sep 17 00:00:00 2001 From: Jaeheon Shim Date: Tue, 14 Apr 2026 20:47:18 -0400 Subject: [PATCH 3/5] Add resume delete option --- .../webserver/user/application/UserService.java | 16 +++++++++++++++- .../webserver/user/infra/UserController.java | 16 +++++++++++++++- 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/src/main/java/org/bytefight/webserver/user/application/UserService.java b/src/main/java/org/bytefight/webserver/user/application/UserService.java index c4b2d95a..56bd2d0a 100644 --- a/src/main/java/org/bytefight/webserver/user/application/UserService.java +++ b/src/main/java/org/bytefight/webserver/user/application/UserService.java @@ -112,7 +112,7 @@ public FileRecord uploadResume(MultipartFile file, UUID userUuid) throws IOExcep String lower = fileName.toLowerCase(); - Set allowed_type = Set.of(".pdf", ".doc", ".docx"); + Set allowed_type = Set.of(".pdf"); boolean valid = allowed_type.stream().anyMatch(lower::endsWith); @@ -133,6 +133,20 @@ public FileRecord uploadResume(MultipartFile file, UUID userUuid) throws IOExcep return newResume; } + @Transactional + public void deleteResume(UUID userUuid) { + User user = userRepository.findByUuid(userUuid).orElseThrow(); + FileRecord resume = user.getResume(); + + if (resume == null) { + throw new NoSuchElementException("No resume found"); + } + + user.setResume(null); + userRepository.save(user); + storageService.delete(resume.getUuid().toString()); + } + public ResumeDto getResume(UUID userUuid) { User user = userRepository.findByUuid(userUuid).orElseThrow(); FileRecord resume = user.getResume(); diff --git a/src/main/java/org/bytefight/webserver/user/infra/UserController.java b/src/main/java/org/bytefight/webserver/user/infra/UserController.java index 03826394..0e1b414d 100644 --- a/src/main/java/org/bytefight/webserver/user/infra/UserController.java +++ b/src/main/java/org/bytefight/webserver/user/infra/UserController.java @@ -6,6 +6,7 @@ import lombok.RequiredArgsConstructor; import java.io.IOException; +import java.util.NoSuchElementException; import org.bytefight.webserver.storage.application.LocalStorageService; import org.bytefight.webserver.storage.domain.FileRecord; @@ -44,12 +45,25 @@ public ResponseEntity uploadResume( } } + @DeleteMapping(value = "/resume") + @Operation( + operationId = "deleteResume", + summary = "Delete the authenticated user's resume") + public ResponseEntity deleteResume(@AuthenticationPrincipal User user) { + userService.deleteResume(user.getUuid()); + return ResponseEntity.noContent().build(); + } + @GetMapping(value = "/resume") @Operation( operationId = "getResume", summary = "Get the authenticated user's resume + a short-lived download link") @Transactional public ResponseEntity getResume(@AuthenticationPrincipal User user) { - return ResponseEntity.ok(userService.getResume(user.getUuid())); + try { + return ResponseEntity.ok(userService.getResume(user.getUuid())); + } catch(NoSuchElementException e) { + return ResponseEntity.notFound().build(); + } } } From 99db088e7ae778b6bbc457284ffc9e02656f7d84 Mon Sep 17 00:00:00 2001 From: Jaeheon Shim Date: Tue, 14 Apr 2026 21:08:52 -0400 Subject: [PATCH 4/5] Add resumes to admin user DTOs and add a new endpoint to allow admins to download resumes --- .../user/application/AdminUserService.java | 29 ++++++++++-- ...a => AdminUserWithPlayerAndResumeDto.java} | 3 +- .../user/infra/AdminUserController.java | 45 +++++++++++++++++-- .../webserver/user/infra/UserRepository.java | 16 ++++--- 4 files changed, 79 insertions(+), 14 deletions(-) rename src/main/java/org/bytefight/webserver/user/domain/dto/{AdminUserWithPlayerDto.java => AdminUserWithPlayerAndResumeDto.java} (79%) diff --git a/src/main/java/org/bytefight/webserver/user/application/AdminUserService.java b/src/main/java/org/bytefight/webserver/user/application/AdminUserService.java index 0f30e7f9..54bc7e4f 100644 --- a/src/main/java/org/bytefight/webserver/user/application/AdminUserService.java +++ b/src/main/java/org/bytefight/webserver/user/application/AdminUserService.java @@ -1,8 +1,16 @@ package org.bytefight.webserver.user.application; +import java.time.Duration; import java.util.List; +import java.util.NoSuchElementException; +import java.util.UUID; -import org.bytefight.webserver.user.domain.dto.AdminUserWithPlayerDto; +import org.bytefight.webserver.storage.application.LocalStorageService; +import org.bytefight.webserver.storage.domain.DownloadLinkDto; +import org.bytefight.webserver.storage.domain.FileRecord; +import org.bytefight.webserver.user.domain.User; +import org.bytefight.webserver.user.domain.dto.AdminUserWithPlayerAndResumeDto; +import org.bytefight.webserver.user.domain.dto.ResumeDto; import org.bytefight.webserver.user.infra.UserRepository; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; @@ -11,15 +19,30 @@ @Service public class AdminUserService { private final UserRepository userRepository; + private final LocalStorageService localStorageService; - public AdminUserService(UserRepository userRepository) { + public AdminUserService(UserRepository userRepository, LocalStorageService localStorageService) { this.userRepository = userRepository; + this.localStorageService = localStorageService; } - public Page listUsers(Pageable pageable, List userIds) { + public Page listUsers(Pageable pageable, List userIds) { if (userIds != null && !userIds.isEmpty()) { return userRepository.findAllWithPlayersByIdIn(userIds, pageable); } return userRepository.findAllWithPlayers(pageable); } + + public ResumeDto getResume(UUID userUuid) { + User user = userRepository.findByUuid(userUuid).orElseThrow(); + FileRecord resume = user.getResume(); + + if (resume == null) { + throw new NoSuchElementException("No resume found"); + } + + DownloadLinkDto link = + localStorageService.getDownloadLink(resume.getUuid().toString(), Duration.ofMinutes(5)); + return ResumeDto.from(link, user); + } } diff --git a/src/main/java/org/bytefight/webserver/user/domain/dto/AdminUserWithPlayerDto.java b/src/main/java/org/bytefight/webserver/user/domain/dto/AdminUserWithPlayerAndResumeDto.java similarity index 79% rename from src/main/java/org/bytefight/webserver/user/domain/dto/AdminUserWithPlayerDto.java rename to src/main/java/org/bytefight/webserver/user/domain/dto/AdminUserWithPlayerAndResumeDto.java index 2108028a..2dd69ac3 100644 --- a/src/main/java/org/bytefight/webserver/user/domain/dto/AdminUserWithPlayerDto.java +++ b/src/main/java/org/bytefight/webserver/user/domain/dto/AdminUserWithPlayerAndResumeDto.java @@ -6,7 +6,7 @@ import java.util.UUID; @Value -public class AdminUserWithPlayerDto { +public class AdminUserWithPlayerAndResumeDto { Long id; UUID uuid; String email; @@ -14,4 +14,5 @@ public class AdminUserWithPlayerDto { boolean isAdmin; Long playerId; String playerUsername; + UUID resumeUuid; } diff --git a/src/main/java/org/bytefight/webserver/user/infra/AdminUserController.java b/src/main/java/org/bytefight/webserver/user/infra/AdminUserController.java index 2747f89c..f78b9c3b 100644 --- a/src/main/java/org/bytefight/webserver/user/infra/AdminUserController.java +++ b/src/main/java/org/bytefight/webserver/user/infra/AdminUserController.java @@ -7,16 +7,23 @@ import java.util.Collection; import java.util.List; import java.util.Map; +import java.util.NoSuchElementException; import java.util.Set; +import java.util.UUID; import org.bytefight.webserver.common.web.RestPageRequest; import org.bytefight.webserver.user.application.AdminUserService; -import org.bytefight.webserver.user.domain.dto.AdminUserWithPlayerDto; +import org.bytefight.webserver.user.domain.dto.AdminUserWithPlayerAndResumeDto; +import org.bytefight.webserver.user.domain.dto.ResumeDto; import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.http.ResponseEntity; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.ModelAttribute; +import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; @@ -29,7 +36,9 @@ public class AdminUserController { private static final int MAX_PAGE_SIZE = 100; private static final String DEFAULT_SORT_FIELD = "createdAt"; private static final Set ALLOWED_SORT_FIELDS = - Set.of("createdAt", "email", "isAdmin", "uuid"); + Set.of("createdAt", "email", "isAdmin", "uuid", "resumeUuid"); + private static final Map SORT_FIELD_MAPPING = + Map.of("resumeUuid", "resume.uuid"); private final AdminUserService adminUserService; @@ -39,14 +48,44 @@ public AdminUserController(AdminUserService adminUserService) { @GetMapping @Operation(operationId = "adminListAllUsers", summary = "REST endpoint to list all users") - public Page listAll(@ModelAttribute RestPageRequest pageRequest) { + public Page listAll(@ModelAttribute RestPageRequest pageRequest) { Pageable pageable = pageRequest.toPageable( DEFAULT_PAGE_SIZE, MAX_PAGE_SIZE, DEFAULT_SORT_FIELD, ALLOWED_SORT_FIELDS); + pageable = remapSort(pageable); List userIds = parseUserIds(pageRequest.getFilter()); return adminUserService.listUsers(pageable, userIds); } + private static Pageable remapSort(Pageable pageable) { + Sort original = pageable.getSort(); + if (original.isUnsorted()) { + return pageable; + } + Sort remapped = + Sort.by( + original.stream() + .map( + o -> + new Sort.Order( + o.getDirection(), + SORT_FIELD_MAPPING.getOrDefault(o.getProperty(), o.getProperty()))) + .toList()); + return PageRequest.of(pageable.getPageNumber(), pageable.getPageSize(), remapped); + } + + @GetMapping("/{userUuid}/resume") + @Operation( + operationId = "adminGetUserResume", + summary = "Get a user's resume + a short-lived download link") + public ResponseEntity getResume(@PathVariable UUID userUuid) { + try { + return ResponseEntity.ok(adminUserService.getResume(userUuid)); + } catch (NoSuchElementException e) { + return ResponseEntity.notFound().build(); + } + } + private static List parseUserIds(Map filter) { if (filter == null || filter.isEmpty()) { return List.of(); diff --git a/src/main/java/org/bytefight/webserver/user/infra/UserRepository.java b/src/main/java/org/bytefight/webserver/user/infra/UserRepository.java index e1ed2336..d115fb20 100644 --- a/src/main/java/org/bytefight/webserver/user/infra/UserRepository.java +++ b/src/main/java/org/bytefight/webserver/user/infra/UserRepository.java @@ -5,7 +5,7 @@ import java.util.UUID; import org.bytefight.webserver.user.domain.User; -import org.bytefight.webserver.user.domain.dto.AdminUserWithPlayerDto; +import org.bytefight.webserver.user.domain.dto.AdminUserWithPlayerAndResumeDto; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; @@ -16,36 +16,38 @@ public interface UserRepository extends JpaRepository { @Query( """ - SELECT new org.bytefight.webserver.user.domain.dto.AdminUserWithPlayerDto( + SELECT new org.bytefight.webserver.user.domain.dto.AdminUserWithPlayerAndResumeDto( u.id, u.uuid, u.email, u.createdAt, u.isAdmin, p.id, - p.username + p.username, + u.resume.uuid ) FROM User u LEFT JOIN Player p ON p.user = u """) - Page findAllWithPlayers(Pageable pageable); + Page findAllWithPlayers(Pageable pageable); @Query( """ - SELECT new org.bytefight.webserver.user.domain.dto.AdminUserWithPlayerDto( + SELECT new org.bytefight.webserver.user.domain.dto.AdminUserWithPlayerAndResumeDto( u.id, u.uuid, u.email, u.createdAt, u.isAdmin, p.id, - p.username + p.username, + u.resume.uuid ) FROM User u LEFT JOIN Player p ON p.user = u WHERE u.id IN :ids """) - Page findAllWithPlayersByIdIn(List ids, Pageable pageable); + Page findAllWithPlayersByIdIn(List ids, Pageable pageable); Optional findByEmail(String email); From 2d3d7c2327ebaba939bb2cfcde04aee44e15c7a4 Mon Sep 17 00:00:00 2001 From: Jaeheon Shim Date: Tue, 14 Apr 2026 21:28:20 -0400 Subject: [PATCH 5/5] Fix admin user listing excluding users without resumes --- .../bytefight/webserver/user/infra/AdminUserController.java | 2 +- .../org/bytefight/webserver/user/infra/UserRepository.java | 6 ++++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/src/main/java/org/bytefight/webserver/user/infra/AdminUserController.java b/src/main/java/org/bytefight/webserver/user/infra/AdminUserController.java index f78b9c3b..e454aac1 100644 --- a/src/main/java/org/bytefight/webserver/user/infra/AdminUserController.java +++ b/src/main/java/org/bytefight/webserver/user/infra/AdminUserController.java @@ -38,7 +38,7 @@ public class AdminUserController { private static final Set ALLOWED_SORT_FIELDS = Set.of("createdAt", "email", "isAdmin", "uuid", "resumeUuid"); private static final Map SORT_FIELD_MAPPING = - Map.of("resumeUuid", "resume.uuid"); + Map.of("resumeUuid", "r.uuid"); private final AdminUserService adminUserService; diff --git a/src/main/java/org/bytefight/webserver/user/infra/UserRepository.java b/src/main/java/org/bytefight/webserver/user/infra/UserRepository.java index d115fb20..252bbe84 100644 --- a/src/main/java/org/bytefight/webserver/user/infra/UserRepository.java +++ b/src/main/java/org/bytefight/webserver/user/infra/UserRepository.java @@ -24,10 +24,11 @@ public interface UserRepository extends JpaRepository { u.isAdmin, p.id, p.username, - u.resume.uuid + r.uuid ) FROM User u LEFT JOIN Player p ON p.user = u + LEFT JOIN u.resume r """) Page findAllWithPlayers(Pageable pageable); @@ -41,10 +42,11 @@ public interface UserRepository extends JpaRepository { u.isAdmin, p.id, p.username, - u.resume.uuid + r.uuid ) FROM User u LEFT JOIN Player p ON p.user = u + LEFT JOIN u.resume r WHERE u.id IN :ids """) Page findAllWithPlayersByIdIn(List ids, Pageable pageable);