diff --git a/raumreservierung-backend/api-spec/raumreservierung-backend.yaml b/raumreservierung-backend/api-spec/raumreservierung-backend.yaml index 05908c5d3..6053b132b 100644 --- a/raumreservierung-backend/api-spec/raumreservierung-backend.yaml +++ b/raumreservierung-backend/api-spec/raumreservierung-backend.yaml @@ -714,6 +714,30 @@ paths: '*/*': schema: $ref: "#/components/schemas/BookingDetailResponseDTO" + /rooms/{roomId}/deletable: + get: + tags: + - room-controller + summary: Check whether a room can be deleted. + description: |- + Check whether a room can be deleted. + Returns false if the room is still referenced in a future booking. + operationId: isRoomDeletable + parameters: + - name: roomId + in: path + description: the UUID of the room to check + required: true + schema: + type: string + format: uuid + responses: + "200": + description: "true if the room can be safely deleted, false otherwise" + content: + '*/*': + schema: + type: boolean /file/{fileId}: get: tags: diff --git a/raumreservierung-backend/src/main/java/de/muenchen/raumreservierung/booking/BookingEventListener.java b/raumreservierung-backend/src/main/java/de/muenchen/raumreservierung/booking/BookingEventListener.java new file mode 100644 index 000000000..8b375b690 --- /dev/null +++ b/raumreservierung-backend/src/main/java/de/muenchen/raumreservierung/booking/BookingEventListener.java @@ -0,0 +1,26 @@ +package de.muenchen.raumreservierung.booking; + +import de.muenchen.raumreservierung.booking.events.FutureBookingCheckEvent; +import de.muenchen.raumreservierung.booking.events.RemoveRoomFromBookingsEvent; +import lombok.RequiredArgsConstructor; +import org.springframework.context.event.EventListener; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class BookingEventListener { + + private final BookingService bookingService; + + @EventListener + public void onFutureBookingCheck(final FutureBookingCheckEvent event) { + event.setFutureBookingExists(bookingService.existsFutureBookingForRoom(event.getRoomId())); + } + + @Transactional + @EventListener + public void onRemoveRoomFromBookings(final RemoveRoomFromBookingsEvent event) { + bookingService.removeRoomFromBookings(event.getRoomId()); + } +} diff --git a/raumreservierung-backend/src/main/java/de/muenchen/raumreservierung/booking/BookingRepository.java b/raumreservierung-backend/src/main/java/de/muenchen/raumreservierung/booking/BookingRepository.java index 3c069e435..ee3ba0d86 100644 --- a/raumreservierung-backend/src/main/java/de/muenchen/raumreservierung/booking/BookingRepository.java +++ b/raumreservierung-backend/src/main/java/de/muenchen/raumreservierung/booking/BookingRepository.java @@ -1,5 +1,6 @@ package de.muenchen.raumreservierung.booking; +import java.util.List; import java.util.Optional; import java.util.UUID; import lombok.NonNull; @@ -23,4 +24,6 @@ public interface BookingRepository extends JpaRepository, JpaSpec @Override @NonNull @EntityGraph(attributePaths = { "appointments", "equipment", "bookedBy", "bookedFor", "room", "seatingType" }) S saveAndFlush(@NonNull S entity); + + List findByRoomId(UUID roomId); } diff --git a/raumreservierung-backend/src/main/java/de/muenchen/raumreservierung/booking/BookingService.java b/raumreservierung-backend/src/main/java/de/muenchen/raumreservierung/booking/BookingService.java index bdbc0efab..419c6fa6b 100644 --- a/raumreservierung-backend/src/main/java/de/muenchen/raumreservierung/booking/BookingService.java +++ b/raumreservierung-backend/src/main/java/de/muenchen/raumreservierung/booking/BookingService.java @@ -17,6 +17,7 @@ import de.muenchen.raumreservierung.security.SecurityContextService; import jakarta.persistence.EntityManager; import java.time.OffsetDateTime; +import java.util.List; import java.util.Objects; import java.util.Set; import java.util.UUID; @@ -39,6 +40,7 @@ @Service @Slf4j @RequiredArgsConstructor +@SuppressWarnings("PMD.CommentDefaultAccessModifier") public class BookingService { private final BookingRepository bookingRepository; private final EntityManager entityManager; @@ -76,7 +78,7 @@ private Page findAllAndFilterSensitiveData(final Pageable pageable, fin final Page bookings = bookingRepository.findAll( statusOrder == null ? bookingSpecification - : bookingSpecification.and(BookingSpecificationBuilder.withFixedStatusOrder(statusOrder.getDirection())), + : bookingSpecification.and(BookingSpecifications.withFixedStatusOrder(statusOrder.getDirection())), statusOrder == null ? pageable : PageRequest.of(pageable.getPageNumber(), pageable.getPageSize())); @@ -336,4 +338,14 @@ private void applyOrganizerAuthorityRules(final Booking booking, final BookingTy } } + boolean existsFutureBookingForRoom(final UUID roomId) { + final Specification spec = BookingSpecificationBuilder.forFutureRoomUsage(roomId); + return bookingRepository.exists(spec); + } + + void removeRoomFromBookings(final UUID roomId) { + final List affectedBookings = bookingRepository.findByRoomId(roomId); + affectedBookings.forEach(booking -> booking.setRoom(null)); + bookingRepository.saveAll(affectedBookings); + } } diff --git a/raumreservierung-backend/src/main/java/de/muenchen/raumreservierung/booking/BookingSpecificationBuilder.java b/raumreservierung-backend/src/main/java/de/muenchen/raumreservierung/booking/BookingSpecificationBuilder.java index 9676f075f..f474cc872 100644 --- a/raumreservierung-backend/src/main/java/de/muenchen/raumreservierung/booking/BookingSpecificationBuilder.java +++ b/raumreservierung-backend/src/main/java/de/muenchen/raumreservierung/booking/BookingSpecificationBuilder.java @@ -2,14 +2,11 @@ import de.muenchen.raumreservierung.booking.dto.BookingFilterDTO; import de.muenchen.raumreservierung.person.domain.Person; -import de.muenchen.raumreservierung.room.Room_; -import jakarta.persistence.criteria.CriteriaBuilder; import java.time.LocalTime; import java.time.OffsetDateTime; import java.util.ArrayList; import java.util.List; import java.util.UUID; -import org.springframework.data.domain.Sort; import org.springframework.data.jpa.domain.Specification; public final class BookingSpecificationBuilder { @@ -29,69 +26,30 @@ public static Specification fromFilterWithPersonOrStatusN final boolean statusNew) { final List> specificationList = new ArrayList<>(); - if (bookingFilterDTO.roomId() != null) { - specificationList.add(filterForRoomId(bookingFilterDTO.roomId())); - } - final OffsetDateTime start = bookingFilterDTO.start(); - if (start != null) { - specificationList.add(filterForStart(start.toLocalDate().atStartOfDay(start.getOffset()).toOffsetDateTime())); - } - final OffsetDateTime end = bookingFilterDTO.end(); - if (end != null) { - specificationList.add(filterForEnd(end.toLocalDate().atTime(LocalTime.MAX).atZone(end.getOffset()).toOffsetDateTime())); - } - final List statusList = bookingFilterDTO.status(); - if (statusList != null && !statusList.isEmpty()) { - specificationList.add(filterForStatus(statusList)); - } - if (person != null && person.getId() != null) { - specificationList.add(filterForPerson(person)); - } + specificationList.add(BookingSpecifications.filterForRoomId(bookingFilterDTO.roomId())); + specificationList.add(BookingSpecifications.filterForStart(normalizeStart(bookingFilterDTO.start()))); + specificationList.add(BookingSpecifications.filterForEnd(normalizeEnd(bookingFilterDTO.end()))); + specificationList.add(BookingSpecifications.filterForStatus(bookingFilterDTO.status())); + specificationList.add(BookingSpecifications.filterForPerson(person)); if (!statusNew) { - specificationList.add(filterForStatusNotNew()); + specificationList.add(BookingSpecifications.filterForStatusNotNew()); } return Specification.allOf(specificationList); } - private static Specification filterForRoomId(final UUID roomId) { - return (root, query, cb) -> cb.equal(root.get(Booking_.room).get(Room_.id), roomId); - } - - private static Specification filterForStart(final OffsetDateTime start) { - return (root, query, cb) -> cb.greaterThanOrEqualTo(root.get(Booking_.schedule).get(ScheduleTemplate_.occupancyStart), start); + public static Specification forFutureRoomUsage(final UUID roomId) { + return Specification.allOf( + BookingSpecifications.filterForRoomId(roomId), + BookingSpecifications.filterForOccupancyEndAfter(OffsetDateTime.now()), + BookingSpecifications.filterExcludingStatus(BookingStatus.CANCELED, BookingStatus.UNFEASIBLE, BookingStatus.NEW)); } - private static Specification filterForEnd(final OffsetDateTime end) { - return (root, query, cb) -> cb.lessThanOrEqualTo(root.get(Booking_.schedule).get(ScheduleTemplate_.occupancyEnd), end); + private static OffsetDateTime normalizeStart(final OffsetDateTime start) { + return start == null ? null : start.toLocalDate().atStartOfDay(start.getOffset()).toOffsetDateTime(); } - private static Specification filterForStatusNotNew() { - return (root, query, cb) -> cb.notEqual(root.get(Booking_.status), BookingStatus.NEW); - } - - private static Specification filterForStatus(final List status) { - return (root, query, cb) -> root.get(Booking_.status).in(status); - } - - private static Specification filterForPerson(final Person person) { - return (root, query, cb) -> cb.or( - cb.equal(root.get(Booking_.bookedBy), person), - cb.equal(root.get(Booking_.bookedFor), person)); - } - - public static Specification withFixedStatusOrder(final Sort.Direction direction) { - return (root, query, cb) -> { - CriteriaBuilder.Case order = cb.selectCase(); - - for (final BookingStatus value : BookingStatus.values()) { - order = order.when(cb.equal(root.get(Booking_.status), value), value.getSortOrder()); - } - - if (query != null) { - query.orderBy(direction.isAscending() ? cb.asc(order) : cb.desc(order)); - } - return null; - }; + private static OffsetDateTime normalizeEnd(final OffsetDateTime end) { + return end == null ? null : end.toLocalDate().atTime(LocalTime.MAX).atZone(end.getOffset()).toOffsetDateTime(); } } diff --git a/raumreservierung-backend/src/main/java/de/muenchen/raumreservierung/booking/BookingSpecifications.java b/raumreservierung-backend/src/main/java/de/muenchen/raumreservierung/booking/BookingSpecifications.java new file mode 100644 index 000000000..5f9cdc509 --- /dev/null +++ b/raumreservierung-backend/src/main/java/de/muenchen/raumreservierung/booking/BookingSpecifications.java @@ -0,0 +1,82 @@ +package de.muenchen.raumreservierung.booking; + +import de.muenchen.raumreservierung.person.domain.Person; +import de.muenchen.raumreservierung.room.Room_; +import jakarta.persistence.criteria.CriteriaBuilder; +import java.time.OffsetDateTime; +import java.util.List; +import java.util.UUID; +import org.springframework.data.domain.Sort; +import org.springframework.data.jpa.domain.Specification; + +@SuppressWarnings("PMD.CommentDefaultAccessModifier") +public final class BookingSpecifications { + + private BookingSpecifications() { + } + + static Specification filterForRoomId(final UUID roomId) { + if (roomId == null) { + return null; + } + return (root, query, cb) -> cb.equal(root.get(Booking_.room).get(Room_.id), roomId); + } + + static Specification filterForStart(final OffsetDateTime start) { + if (start == null) { + return null; + } + return (root, query, cb) -> cb.greaterThanOrEqualTo(root.get(Booking_.schedule).get(ScheduleTemplate_.occupancyStart), start); + } + + static Specification filterForEnd(final OffsetDateTime end) { + if (end == null) { + return null; + } + return (root, query, cb) -> cb.lessThanOrEqualTo(root.get(Booking_.schedule).get(ScheduleTemplate_.occupancyEnd), end); + } + + static Specification filterForStatusNotNew() { + return (root, query, cb) -> cb.notEqual(root.get(Booking_.status), BookingStatus.NEW); + } + + static Specification filterForStatus(final List status) { + if (status == null || status.isEmpty()) { + return null; + } + return (root, query, cb) -> root.get(Booking_.status).in(status); + } + + static Specification filterForPerson(final Person person) { + if (person == null || person.getId() == null) { + return null; + } + return (root, query, cb) -> cb.or( + cb.equal(root.get(Booking_.bookedBy), person), + cb.equal(root.get(Booking_.bookedFor), person)); + } + + static Specification withFixedStatusOrder(final Sort.Direction direction) { + return (root, query, cb) -> { + CriteriaBuilder.Case order = cb.selectCase(); + + for (final BookingStatus value : BookingStatus.values()) { + order = order.when(cb.equal(root.get(Booking_.status), value), value.getSortOrder()); + } + + if (query != null) { + query.orderBy(direction.isAscending() ? cb.asc(order) : cb.desc(order)); + } + return null; + }; + } + + static Specification filterForOccupancyEndAfter(final OffsetDateTime now) { + return (root, query, cb) -> cb.greaterThan(root.get(Booking_.schedule).get(ScheduleTemplate_.occupancyEnd), now); + } + + static Specification filterExcludingStatus(final BookingStatus... status) { + return (root, query, cb) -> cb.not(root.get(Booking_.status).in(List.of(status))); + } + +} diff --git a/raumreservierung-backend/src/main/java/de/muenchen/raumreservierung/booking/events/FutureBookingCheckEvent.java b/raumreservierung-backend/src/main/java/de/muenchen/raumreservierung/booking/events/FutureBookingCheckEvent.java new file mode 100644 index 000000000..6df7109cf --- /dev/null +++ b/raumreservierung-backend/src/main/java/de/muenchen/raumreservierung/booking/events/FutureBookingCheckEvent.java @@ -0,0 +1,14 @@ +package de.muenchen.raumreservierung.booking.events; + +import java.util.UUID; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import lombok.Setter; + +@Getter +@RequiredArgsConstructor +public class FutureBookingCheckEvent { + private final UUID roomId; + @Setter + private boolean futureBookingExists; +} diff --git a/raumreservierung-backend/src/main/java/de/muenchen/raumreservierung/booking/events/RemoveRoomFromBookingsEvent.java b/raumreservierung-backend/src/main/java/de/muenchen/raumreservierung/booking/events/RemoveRoomFromBookingsEvent.java new file mode 100644 index 000000000..c2bfbc7a6 --- /dev/null +++ b/raumreservierung-backend/src/main/java/de/muenchen/raumreservierung/booking/events/RemoveRoomFromBookingsEvent.java @@ -0,0 +1,11 @@ +package de.muenchen.raumreservierung.booking.events; + +import java.util.UUID; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public class RemoveRoomFromBookingsEvent { + private final UUID roomId; +} diff --git a/raumreservierung-backend/src/main/java/de/muenchen/raumreservierung/common/ExceptionMessageConstants.java b/raumreservierung-backend/src/main/java/de/muenchen/raumreservierung/common/ExceptionMessageConstants.java index 5bde35db6..3fb11fb7a 100644 --- a/raumreservierung-backend/src/main/java/de/muenchen/raumreservierung/common/ExceptionMessageConstants.java +++ b/raumreservierung-backend/src/main/java/de/muenchen/raumreservierung/common/ExceptionMessageConstants.java @@ -9,6 +9,7 @@ public class ExceptionMessageConstants { public static final String MSG_NOT_FOUND = "Could not find entity with id %s"; public static final String MSG_NOT_FOUND_LDAP = "Could not find ldap entry with id %s"; public static final String MSG_CANNOT_DELETE_ACTIVE = "Cannot delete entity with id %s"; + public static final String MSG_CANNOT_DELETE_IN_FUTURE_BOOKING = "Cannot delete entity with id %s, because it is used in a future booking"; public static final String MSG_START_DATE_AFTER_END_DATE = "Start date after end date"; public static final String MSG_UNAUTHORIZED_ACTION = "Unauthorized action"; public static final String MSG_SEATINGTYPE_NOT_AVAILABLE = "Seating type not available in selected room or no room selected"; diff --git a/raumreservierung-backend/src/main/java/de/muenchen/raumreservierung/room/RoomController.java b/raumreservierung-backend/src/main/java/de/muenchen/raumreservierung/room/RoomController.java index e1b797b41..4127b8b19 100644 --- a/raumreservierung-backend/src/main/java/de/muenchen/raumreservierung/room/RoomController.java +++ b/raumreservierung-backend/src/main/java/de/muenchen/raumreservierung/room/RoomController.java @@ -1,5 +1,6 @@ package de.muenchen.raumreservierung.room; +import de.muenchen.raumreservierung.booking.BookingService; import de.muenchen.raumreservierung.room.dto.RoomDetailsResponseDTO; import de.muenchen.raumreservierung.room.dto.RoomListResponseDTO; import de.muenchen.raumreservierung.room.dto.RoomMapper; @@ -31,6 +32,7 @@ public class RoomController { private final RoomService roomService; private final RoomMapper roomMapper; + private final BookingService bookingService; @Transactional @GetMapping @@ -71,4 +73,17 @@ public RoomDetailsResponseDTO updateRoom(@Valid @RequestBody final RoomRequestDT public void deleteRoom(@Valid @PathVariable("roomId") final UUID roomId) { roomService.deleteRoom(roomId); } + + /** + * Check whether a room can be deleted. + * Returns false if the room is still referenced in a future booking. + * + * @param roomId the UUID of the room to check + * @return true if the room can be safely deleted, false otherwise + */ + @GetMapping("/{roomId}/deletable") + @ResponseStatus(HttpStatus.OK) + public boolean isRoomDeletable(@PathVariable final UUID roomId) { + return !roomService.existsFutureBookingForRoom(roomId); + } } diff --git a/raumreservierung-backend/src/main/java/de/muenchen/raumreservierung/room/RoomService.java b/raumreservierung-backend/src/main/java/de/muenchen/raumreservierung/room/RoomService.java index 700acac50..324d6a5ac 100644 --- a/raumreservierung-backend/src/main/java/de/muenchen/raumreservierung/room/RoomService.java +++ b/raumreservierung-backend/src/main/java/de/muenchen/raumreservierung/room/RoomService.java @@ -1,8 +1,11 @@ package de.muenchen.raumreservierung.room; import static de.muenchen.raumreservierung.common.ExceptionMessageConstants.MSG_CANNOT_DELETE_ACTIVE; +import static de.muenchen.raumreservierung.common.ExceptionMessageConstants.MSG_CANNOT_DELETE_IN_FUTURE_BOOKING; import static de.muenchen.raumreservierung.common.ExceptionMessageConstants.MSG_NOT_FOUND; +import de.muenchen.raumreservierung.booking.events.FutureBookingCheckEvent; +import de.muenchen.raumreservierung.booking.events.RemoveRoomFromBookingsEvent; import de.muenchen.raumreservierung.common.ConflictException; import de.muenchen.raumreservierung.common.NotFoundException; import de.muenchen.raumreservierung.file.FileAttachment; @@ -14,6 +17,7 @@ import java.util.UUID; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.stereotype.Service; @@ -25,6 +29,7 @@ public class RoomService { private final RoomRepository roomRepository; private final EntityManager entityManager; private final FileAttachmentService fileAttachmentService; + private final ApplicationEventPublisher eventPublisher; public Room getById(final UUID roomId) { return getEntityOrThrowException(roomId); @@ -95,6 +100,12 @@ public void deleteRoom(final UUID roomId) { throw new ConflictException(String.format(MSG_CANNOT_DELETE_ACTIVE, roomId)); } + if (existsFutureBookingForRoom(roomId)) { + throw new ConflictException(String.format(MSG_CANNOT_DELETE_IN_FUTURE_BOOKING, roomId)); + } + + eventPublisher.publishEvent(new RemoveRoomFromBookingsEvent(roomId)); + log.debug("Deleted room to {}", roomId); roomRepository.deleteById(roomId); } @@ -120,4 +131,9 @@ public int findAbsoluteMaxCapacity() { return roomRepository.findFirstByOrderByCapacityDesc().map(Room::getCapacity).orElse(0); } + public boolean existsFutureBookingForRoom(final UUID roomId) { + final FutureBookingCheckEvent event = new FutureBookingCheckEvent(roomId); + eventPublisher.publishEvent(event); + return event.isFutureBookingExists(); + } } diff --git a/raumreservierung-backend/src/test/java/de/muenchen/raumreservierung/booking/BookingServiceIntegrationTest.java b/raumreservierung-backend/src/test/java/de/muenchen/raumreservierung/booking/BookingServiceIntegrationTest.java index 2375a18ad..f9dc653af 100644 --- a/raumreservierung-backend/src/test/java/de/muenchen/raumreservierung/booking/BookingServiceIntegrationTest.java +++ b/raumreservierung-backend/src/test/java/de/muenchen/raumreservierung/booking/BookingServiceIntegrationTest.java @@ -446,4 +446,51 @@ void updateBooking_shouldNotChangeStatus_WhenBookingTypeStaysDefault() { assertNotNull(result); assertThat(result.getStatus()).isEqualTo(BookingStatus.ROOM_APPROVED); } + + @Test + @WithMockJwt(lhmObjectID = "000001", authorities = { Roles.ANWENDER }) + void existsFutureBookingForRoom_shouldReturnTrue_whenRoomHasBookingExtendingIntoTheFuture() { + boolean result = bookingService.existsFutureBookingForRoom(existingBooking.getRoom().getId()); + + assertThat(result).isTrue(); + } + + @Test + @WithMockJwt(lhmObjectID = "000001", authorities = { Roles.ANWENDER }) + void existsFutureBookingForRoom_shouldReturnFalse_whenRoomHasNoBookingsAtAll() { + boolean result = bookingService.existsFutureBookingForRoom(room2.getId()); + + assertThat(result).isFalse(); + } + + @Test + @WithMockJwt(lhmObjectID = "000001", authorities = { Roles.ANWENDER }) + void existsFutureBookingForRoom_shouldReturnFalse_whenRoomOnlyHasBookingsEntirelyInThePast() { + OffsetDateTime past = OffsetDateTime.now(ZoneOffset.UTC).minusDays(2); + ScheduleTemplate pastSchedule = new ScheduleTemplate( + past, + past.plusHours(2), + past.plusMinutes(15), + past.plusHours(1).plusMinutes(30)); + + Booking pastBooking = new Booking(); + pastBooking.updateFrom(existingBooking); + pastBooking.setSchedule(pastSchedule); + pastBooking.setRoom(room2); + pastBooking.setSchedule(null); + bookingRepository.save(pastBooking); + + boolean result = bookingService.existsFutureBookingForRoom(room2.getId()); + + assertThat(result).isFalse(); + } + + @Test + @WithMockJwt(lhmObjectID = "000001", authorities = { Roles.ANWENDER }) + void removeRoomFromBookings_shouldClearRoomOnAllBookingsAssignedToThatRoom() { + bookingService.removeRoomFromBookings(existingBooking.getRoom().getId()); + + Booking reloaded = bookingRepository.findById(existingBooking.getId()).orElseThrow(); + assertThat(reloaded.getRoom()).isNull(); + } } diff --git a/raumreservierung-backend/src/test/java/de/muenchen/raumreservierung/room/RoomServiceTest.java b/raumreservierung-backend/src/test/java/de/muenchen/raumreservierung/room/RoomServiceTest.java index 0616acc21..d1fa972f1 100644 --- a/raumreservierung-backend/src/test/java/de/muenchen/raumreservierung/room/RoomServiceTest.java +++ b/raumreservierung-backend/src/test/java/de/muenchen/raumreservierung/room/RoomServiceTest.java @@ -1,22 +1,34 @@ package de.muenchen.raumreservierung.room; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.AssertionsForClassTypes.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +import de.muenchen.raumreservierung.booking.events.FutureBookingCheckEvent; +import de.muenchen.raumreservierung.booking.events.RemoveRoomFromBookingsEvent; +import de.muenchen.raumreservierung.common.ConflictException; +import de.muenchen.raumreservierung.common.NotFoundException; import de.muenchen.raumreservierung.equipment.Equipment; import de.muenchen.raumreservierung.person.domain.InternalPerson; import de.muenchen.raumreservierung.person.domain.Person; import de.muenchen.raumreservierung.seating.SeatingType; import jakarta.persistence.EntityManager; import java.util.HashSet; +import java.util.Optional; import java.util.Set; import java.util.UUID; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.context.ApplicationEventPublisher; @ExtendWith(MockitoExtension.class) public class RoomServiceTest { @@ -27,6 +39,9 @@ public class RoomServiceTest { @Mock private EntityManager entityManager; + @Mock + private ApplicationEventPublisher eventPublisher; + @InjectMocks private RoomService roomService; @@ -46,7 +61,7 @@ public void givenRoom_thenReturnsCorrectEntity_whenCreateRoomOnlyEquipment() { roomFull.setId(generatedId); when(roomRepository.saveAndFlush(any(Room.class))).thenReturn(roomFull); - when(roomRepository.findWithDetailsById(generatedId)).thenReturn(java.util.Optional.of(roomFull)); + when(roomRepository.findWithDetailsById(generatedId)).thenReturn(Optional.of(roomFull)); final Room result = roomService.createRoom(roomRequest); @@ -71,7 +86,7 @@ public void givenRoom_thenReturnsCorrectEntity_whenCreateRoomOnlySeatingCapacity roomFull.setId(generatedId); when(roomRepository.saveAndFlush(any(Room.class))).thenReturn(roomFull); - when(roomRepository.findWithDetailsById(generatedId)).thenReturn(java.util.Optional.of(roomFull)); + when(roomRepository.findWithDetailsById(generatedId)).thenReturn(Optional.of(roomFull)); final Room result = roomService.createRoom(roomRequest); @@ -79,6 +94,117 @@ public void givenRoom_thenReturnsCorrectEntity_whenCreateRoomOnlySeatingCapacity assertThat(result).usingRecursiveComparison().ignoringFields("id").isEqualTo(roomFull); } + @Test + public void givenInactiveRoomWithoutFutureBooking_whenDeleteRoom_thenPublishesEventAndDeletes() { + // Given + final UUID roomId = UUID.randomUUID(); + final Room roomToDelete = getExampleRoom(); + roomToDelete.setId(roomId); + roomToDelete.setActive(false); + + when(roomRepository.findWithDetailsById(roomId)).thenReturn(Optional.of(roomToDelete)); + + // When + roomService.deleteRoom(roomId); + + // Then + verify(roomRepository).deleteById(roomId); + final ArgumentCaptor eventCaptor = ArgumentCaptor.forClass(Object.class); + verify(eventPublisher, times(2)).publishEvent(eventCaptor.capture()); + assertThat(eventCaptor.getAllValues()) + .anyMatch(event -> event instanceof FutureBookingCheckEvent) + .anyMatch(event -> event instanceof RemoveRoomFromBookingsEvent); + } + + @Test + public void givenActiveRoom_whenDeleteRoom_thenThrowsConflictExceptionAndDoesNotDelete() { + // Given + final UUID roomId = UUID.randomUUID(); + final Room activeRoom = getExampleRoom(); + activeRoom.setId(roomId); + activeRoom.setActive(true); + + when(roomRepository.findWithDetailsById(roomId)).thenReturn(Optional.of(activeRoom)); + + // When / Then + assertThatThrownBy(() -> roomService.deleteRoom(roomId)) + .isInstanceOf(ConflictException.class); + + verify(roomRepository, never()).deleteById(any()); + verify(eventPublisher, never()).publishEvent(any(RemoveRoomFromBookingsEvent.class)); + } + + @Test + public void givenInactiveRoomWithFutureBooking_whenDeleteRoom_thenThrowsConflictExceptionAndDoesNotDelete() { + // Given + final UUID roomId = UUID.randomUUID(); + final Room roomToDelete = getExampleRoom(); + roomToDelete.setId(roomId); + roomToDelete.setActive(false); + + when(roomRepository.findWithDetailsById(roomId)).thenReturn(Optional.of(roomToDelete)); + + doAnswer(invocation -> { + final FutureBookingCheckEvent event = invocation.getArgument(0); + event.setFutureBookingExists(true); + return null; + }).when(eventPublisher).publishEvent(any(FutureBookingCheckEvent.class)); + + // When / Then + assertThatThrownBy(() -> roomService.deleteRoom(roomId)) + .isInstanceOf(ConflictException.class); + + verify(roomRepository, never()).deleteById(any()); + verify(eventPublisher, never()).publishEvent(any(RemoveRoomFromBookingsEvent.class)); + } + + @Test + public void givenUnknownRoomId_whenDeleteRoom_thenThrowsNotFoundExceptionAndDoesNothingElse() { + // Given + final UUID roomId = UUID.randomUUID(); + when(roomRepository.findWithDetailsById(roomId)).thenReturn(Optional.empty()); + + // When / Then + assertThatThrownBy(() -> roomService.deleteRoom(roomId)) + .isInstanceOf(NotFoundException.class); + + verify(roomRepository, never()).deleteById(any()); + verify(eventPublisher, never()).publishEvent(any()); + } + + @Test + public void givenNoListenerMarksFutureBooking_whenExistsFutureBookingForRoom_thenReturnsFalse() { + // Given + final UUID roomId = UUID.randomUUID(); + + // When + final boolean result = roomService.existsFutureBookingForRoom(roomId); + + // Then + assertThat(result).isFalse(); + final ArgumentCaptor eventCaptor = ArgumentCaptor.forClass(FutureBookingCheckEvent.class); + verify(eventPublisher).publishEvent(eventCaptor.capture()); + assertThat(eventCaptor.getValue().getRoomId()).isEqualTo(roomId); + } + + @Test + public void givenListenerMarksFutureBooking_whenExistsFutureBookingForRoom_thenReturnsTrue() { + // Given + final UUID roomId = UUID.randomUUID(); + doAnswer(invocation -> { + final FutureBookingCheckEvent event = invocation.getArgument(0); + event.setFutureBookingExists(true); + return null; + }).when(eventPublisher).publishEvent(any(FutureBookingCheckEvent.class)); + + // When + final boolean result = roomService.existsFutureBookingForRoom(roomId); + + // Then + assertThat(result).isTrue(); + verify(eventPublisher).publishEvent(any(FutureBookingCheckEvent.class)); + } + private Room buildExampleRoomWithEquipmentAndSeating(Room room, Set seatingCapacities, Set equipments) { room.setRoomSeatingCapacities(seatingCapacities); room.setEquipment(equipments); diff --git a/raumreservierung-frontend/src/components/common/CrudCard.vue b/raumreservierung-frontend/src/components/common/CrudCard.vue index 8312d64d6..bd0939c0f 100644 --- a/raumreservierung-frontend/src/components/common/CrudCard.vue +++ b/raumreservierung-frontend/src/components/common/CrudCard.vue @@ -53,15 +53,32 @@ v-else-if="dialogMode === 'delete'" :title="t('generics.delete', { domain })" :text="t('generics.confirmDelete', { domain })" - :loading="loading" + :loading="loading || deleteCheckLoading" @cancel="closeDialog" @confirm="executeDelete" > + @@ -124,11 +141,15 @@ const { maxDialogWidth = "800px", emptyItemTemplate, loading = false, + canDeleteItem = true, + deleteCheckLoading = false, } = defineProps<{ maxDialogWidth?: string; emptyItemTemplate: T; domain: string; loading?: boolean; + canDeleteItem?: boolean; + deleteCheckLoading?: boolean; }>(); const activeItem = ref({ ...emptyItemTemplate } as T); @@ -139,6 +160,7 @@ const emit = defineEmits<{ create: [item: T]; update: [item: T]; delete: [id: string]; + "delete-prompt": [id: string]; // Sadly there is no type for the emit of updatedOptions ... // eslint-disable-next-line @typescript-eslint/no-explicit-any "update:options": [options: any]; @@ -168,9 +190,12 @@ const openRead = (item: T) => { dialogMode.value = "read"; }; -const promptDelete = (item: T) => { +const promptDelete = async (item: T) => { activeItem.value = item; dialogMode.value = "delete"; + if (item.id) { + emit("delete-prompt", item.id); + } }; const handleSave = () => { diff --git a/raumreservierung-frontend/src/composables/api/useRoomsApi.ts b/raumreservierung-frontend/src/composables/api/useRoomsApi.ts index 63ec92cfc..6d5ae64fd 100644 --- a/raumreservierung-frontend/src/composables/api/useRoomsApi.ts +++ b/raumreservierung-frontend/src/composables/api/useRoomsApi.ts @@ -86,3 +86,19 @@ export const useCreateRoom = () => { }, }); }; + +export const useCheckRoomDeletable = (roomId: Ref) => { + const api = ApiFactory.getInstance(RoomControllerApi); + return useQuery({ + queryKey: [ROOM_KEY, "deletable", roomId], + queryFn: () => { + if (!roomId.value) { + throw new Error("Room ID is required"); + } + return api.isRoomDeletable({ + roomId: roomId.value, + }); + }, + enabled: computed(() => !!roomId.value), + }); +}; diff --git a/raumreservierung-frontend/src/locales/de.json b/raumreservierung-frontend/src/locales/de.json index 252eab1b8..b1e64739a 100644 --- a/raumreservierung-frontend/src/locales/de.json +++ b/raumreservierung-frontend/src/locales/de.json @@ -128,6 +128,10 @@ "link": "Anfrage ohne Raumauswahl" } }, + "roomEditView": { + "deactivationConfirmTitle": "Raum deaktivieren", + "deactivationFutureBookingsWarning": "Dieser Raum wird in zukünftigen Buchungen verwendet. Sie können den Raum dennoch deaktivieren." + }, "bookingDetailsView": { "header": "Buchungsdetails", "participants": "Teilnehmende {count}", @@ -166,6 +170,8 @@ "inActive": "{domain} inaktiv", "confirmMsg": "Möchten Sie den \"{domain}\" endgültig löschen?", "confirmDelete": "Möchten Sie den Eintrag aus \"{domain}\" endgültig löschen?", + "checkDelete": "Es wird geprüft, ob Entität gelöscht werden kann", + "cantDelete": "Das Löschen ist nicht möglich – die Entität wird in einer aktuellen oder zukünftigen Buchung verwendet. Bitte stattdessen deaktivieren.", "deleted": "{domain} wurde gelöscht.", "updated": "{domain} wurde aktualisiert.", "created": "{domain} wurde erstellt.", diff --git a/raumreservierung-frontend/src/views/rooms/RoomsEditView.vue b/raumreservierung-frontend/src/views/rooms/RoomsEditView.vue index 69d10ba75..3c4b16e00 100644 --- a/raumreservierung-frontend/src/views/rooms/RoomsEditView.vue +++ b/raumreservierung-frontend/src/views/rooms/RoomsEditView.vue @@ -26,6 +26,7 @@ :text="t('generics.delete', { domain: t('domain.room.header') })" secondary :append-icon="mdiTrashCanOutline" + @click="deleteRoomId = roomId" /> + + + + + + @@ -290,6 +339,7 @@ import EquipmentSelector from "@/components/rooms/EquipmentSelector.vue"; import SeatingCapacityEditor from "@/components/rooms/SeatingCapacitySelector.vue"; import { useUploadFile } from "@/composables/api/useFileAttachmentApi.ts"; import { + useCheckRoomDeletable, useCreateRoom, useDeleteRoom, useGetRoom, @@ -304,6 +354,9 @@ import { EMPTY_ROOM_DATA, mapResponseToRequest } from "@/util/roomTypeUtil.ts"; const isValid = ref(false); const roomData = ref(EMPTY_ROOM_DATA); +const showDeactivateConfirm = ref(false); +const deactivateCheckId = ref(); + const router = useRouter(); const route = useRoute(); @@ -314,6 +367,7 @@ const roomId = computed(() => (route.params.id as string) || undefined); const pictureMetaData = ref(); const isDeletable = ref(false); +const deleteRoomId = ref(); const { t } = useI18n(); @@ -325,25 +379,11 @@ const { error: getRoomError, } = useGetRoom(roomId); -const { - mutateAsync: updateRoom, - data: updateRoomData, - isPending: updateRoomLoading, - error: updateRoomError, -} = useUpdateRoom(); +const { mutate: updateRoom, isPending: updateRoomLoading } = useUpdateRoom(); -const { - mutateAsync: createRoom, - data: createRoomData, - isPending: createRoomLoading, - error: createRoomError, -} = useCreateRoom(); +const { mutate: createRoom, isPending: createRoomLoading } = useCreateRoom(); -const { - mutateAsync: deleteRoom, - isPending: deleteRoomLoading, - error: deleteRoomError, -} = useDeleteRoom(); +const { mutate: deleteRoom, isPending: deleteRoomLoading } = useDeleteRoom(); const { mutateAsync: uploadFile, @@ -352,6 +392,12 @@ const { error: uploadFileError, } = useUploadFile(); +const { data: canDeleteItem, isFetching: deleteCheckLoading } = + useCheckRoomDeletable(deleteRoomId); + +const { data: canDeactivate, refetch: refetchDeactivateCheck } = + useCheckRoomDeletable(deactivateCheckId); + watch( [() => roomReqData.value?.id, getRoomError], ([newId, hasError]) => { @@ -393,28 +439,55 @@ const uploadPicture = async (value: File | File[]) => { }; }; -const handleSave = async () => { - if (roomId.value) { - await updateRoom({ - roomId: roomId.value, - roomRequestDTO: roomData.value, - }); - - if (!updateRoomError.value && updateRoomData.value) { - onSuccess( - updateRoomData.value, - t("generics.updated", { domain: t("domain.room.header") }) - ); - } - } else { - await createRoom({ roomRequestDTO: roomData.value }); +const handleSave = () => { + if (roomData.value.isActive === true || !roomId.value) { + performSave(); + return; + } - if (!createRoomError.value && createRoomData.value) { - onSuccess( - createRoomData.value, - t("generics.created", { domain: t("domain.room.header") }) - ); + deactivateCheckId.value = roomId.value; + refetchDeactivateCheck().then(({ data }) => { + if (data === false) { + showDeactivateConfirm.value = true; + } else { + performSave(); } + }); +}; + +const handleConfirmedSave = () => { + showDeactivateConfirm.value = false; + performSave(); +}; + +const performSave = () => { + if (roomId.value) { + updateRoom( + { + roomId: roomId.value, + roomRequestDTO: roomData.value, + }, + { + onSuccess: (updatedRoom) => { + onSuccess( + updatedRoom, + t("generics.updated", { domain: t("domain.room.header") }) + ); + }, + } + ); + } else { + createRoom( + { roomRequestDTO: roomData.value }, + { + onSuccess: (createdRoom) => { + onSuccess( + createdRoom, + t("generics.created", { domain: t("domain.room.header") }) + ); + }, + } + ); } }; @@ -435,16 +508,18 @@ const onSuccess = ( const handleDelete = async () => { if (roomId.value) { - await deleteRoom({ roomId: roomId.value }); - - if (!deleteRoomError.value) { - snackbar.add({ - level: Levels.SUCCESS, - message: t("generics.deleted", { domain: t("domain.room.header") }), - }); - - await router.push({ name: ROUTES.ROOMS_LIST }); - } + deleteRoom( + { roomId: roomId.value }, + { + onSuccess: () => { + snackbar.add({ + level: Levels.SUCCESS, + message: t("generics.deleted", { domain: t("domain.room.header") }), + }); + router.push({ name: ROUTES.ROOMS_LIST }); + }, + } + ); } };