Skip to content
Open
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
21,560 changes: 21,560 additions & 0 deletions resources/schools_list_2025_december.csv

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -1055,6 +1055,7 @@ public void cancelBooking(final IsaacEventPageDTO event, final RegisteredUserDTO

Long reservedById;
BookingStatus previousBookingStatus;
DetailedEventBookingDTO oldestValidWaitingListBooking = null;
EventBookingDTO updatedWaitingListBooking;
try (ITransaction transaction = transactionManager.getTransaction()) {
// Obtain an exclusive database lock to lock the booking
Expand All @@ -1074,11 +1075,39 @@ public void cancelBooking(final IsaacEventPageDTO event, final RegisteredUserDTO
this.bookingPersistenceManager.adminGetBookingsByEventIdAndStatus(event.getId(),
BookingStatus.WAITING_LIST);
if (!waitingListBookings.isEmpty()) {
DetailedEventBookingDTO oldestWaitingListBooking =
Collections.min(waitingListBookings, Comparator.comparing(EventBookingDTO::getBookingDate));
updatedWaitingListBooking = this.bookingPersistenceManager.updateBookingStatus(transaction, event.getId(),
oldestWaitingListBooking.getUserBooked().getId(), BookingStatus.CONFIRMED,
oldestWaitingListBooking.getAdditionalInformation());
log.info("Event {} has {} users on waiting list after cancellation. Attempting auto-promotion.",
event.getId(), waitingListBookings.size());

// Filter out deleted users - try to get each user's details
// If getUserDTOById throws NoUserException, the user is deleted/not found
for (DetailedEventBookingDTO booking : waitingListBookings.stream()
.sorted(Comparator.comparing(EventBookingDTO::getBookingDate))
.toList()) {
try {
userAccountManager.getUserDTOById(booking.getUserBooked().getId());
// User exists and is not deleted - this is our candidate
oldestValidWaitingListBooking = booking;
break;
} catch (NoUserException e) {
// User deleted or not found - skip to next
log.debug("Skipping deleted/not found user {} on waiting list for event {}",
booking.getUserBooked().getId(), event.getId());
}
}

if (oldestValidWaitingListBooking != null) {
log.info("Promoting user {} from waiting list for event {}",
oldestValidWaitingListBooking.getUserBooked().getId(), event.getId());

updatedWaitingListBooking = this.bookingPersistenceManager.updateBookingStatus(transaction, event.getId(),
oldestValidWaitingListBooking.getUserBooked().getId(), BookingStatus.CONFIRMED,
oldestValidWaitingListBooking.getAdditionalInformation());
} else {
log.info(
"Event {} has no eligible waiting list users to promote after cancellation (all deleted/not found)",
event.getId());
updatedWaitingListBooking = null;
}
} else {
updatedWaitingListBooking = null;
}
Expand All @@ -1102,8 +1131,8 @@ public void cancelBooking(final IsaacEventPageDTO event, final RegisteredUserDTO
+ "student cancellation email was not sent", reservedById);
}

if (updatedWaitingListBooking != null) {
Long promotedBookingUserId = updatedWaitingListBooking.getUserBooked().getId();
if (updatedWaitingListBooking != null && oldestValidWaitingListBooking != null) {
Long promotedBookingUserId = oldestValidWaitingListBooking.getUserBooked().getId();
try {
RegisteredUserDTO promotedBookingUser =
userAccountManager.getUserDTOById(promotedBookingUserId);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -491,4 +491,31 @@ public List<DetailedEventBookingDTO> getBookingsByEventIdForUsers(String competi
throws SegueDatabaseException {
return this.getBookingByEventIdAndUsersId(competitionId, userIds);
}

/**
* Get all RESERVED bookings that have expired based on their reservationCloseDate.
*
* @return list of expired RESERVED bookings
* @throws SegueDatabaseException if an error occurs
*/
public List<DetailedEventBookingDTO> getExpiredReservedBookings() throws SegueDatabaseException {
try {
Iterable<EventBooking> expiredBookings = dao.findExpiredReservedBookings();
List<DetailedEventBookingDTO> result = new ArrayList<>();
for (EventBooking booking : expiredBookings) {
try {
DetailedEventBookingDTO dto = convertToDTO(booking);
if (dto != null) {
result.add(dto);
}
} catch (SegueDatabaseException e) {
log.error("Error converting expired booking to DTO for booking id {}", booking.getId(), e);
}
}
return result;
} catch (SegueDatabaseException e) {
log.error("Error retrieving expired reserved bookings from database", e);
throw e;
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -200,4 +200,12 @@ Iterable<EventBooking> findAllByEventIdAndStatus(String eventId, @Nullable Booki
* @param userId - user id
*/
void deleteAdditionalInformation(Long userId) throws SegueDatabaseException;

/**
* Find all RESERVED bookings that have expired based on their reservationCloseDate.
*
* @return an iterable with all expired RESERVED bookings
* @throws SegueDatabaseException - if an error occurs
*/
Iterable<EventBooking> findExpiredReservedBookings() throws SegueDatabaseException;
}
Original file line number Diff line number Diff line change
Expand Up @@ -758,6 +758,31 @@
}
}

/**
* Find all RESERVED bookings that have expired based on their reservationCloseDate.
*
* @return an iterable with all expired RESERVED bookings
* @throws SegueDatabaseException if an error occurs
*/
public Iterable<EventBooking> findExpiredReservedBookings() throws SegueDatabaseException {

Check notice

Code scanning / CodeQL

Missing Override annotation Note

This method overrides
EventBookings.findExpiredReservedBookings
; it is advisable to add an Override annotation.
String query = "SELECT event_bookings.* FROM event_bookings "
+ "WHERE status = 'RESERVED' "
+ "AND (additional_booking_information->>'reservationCloseDate')::timestamptz < NOW()";

try (Connection conn = ds.getDatabaseConnection();
PreparedStatement pst = conn.prepareStatement(query);
ResultSet results = pst.executeQuery()
) {
List<EventBooking> returnResult = new ArrayList<>();
while (results.next()) {
returnResult.add(buildPgEventBooking(results));
}
return returnResult;
} catch (SQLException e) {
throw new SegueDatabaseException(EXCEPTION_MESSAGE_POSTGRES_ERROR, e);
}
}

/**
* Create a pgEventBooking from a results set.
* <br>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,7 @@
import uk.ac.cam.cl.dtg.segue.scheduler.SegueJobService;
import uk.ac.cam.cl.dtg.segue.scheduler.SegueScheduledDatabaseScriptJob;
import uk.ac.cam.cl.dtg.segue.scheduler.SegueScheduledJob;
import uk.ac.cam.cl.dtg.segue.scheduler.jobs.CancelExpiredReservationsJob;
import uk.ac.cam.cl.dtg.segue.scheduler.jobs.DeleteEventAdditionalBookingInformationJob;
import uk.ac.cam.cl.dtg.segue.scheduler.jobs.DeleteEventAdditionalBookingInformationOneYearJob;
import uk.ac.cam.cl.dtg.segue.scheduler.jobs.EventFeedbackEmailJob;
Expand Down Expand Up @@ -1002,11 +1003,14 @@ private static SegueJobService getSegueJobService(final PropertiesLoader propert
"SQL scheduled job that deletes old AnonymousUsers",
CRON_STRING_0230_DAILY, "db_scripts/scheduled/anonymous-user-clean-up.sql");

SegueScheduledJob cleanUpExpiredReservations = new SegueScheduledDatabaseScriptJob(
SegueScheduledJob cleanUpExpiredReservations = SegueScheduledJob.createCustomJob(
"cleanUpExpiredReservations",
CRON_GROUP_NAME_SQL_MAINTENANCE,
"SQL scheduled job that deletes expired reservations for the event booking system",
CRON_STRING_0700_DAILY, "db_scripts/scheduled/expired-reservations-clean-up.sql");
CRON_GROUP_NAME_JAVA_JOB,
"Clean up expired RESERVED event bookings and trigger auto-promotion of waiting list users",
CRON_STRING_0700_DAILY,
Maps.newHashMap(),
new CancelExpiredReservationsJob()
);

SegueScheduledJob deleteEventAdditionalBookingInformation = SegueScheduledJob.createCustomJob(
"deleteEventAdditionalBookingInformation",
Expand Down
7 changes: 5 additions & 2 deletions src/main/java/uk/ac/cam/cl/dtg/segue/etl/ContentIndexer.java
Original file line number Diff line number Diff line change
Expand Up @@ -408,8 +408,11 @@ private Content augmentChildContent(final Content content, final String canonica

// hack to get cards to count as children:
if (content instanceof IsaacCardDeck) {
for (IsaacCard card : ((IsaacCardDeck) content).getCards()) {
this.augmentChildContent(card, canonicalSourceFile, newParentId, parentPublished);
List<IsaacCard> cards = ((IsaacCardDeck) content).getCards();
if (cards != null) {
for (IsaacCard card : cards) {
this.augmentChildContent(card, canonicalSourceFile, newParentId, parentPublished);
}
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package uk.ac.cam.cl.dtg.segue.scheduler.jobs;

import org.quartz.Job;
import org.quartz.JobExecutionContext;
import org.quartz.JobExecutionException;

/**
* Legacy wrapper for backwards compatibility with existing Quartz scheduler database entries.
* This job delegates to ExpiredReservationsCleanUpJob which contains the actual implementation.
*

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚫 [checkstyle] <com.puppycrawl.tools.checkstyle.checks.javadoc.JavadocParagraphCheck> reported by reviewdog 🐶
Empty line should be followed by

tag on the next line.

* This allows us to keep the existing trigger name in the database while using the new
* ExpiredReservationsCleanUpJob implementation.
*/
public class CancelExpiredReservationsJob implements Job {

private final ExpiredReservationsCleanUpJob delegate = new ExpiredReservationsCleanUpJob();

@Override
public void execute(final JobExecutionContext context) throws JobExecutionException {
delegate.execute(context);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
package uk.ac.cam.cl.dtg.segue.scheduler.jobs;

import com.google.inject.Injector;
import java.util.List;
import org.quartz.Job;
import org.quartz.JobExecutionContext;
import org.quartz.JobExecutionException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import uk.ac.cam.cl.dtg.isaac.api.managers.EventBookingManager;
import uk.ac.cam.cl.dtg.isaac.dao.EventBookingPersistenceManager;
import uk.ac.cam.cl.dtg.isaac.dto.IsaacEventPageDTO;
import uk.ac.cam.cl.dtg.isaac.dto.eventbookings.DetailedEventBookingDTO;
import uk.ac.cam.cl.dtg.isaac.dto.users.RegisteredUserDTO;
import uk.ac.cam.cl.dtg.segue.api.managers.IUserAccountManager;
import uk.ac.cam.cl.dtg.segue.auth.exceptions.NoUserException;
import uk.ac.cam.cl.dtg.segue.configuration.SegueGuiceConfigurationModule;
import uk.ac.cam.cl.dtg.segue.dao.ResourceNotFoundException;
import uk.ac.cam.cl.dtg.segue.dao.SegueDatabaseException;
import uk.ac.cam.cl.dtg.segue.dao.content.ContentManagerException;
import uk.ac.cam.cl.dtg.segue.dao.content.GitContentManager;

/**
* Scheduled job to clean up expired RESERVED event bookings and trigger auto-promotion of waiting list users.
* This job replaces the simple SQL cleanup with proper auto-promotion logic by:
* 1. Finding all RESERVED bookings that have passed their reservationCloseDate
* 2. For each expired reservation, calling cancelBooking() to trigger auto-promotion
* 3. Promoting waiting list users in chronological order as spots become available
* Previously, the SQL-only approach would cancel reservations but not promote waiting list users,
* leaving events marked as full of available spots.
*/
public class ExpiredReservationsCleanUpJob implements Job {
private static final Logger log = LoggerFactory.getLogger(ExpiredReservationsCleanUpJob.class);

private final EventBookingPersistenceManager bookingPersistenceManager;
private final EventBookingManager bookingManager;
private final IUserAccountManager userAccountManager;
private final GitContentManager contentManager;

/**
* Constructor for Quartz Job - must be no-arg and retrieve dependencies from injector.
*/
public ExpiredReservationsCleanUpJob() {
Injector injector = SegueGuiceConfigurationModule.getGuiceInjector();
bookingPersistenceManager = injector.getInstance(EventBookingPersistenceManager.class);
bookingManager = injector.getInstance(EventBookingManager.class);
userAccountManager = injector.getInstance(IUserAccountManager.class);
contentManager = injector.getInstance(GitContentManager.class);
}

@Override
public void execute(final JobExecutionContext context) throws JobExecutionException {
try {
log.info("Starting ExpiredReservationsCleanUpJob");

List<DetailedEventBookingDTO> expiredReservations =
bookingPersistenceManager.getExpiredReservedBookings();

if (expiredReservations.isEmpty()) {
log.info("No expired reservations found");
return;
}

log.info("Found {} expired RESERVED bookings to process", expiredReservations.size());

int successCount = 0;
int failureCount = 0;

for (DetailedEventBookingDTO expiredBooking : expiredReservations) {
try {
// Get the event details
IsaacEventPageDTO event = (IsaacEventPageDTO) contentManager
.getContentById(expiredBooking.getEventId());

if (event == null) {
log.warn("Event {} not found for expired booking {}. Skipping.",
expiredBooking.getEventId(), expiredBooking.getBookingId());
failureCount++;
continue;
}

// Get the user details
RegisteredUserDTO user = userAccountManager
.getUserDTOById(expiredBooking.getUserBooked().getId());

if (user == null) {
log.warn("User {} not found for expired booking {}. Skipping.",
expiredBooking.getUserBooked().getId(), expiredBooking.getBookingId());
failureCount++;
continue;
}

// Cancel the booking - this triggers auto-promotion of waiting list users!
try {
bookingManager.cancelBooking(event, user);
log.info("Cancelled expired RESERVED booking for user {} on event {} (booking id: {})",
user.getId(), event.getId(), expiredBooking.getBookingId());
successCount++;
} catch (SegueDatabaseException e) {
log.error("Database error while cancelling expired reservation {} for user {} on event {}",
expiredBooking.getBookingId(), user.getId(), event.getId(), e);
failureCount++;
} catch (ContentManagerException e) {
log.error("Content manager error while cancelling expired reservation {} for user {} on event {}",
expiredBooking.getBookingId(), user.getId(), event.getId(), e);
failureCount++;
}
} catch (NoUserException e) {
log.warn("User not found for expired booking {}", expiredBooking.getBookingId(), e);
failureCount++;
} catch (ResourceNotFoundException e) {
log.warn("Event not found for expired booking {}", expiredBooking.getBookingId(), e);
failureCount++;
} catch (ContentManagerException e) {
log.error("Content manager error while retrieving event for expired booking {}",
expiredBooking.getBookingId(), e);
failureCount++;
}
}

log.info("ExpiredReservationsCleanUpJob completed: {} successful cancellations, {} failures",
successCount, failureCount);

} catch (SegueDatabaseException e) {
log.error("Failed to retrieve expired reservations for ExpiredReservationsCleanUpJob", e);
throw new JobExecutionException(e);
}
}
}
Loading
Loading