Skip to content
7 changes: 5 additions & 2 deletions .github/workflows/pr-check.yml
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ jobs:
build/classes
build/libs
build/test-results
build/reports/jacoco
retention-days: 1

sonarcloud:
Expand Down Expand Up @@ -69,7 +70,9 @@ jobs:
-Dsonar.host.url=https://sonarcloud.io
-Dsonar.organization=${{ secrets.SONAR_ORGANIZATION }}
-Dsonar.projectKey=${{ secrets.SONAR_PROJECT_KEY }}
-Dsonar.sources=.
-Dsonar.sources=src/main/java
-Dsonar.tests=src/test/java
-Dsonar.java.binaries=build/classes/java/main
-Dsonar.java.test.binaries=build/classes/java/test
-Dsonar.junit.reportPaths=build/test-results/test
-Dsonar.junit.reportPaths=build/test-results/test
-Dsonar.coverage.jacoco.xmlReportPaths=build/reports/jacoco/test/jacocoTestReport.xml
9 changes: 9 additions & 0 deletions build.gradle.kts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
plugins {
java
jacoco
id("org.springframework.boot") version "4.0.1"
id("io.spring.dependency-management") version "1.1.7"
}
Expand Down Expand Up @@ -74,4 +75,12 @@ dependencies {

tasks.withType<Test> {
useJUnitPlatform()
finalizedBy(tasks.jacocoTestReport)
}

tasks.jacocoTestReport {
dependsOn(tasks.test)
reports {
xml.required = true
}
}
44 changes: 44 additions & 0 deletions src/main/java/com/devoops/user/controller/UserController.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
package com.devoops.user.controller;

import com.devoops.user.dto.request.ChangePasswordRequest;
import com.devoops.user.dto.request.UpdateUserRequest;
import com.devoops.user.dto.response.AuthenticationResponse;
import com.devoops.user.dto.response.UserResponse;
import com.devoops.user.service.UserService;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.*;

@RestController
@RequestMapping("/api/user/me")
@RequiredArgsConstructor
public class UserController {

private final UserService userService;

@GetMapping
@PreAuthorize("hasAnyRole('HOST', 'GUEST')")
public ResponseEntity<UserResponse> getProfile(Authentication auth) {
return ResponseEntity.ok(userService.getProfile(auth));
}

@PutMapping
@PreAuthorize("hasAnyRole('HOST', 'GUEST')")
public ResponseEntity<AuthenticationResponse> updateProfile(
Authentication auth,
@RequestBody @Valid UpdateUserRequest request) {
return ResponseEntity.ok(userService.updateProfile(auth, request));
}

@PutMapping("/password")
@PreAuthorize("hasAnyRole('HOST', 'GUEST')")
public ResponseEntity<Void> changePassword(
Authentication auth,
@RequestBody @Valid ChangePasswordRequest request) {
userService.changePassword(auth, request);
return ResponseEntity.noContent().build();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package com.devoops.user.dto.request;

import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;

public record ChangePasswordRequest(
@NotBlank String currentPassword,
@Size(min = 8) String newPassword
) {}
12 changes: 12 additions & 0 deletions src/main/java/com/devoops/user/dto/request/UpdateUserRequest.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package com.devoops.user.dto.request;

import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.Size;

public record UpdateUserRequest(
@Size(min = 3, max = 50) String username,
@Email String email,
@Size(max = 100) String firstName,
@Size(max = 100) String lastName,
@Size(max = 150) String residence
) {}
36 changes: 36 additions & 0 deletions src/main/java/com/devoops/user/entity/BaseEntity.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
package com.devoops.user.entity;

import jakarta.persistence.*;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import lombok.experimental.SuperBuilder;
import org.hibernate.annotations.CreationTimestamp;
import org.hibernate.annotations.UpdateTimestamp;

import java.time.LocalDateTime;
import java.util.UUID;

@MappedSuperclass
@Getter
@Setter
@NoArgsConstructor
@SuperBuilder
public abstract class BaseEntity {

@Id
@GeneratedValue(strategy = GenerationType.UUID)
private UUID id;

@CreationTimestamp
@Column(updatable = false)
private LocalDateTime createdAt;

@UpdateTimestamp
private LocalDateTime updatedAt;

@Builder.Default
@Column(nullable = false)
private boolean isDeleted = false;
}
31 changes: 6 additions & 25 deletions src/main/java/com/devoops/user/entity/User.java
Original file line number Diff line number Diff line change
Expand Up @@ -2,29 +2,26 @@

import jakarta.persistence.*;
import lombok.*;
import lombok.experimental.SuperBuilder;
import org.hibernate.annotations.JdbcTypeCode;
import org.hibernate.annotations.SQLRestriction;
import org.hibernate.type.SqlTypes;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;

import java.time.LocalDateTime;
import java.util.Collection;
import java.util.List;
import java.util.UUID;

@Entity
@Table(name = "users")
@SQLRestriction("is_deleted = false")
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class User implements UserDetails {

@Id
@GeneratedValue(strategy = GenerationType.UUID)
private UUID id;
@SuperBuilder
public class User extends BaseEntity implements UserDetails {

@Column(nullable = false, unique = true, length = 50)
private String username;
Expand All @@ -49,24 +46,8 @@ public class User implements UserDetails {
@JdbcTypeCode(SqlTypes.NAMED_ENUM)
private Role role;

@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;

@Column(name = "updated_at", nullable = false)
private LocalDateTime updatedAt;

@PrePersist
protected void onCreate() {
createdAt = LocalDateTime.now();
updatedAt = LocalDateTime.now();
}

@PreUpdate
protected void onUpdate() {
updatedAt = LocalDateTime.now();
}

@Override
@NonNull
public Collection<? extends GrantedAuthority> getAuthorities() {
return List.of(new SimpleGrantedAuthority("ROLE_" + role.name()));
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,14 @@
@RestControllerAdvice
public class GlobalExceptionHandler {

@ExceptionHandler(UserNotFoundException.class)
public ProblemDetail handleUserNotFound(UserNotFoundException ex) {
ProblemDetail problemDetail = ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, ex.getMessage());
problemDetail.setTitle("User Not Found");
problemDetail.setProperty("timestamp", Instant.now());
return problemDetail;
}

@ExceptionHandler(UserAlreadyExistsException.class)
public ProblemDetail handleUserAlreadyExists(UserAlreadyExistsException ex) {
ProblemDetail problemDetail = ProblemDetail.forStatusAndDetail(HttpStatus.CONFLICT, ex.getMessage());
Expand All @@ -34,6 +42,14 @@ public ProblemDetail handleInvalidCredentials(InvalidCredentialsException ex) {
return problemDetail;
}

@ExceptionHandler(InvalidPasswordException.class)
public ProblemDetail handleInvalidPassword(InvalidPasswordException ex) {
ProblemDetail problemDetail = ProblemDetail.forStatusAndDetail(HttpStatus.BAD_REQUEST, ex.getMessage());
problemDetail.setTitle("Invalid Password");
problemDetail.setProperty("timestamp", Instant.now());
return problemDetail;
}

@ExceptionHandler({BadCredentialsException.class, UsernameNotFoundException.class})
public ProblemDetail handleAuthenticationFailure(Exception ex) {
ProblemDetail problemDetail = ProblemDetail.forStatusAndDetail(HttpStatus.UNAUTHORIZED, "Invalid credentials");
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package com.devoops.user.exception;

public class InvalidPasswordException extends RuntimeException {
public InvalidPasswordException(String message) {
super(message);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package com.devoops.user.exception;

public class UserNotFoundException extends RuntimeException {
public UserNotFoundException(String message) {
super(message);
}
}
1 change: 1 addition & 0 deletions src/main/java/com/devoops/user/mapper/UserMapper.java
Original file line number Diff line number Diff line change
Expand Up @@ -15,5 +15,6 @@ public interface UserMapper {
@Mapping(target = "password", ignore = true)
@Mapping(target = "createdAt", ignore = true)
@Mapping(target = "updatedAt", ignore = true)
@Mapping(target = "isDeleted", ignore = true)
User toEntity(RegisterRequest request);
}
73 changes: 73 additions & 0 deletions src/main/java/com/devoops/user/service/UserService.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
package com.devoops.user.service;

import com.devoops.user.dto.request.ChangePasswordRequest;
import com.devoops.user.dto.request.UpdateUserRequest;
import com.devoops.user.dto.response.AuthenticationResponse;
import com.devoops.user.dto.response.UserResponse;
import com.devoops.user.entity.User;
import com.devoops.user.exception.InvalidCredentialsException;
import com.devoops.user.exception.InvalidPasswordException;
import com.devoops.user.exception.UserAlreadyExistsException;
import com.devoops.user.exception.UserNotFoundException;
import com.devoops.user.mapper.UserMapper;
import com.devoops.user.repository.UserRepository;
import com.devoops.user.security.JwtService;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.Authentication;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;

@Service
@RequiredArgsConstructor
public class UserService {

private final UserRepository userRepository;
private final UserMapper userMapper;
private final PasswordEncoder passwordEncoder;
private final JwtService jwtService;

public UserResponse getProfile(Authentication auth) {
User user = (User) auth.getPrincipal();
return userMapper.toUserResponse(user);
}

public AuthenticationResponse updateProfile(Authentication auth, UpdateUserRequest request) {
User user = (User) auth.getPrincipal();
if (user == null)
throw new UserNotFoundException("User does not exist");

if (request.username() != null && !request.username().equals(user.getUsername())) {
if (userRepository.existsByUsername(request.username())) {
throw new UserAlreadyExistsException("Username already taken");
}
user.setUsername(request.username());
}

if (request.email() != null && !request.email().equals(user.getEmail())) {
if (userRepository.existsByEmail(request.email())) {
throw new UserAlreadyExistsException("Email already taken");
}
user.setEmail(request.email());
}

if (request.firstName() != null) user.setFirstName(request.firstName());
if (request.lastName() != null) user.setLastName(request.lastName());
if (request.residence() != null) user.setResidence(request.residence());

User saved = userRepository.save(user);
return new AuthenticationResponse(jwtService.generateToken(saved), jwtService.getExpirationTime(), userMapper.toUserResponse(saved));
}

public void changePassword(Authentication auth, ChangePasswordRequest request) {
User user = (User) auth.getPrincipal();
if (user == null)
throw new UserNotFoundException("User does not exist");

if (!passwordEncoder.matches(request.currentPassword(), user.getPassword())) {
throw new InvalidPasswordException("Current password is incorrect");
}

user.setPassword(passwordEncoder.encode(request.newPassword()));
userRepository.save(user);
}
}
1 change: 1 addition & 0 deletions src/main/resources/db/migration/V2__add_soft_delete.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
ALTER TABLE users ADD COLUMN is_deleted BOOLEAN NOT NULL DEFAULT FALSE;
Loading