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
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import com.devoops.reservation.config.UserContext;
import com.devoops.reservation.dto.request.CreateReservationRequest;
import com.devoops.reservation.dto.response.ReservationResponse;
import com.devoops.reservation.dto.response.ReservationWithGuestInfoResponse;
import com.devoops.reservation.service.ReservationService;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
Expand Down Expand Up @@ -47,8 +48,8 @@ public ResponseEntity<List<ReservationResponse>> getByGuest(UserContext userCont

@GetMapping("/host")
@RequireRole("HOST")
public ResponseEntity<List<ReservationResponse>> getByHost(UserContext userContext) {
return ResponseEntity.ok(reservationService.getByHostId(userContext));
public ResponseEntity<List<ReservationWithGuestInfoResponse>> getByHost(UserContext userContext) {
return ResponseEntity.ok(reservationService.getByHostIdWithGuestInfo(userContext));
}


Expand All @@ -69,4 +70,20 @@ public ResponseEntity<Void> cancel(
reservationService.cancelReservation(id, userContext);
return ResponseEntity.noContent().build();
}

@PutMapping("/{id}/approve")
@RequireRole("HOST")
public ResponseEntity<ReservationResponse> approve(
@PathVariable UUID id,
UserContext userContext) {
return ResponseEntity.ok(reservationService.approveReservation(id, userContext));
}

@PutMapping("/{id}/reject")
@RequireRole("HOST")
public ResponseEntity<ReservationResponse> reject(
@PathVariable UUID id,
UserContext userContext) {
return ResponseEntity.ok(reservationService.rejectReservation(id, userContext));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package com.devoops.reservation.dto.message;

import java.time.LocalDate;
import java.util.UUID;

public record ReservationResponseMessage(
UUID userId,
String userEmail,
String hostName,
String accommodationName,
ReservationResponseStatus status,
LocalDate checkIn,
LocalDate checkOut
) {
public enum ReservationResponseStatus {
APPROVED, DECLINED
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
package com.devoops.reservation.dto.response;

import com.devoops.reservation.entity.ReservationStatus;

import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.UUID;

public record ReservationWithGuestInfoResponse(
UUID id,
UUID accommodationId,
UUID guestId,
UUID hostId,
LocalDate startDate,
LocalDate endDate,
int guestCount,
BigDecimal totalPrice,
ReservationStatus status,
LocalDateTime createdAt,
LocalDateTime updatedAt,
long guestCancellationCount
) {
public static ReservationWithGuestInfoResponse from(ReservationResponse response, long cancellationCount) {
return new ReservationWithGuestInfoResponse(
response.id(),
response.accommodationId(),
response.guestId(),
response.hostId(),
response.startDate(),
response.endDate(),
response.guestCount(),
response.totalPrice(),
response.status(),
response.createdAt(),
response.updatedAt(),
cancellationCount
);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import com.devoops.reservation.dto.message.ReservationCancelledMessage;
import com.devoops.reservation.dto.message.ReservationCreatedMessage;
import com.devoops.reservation.dto.message.ReservationResponseMessage;
import com.devoops.reservation.entity.Reservation;
import com.devoops.reservation.grpc.UserGrpcClient;
import com.devoops.reservation.grpc.UserSummaryResult;
Expand All @@ -28,6 +29,9 @@ public class ReservationEventPublisherService {
@Value("${rabbitmq.routing-key.reservation-cancelled}")
private String reservationCancelledRoutingKey;

@Value("${rabbitmq.routing-key.reservation-response}")
private String reservationResponseRoutingKey;

public void publishReservationCreated(Reservation reservation, String accommodationName) {
UserSummaryResult hostSummary = userGrpcClient.getUserSummary(reservation.getHostId());
UserSummaryResult guestSummary = userGrpcClient.getUserSummary(reservation.getGuestId());
Expand Down Expand Up @@ -87,4 +91,38 @@ public void publishReservationCancelled(Reservation reservation, String accommod

rabbitTemplate.convertAndSend(notificationExchange, reservationCancelledRoutingKey, message);
}

public void publishReservationResponse(Reservation reservation, String accommodationName, boolean approved) {
UserSummaryResult guestSummary = userGrpcClient.getUserSummary(reservation.getGuestId());
UserSummaryResult hostSummary = userGrpcClient.getUserSummary(reservation.getHostId());

if (!guestSummary.found()) {
log.warn("Guest not found for reservation {}, skipping notification", reservation.getId());
return;
}

if (!hostSummary.found()) {
log.warn("Host not found for reservation {}, skipping notification", reservation.getId());
return;
}

ReservationResponseMessage.ReservationResponseStatus status = approved
? ReservationResponseMessage.ReservationResponseStatus.APPROVED
: ReservationResponseMessage.ReservationResponseStatus.DECLINED;

ReservationResponseMessage message = new ReservationResponseMessage(
reservation.getGuestId(),
guestSummary.email(),
hostSummary.getFullName(),
accommodationName,
status,
reservation.getStartDate(),
reservation.getEndDate()
);

log.info("Publishing reservation response event: reservationId={}, guestEmail={}, status={}",
reservation.getId(), guestSummary.email(), status);

rabbitTemplate.convertAndSend(notificationExchange, reservationResponseRoutingKey, message);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import com.devoops.reservation.config.UserContext;
import com.devoops.reservation.dto.request.CreateReservationRequest;
import com.devoops.reservation.dto.response.ReservationResponse;
import com.devoops.reservation.dto.response.ReservationWithGuestInfoResponse;
import com.devoops.reservation.entity.Reservation;
import com.devoops.reservation.entity.ReservationStatus;
import com.devoops.reservation.exception.AccommodationNotFoundException;
Expand Down Expand Up @@ -109,6 +110,19 @@ public List<ReservationResponse> getByHostId(UserContext userContext) {
return reservationMapper.toResponseList(reservations);
}

@Transactional(readOnly = true)
public List<ReservationWithGuestInfoResponse> getByHostIdWithGuestInfo(UserContext userContext) {
List<Reservation> reservations = reservationRepository.findByHostId(userContext.userId());
return reservations.stream()
.map(reservation -> {
ReservationResponse response = reservationMapper.toResponse(reservation);
long cancellationCount = reservationRepository.countByGuestIdAndStatus(
reservation.getGuestId(), ReservationStatus.CANCELLED);
return ReservationWithGuestInfoResponse.from(response, cancellationCount);
})
.toList();
}

@Transactional
public void deleteRequest(UUID id, UserContext userContext) {
Reservation reservation = findReservationOrThrow(id);
Expand Down Expand Up @@ -160,16 +174,78 @@ public void cancelReservation(UUID id, UserContext userContext) {
reservationRepository.save(reservation);
log.info("Guest {} cancelled reservation {}", userContext.userId(), id);

// Fetch accommodation name for notification
AccommodationValidationResult accommodationInfo = accommodationGrpcClient.validateAndCalculatePrice(
String accommodationName = fetchAccommodationName(reservation);
eventPublisher.publishReservationCancelled(reservation, accommodationName);
}

@Transactional
public ReservationResponse approveReservation(UUID id, UserContext userContext) {
Reservation reservation = findReservationOrThrow(id);

// Only the host who owns this reservation can approve it
if (!reservation.getHostId().equals(userContext.userId())) {
throw new ForbiddenException("You can only approve reservations for your own accommodations");
}

// Can only approve PENDING reservations
if (reservation.getStatus() != ReservationStatus.PENDING) {
throw new InvalidReservationException(
"Only pending reservations can be approved. Current status: " + reservation.getStatus()
);
}

// Approve the reservation
reservation.setStatus(ReservationStatus.APPROVED);
reservationRepository.save(reservation);
log.info("Host {} approved reservation {}", userContext.userId(), id);

// Auto-reject overlapping pending reservations
List<Reservation> overlappingPending = reservationRepository.findOverlappingPending(
reservation.getAccommodationId(),
reservation.getStartDate(),
reservation.getEndDate(),
reservation.getGuestCount()
reservation.getId()
);
String accommodationName = accommodationInfo.valid() ? accommodationInfo.accommodationName() : "Unknown Accommodation";

eventPublisher.publishReservationCancelled(reservation, accommodationName);
for (Reservation overlapping : overlappingPending) {
overlapping.setStatus(ReservationStatus.REJECTED);
reservationRepository.save(overlapping);
log.info("Auto-rejected overlapping reservation {} due to approval of reservation {}",
overlapping.getId(), id);
}

// Fetch accommodation name and publish notification
String accommodationName = fetchAccommodationName(reservation);
eventPublisher.publishReservationResponse(reservation, accommodationName, true);

return reservationMapper.toResponse(reservation);
}

@Transactional
public ReservationResponse rejectReservation(UUID id, UserContext userContext) {
Reservation reservation = findReservationOrThrow(id);

// Only the host who owns this reservation can reject it
if (!reservation.getHostId().equals(userContext.userId())) {
throw new ForbiddenException("You can only reject reservations for your own accommodations");
}

// Can only reject PENDING reservations
if (reservation.getStatus() != ReservationStatus.PENDING) {
throw new InvalidReservationException(
"Only pending reservations can be rejected. Current status: " + reservation.getStatus()
);
}

reservation.setStatus(ReservationStatus.REJECTED);
reservationRepository.save(reservation);
log.info("Host {} rejected reservation {}", userContext.userId(), id);

// Fetch accommodation name and publish notification
String accommodationName = fetchAccommodationName(reservation);
eventPublisher.publishReservationResponse(reservation, accommodationName, false);

return reservationMapper.toResponse(reservation);
}

// === Helper Methods ===
Expand All @@ -195,4 +271,13 @@ private void validateAccessToReservation(Reservation reservation, UserContext us
}
}

private String fetchAccommodationName(Reservation reservation) {
AccommodationValidationResult accommodationInfo = accommodationGrpcClient.validateAndCalculatePrice(
reservation.getAccommodationId(),
reservation.getStartDate(),
reservation.getEndDate(),
reservation.getGuestCount()
);
return accommodationInfo.valid() ? accommodationInfo.accommodationName() : "Unknown Accommodation";
}
}
1 change: 1 addition & 0 deletions src/main/resources/application.properties
Original file line number Diff line number Diff line change
Expand Up @@ -51,3 +51,4 @@ spring.rabbitmq.password=${RABBITMQ_PASSWORD:devoops123}
rabbitmq.exchange.notification=notification.exchange
rabbitmq.routing-key.reservation-created=notification.reservation.created
rabbitmq.routing-key.reservation-cancelled=notification.reservation.cancelled
rabbitmq.routing-key.reservation-response=notification.reservation.response
Loading