Skip to content
1 change: 1 addition & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ services:
YC_ACCESS_KEY: ${YC_ACCESS_KEY}
YC_SECRET_KEY: ${YC_SECRET_KEY}
YC_BUCKET: ${YC_BUCKET}
GRAPHHOPPER_API_KEY: ${GRAPHHOPPER_API_KEY}
depends_on:
db:
condition: service_healthy
Expand Down
93 changes: 84 additions & 9 deletions src/main/java/goodroad/api/ApiErrors.java
Original file line number Diff line number Diff line change
@@ -1,10 +1,23 @@
package goodroad.api;

import jakarta.validation.ConstraintViolationException;
import lombok.extern.slf4j.Slf4j;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.http.converter.HttpMessageNotReadableException;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.web.HttpMediaTypeNotSupportedException;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.MissingServletRequestParameterException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.method.annotation.HandlerMethodValidationException;
import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException;
import org.springframework.web.multipart.MaxUploadSizeExceededException;
import org.springframework.web.multipart.support.MissingServletRequestPartException;
import org.springframework.web.servlet.resource.NoResourceFoundException;

import java.time.Instant;

@Slf4j
Expand All @@ -23,8 +36,8 @@ public static class ApiException extends RuntimeException {
private final HttpStatus status;
private final String code;

public ApiException(HttpStatus status, String code, String msg) {
super(msg);
public ApiException(HttpStatus status, String code, String message) {
super(message);
this.status = status;
this.code = code;
}
Expand All @@ -42,16 +55,78 @@ public String code() {
public static class GlobalHandler {

@ExceptionHandler(ApiException.class)
public ResponseEntity<ApiError> handleApiException(ApiException e) {
log.error("API exception: code={}, msg={}", e.code(), e.getMessage(), e);
return ResponseEntity.status(e.status()).body(ApiError.of(e.code(), e.getMessage()));
public ResponseEntity<ApiError> handleApiException(ApiException exception) {
if (exception.status().is5xxServerError()) {
log.error("API exception: code={}, msg={}", exception.code(), exception.getMessage(), exception);
}
return ResponseEntity.status(exception.status())
.body(ApiError.of(exception.code(), exception.getMessage()));
}

@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ApiError> handleValidation(MethodArgumentNotValidException exception) {
return badRequest("REQUEST_VALIDATION_FAILED", "Request fields are invalid");
}

@ExceptionHandler(HttpMessageNotReadableException.class)
public ResponseEntity<ApiError> handleUnreadableBody(HttpMessageNotReadableException exception) {
return badRequest("REQUEST_BODY_INVALID", "Request body is missing or contains invalid JSON");
}

@ExceptionHandler({
MethodArgumentTypeMismatchException.class,
MissingServletRequestParameterException.class,
ConstraintViolationException.class,
HandlerMethodValidationException.class
})
public ResponseEntity<ApiError> handleInvalidParameters(Exception exception) {
return badRequest("REQUEST_VALIDATION_FAILED", "Request parameters are invalid");
}

@ExceptionHandler(MissingServletRequestPartException.class)
public ResponseEntity<ApiError> handleMissingPart(MissingServletRequestPartException exception) {
return badRequest("REQUEST_PART_MISSING", "Required request part is missing");
}

@ExceptionHandler(MaxUploadSizeExceededException.class)
public ResponseEntity<ApiError> handleUploadTooLarge(MaxUploadSizeExceededException exception) {
return ResponseEntity.status(HttpStatus.PAYLOAD_TOO_LARGE)
.body(ApiError.of("FILE_TOO_LARGE", "Uploaded file is too large"));
}

@ExceptionHandler(HttpMediaTypeNotSupportedException.class)
public ResponseEntity<ApiError> handleUnsupportedMediaType(HttpMediaTypeNotSupportedException exception) {
return ResponseEntity.status(HttpStatus.UNSUPPORTED_MEDIA_TYPE)
.body(ApiError.of("CONTENT_TYPE_UNSUPPORTED", "Content type is not supported"));
}

@ExceptionHandler(DataIntegrityViolationException.class)
public ResponseEntity<ApiError> handleConflict(DataIntegrityViolationException exception) {
return ResponseEntity.status(HttpStatus.CONFLICT)
.body(ApiError.of("DATA_CONFLICT", "Operation conflicts with current data"));
}

@ExceptionHandler(AccessDeniedException.class)
public ResponseEntity<ApiError> handleAccessDenied(AccessDeniedException exception) {
return ResponseEntity.status(HttpStatus.FORBIDDEN)
.body(ApiError.of("ACCESS_DENIED", "Access denied"));
}

@ExceptionHandler(NoResourceFoundException.class)
public ResponseEntity<ApiError> handleNotFound(NoResourceFoundException exception) {
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(ApiError.of("ENDPOINT_NOT_FOUND", "Endpoint not found"));
}

@ExceptionHandler(Exception.class)
public ResponseEntity<ApiError> handleServerError(Exception e) {
log.error("Unexpected exception", e);
public ResponseEntity<ApiError> handleServerError(Exception exception) {
log.error("Unexpected exception", exception);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(ApiError.of("SERVER_INTERNAL_ERROR", "Server internal error"));
}

private ResponseEntity<ApiError> badRequest(String code, String message) {
return ResponseEntity.badRequest().body(ApiError.of(code, message));
}
}
}
}
57 changes: 30 additions & 27 deletions src/main/java/goodroad/storage/StorageService.java
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
package goodroad.storage;

import goodroad.api.ApiErrors.ApiException;
import goodroad.validation.UploadValidationService;
import goodroad.validation.UploadValidationService.UploadPurpose;
import goodroad.validation.UploadValidationService.VerifiedUpload;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;
import software.amazon.awssdk.core.sync.RequestBody;
Expand All @@ -15,101 +20,99 @@
public class StorageService {

private final S3Client s3Client;
private final UploadValidationService uploadValidator;

@Value("${yandex.storage.bucket}")
private String bucket;

public String uploadAvatar(MultipartFile file, String userId) {

try {
VerifiedUpload verified = uploadValidator.validate(file, UploadPurpose.AVATAR);

String ext = getExt(file.getOriginalFilename());
try {

String key = "avatars/" + userId + "/" + UUID.randomUUID() + ext;
String key = "avatars/" + userId + "/" + UUID.randomUUID() + verified.extension();

s3Client.putObject(
PutObjectRequest.builder()
.bucket(bucket)
.key(key)
.contentType(file.getContentType())
.contentType(verified.contentType())
.contentLength((long) verified.bytes().length)
.build(),
RequestBody.fromBytes(file.getBytes())
RequestBody.fromBytes(verified.bytes())
);

return "https://storage.yandexcloud.net/"
+ bucket + "/"
+ key;

} catch (Exception e) {
throw new RuntimeException("Upload failed", e);
} catch (RuntimeException e) {
throw new ApiException(HttpStatus.BAD_GATEWAY, "STORAGE_UNAVAILABLE", "File storage is unavailable");
}
}

public String uploadReviewPhoto(MultipartFile file, String userId) {

try {
VerifiedUpload verified = uploadValidator.validate(file, UploadPurpose.REVIEW_PHOTO);

String ext = getExt(file.getOriginalFilename());
try {

String key = "reviews/"
+ userId
+ "/"
+ UUID.randomUUID()
+ ext;
+ verified.extension();

s3Client.putObject(
PutObjectRequest.builder()
.bucket(bucket)
.key(key)
.contentType(file.getContentType())
.contentType(verified.contentType())
.contentLength((long) verified.bytes().length)
.build(),
RequestBody.fromBytes(file.getBytes())
RequestBody.fromBytes(verified.bytes())
);

return "https://storage.yandexcloud.net/"
+ bucket
+ "/"
+ key;

} catch (Exception e) {
throw new RuntimeException("Upload failed", e);
} catch (RuntimeException e) {
throw new ApiException(HttpStatus.BAD_GATEWAY, "STORAGE_UNAVAILABLE", "File storage is unavailable");
}
}

public String uploadVolunteerCertificate(MultipartFile file, String userId) {

try {
VerifiedUpload verified = uploadValidator.validate(file, UploadPurpose.VOLUNTEER_CERTIFICATE);

String ext = getExt(file.getOriginalFilename());
try {

String key = "volunteer-certificates/"
+ userId
+ "/"
+ UUID.randomUUID()
+ ext;
+ verified.extension();

s3Client.putObject(
PutObjectRequest.builder()
.bucket(bucket)
.key(key)
.contentType(file.getContentType())
.contentType(verified.contentType())
.contentLength((long) verified.bytes().length)
.build(),
RequestBody.fromBytes(file.getBytes())
RequestBody.fromBytes(verified.bytes())
);

return "https://storage.yandexcloud.net/"
+ bucket
+ "/"
+ key;

} catch (Exception e) {
throw new RuntimeException("Upload failed", e);
} catch (RuntimeException e) {
throw new ApiException(HttpStatus.BAD_GATEWAY, "STORAGE_UNAVAILABLE", "File storage is unavailable");
}
}

private String getExt(String name) {
if (name == null) return "";
int i = name.lastIndexOf(".");
return i == -1 ? "" : name.substring(i);
}
}
11 changes: 10 additions & 1 deletion src/main/java/goodroad/users/repository/UserRepo.java
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import org.springframework.data.jpa.repository.*;
import org.springframework.data.repository.query.Param;
import jakarta.persistence.LockModeType;
import java.time.Instant;
import java.util.List;
import java.util.Optional;
Expand All @@ -10,6 +11,14 @@ public interface UserRepo extends JpaRepository<UserEntity, Long> {

Optional<UserEntity> findByPhoneHash(String phoneHash);

@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("select user from UserEntity user where user.phoneHash = :phoneHash")
Optional<UserEntity> findByPhoneHashForUpdate(@Param("phoneHash") String phoneHash);

@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("select user from UserEntity user where user.id = :id")
Optional<UserEntity> findByIdForUpdate(@Param("id") Long id);

List<UserEntity> findByRoleIn(List<String> roles);

@Modifying
Expand All @@ -20,4 +29,4 @@ public interface UserRepo extends JpaRepository<UserEntity, Long> {
and user.lastActiveAt < :cutoff
""")
int deleteInactiveBefore(@Param("cutoff") Instant cutoff);
}
}
31 changes: 19 additions & 12 deletions src/main/java/goodroad/users/users/UserController.java
Original file line number Diff line number Diff line change
Expand Up @@ -9,37 +9,44 @@
@RequestMapping("/users")
public class UserController {

private final UserSettingsService service;
private final UserProfileService service;

public UserController(UserSettingsService service) {
public UserController(UserProfileService service) {
this.service = service;
}

@GetMapping("")
public UserSettingsService.SettingsView getCurrentUser() {
public UserProfileService.ProfileView getCurrentUser() {
String currentUsername = SecurityContextHolder.getContext().getAuthentication().getName();
return service.getCurrentUser(currentUsername);
}

@PutMapping("")
public UserSettingsService.SettingsView updateCurrentUser(
@RequestBody UserSettingsService.UpdateSettingsReq req
public UserProfileService.ProfileView updateProfile(
@RequestBody UserProfileService.UpdateProfileReq req
) {
String currentUsername = SecurityContextHolder.getContext().getAuthentication().getName();
return service.updateCurrentUserSettings(currentUsername, req);
return service.updateProfile(currentUsername, req);
}

@PutMapping("/phone")
public UserProfileService.ProfileView changePhone(
@RequestBody UserProfileService.ChangePhoneReq req
) {
String currentUsername = SecurityContextHolder.getContext().getAuthentication().getName();
return service.changePhone(currentUsername, req);
}

@PostMapping("")
public void changePassword(
@RequestParam String oldPassword,
@RequestParam String newPassword
@RequestBody UserProfileService.ChangePasswordReq req
) {
String currentUsername = SecurityContextHolder.getContext().getAuthentication().getName();
service.changePassword(currentUsername, oldPassword, newPassword);
service.changePassword(currentUsername, req);
}

@PostMapping(value = "/avatar", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public UserSettingsService.AvatarUploadResp uploadAvatar(
public UserProfileService.AvatarUploadResp uploadAvatar(
@RequestParam("file") MultipartFile file
) {
String currentUsername = SecurityContextHolder.getContext().getAuthentication().getName();
Expand All @@ -48,7 +55,7 @@ public UserSettingsService.AvatarUploadResp uploadAvatar(

@DeleteMapping("")
public void deleteCurrentUser(
@RequestBody UserSettingsService.DeleteAccountReq req
@RequestBody UserProfileService.DeleteAccountReq req
) {
String currentUsername = SecurityContextHolder.getContext().getAuthentication().getName();
service.deleteCurrent(currentUsername, req);
Expand All @@ -57,7 +64,7 @@ public void deleteCurrentUser(
@DeleteMapping("/{id}")
public void deleteUserByAdmin(
@PathVariable String id,
@RequestBody UserSettingsService.DeleteAccountReq req
@RequestBody UserProfileService.DeleteAccountReq req
) {
String currentUsername = SecurityContextHolder.getContext().getAuthentication().getName();
service.deleteByAdmin(currentUsername, id, req);
Expand Down
Loading