Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,9 @@ dependencies {
implementation("io.grpc:grpc-netty-shaded:$grpcVersion")
compileOnly("javax.annotation:javax.annotation-api:1.3.2")

// gRPC Client
implementation("net.devh:grpc-client-spring-boot-starter:3.1.0.RELEASE")

// Tracing (Zipkin)
implementation("org.springframework.boot:spring-boot-micrometer-tracing-brave")
implementation("org.springframework.boot:spring-boot-starter-zipkin")
Expand Down
6 changes: 5 additions & 1 deletion environment/.local.env
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,8 @@ RABBITMQ_HOST=devoops-rabbitmq
RABBITMQ_PORT=5672
RABBITMQ_USERNAME=devoops
RABBITMQ_PASSWORD=devoops123
RABBITMQ_VHOST=/
RABBITMQ_VHOST=/
RESERVATION_SERVICE_GRPC_HOST=devoops-reservation-service
RESERVATION_SERVICE_GRPC_PORT=9090
ACCOMMODATION_SERVICE_GRPC_HOST=devoops-accommodation-service
ACCOMMODATION_SERVICE_GRPC_PORT=9090
7 changes: 7 additions & 0 deletions src/main/java/com/devoops/user/controller/UserController.java
Original file line number Diff line number Diff line change
Expand Up @@ -41,4 +41,11 @@ public ResponseEntity<Void> changePassword(
userService.changePassword(userContext.userId(), request);
return ResponseEntity.noContent().build();
}

@DeleteMapping
@RequireRole({"HOST", "GUEST"})
public ResponseEntity<Void> deleteAccount(UserContext userContext) {
userService.deleteAccount(userContext.userId());
return ResponseEntity.noContent().build();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package com.devoops.user.exception;

import lombok.Getter;

/**
* Exception thrown when an account cannot be deleted due to business rules.
* For example, when a guest has active reservations or a host has future reservations.
*/
@Getter
public class AccountDeletionException extends RuntimeException {

private final int activeReservationCount;

public AccountDeletionException(String message, int activeReservationCount) {
super(message);
this.activeReservationCount = activeReservationCount;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -95,4 +95,13 @@ public ProblemDetail handleValidationErrors(MethodArgumentNotValidException ex)
problemDetail.setProperty("errors", errors);
return problemDetail;
}

@ExceptionHandler(AccountDeletionException.class)
public ProblemDetail handleAccountDeletion(AccountDeletionException ex) {
ProblemDetail problemDetail = ProblemDetail.forStatusAndDetail(HttpStatus.CONFLICT, ex.getMessage());
problemDetail.setTitle("Account Deletion Failed");
problemDetail.setProperty("timestamp", Instant.now());
problemDetail.setProperty("activeReservationCount", ex.getActiveReservationCount());
return problemDetail;
}
}
47 changes: 47 additions & 0 deletions src/main/java/com/devoops/user/grpc/AccommodationGrpcClient.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
package com.devoops.user.grpc;

import com.devoops.user.grpc.proto.accommodation.*;
import io.grpc.StatusRuntimeException;
import lombok.extern.slf4j.Slf4j;
import net.devh.boot.grpc.client.inject.GrpcClient;
import org.springframework.stereotype.Component;

import java.util.UUID;

@Component
@Slf4j
public class AccommodationGrpcClient {

@GrpcClient("accommodation-service")
private AccommodationInternalServiceGrpc.AccommodationInternalServiceBlockingStub accommodationStub;

/**
* Delete all accommodations for a host (cascade deletion).
*/
public CascadeDeleteResult deleteAccommodationsByHost(UUID hostId) {
log.debug("Deleting all accommodations for host {}", hostId);

try {
DeleteByHostRequest request = DeleteByHostRequest.newBuilder()
.setHostId(hostId.toString())
.build();

DeleteByHostResponse response = accommodationStub.deleteAccommodationsByHost(request);

if (response.getSuccess()) {
log.info("Successfully deleted {} accommodations for host {}", response.getDeletedCount(), hostId);
} else {
log.error("Failed to delete accommodations for host {}: {}", hostId, response.getErrorMessage());
}

return new CascadeDeleteResult(
response.getSuccess(),
response.getDeletedCount(),
response.getErrorMessage()
);
} catch (StatusRuntimeException e) {
log.error("gRPC error deleting accommodations for host {}: {}", hostId, e.getStatus(), e);
throw new RuntimeException("Failed to delete host accommodations", e);
}
}
}
15 changes: 15 additions & 0 deletions src/main/java/com/devoops/user/grpc/CascadeDeleteResult.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package com.devoops.user.grpc;

/**
* Result of cascade deleting accommodations for a host.
*
* @param success true if deletion was successful
* @param deletedCount the number of accommodations deleted
* @param errorMessage error message if deletion failed, empty otherwise
*/
public record CascadeDeleteResult(
boolean success,
int deletedCount,
String errorMessage
) {
}
15 changes: 15 additions & 0 deletions src/main/java/com/devoops/user/grpc/DeletionCheckResult.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package com.devoops.user.grpc;

/**
* Result of checking whether a user can be deleted.
*
* @param canBeDeleted true if the user can be deleted
* @param reason the reason if the user cannot be deleted, empty otherwise
* @param activeReservationCount the number of active reservations blocking deletion
*/
public record DeletionCheckResult(
boolean canBeDeleted,
String reason,
int activeReservationCount
) {
}
65 changes: 65 additions & 0 deletions src/main/java/com/devoops/user/grpc/ReservationGrpcClient.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
package com.devoops.user.grpc;

import com.devoops.user.grpc.proto.reservation.*;
import io.grpc.StatusRuntimeException;
import lombok.extern.slf4j.Slf4j;
import net.devh.boot.grpc.client.inject.GrpcClient;
import org.springframework.stereotype.Component;

import java.util.UUID;

@Component
@Slf4j
public class ReservationGrpcClient {

@GrpcClient("reservation-service")
private ReservationInternalServiceGrpc.ReservationInternalServiceBlockingStub reservationStub;

/**
* Check if a guest can be deleted (has no active reservations).
*/
public DeletionCheckResult checkGuestCanBeDeleted(UUID guestId) {
log.debug("Checking if guest {} can be deleted", guestId);

try {
CheckGuestDeletionRequest request = CheckGuestDeletionRequest.newBuilder()
.setGuestId(guestId.toString())
.build();

CheckDeletionResponse response = reservationStub.checkGuestCanBeDeleted(request);

return new DeletionCheckResult(
response.getCanBeDeleted(),
response.getReason(),
response.getActiveReservationCount()
);
} catch (StatusRuntimeException e) {
log.error("gRPC error checking if guest {} can be deleted: {}", guestId, e.getStatus(), e);
throw new RuntimeException("Failed to check guest deletion eligibility", e);
}
}

/**
* Check if a host can be deleted (has no active reservations on their accommodations).
*/
public DeletionCheckResult checkHostCanBeDeleted(UUID hostId) {
log.debug("Checking if host {} can be deleted", hostId);

try {
CheckHostDeletionRequest request = CheckHostDeletionRequest.newBuilder()
.setHostId(hostId.toString())
.build();

CheckDeletionResponse response = reservationStub.checkHostCanBeDeleted(request);

return new DeletionCheckResult(
response.getCanBeDeleted(),
response.getReason(),
response.getActiveReservationCount()
);
} catch (StatusRuntimeException e) {
log.error("gRPC error checking if host {} can be deleted: {}", hostId, e.getStatus(), e);
throw new RuntimeException("Failed to check host deletion eligibility", e);
}
}
}
23 changes: 18 additions & 5 deletions src/main/java/com/devoops/user/grpc/UserGrpcService.java
Original file line number Diff line number Diff line change
Expand Up @@ -42,22 +42,35 @@ private GetUserSummaryResponse processRequest(GetUserSummaryRequest request) {
return buildNotFoundResponse();
}

// First try to find active (non-deleted) user
Optional<User> userOpt = userRepository.findById(userId);
if (userOpt.isEmpty()) {
log.debug("User not found: {}", userId);
return buildNotFoundResponse();
if (userOpt.isPresent()) {
User user = userOpt.get();
log.debug("Found active user: {} {} ({})", user.getFirstName(), user.getLastName(), user.getRole());
return buildUserResponse(user, false);
}

// If not found, check if user exists but is deleted
Optional<User> deletedUserOpt = userRepository.findByIdIncludingDeleted(userId);
if (deletedUserOpt.isPresent()) {
User user = deletedUserOpt.get();
log.debug("Found deleted user: {} {} ({})", user.getFirstName(), user.getLastName(), user.getRole());
return buildUserResponse(user, true);
}

User user = userOpt.get();
log.debug("Found user: {} {} ({})", user.getFirstName(), user.getLastName(), user.getRole());
log.debug("User not found: {}", userId);
return buildNotFoundResponse();
}

private GetUserSummaryResponse buildUserResponse(User user, boolean isDeleted) {
return GetUserSummaryResponse.newBuilder()
.setFound(true)
.setUserId(user.getId().toString())
.setEmail(user.getEmail())
.setFirstName(user.getFirstName())
.setLastName(user.getLastName())
.setRole(user.getRole().name())
.setIsDeleted(isDeleted)
.build();
}

Expand Down
10 changes: 10 additions & 0 deletions src/main/java/com/devoops/user/repository/UserRepository.java
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

import com.devoops.user.entity.User;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;

import java.util.Optional;
Expand All @@ -19,4 +21,12 @@ public interface UserRepository extends JpaRepository<User, UUID> {
boolean existsByUsername(String username);

boolean existsByEmail(String email);

/**
* Find a user by ID including deleted users.
* This bypasses the @SQLRestriction filter to allow fetching deleted user info
* for historical data display (e.g., showing guest name on past reservations).
*/
@Query(value = "SELECT * FROM users WHERE id = :id", nativeQuery = true)
Optional<User> findByIdIncludingDeleted(@Param("id") UUID id);
}
71 changes: 71 additions & 0 deletions src/main/java/com/devoops/user/service/UserService.java
Original file line number Diff line number Diff line change
Expand Up @@ -4,27 +4,38 @@
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.Role;
import com.devoops.user.entity.User;
import com.devoops.user.exception.AccountDeletionException;
import com.devoops.user.exception.InvalidPasswordException;
import com.devoops.user.exception.UserAlreadyExistsException;
import com.devoops.user.exception.UserNotFoundException;
import com.devoops.user.grpc.AccommodationGrpcClient;
import com.devoops.user.grpc.CascadeDeleteResult;
import com.devoops.user.grpc.DeletionCheckResult;
import com.devoops.user.grpc.ReservationGrpcClient;
import com.devoops.user.mapper.UserMapper;
import com.devoops.user.repository.UserRepository;
import com.devoops.user.security.JwtService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.util.UUID;

@Service
@RequiredArgsConstructor
@Slf4j
public class UserService {

private final UserRepository userRepository;
private final UserMapper userMapper;
private final PasswordEncoder passwordEncoder;
private final JwtService jwtService;
private final ReservationGrpcClient reservationGrpcClient;
private final AccommodationGrpcClient accommodationGrpcClient;

public UserResponse getProfile(UUID userId) {
User user = userRepository.findById(userId)
Expand Down Expand Up @@ -69,4 +80,64 @@ public void changePassword(UUID userId, ChangePasswordRequest request) {
user.setPassword(passwordEncoder.encode(request.newPassword()));
userRepository.save(user);
}

/**
* Delete a user account.
* <p>
* Guests can only delete their account if they have no active reservations
* (PENDING or APPROVED with endDate >= today).
* <p>
* Hosts can only delete their account if no future reservations exist for any
* of their accommodations. When a host deletes their account, all their
* accommodations are also soft-deleted.
*
* @param userId the ID of the user to delete
* @throws AccountDeletionException if the account cannot be deleted
*/
@Transactional
public void deleteAccount(UUID userId) {
User user = userRepository.findById(userId)
.orElseThrow(() -> new UserNotFoundException("User does not exist"));

log.info("Attempting to delete account for user {} with role {}", userId, user.getRole());

if (user.getRole() == Role.GUEST) {
DeletionCheckResult checkResult = reservationGrpcClient.checkGuestCanBeDeleted(userId);

if (!checkResult.canBeDeleted()) {
log.warn("Cannot delete guest account {}: {}", userId, checkResult.reason());
throw new AccountDeletionException(
"Cannot delete account: you have " + checkResult.activeReservationCount() +
" active reservation(s). Please cancel or complete them before deleting your account.",
checkResult.activeReservationCount()
);
}
} else if (user.getRole() == Role.HOST) {
DeletionCheckResult checkResult = reservationGrpcClient.checkHostCanBeDeleted(userId);

if (!checkResult.canBeDeleted()) {
log.warn("Cannot delete host account {}: {}", userId, checkResult.reason());
throw new AccountDeletionException(
"Cannot delete account: you have " + checkResult.activeReservationCount() +
" active reservation(s) on your accommodations. " +
"Please wait for them to complete before deleting your account.",
checkResult.activeReservationCount()
);
}

// Cascade delete all accommodations for the host
CascadeDeleteResult cascadeResult = accommodationGrpcClient.deleteAccommodationsByHost(userId);
if (!cascadeResult.success()) {
log.error("Failed to delete accommodations for host {}: {}", userId, cascadeResult.errorMessage());
throw new RuntimeException("Failed to delete accommodations: " + cascadeResult.errorMessage());
}
log.info("Deleted {} accommodations for host {}", cascadeResult.deletedCount(), userId);
}

// Soft delete the user
user.setDeleted(true);
userRepository.save(user);

log.info("Successfully deleted account for user {}", userId);
}
}
Loading