diff --git a/listing-command/listing-command-api/src/main/java/uk/gov/moj/cpp/listing/command/api/ListingCommandApi.java b/listing-command/listing-command-api/src/main/java/uk/gov/moj/cpp/listing/command/api/ListingCommandApi.java
index 5ca66cedd..ae219effd 100644
--- a/listing-command/listing-command-api/src/main/java/uk/gov/moj/cpp/listing/command/api/ListingCommandApi.java
+++ b/listing-command/listing-command-api/src/main/java/uk/gov/moj/cpp/listing/command/api/ListingCommandApi.java
@@ -39,9 +39,14 @@
import uk.gov.justice.services.messaging.JsonEnvelope;
import uk.gov.moj.cpp.listing.command.api.courtcentre.CourtCentreFactory;
import uk.gov.moj.cpp.listing.command.api.service.HearingEnrichmentOrchestrator;
+import uk.gov.moj.cpp.listing.command.api.service.HearingLookupService;
+import uk.gov.moj.cpp.listing.common.pastdate.MoveHearingToPastDateException;
+import uk.gov.moj.cpp.listing.common.pastdate.MoveHearingToPastDateResult;
+import uk.gov.moj.cpp.listing.common.service.CourtSchedulerServiceAdapter;
import uk.gov.moj.cpp.listing.common.service.HearingSlotsService;
import uk.gov.moj.cpp.listing.domain.VacateTrialEnriched;
+import java.time.LocalDate;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
@@ -71,6 +76,27 @@ public class ListingCommandApi {
private static final String LISTING_COMMAND_LIST_UNSCHEDULED_NEXT_HEARINGS_ENRICHED = "listing.command.list-unscheduled-next-hearings-enriched";
private static final String LISTING_COMMAND_EXTEND_HEARING_FOR_HEARING_ENRICHED = "listing.command.extend-hearing-for-hearing-enriched";
private static final String LISTING_COMMAND_VACATE_TRIAL = "listing.command.vacate-trial-enriched";
+ private static final String LISTING_COMMAND_MOVE_HEARING_TO_PAST_DATE_ENRICHED = "listing.command.move-hearing-to-past-date-enriched";
+ private static final String COURT_CENTRE_ID = "courtCentreId";
+ private static final String START_DATE = "startDate";
+ private static final String JURISDICTION = "jurisdiction";
+ private static final String JURISDICTION_TYPE = "jurisdictionType";
+ private static final String ESTIMATED_MINUTES = "estimatedMinutes";
+ private static final String COURT_SCHEDULE_ID = "courtScheduleId";
+ private static final String COURT_ROOM_ID = "courtRoomId";
+ private static final String SESSION_DATE = "sessionDate";
+ private static final String SESSION_START_TIME = "sessionStartTime";
+ private static final String SESSION_END_TIME = "sessionEndTime";
+ private static final String DURATION_IN_MINUTES = "durationInMinutes";
+ private static final String HEARING_DAYS = "hearingDays";
+ private static final String DAY_START_TIME = "startTime";
+ private static final String DAY_END_TIME = "endTime";
+ private static final String DAY_DURATION_MINUTES = "durationMinutes";
+ private static final String ERROR_CODE = "errorCode";
+ private static final String MESSAGE = "message";
+ public static final String HEARING_ID_NOT_FOUND = "HEARING_ID_NOT_FOUND";
+ public static final String FUTURE_DATE_NOT_ALLOWED = "FUTURE_DATE_NOT_ALLOWED";
+ private static final String CROWN_JURISDICTION = "CROWN";
private static final String LISTING_COMMAND_CORRECT_HEARING_DAYS_WO_CC = "listing.command.correct-hearing-days-without-court-centre";
private static final String LISTING_COMMAND_DUPLICATE_UNALLOCATED_HEARING = "listing.command.mark-unallocated-hearing-as-duplicate";
private static final String LISTING_COMMAND_UPDATE_EXISTING_HEARING = "listing.command.update-existing-hearing";
@@ -101,6 +127,10 @@ public class ListingCommandApi {
private HearingSlotsService hearingSlotsService;
@Inject
private HearingEnrichmentOrchestrator hearingEnrichmentOrchestrator;
+ @Inject
+ private CourtSchedulerServiceAdapter courtSchedulerServiceAdapter;
+ @Inject
+ private HearingLookupService hearingLookupService;
@Handles("listing.command.list-court-hearing")
public void handleListCourtHearing(final JsonEnvelope envelope) {
@@ -329,6 +359,113 @@ public void handleVacateTrial(final JsonEnvelope envelope) {
envelope.payload()));
}
+ @Handles("listing.command.move-hearing-to-past-date")
+ public void handleMoveHearingToPastDate(final JsonEnvelope envelope) {
+ final JsonObject payload = envelope.payloadAsJsonObject();
+
+ if (LOGGER.isDebugEnabled()) {
+ LOGGER.debug("'listing.command.move-hearing-to-past-date' received with payload {}", envelope.toObfuscatedDebugString());
+ }
+
+ final UUID hearingId = fromString(payload.getString(HEARING_ID));
+ final UUID courtCentreId = fromString(payload.getString(COURT_CENTRE_ID));
+ final LocalDate startDate = LocalDate.parse(payload.getString(START_DATE));
+
+ final JsonObject hearing = hearingLookupService.findHearing(hearingId, envelope)
+ .orElseThrow(() -> new MoveHearingToPastDateException(422,
+ buildMoveHearingToPastDateErrorBody(HEARING_ID_NOT_FOUND, "No hearing found for hearingId " + hearingId),
+ "No hearing found for hearingId " + hearingId));
+
+ final String jurisdictionType = hearing.getString(JURISDICTION_TYPE, null);
+
+ final JsonObjectBuilder enrichedBuilder = createObjectBuilder()
+ .add(HEARING_ID, hearingId.toString())
+ .add(JURISDICTION, jurisdictionType == null ? "" : jurisdictionType)
+ .add(START_DATE, startDate.toString())
+ .add(COURT_CENTRE_ID, courtCentreId.toString());
+
+ if (CROWN_JURISDICTION.equals(jurisdictionType)) {
+ // Baris decision D1: CROWN moves are listing-side only, courtscheduler is never called.
+ rejectCrownMoveToFutureDate(startDate);
+ enrichWithExistingDayDetails(enrichedBuilder, hearing, startDate);
+ } else {
+ enrichWithBookedPastDateSlot(enrichedBuilder, hearingId, courtCentreId, startDate, hearing);
+ }
+
+ sender.send(envelopeFrom(metadataFrom(envelope.metadata()).withName(LISTING_COMMAND_MOVE_HEARING_TO_PAST_DATE_ENRICHED),
+ enrichedBuilder.build()));
+ }
+
+ /**
+ * CROWN moves never call courtscheduler, so the re-dated hearing day is rebuilt from the
+ * hearing's own current first sitting day — same room and time-of-day, on the new past date.
+ */
+ private static void enrichWithExistingDayDetails(final JsonObjectBuilder enrichedBuilder, final JsonObject hearing, final LocalDate startDate) {
+ final JsonArray hearingDays = hearing.containsKey(HEARING_DAYS) ? hearing.getJsonArray(HEARING_DAYS) : null;
+ if (hearingDays == null || hearingDays.isEmpty()) {
+ return;
+ }
+ final JsonObject day = hearingDays.getJsonObject(0);
+ enrichedBuilder.add(SESSION_DATE, startDate.toString());
+ if (day.containsKey(COURT_ROOM_ID) && !day.isNull(COURT_ROOM_ID)) {
+ enrichedBuilder.add(COURT_ROOM_ID, day.getString(COURT_ROOM_ID));
+ }
+ if (day.containsKey(DAY_START_TIME) && !day.isNull(DAY_START_TIME)) {
+ enrichedBuilder.add(SESSION_START_TIME,
+ java.time.ZonedDateTime.parse(day.getString(DAY_START_TIME)).with(startDate).toString());
+ }
+ if (day.containsKey(DAY_END_TIME) && !day.isNull(DAY_END_TIME)) {
+ enrichedBuilder.add(SESSION_END_TIME,
+ java.time.ZonedDateTime.parse(day.getString(DAY_END_TIME)).with(startDate).toString());
+ }
+ if (day.containsKey(DAY_DURATION_MINUTES) && !day.isNull(DAY_DURATION_MINUTES)) {
+ enrichedBuilder.add(DURATION_IN_MINUTES, day.getInt(DAY_DURATION_MINUTES));
+ }
+ }
+
+ private static void rejectCrownMoveToFutureDate(final LocalDate startDate) {
+ if (startDate.isAfter(LocalDate.now())) {
+ throw new MoveHearingToPastDateException(422,
+ buildMoveHearingToPastDateErrorBody(FUTURE_DATE_NOT_ALLOWED, "Hearings can only be moved to today or an earlier date"),
+ "Hearings can only be moved to today or an earlier date");
+ }
+ }
+
+ private void enrichWithBookedPastDateSlot(final JsonObjectBuilder enrichedBuilder, final UUID hearingId,
+ final UUID courtCentreId, final LocalDate startDate, final JsonObject hearing) {
+ final Integer durationInMinutes = (hearing.containsKey(ESTIMATED_MINUTES) && !hearing.isNull(ESTIMATED_MINUTES))
+ ? hearing.getInt(ESTIMATED_MINUTES) : null;
+
+ final MoveHearingToPastDateResult slot =
+ courtSchedulerServiceAdapter.moveHearingToPastDate(hearingId, courtCentreId, startDate, durationInMinutes);
+
+ if (slot.courtScheduleId() != null) {
+ enrichedBuilder.add(COURT_SCHEDULE_ID, slot.courtScheduleId().toString());
+ }
+ if (slot.courtRoomId() != null) {
+ enrichedBuilder.add(COURT_ROOM_ID, slot.courtRoomId());
+ }
+ if (slot.sessionDate() != null) {
+ enrichedBuilder.add(SESSION_DATE, slot.sessionDate().toString());
+ }
+ if (slot.sessionStartTime() != null) {
+ enrichedBuilder.add(SESSION_START_TIME, slot.sessionStartTime());
+ }
+ if (slot.sessionEndTime() != null) {
+ enrichedBuilder.add(SESSION_END_TIME, slot.sessionEndTime());
+ }
+ if (slot.durationInMinutes() != null) {
+ enrichedBuilder.add(DURATION_IN_MINUTES, slot.durationInMinutes());
+ }
+ }
+
+ private static JsonObject buildMoveHearingToPastDateErrorBody(final String errorCode, final String message) {
+ return createObjectBuilder()
+ .add(ERROR_CODE, errorCode)
+ .add(MESSAGE, message)
+ .build();
+ }
+
@Handles("listing.command.extend-hearing-for-hearing")
public void handleExtendHearingForHearing(final JsonEnvelope envelope) {
diff --git a/listing-command/listing-command-api/src/main/java/uk/gov/moj/cpp/listing/command/api/mapper/ListingCommandCommonProviders.java b/listing-command/listing-command-api/src/main/java/uk/gov/moj/cpp/listing/command/api/mapper/ListingCommandCommonProviders.java
new file mode 100644
index 000000000..eeb477f6d
--- /dev/null
+++ b/listing-command/listing-command-api/src/main/java/uk/gov/moj/cpp/listing/command/api/mapper/ListingCommandCommonProviders.java
@@ -0,0 +1,33 @@
+package uk.gov.moj.cpp.listing.command.api.mapper;
+
+import uk.gov.justice.services.adapter.rest.application.DefaultCommonProviders;
+
+import java.util.Set;
+
+import javax.enterprise.inject.Specializes;
+
+/**
+ * Registers the listing command-api's custom JAX-RS providers with the generated REST
+ * {@code Application}. The framework-generated Application injects {@code CommonProviders} and
+ * builds its {@code getClasses()} set solely from {@link #providers()} — providers are NOT
+ * classpath-scanned, so a {@code @Provider} annotation alone does nothing. {@code @Specializes}
+ * makes this bean replace the framework's {@code @Default DefaultCommonProviders} at that
+ * injection point while inheriting its qualifiers.
+ *
+ * This is the single specialization point for command-api JAX-RS providers.
+ * CDI allows only ONE bean to specialize {@link DefaultCommonProviders} per deployment — adding a
+ * second {@code @Specializes DefaultCommonProviders} subclass anywhere on the classpath makes the
+ * whole WAR fail to deploy (inconsistent specialization). Register any future command-api
+ * provider (exception mappers, filters, features) by adding its class here; do NOT create another
+ * specializing subclass.
+ */
+@Specializes
+public class ListingCommandCommonProviders extends DefaultCommonProviders {
+
+ @Override
+ public Set> providers() {
+ final Set> providers = super.providers();
+ providers.add(MoveHearingToPastDateExceptionMapper.class);
+ return providers;
+ }
+}
diff --git a/listing-command/listing-command-api/src/main/java/uk/gov/moj/cpp/listing/command/api/mapper/MoveHearingToPastDateExceptionMapper.java b/listing-command/listing-command-api/src/main/java/uk/gov/moj/cpp/listing/command/api/mapper/MoveHearingToPastDateExceptionMapper.java
new file mode 100644
index 000000000..ecbe10515
--- /dev/null
+++ b/listing-command/listing-command-api/src/main/java/uk/gov/moj/cpp/listing/command/api/mapper/MoveHearingToPastDateExceptionMapper.java
@@ -0,0 +1,46 @@
+package uk.gov.moj.cpp.listing.command.api.mapper;
+
+import static javax.json.Json.createObjectBuilder;
+import static javax.ws.rs.core.MediaType.APPLICATION_JSON;
+import static javax.ws.rs.core.Response.status;
+import static uk.gov.justice.services.messaging.JsonObjects.getString;
+
+import uk.gov.moj.cpp.listing.common.pastdate.MoveHearingToPastDateException;
+
+import javax.inject.Inject;
+import javax.json.JsonObject;
+import javax.json.JsonObjectBuilder;
+import javax.ws.rs.core.Response;
+import javax.ws.rs.ext.ExceptionMapper;
+import javax.ws.rs.ext.Provider;
+
+import org.slf4j.Logger;
+
+@Provider
+public class MoveHearingToPastDateExceptionMapper implements ExceptionMapper {
+
+ @Inject
+ Logger logger;
+
+ @Override
+ public Response toResponse(final MoveHearingToPastDateException exception) {
+ logger.debug("move-hearing-to-past-date rejected", exception);
+
+ final JsonObjectBuilder builder = createObjectBuilder();
+ if (exception.getErrorCode() != null) {
+ builder.add("errorCode", exception.getErrorCode());
+ }
+ final JsonObject responseBody = exception.getResponseBody();
+ final String message = responseBody == null
+ ? exception.getMessage()
+ : getString(responseBody, "message").orElse(exception.getMessage());
+ if (message != null) {
+ builder.add("message", message);
+ }
+
+ return status(exception.getHttpStatus())
+ .entity(builder.build().toString())
+ .type(APPLICATION_JSON)
+ .build();
+ }
+}
diff --git a/listing-command/listing-command-api/src/main/java/uk/gov/moj/cpp/listing/command/api/service/HearingLookupService.java b/listing-command/listing-command-api/src/main/java/uk/gov/moj/cpp/listing/command/api/service/HearingLookupService.java
new file mode 100644
index 000000000..b3420ca66
--- /dev/null
+++ b/listing-command/listing-command-api/src/main/java/uk/gov/moj/cpp/listing/command/api/service/HearingLookupService.java
@@ -0,0 +1,60 @@
+package uk.gov.moj.cpp.listing.command.api.service;
+
+import static uk.gov.justice.services.core.annotation.Component.QUERY_API;
+import static uk.gov.justice.services.messaging.JsonEnvelope.metadataFrom;
+import static uk.gov.justice.services.messaging.JsonObjects.createObjectBuilder;
+
+import uk.gov.justice.services.core.annotation.ServiceComponent;
+import uk.gov.justice.services.core.enveloper.Enveloper;
+import uk.gov.justice.services.messaging.JsonEnvelope;
+import uk.gov.justice.services.messaging.Metadata;
+import uk.gov.moj.cpp.listing.query.view.HearingQueryView;
+
+import java.util.Optional;
+import java.util.UUID;
+
+import javax.enterprise.context.ApplicationScoped;
+import javax.inject.Inject;
+import javax.json.JsonObject;
+import javax.ws.rs.NotFoundException;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Synchronous, in-process lookup of a hearing from the listing viewstore, for command-api-side
+ * existence/validation checks that must happen before a command is sent (e.g.
+ * move-hearing-to-past-date's unknown-hearingId 422). Mirrors the
+ * {@code uk.gov.moj.cpp.listing.command.service.HearingService} pattern already used from
+ * listing-command-handler, moved into command-api since the check has to happen here to be able
+ * to reject with a synchronous 422 before anything is sent.
+ */
+@ApplicationScoped
+public class HearingLookupService {
+
+ private static final Logger LOGGER = LoggerFactory.getLogger(HearingLookupService.class);
+ private static final String HEARING_QUERY_BY_HEARING_ID = "listing.search.hearing";
+
+ @Inject
+ private Enveloper enveloper;
+
+ @Inject
+ @ServiceComponent(QUERY_API)
+ private HearingQueryView hearingQueryView;
+
+ public Optional findHearing(final UUID hearingId, final JsonEnvelope envelope) {
+ final JsonObject payload = createObjectBuilder().add("id", hearingId.toString()).build();
+
+ final Metadata metadata = metadataFrom(envelope.metadata()).withName(HEARING_QUERY_BY_HEARING_ID).build();
+ final JsonEnvelope query = JsonEnvelope.envelopeFrom(metadata, payload);
+ final JsonEnvelope request = enveloper.withMetadataFrom(query, HEARING_QUERY_BY_HEARING_ID).apply(payload);
+
+ try {
+ final JsonEnvelope response = hearingQueryView.getHearingById(request);
+ return Optional.of(response.payloadAsJsonObject());
+ } catch (final NotFoundException e) {
+ LOGGER.debug("No hearing found for hearingId {}", hearingId, e);
+ return Optional.empty();
+ }
+ }
+}
diff --git a/listing-command/listing-command-api/src/main/resources/uk/gov/moj/cpp/listing/command/api/accesscontrol/listing-command-api.drl b/listing-command/listing-command-api/src/main/resources/uk/gov/moj/cpp/listing/command/api/accesscontrol/listing-command-api.drl
index f87b40a53..1993fe74a 100644
--- a/listing-command/listing-command-api/src/main/resources/uk/gov/moj/cpp/listing/command/api/accesscontrol/listing-command-api.drl
+++ b/listing-command/listing-command-api/src/main/resources/uk/gov/moj/cpp/listing/command/api/accesscontrol/listing-command-api.drl
@@ -239,3 +239,12 @@ rule "Command - API - Action - listing.update-hearing-add-case-bdf"
$outcome.setSuccess(true);
end
+rule "Command - API - Action - listing.command.move-hearing-to-past-date"
+ when
+ $outcome: Outcome();
+ $action: Action(name == "listing.command.move-hearing-to-past-date");
+ eval(userAndGroupProvider.isMemberOfAnyOfTheSuppliedGroups($action, RuleConstants.LISTING_OFFICERS, RuleConstants.CROWN_COURT_ADMIN, RuleConstants.COURT_ADMINISTRATORS, RuleConstants.COURT_CLERKS, RuleConstants.LEGAL_ADVISERS, RuleConstants.COURT_ASSOCIATE));
+ then
+ $outcome.setSuccess(true);
+end
+
diff --git a/listing-command/listing-command-api/src/raml/json/listing.command.move-hearing-to-past-date.json b/listing-command/listing-command-api/src/raml/json/listing.command.move-hearing-to-past-date.json
new file mode 100644
index 000000000..ea5235ca8
--- /dev/null
+++ b/listing-command/listing-command-api/src/raml/json/listing.command.move-hearing-to-past-date.json
@@ -0,0 +1,4 @@
+{
+ "courtCentreId": "07e45c88-9e5d-3e44-b664-d5345bb13be2",
+ "startDate": "2026-05-01"
+}
diff --git a/listing-command/listing-command-api/src/raml/json/schema/listing.command.move-hearing-to-past-date.json b/listing-command/listing-command-api/src/raml/json/schema/listing.command.move-hearing-to-past-date.json
new file mode 100644
index 000000000..0327ea7fd
--- /dev/null
+++ b/listing-command/listing-command-api/src/raml/json/schema/listing.command.move-hearing-to-past-date.json
@@ -0,0 +1,19 @@
+{
+ "$schema": "http://json-schema.org/draft-04/schema#",
+ "id": "http://justice.gov.uk/listing/courts/listing.command.move-hearing-to-past-date.json",
+ "type": "object",
+ "properties": {
+ "courtCentreId": {
+ "$ref": "http://justice.gov.uk/domain/core/common/definitions.json#/definitions/uuid"
+ },
+ "startDate": {
+ "type": "string",
+ "format": "date"
+ }
+ },
+ "required": [
+ "courtCentreId",
+ "startDate"
+ ],
+ "additionalProperties": false
+}
diff --git a/listing-command/listing-command-api/src/raml/listing-command-api.raml b/listing-command/listing-command-api/src/raml/listing-command-api.raml
index f0d378ebe..57a22865f 100644
--- a/listing-command/listing-command-api/src/raml/listing-command-api.raml
+++ b/listing-command/listing-command-api/src/raml/listing-command-api.raml
@@ -103,6 +103,9 @@ protocols: [ HTTP, HTTPS ]
(mapping):
requestType: application/vnd.listing.command.vacate-trial+json
name: listing.command.vacate-trial
+ (mapping):
+ requestType: application/vnd.listing.command.move-hearing-to-past-date+json
+ name: listing.command.move-hearing-to-past-date
(mapping):
requestType: application/vnd.listing.duplicate-unallocated-hearing+json
name: listing.mark-unallocated-hearing-as-duplicate
@@ -144,6 +147,9 @@ protocols: [ HTTP, HTTPS ]
application/vnd.listing.command.vacate-trial+json:
example: !include json/listing.command.vacate-trial.json
schema: !include json/schema/listing.command.vacate-trial.json
+ application/vnd.listing.command.move-hearing-to-past-date+json:
+ example: !include json/listing.command.move-hearing-to-past-date.json
+ schema: !include json/schema/listing.command.move-hearing-to-past-date.json
application/vnd.listing.duplicate-unallocated-hearing+json: !!null
application/vnd.listing.next-hearings-v2+json:
example: !include json/listing.list-next-hearings-v2.json
diff --git a/listing-command/listing-command-api/src/test/java/uk/gov/moj/cpp/listing/command/api/ListingAccessControlTest.java b/listing-command/listing-command-api/src/test/java/uk/gov/moj/cpp/listing/command/api/ListingAccessControlTest.java
index 9f8b3351e..45d681416 100644
--- a/listing-command/listing-command-api/src/test/java/uk/gov/moj/cpp/listing/command/api/ListingAccessControlTest.java
+++ b/listing-command/listing-command-api/src/test/java/uk/gov/moj/cpp/listing/command/api/ListingAccessControlTest.java
@@ -46,6 +46,7 @@ public class ListingAccessControlTest extends BaseDroolsAccessControlTest {
private static final String ACTION_MARK_UNALLOCATED_HEARING_AS_DUPLICATE = "listing.mark-unallocated-hearing-as-duplicate";
private static final String ACTION_DELETE_HEARING = "listing.command.delete-hearing";
private static final String ACTION_DELETE_PREVIOUS_HEARINGS_AND_CREATE_NEXT_HEARING = "listing.delete-previous-hearings-and-create-next-hearing";
+ private static final String ACTION_MOVE_HEARING_TO_PAST_DATE = "listing.command.move-hearing-to-past-date";
@@ -307,4 +308,25 @@ public void shouldNotAllowNonSystemUserToDeleteHearing() {
assertFailureOutcome(results);
}
+
+ @Test
+ public void shouldAllowAuthorisedUserToMoveHearingToPastDate() {
+ final Action action = createActionFor(ACTION_MOVE_HEARING_TO_PAST_DATE);
+ given(userAndGroupProvider.isMemberOfAnyOfTheSuppliedGroups(action, LISTING_OFFICERS,
+ CROWN_COURT_ADMIN, COURT_ADMINISTRATORS, COURT_CLERKS, LEGAL_ADVISERS, COURT_ASSOCIATE))
+ .willReturn(true);
+
+ final ExecutionResults results = executeRulesWith(action);
+
+ assertSuccessfulOutcome(results);
+ }
+
+ @Test
+ public void shouldNotAllowUnauthorisedUserToMoveHearingToPastDate() {
+ final Action action = createActionFor(ACTION_MOVE_HEARING_TO_PAST_DATE);
+
+ final ExecutionResults results = executeRulesWith(action);
+
+ assertFailureOutcome(results);
+ }
}
\ No newline at end of file
diff --git a/listing-command/listing-command-api/src/test/java/uk/gov/moj/cpp/listing/command/api/ListingCommandApiTest.java b/listing-command/listing-command-api/src/test/java/uk/gov/moj/cpp/listing/command/api/ListingCommandApiTest.java
index cd85ad3df..5308dbee2 100644
--- a/listing-command/listing-command-api/src/test/java/uk/gov/moj/cpp/listing/command/api/ListingCommandApiTest.java
+++ b/listing-command/listing-command-api/src/test/java/uk/gov/moj/cpp/listing/command/api/ListingCommandApiTest.java
@@ -68,6 +68,10 @@
import uk.gov.justice.services.test.utils.core.messaging.MetadataBuilderFactory;
import uk.gov.moj.cpp.listing.command.api.courtcentre.CourtCentreFactory;
import uk.gov.moj.cpp.listing.command.api.service.HearingEnrichmentOrchestrator;
+import uk.gov.moj.cpp.listing.command.api.service.HearingLookupService;
+import uk.gov.moj.cpp.listing.common.pastdate.MoveHearingToPastDateException;
+import uk.gov.moj.cpp.listing.common.pastdate.MoveHearingToPastDateResult;
+import uk.gov.moj.cpp.listing.common.service.CourtSchedulerServiceAdapter;
import uk.gov.moj.cpp.listing.common.service.HearingSlotsService;
import uk.gov.moj.cpp.listing.domain.JudicialRole;
import uk.gov.moj.cpp.listing.domain.JudicialRoleType;
@@ -80,10 +84,12 @@
import java.util.Arrays;
import java.util.List;
import java.util.Map;
+import java.util.Optional;
import java.util.UUID;
import java.util.concurrent.atomic.AtomicInteger;
import uk.gov.justice.services.messaging.JsonObjects;
+import javax.json.Json;
import javax.json.JsonArray;
import javax.json.JsonObject;
import javax.json.JsonReader;
@@ -129,6 +135,10 @@ public class ListingCommandApiTest {
private HearingSlotsService hearingSlotsService;
@Mock
private HearingEnrichmentOrchestrator hearingEnrichmentOrchestrator;
+ @Mock
+ private CourtSchedulerServiceAdapter courtSchedulerServiceAdapter;
+ @Mock
+ private HearingLookupService hearingLookupService;
private static final Type HEARING_TYPE = Type.type()
.withId(fromString("6e1bef55-7e13-4615-b3ba-8663f4438e16"))
@@ -587,6 +597,221 @@ public void shouldVacateTheTrial() {
verify(sender, times(1)).send(senderJsonEnvelopeCaptor.capture());
}
+ @Test
+ public void shouldRejectMoveHearingToPastDateWhenHearingIdUnknown() {
+ final UUID hearingId = randomUUID();
+ final UUID courtCentreId = randomUUID();
+
+ given(envelope.payloadAsJsonObject()).willReturn(payload);
+ given(payload.getString("hearingId")).willReturn(hearingId.toString());
+ given(payload.getString("courtCentreId")).willReturn(courtCentreId.toString());
+ given(payload.getString("startDate")).willReturn("2026-05-01");
+ given(hearingLookupService.findHearing(hearingId, envelope)).willReturn(Optional.empty());
+
+ final MoveHearingToPastDateException thrown = assertThrows(MoveHearingToPastDateException.class,
+ () -> listingCommandApi.handleMoveHearingToPastDate(envelope));
+
+ assertThat(thrown.getHttpStatus(), is(422));
+ assertThat(thrown.getErrorCode(), is("HEARING_ID_NOT_FOUND"));
+ verify(sender, never()).send(any());
+ }
+
+ @Test
+ public void shouldMoveMagistratesHearingToPastDateEnrichWithSlotDetailsAndSend() {
+ final UUID hearingId = randomUUID();
+ final UUID courtCentreId = randomUUID();
+ final UUID courtScheduleId = randomUUID();
+ final LocalDate startDate = LocalDate.parse("2026-05-01");
+
+ given(envelope.payloadAsJsonObject()).willReturn(payload);
+ given(payload.getString("hearingId")).willReturn(hearingId.toString());
+ given(payload.getString("courtCentreId")).willReturn(courtCentreId.toString());
+ given(payload.getString("startDate")).willReturn(startDate.toString());
+ given(envelope.metadata()).willReturn(metadataWithRandomUUIDAndName().build());
+
+ final JsonObject hearing = Json.createObjectBuilder()
+ .add("id", hearingId.toString())
+ .add("jurisdictionType", "MAGISTRATES")
+ .add("estimatedMinutes", 30)
+ .build();
+ given(hearingLookupService.findHearing(hearingId, envelope)).willReturn(Optional.of(hearing));
+
+ final MoveHearingToPastDateResult slot = new MoveHearingToPastDateResult(courtScheduleId,
+ "9d324f4f-6c3b-451f-ac1e-f459db781153", startDate, "2026-05-01T09:00:00Z", "2026-05-01T17:00:00Z", 30);
+ given(courtSchedulerServiceAdapter.moveHearingToPastDate(hearingId, courtCentreId, startDate, 30)).willReturn(slot);
+
+ final ArgumentCaptor captor = forClass(Envelope.class);
+
+ listingCommandApi.handleMoveHearingToPastDate(envelope);
+
+ verify(courtSchedulerServiceAdapter).moveHearingToPastDate(hearingId, courtCentreId, startDate, 30);
+ verify(sender, times(1)).send(captor.capture());
+ final JsonObject sent = (JsonObject) captor.getValue().payload();
+ assertThat(sent.getString("hearingId"), is(hearingId.toString()));
+ assertThat(sent.getString("jurisdiction"), is("MAGISTRATES"));
+ assertThat(sent.getString("courtScheduleId"), is(courtScheduleId.toString()));
+ assertThat(sent.getString("sessionDate"), is(startDate.toString()));
+ assertThat(sent.getInt("durationInMinutes"), is(30));
+ }
+
+ @Test
+ public void shouldNotSendWhenCourtschedulerRejectsMagistratesMove() {
+ final UUID hearingId = randomUUID();
+ final UUID courtCentreId = randomUUID();
+ final LocalDate startDate = LocalDate.parse("2999-01-01");
+
+ given(envelope.payloadAsJsonObject()).willReturn(payload);
+ given(payload.getString("hearingId")).willReturn(hearingId.toString());
+ given(payload.getString("courtCentreId")).willReturn(courtCentreId.toString());
+ given(payload.getString("startDate")).willReturn(startDate.toString());
+
+ final JsonObject hearing = Json.createObjectBuilder()
+ .add("id", hearingId.toString())
+ .add("jurisdictionType", "MAGISTRATES")
+ .build();
+ given(hearingLookupService.findHearing(hearingId, envelope)).willReturn(Optional.of(hearing));
+
+ final JsonObject body = Json.createObjectBuilder()
+ .add("errorCode", "FUTURE_DATE_NOT_ALLOWED")
+ .add("message", "Hearings can only be moved to today or an earlier date")
+ .build();
+ given(courtSchedulerServiceAdapter.moveHearingToPastDate(any(), any(), any(), any()))
+ .willThrow(new MoveHearingToPastDateException(422, body, "rejected"));
+
+ final MoveHearingToPastDateException thrown = assertThrows(MoveHearingToPastDateException.class,
+ () -> listingCommandApi.handleMoveHearingToPastDate(envelope));
+ assertThat(thrown.getHttpStatus(), is(422));
+ assertThat(thrown.getErrorCode(), is("FUTURE_DATE_NOT_ALLOWED"));
+ verify(sender, never()).send(any());
+ }
+
+ @Test
+ public void shouldNotSendWhenCourtschedulerFindsNoSessionForMagistratesMove() {
+ final UUID hearingId = randomUUID();
+ final UUID courtCentreId = randomUUID();
+ final LocalDate startDate = LocalDate.parse("2026-05-01");
+
+ given(envelope.payloadAsJsonObject()).willReturn(payload);
+ given(payload.getString("hearingId")).willReturn(hearingId.toString());
+ given(payload.getString("courtCentreId")).willReturn(courtCentreId.toString());
+ given(payload.getString("startDate")).willReturn(startDate.toString());
+
+ final JsonObject hearing = Json.createObjectBuilder()
+ .add("id", hearingId.toString())
+ .add("jurisdictionType", "MAGISTRATES")
+ .build();
+ given(hearingLookupService.findHearing(hearingId, envelope)).willReturn(Optional.of(hearing));
+
+ final JsonObject noSessionBody = Json.createObjectBuilder()
+ .add("errorCode", "NO_SESSION_FOUND")
+ .add("message", "No court-schedule session found")
+ .build();
+ given(courtSchedulerServiceAdapter.moveHearingToPastDate(any(), any(), any(), any()))
+ .willThrow(new MoveHearingToPastDateException(422, noSessionBody, "no session"));
+
+ final MoveHearingToPastDateException thrown = assertThrows(MoveHearingToPastDateException.class,
+ () -> listingCommandApi.handleMoveHearingToPastDate(envelope));
+ assertThat(thrown.getHttpStatus(), is(422));
+ assertThat(thrown.getErrorCode(), is("NO_SESSION_FOUND"));
+ verify(sender, never()).send(any());
+ }
+
+ @Test
+ public void shouldMoveCrownHearingToPastDateListingSideOnlyWithoutCallingCourtScheduler() {
+ final UUID hearingId = randomUUID();
+ final UUID courtCentreId = randomUUID();
+ final LocalDate startDate = LocalDate.now().minusDays(1);
+
+ given(envelope.payloadAsJsonObject()).willReturn(payload);
+ given(payload.getString("hearingId")).willReturn(hearingId.toString());
+ given(payload.getString("courtCentreId")).willReturn(courtCentreId.toString());
+ given(payload.getString("startDate")).willReturn(startDate.toString());
+ given(envelope.metadata()).willReturn(metadataWithRandomUUIDAndName().build());
+
+ final JsonObject hearing = Json.createObjectBuilder()
+ .add("id", hearingId.toString())
+ .add("jurisdictionType", "CROWN")
+ .build();
+ given(hearingLookupService.findHearing(hearingId, envelope)).willReturn(Optional.of(hearing));
+
+ final ArgumentCaptor captor = forClass(Envelope.class);
+
+ listingCommandApi.handleMoveHearingToPastDate(envelope);
+
+ verify(courtSchedulerServiceAdapter, never()).moveHearingToPastDate(any(), any(), any(), any());
+ verify(sender, times(1)).send(captor.capture());
+ final JsonObject sent = (JsonObject) captor.getValue().payload();
+ assertThat(sent.getString("hearingId"), is(hearingId.toString()));
+ assertThat(sent.getString("jurisdiction"), is("CROWN"));
+ assertThat(sent.getString("startDate"), is(startDate.toString()));
+ assertThat(sent.containsKey("courtScheduleId"), is(false));
+ }
+
+ @Test
+ public void shouldEnrichCrownMoveWithExistingDayDetailsReDatedToStartDate() {
+ final UUID hearingId = randomUUID();
+ final UUID courtCentreId = randomUUID();
+ final UUID crownRoomId = randomUUID();
+ final LocalDate startDate = LocalDate.now().minusDays(1);
+
+ given(envelope.payloadAsJsonObject()).willReturn(payload);
+ given(payload.getString("hearingId")).willReturn(hearingId.toString());
+ given(payload.getString("courtCentreId")).willReturn(courtCentreId.toString());
+ given(payload.getString("startDate")).willReturn(startDate.toString());
+ given(envelope.metadata()).willReturn(metadataWithRandomUUIDAndName().build());
+
+ final JsonObject hearing = Json.createObjectBuilder()
+ .add("id", hearingId.toString())
+ .add("jurisdictionType", "CROWN")
+ .add("hearingDays", Json.createArrayBuilder()
+ .add(Json.createObjectBuilder()
+ .add("hearingDate", LocalDate.now().plusDays(3).toString())
+ .add("startTime", LocalDate.now().plusDays(3) + "T10:30:00Z")
+ .add("durationMinutes", 45)
+ .add("courtRoomId", crownRoomId.toString())))
+ .build();
+ given(hearingLookupService.findHearing(hearingId, envelope)).willReturn(Optional.of(hearing));
+
+ final ArgumentCaptor captor = forClass(Envelope.class);
+
+ listingCommandApi.handleMoveHearingToPastDate(envelope);
+
+ verify(courtSchedulerServiceAdapter, never()).moveHearingToPastDate(any(), any(), any(), any());
+ verify(sender, times(1)).send(captor.capture());
+ final JsonObject sent = (JsonObject) captor.getValue().payload();
+ assertThat(sent.getString("sessionDate"), is(startDate.toString()));
+ assertThat(sent.getString("courtRoomId"), is(crownRoomId.toString()));
+ assertThat(sent.getString("sessionStartTime"), is(startDate + "T10:30Z"));
+ assertThat(sent.getInt("durationInMinutes"), is(45));
+ assertThat(sent.containsKey("courtScheduleId"), is(false));
+ }
+
+ @Test
+ public void shouldRejectCrownMoveToFutureDate() {
+ final UUID hearingId = randomUUID();
+ final UUID courtCentreId = randomUUID();
+ final LocalDate startDate = LocalDate.now().plusDays(1);
+
+ given(envelope.payloadAsJsonObject()).willReturn(payload);
+ given(payload.getString("hearingId")).willReturn(hearingId.toString());
+ given(payload.getString("courtCentreId")).willReturn(courtCentreId.toString());
+ given(payload.getString("startDate")).willReturn(startDate.toString());
+
+ final JsonObject hearing = Json.createObjectBuilder()
+ .add("id", hearingId.toString())
+ .add("jurisdictionType", "CROWN")
+ .build();
+ given(hearingLookupService.findHearing(hearingId, envelope)).willReturn(Optional.of(hearing));
+
+ final MoveHearingToPastDateException thrown = assertThrows(MoveHearingToPastDateException.class,
+ () -> listingCommandApi.handleMoveHearingToPastDate(envelope));
+
+ assertThat(thrown.getHttpStatus(), is(422));
+ assertThat(thrown.getErrorCode(), is("FUTURE_DATE_NOT_ALLOWED"));
+ verify(courtSchedulerServiceAdapter, never()).moveHearingToPastDate(any(), any(), any(), any());
+ verify(sender, never()).send(any());
+ }
+
@Test
public void shouldListCourtHearing() {
diff --git a/listing-command/listing-command-api/src/test/java/uk/gov/moj/cpp/listing/command/api/mapper/MoveHearingToPastDateExceptionMapperTest.java b/listing-command/listing-command-api/src/test/java/uk/gov/moj/cpp/listing/command/api/mapper/MoveHearingToPastDateExceptionMapperTest.java
new file mode 100644
index 000000000..cf37f25ea
--- /dev/null
+++ b/listing-command/listing-command-api/src/test/java/uk/gov/moj/cpp/listing/command/api/mapper/MoveHearingToPastDateExceptionMapperTest.java
@@ -0,0 +1,74 @@
+package uk.gov.moj.cpp.listing.command.api.mapper;
+
+import static javax.json.Json.createObjectBuilder;
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.Matchers.containsString;
+import static org.hamcrest.Matchers.is;
+
+import uk.gov.moj.cpp.listing.common.pastdate.MoveHearingToPastDateException;
+
+import javax.json.JsonObject;
+import javax.ws.rs.core.Response;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.slf4j.LoggerFactory;
+
+class MoveHearingToPastDateExceptionMapperTest {
+
+ private MoveHearingToPastDateExceptionMapper mapper;
+
+ @BeforeEach
+ void setUp() {
+ mapper = new MoveHearingToPastDateExceptionMapper();
+ mapper.logger = LoggerFactory.getLogger(MoveHearingToPastDateExceptionMapperTest.class);
+ }
+
+ @Test
+ void futureDate_returns422_withErrorCodeAndMessage() {
+ final JsonObject body = createObjectBuilder()
+ .add("errorCode", "FUTURE_DATE_NOT_ALLOWED")
+ .add("message", "Hearings can only be moved to today or an earlier date")
+ .build();
+
+ final Response response = mapper.toResponse(new MoveHearingToPastDateException(422, body, "rejected"));
+
+ assertThat(response.getStatus(), is(422));
+ final String entity = response.getEntity().toString();
+ assertThat(entity, containsString("\"errorCode\":\"FUTURE_DATE_NOT_ALLOWED\""));
+ assertThat(entity, containsString("\"message\":\"Hearings can only be moved to today or an earlier date\""));
+ }
+
+ @Test
+ void unknownHearing_returns422_withHearingIdNotFound() {
+ final JsonObject body = createObjectBuilder()
+ .add("errorCode", "HEARING_ID_NOT_FOUND")
+ .add("message", "No hearing found")
+ .build();
+
+ final Response response = mapper.toResponse(new MoveHearingToPastDateException(422, body, "rejected"));
+
+ assertThat(response.getStatus(), is(422));
+ assertThat(response.getEntity().toString(), containsString("\"errorCode\":\"HEARING_ID_NOT_FOUND\""));
+ }
+
+ @Test
+ void noSession_propagates404_withMessage() {
+ final JsonObject body = createObjectBuilder()
+ .add("message", "No court-schedule session found")
+ .build();
+
+ final Response response = mapper.toResponse(new MoveHearingToPastDateException(404, body, "not found"));
+
+ assertThat(response.getStatus(), is(404));
+ assertThat(response.getEntity().toString(), containsString("\"message\":\"No court-schedule session found\""));
+ }
+
+ @Test
+ void nullBody_fallsBackToExceptionMessage() {
+ final Response response = mapper.toResponse(new MoveHearingToPastDateException(500, null, "unexpected failure"));
+
+ assertThat(response.getStatus(), is(500));
+ assertThat(response.getEntity().toString(), containsString("\"message\":\"unexpected failure\""));
+ }
+}
diff --git a/listing-command/listing-command-api/src/test/java/uk/gov/moj/cpp/listing/command/api/service/HearingLookupServiceTest.java b/listing-command/listing-command-api/src/test/java/uk/gov/moj/cpp/listing/command/api/service/HearingLookupServiceTest.java
new file mode 100644
index 000000000..b540ac3f4
--- /dev/null
+++ b/listing-command/listing-command-api/src/test/java/uk/gov/moj/cpp/listing/command/api/service/HearingLookupServiceTest.java
@@ -0,0 +1,75 @@
+package uk.gov.moj.cpp.listing.command.api.service;
+
+import static java.util.UUID.randomUUID;
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.Matchers.is;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.when;
+import static uk.gov.justice.services.test.utils.core.enveloper.EnveloperFactory.createEnveloper;
+import static uk.gov.justice.services.test.utils.core.messaging.MetadataBuilderFactory.metadataWithRandomUUIDAndName;
+
+import uk.gov.justice.services.core.enveloper.Enveloper;
+import uk.gov.justice.services.messaging.JsonEnvelope;
+import uk.gov.moj.cpp.listing.query.view.HearingQueryView;
+
+import java.util.Optional;
+import java.util.UUID;
+
+import javax.json.Json;
+import javax.json.JsonObject;
+import javax.ws.rs.NotFoundException;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.Spy;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+@ExtendWith(MockitoExtension.class)
+class HearingLookupServiceTest {
+
+ @Spy
+ private final Enveloper enveloper = createEnveloper();
+
+ @Mock
+ private HearingQueryView hearingQueryView;
+
+ @Mock
+ private JsonEnvelope envelope;
+
+ @InjectMocks
+ private HearingLookupService hearingLookupService;
+
+ @Test
+ void shouldReturnHearingWhenFound() {
+ final UUID hearingId = randomUUID();
+ final JsonObject hearingPayload = Json.createObjectBuilder()
+ .add("id", hearingId.toString())
+ .add("jurisdictionType", "MAGISTRATES")
+ .build();
+
+ when(envelope.metadata()).thenReturn(metadataWithRandomUUIDAndName().build());
+ when(hearingQueryView.getHearingById(any(JsonEnvelope.class)))
+ .thenReturn(JsonEnvelope.envelopeFrom(metadataWithRandomUUIDAndName().build(), hearingPayload));
+
+ final Optional result = hearingLookupService.findHearing(hearingId, envelope);
+
+ assertTrue(result.isPresent());
+ assertThat(result.get().getString("jurisdictionType"), is("MAGISTRATES"));
+ }
+
+ @Test
+ void shouldReturnEmptyWhenHearingNotFound() {
+ final UUID hearingId = randomUUID();
+
+ when(envelope.metadata()).thenReturn(metadataWithRandomUUIDAndName().build());
+ when(hearingQueryView.getHearingById(any(JsonEnvelope.class)))
+ .thenThrow(new NotFoundException("no hearing"));
+
+ final Optional result = hearingLookupService.findHearing(hearingId, envelope);
+
+ assertTrue(result.isEmpty());
+ }
+}
diff --git a/listing-command/listing-command-api/src/test/resources/create-change-hearing-to-past-date-permission.json b/listing-command/listing-command-api/src/test/resources/create-change-hearing-to-past-date-permission.json
new file mode 100644
index 000000000..2f33b700e
--- /dev/null
+++ b/listing-command/listing-command-api/src/test/resources/create-change-hearing-to-past-date-permission.json
@@ -0,0 +1,6 @@
+{
+"object":"Change hearing to past date",
+"action":"Link",
+"key":"Change hearing to past date_Link",
+"keyWithOutSource":"Change hearing to past date_Link"
+}
diff --git a/listing-command/listing-command-handler/src/main/java/uk/gov/moj/cpp/listing/command/handler/ListingCommandHandler.java b/listing-command/listing-command-handler/src/main/java/uk/gov/moj/cpp/listing/command/handler/ListingCommandHandler.java
index a9ecb14dd..d517b4cab 100644
--- a/listing-command/listing-command-handler/src/main/java/uk/gov/moj/cpp/listing/command/handler/ListingCommandHandler.java
+++ b/listing-command/listing-command-handler/src/main/java/uk/gov/moj/cpp/listing/command/handler/ListingCommandHandler.java
@@ -189,6 +189,16 @@ public class ListingCommandHandler {
private static final String HEARING_DAY_COURT_SCHEDULES = "hearingDayCourtSchedules";
private static final String PROSECUTION_CASE = "prosecutionCase";
public static final String OUCODE = "oucode";
+ private static final String JURISDICTION = "jurisdiction";
+ private static final String START_DATE = "startDate";
+ private static final String COURT_SCHEDULE_ID = "courtScheduleId";
+ private static final String SESSION_DATE = "sessionDate";
+ private static final String MOVE_COURT_CENTRE_ID = "courtCentreId";
+ private static final String MOVE_COURT_ROOM_ID = "courtRoomId";
+ private static final String SESSION_START_TIME = "sessionStartTime";
+ private static final String SESSION_END_TIME = "sessionEndTime";
+ private static final String DURATION_IN_MINUTES = "durationInMinutes";
+ private static final String CROWN_JURISDICTION = "CROWN";
@Inject
private EventSource eventSource;
@@ -406,6 +416,73 @@ public void vacateTrial(final JsonEnvelope command) throws EventStreamException
updateHearingEventStream(command, vacateTrialEnriched.getHearingId(), (Hearing hearing) -> hearing.vacateTrial(vacateTrialEnriched.getHearingId(), vacateTrialEnriched.getVacatedTrialReasonId()));
}
+ @Handles("listing.command.move-hearing-to-past-date-enriched")
+ public void moveHearingToPastDate(final JsonEnvelope command) throws EventStreamException {
+
+ LOGGER.info("'listing.command.move-hearing-to-past-date-enriched' received with payload {}", command.toObfuscatedDebugString());
+
+ final JsonObject payload = command.payloadAsJsonObject();
+ final UUID hearingId = fromString(payload.getString(HEARING_ID));
+ final String jurisdiction = payload.getString(JURISDICTION);
+ final LocalDate startDate = parse(payload.getString(START_DATE));
+
+ // hearing-day-court-schedule-updated matches days BY DATE in the projection, so it cannot
+ // move a day to a new date. Both paths re-issue the single day on the past date instead:
+ // MAGS carries the slot booked by courtscheduler, CROWN carries the hearing's own existing
+ // room/time (enriched by command-api from its current first day - courtscheduler is never
+ // called for CROWN before Phase 2, Baris decision D1).
+ if (CROWN_JURISDICTION.equals(jurisdiction)) {
+ final uk.gov.moj.cpp.listing.domain.HearingDay movedDay = buildMovedHearingDay(payload, startDate, Optional.empty());
+ updateHearingEventStream(command, hearingId, (Hearing hearing) -> Stream.concat(
+ hearing.changeStartDate(startDate, hearingId),
+ hearing.assignHearingDaysV2(hearingId, List.of(movedDay), null, null,
+ uk.gov.justice.core.courts.JurisdictionType.CROWN, emptyList())));
+ } else {
+ final LocalDate sessionDate = parse(payload.getString(SESSION_DATE));
+ final UUID courtScheduleId = fromString(payload.getString(COURT_SCHEDULE_ID));
+ final uk.gov.moj.cpp.listing.domain.HearingDay movedDay = buildMovedHearingDay(payload, sessionDate, Optional.of(courtScheduleId));
+ updateHearingEventStream(command, hearingId, (Hearing hearing) -> Stream.concat(
+ hearing.changeStartDate(startDate, hearingId),
+ hearing.assignHearingDaysV2(hearingId, List.of(movedDay), null, null,
+ uk.gov.justice.core.courts.JurisdictionType.MAGISTRATES, emptyList())));
+ }
+ }
+
+ private static uk.gov.moj.cpp.listing.domain.HearingDay buildMovedHearingDay(final JsonObject payload,
+ final LocalDate dayDate,
+ final Optional courtScheduleId) {
+ final Optional courtCentreId = payload.containsKey(MOVE_COURT_CENTRE_ID)
+ ? Optional.of(fromString(payload.getString(MOVE_COURT_CENTRE_ID))) : Optional.empty();
+ final Optional courtRoomId = payload.containsKey(MOVE_COURT_ROOM_ID)
+ ? Optional.of(fromString(payload.getString(MOVE_COURT_ROOM_ID))) : Optional.empty();
+ final ZonedDateTime dayStartTime = payload.containsKey(SESSION_START_TIME)
+ ? ZonedDateTime.parse(payload.getString(SESSION_START_TIME))
+ : dayDate.atStartOfDay(java.time.ZoneOffset.UTC);
+ final Integer durationInMinutes = payload.containsKey(DURATION_IN_MINUTES)
+ ? payload.getInt(DURATION_IN_MINUTES) : null;
+ // hearing-days-changed-for-hearing requires endTime on every day; the normal listing flows
+ // always compute it as startTime + duration, so mirror that when the payload has no end time.
+ final ZonedDateTime dayEndTime;
+ if (payload.containsKey(SESSION_END_TIME)) {
+ dayEndTime = ZonedDateTime.parse(payload.getString(SESSION_END_TIME));
+ } else if (durationInMinutes != null) {
+ dayEndTime = dayStartTime.plusMinutes(durationInMinutes);
+ } else {
+ dayEndTime = dayStartTime;
+ }
+
+ return uk.gov.moj.cpp.listing.domain.HearingDay.hearingDay()
+ .withHearingDate(dayDate)
+ .withStartTime(dayStartTime)
+ .withEndTime(dayEndTime)
+ .withDurationMinutes(durationInMinutes)
+ .withSequence(1)
+ .withCourtScheduleId(courtScheduleId)
+ .withCourtCentreId(courtCentreId)
+ .withCourtRoomId(courtRoomId)
+ .build();
+ }
+
@Handles("listing.command.hearing-vacate-trial")
public void hearingVacateTrial(final JsonEnvelope command) throws EventStreamException {
LOGGER.info("'listing.command.hearing-vacate-trial' received with payload {}", command.toObfuscatedDebugString());
diff --git a/listing-command/listing-command-handler/src/raml/json/listing.command.move-hearing-to-past-date-enriched.json b/listing-command/listing-command-handler/src/raml/json/listing.command.move-hearing-to-past-date-enriched.json
new file mode 100644
index 000000000..dca55cc54
--- /dev/null
+++ b/listing-command/listing-command-handler/src/raml/json/listing.command.move-hearing-to-past-date-enriched.json
@@ -0,0 +1,12 @@
+{
+ "hearingId": "0baecac5-222b-402d-9047-84803679edae",
+ "jurisdiction": "MAGISTRATES",
+ "startDate": "2026-05-01",
+ "courtCentreId": "07e45c88-9e5d-3e44-b664-d5345bb13be2",
+ "courtScheduleId": "5e2a3f91-9e5d-3e44-b664-d5345bb13be2",
+ "courtRoomId": "9d324f4f-6c3b-451f-ac1e-f459db781153",
+ "sessionDate": "2026-05-01",
+ "sessionStartTime": "2026-05-01T09:00:00Z",
+ "sessionEndTime": "2026-05-01T17:00:00Z",
+ "durationInMinutes": 30
+}
diff --git a/listing-command/listing-command-handler/src/raml/json/schema/listing.command.move-hearing-to-past-date-enriched.json b/listing-command/listing-command-handler/src/raml/json/schema/listing.command.move-hearing-to-past-date-enriched.json
new file mode 100644
index 000000000..7de8fda09
--- /dev/null
+++ b/listing-command/listing-command-handler/src/raml/json/schema/listing.command.move-hearing-to-past-date-enriched.json
@@ -0,0 +1,47 @@
+{
+ "$schema": "http://json-schema.org/draft-04/schema#",
+ "id": "http://justice.gov.uk/listing/courts/listing.command.move-hearing-to-past-date-enriched.json",
+ "type": "object",
+ "properties": {
+ "hearingId": {
+ "$ref": "http://justice.gov.uk/domain/core/common/definitions.json#/definitions/uuid"
+ },
+ "jurisdiction": {
+ "type": "string",
+ "enum": ["CROWN", "MAGISTRATES"]
+ },
+ "startDate": {
+ "type": "string",
+ "format": "date"
+ },
+ "courtCentreId": {
+ "$ref": "http://justice.gov.uk/domain/core/common/definitions.json#/definitions/uuid"
+ },
+ "courtScheduleId": {
+ "$ref": "http://justice.gov.uk/domain/core/common/definitions.json#/definitions/uuid"
+ },
+ "courtRoomId": {
+ "type": "string"
+ },
+ "sessionDate": {
+ "type": "string",
+ "format": "date"
+ },
+ "sessionStartTime": {
+ "type": "string"
+ },
+ "sessionEndTime": {
+ "type": "string"
+ },
+ "durationInMinutes": {
+ "type": "integer"
+ }
+ },
+ "required": [
+ "hearingId",
+ "jurisdiction",
+ "startDate",
+ "courtCentreId"
+ ],
+ "additionalProperties": false
+}
diff --git a/listing-command/listing-command-handler/src/raml/listing-command-handler.messaging.raml b/listing-command/listing-command-handler/src/raml/listing-command-handler.messaging.raml
index cb7060834..6ef00e9a4 100644
--- a/listing-command/listing-command-handler/src/raml/listing-command-handler.messaging.raml
+++ b/listing-command/listing-command-handler/src/raml/listing-command-handler.messaging.raml
@@ -193,6 +193,10 @@ baseUri: message://command/handler/message/listing
example: !include json/listing.command.vacate-trial-enriched.json
schema: !include json/schema/listing.command.vacate-trial-enriched.json
+ application/vnd.listing.command.move-hearing-to-past-date-enriched+json:
+ example: !include json/listing.command.move-hearing-to-past-date-enriched.json
+ schema: !include json/schema/listing.command.move-hearing-to-past-date-enriched.json
+
application/vnd.listing.command.hearing-vacate-trial+json:
example: !include json/listing.command.hearing-vacate-trial.json
schema: !include json/schema/listing.command.hearing-vacate-trial.json
diff --git a/listing-command/listing-command-handler/src/test/java/uk/gov/moj/cpp/listing/command/handler/ListingCommandHandlerTest.java b/listing-command/listing-command-handler/src/test/java/uk/gov/moj/cpp/listing/command/handler/ListingCommandHandlerTest.java
index 3b377d10a..9f09a160b 100644
--- a/listing-command/listing-command-handler/src/test/java/uk/gov/moj/cpp/listing/command/handler/ListingCommandHandlerTest.java
+++ b/listing-command/listing-command-handler/src/test/java/uk/gov/moj/cpp/listing/command/handler/ListingCommandHandlerTest.java
@@ -23,6 +23,7 @@
import static org.mockito.ArgumentMatchers.anyList;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.ArgumentMatchers.isNull;
import static org.mockito.BDDMockito.given;
import static org.mockito.Mockito.any;
import static org.mockito.Mockito.atLeast;
@@ -99,6 +100,7 @@
import uk.gov.justice.listing.events.CaseEjected;
import uk.gov.justice.listing.events.CaseIdentifierUpdated;
import uk.gov.justice.listing.events.CasesAddedToHearing;
+import uk.gov.justice.listing.events.HearingDayCourtSchedule;
import uk.gov.justice.listing.events.CourtApplicationAddedToHearing;
import uk.gov.justice.listing.events.CourtApplicationToBeUpdated;
import uk.gov.justice.listing.events.CourtListRestricted;
@@ -2549,6 +2551,54 @@ public void listingCommandHandlerShouldVacateTrial() throws Exception {
verify(hearing, times(1)).vacateTrial(HEARING_ID_1, REASON);
}
+ @Test
+ public void listingCommandHandlerShouldMoveMagistratesHearingToPastDate() throws Exception {
+ final UUID courtScheduleId = randomUUID();
+ final JsonEnvelope commandEnvelope = getEnvelopeForMoveHearingToPastDate(courtScheduleId, "2026-05-01");
+
+ when(eventSource.getStreamById(any(UUID.class))).thenReturn(eventStream);
+ when(aggregateService.get(eventStream, Hearing.class)).thenReturn(hearing);
+ when(hearing.changeStartDate(eq(LocalDate.parse("2026-05-01")), eq(HEARING_ID_1))).thenReturn(Stream.empty());
+ when(hearing.assignHearingDaysV2(eq(HEARING_ID_1), any(), isNull(), isNull(),
+ eq(uk.gov.justice.core.courts.JurisdictionType.MAGISTRATES), eq(emptyList()))).thenReturn(Stream.empty());
+
+ listingCommandHandler.moveHearingToPastDate(commandEnvelope);
+
+ final ArgumentCaptor> captor = ArgumentCaptor.forClass(List.class);
+ verify(hearing, times(1)).changeStartDate(LocalDate.parse("2026-05-01"), HEARING_ID_1);
+ verify(hearing, times(1)).assignHearingDaysV2(eq(HEARING_ID_1), captor.capture(), isNull(), isNull(),
+ eq(uk.gov.justice.core.courts.JurisdictionType.MAGISTRATES), eq(emptyList()));
+ verify(hearing, never()).raiseHearingDayCourtSchedulesUpdated(any(), any());
+ final uk.gov.moj.cpp.listing.domain.HearingDay movedDay = captor.getValue().get(0);
+ assertThat(movedDay.getCourtScheduleId().orElse(null), is(courtScheduleId));
+ assertThat(movedDay.getHearingDate(), is(LocalDate.parse("2026-05-01")));
+ }
+
+ @Test
+ public void listingCommandHandlerShouldMoveCrownHearingToPastDateListingSideOnly() throws Exception {
+ final String startDate = "2026-05-01";
+ final UUID crownRoomId = randomUUID();
+ final JsonEnvelope commandEnvelope = getEnvelopeForMoveCrownHearingToPastDate(startDate, crownRoomId);
+
+ when(eventSource.getStreamById(any(UUID.class))).thenReturn(eventStream);
+ when(aggregateService.get(eventStream, Hearing.class)).thenReturn(hearing);
+ when(hearing.changeStartDate(eq(LocalDate.parse(startDate)), eq(HEARING_ID_1))).thenReturn(Stream.empty());
+ when(hearing.assignHearingDaysV2(eq(HEARING_ID_1), any(), isNull(), isNull(),
+ eq(uk.gov.justice.core.courts.JurisdictionType.CROWN), eq(emptyList()))).thenReturn(Stream.empty());
+
+ listingCommandHandler.moveHearingToPastDate(commandEnvelope);
+
+ final ArgumentCaptor> captor = ArgumentCaptor.forClass(List.class);
+ verify(hearing, times(1)).changeStartDate(LocalDate.parse(startDate), HEARING_ID_1);
+ verify(hearing, times(1)).assignHearingDaysV2(eq(HEARING_ID_1), captor.capture(), isNull(), isNull(),
+ eq(uk.gov.justice.core.courts.JurisdictionType.CROWN), eq(emptyList()));
+ verify(hearing, never()).raiseHearingDayCourtSchedulesUpdated(any(), any());
+ final uk.gov.moj.cpp.listing.domain.HearingDay movedDay = captor.getValue().get(0);
+ assertThat(movedDay.getHearingDate(), is(LocalDate.parse(startDate)));
+ assertThat(movedDay.getCourtScheduleId().isPresent(), is(false));
+ assertThat(movedDay.getCourtRoomId().orElse(null), is(crownRoomId));
+ }
+
@Test
public void listingCommandHandlerShouldHearingVacateTrial() throws Exception {
final JsonEnvelope commandEnvelope = getEnvelopeForHearingVacateTrial(REASON);
@@ -2739,6 +2789,22 @@ private JsonEnvelope getEnvelopeForVacateTrial(final UUID reason) {
return createEnvelope("listing.command.vacate-trial-enriched", jsonReader.readObject());
}
+ private JsonEnvelope getEnvelopeForMoveHearingToPastDate(final UUID courtScheduleId, final String sessionDate) {
+ final String requestBody = "{\"hearingId\":\"" + HEARING_ID_1 + "\",\"jurisdiction\":\"MAGISTRATES\",\"startDate\":\""
+ + sessionDate + "\",\"courtCentreId\":\"" + randomUUID() + "\",\"courtScheduleId\":\"" + courtScheduleId
+ + "\",\"sessionDate\":\"" + sessionDate + "\"}";
+ final JsonReader jsonReader = JsonObjects.createReader(new StringReader(requestBody));
+ return createEnvelope("listing.command.move-hearing-to-past-date-enriched", jsonReader.readObject());
+ }
+
+ private JsonEnvelope getEnvelopeForMoveCrownHearingToPastDate(final String startDate, final UUID courtRoomId) {
+ final String requestBody = "{\"hearingId\":\"" + HEARING_ID_1 + "\",\"jurisdiction\":\"CROWN\",\"startDate\":\"" + startDate
+ + "\",\"courtCentreId\":\"" + randomUUID() + "\",\"courtRoomId\":\"" + courtRoomId
+ + "\",\"sessionDate\":\"" + startDate + "\",\"sessionStartTime\":\"" + startDate + "T10:00:00Z\",\"durationInMinutes\":25}";
+ final JsonReader jsonReader = JsonObjects.createReader(new StringReader(requestBody));
+ return createEnvelope("listing.command.move-hearing-to-past-date-enriched", jsonReader.readObject());
+ }
+
private JsonEnvelope getEnvelopeForHearingVacateTrial(final UUID reason) {
final String requestBody = "{\"hearingId\":\"" + HEARING_ID_1 + "\",\"vacatedTrialReasonId\":\"" + reason + "\"}";
final JsonReader jsonReader = JsonObjects.createReader(new StringReader(requestBody));
diff --git a/listing-common/src/main/java/uk/gov/moj/cpp/listing/common/pastdate/MoveHearingToPastDateException.java b/listing-common/src/main/java/uk/gov/moj/cpp/listing/common/pastdate/MoveHearingToPastDateException.java
new file mode 100644
index 000000000..852a60f49
--- /dev/null
+++ b/listing-common/src/main/java/uk/gov/moj/cpp/listing/common/pastdate/MoveHearingToPastDateException.java
@@ -0,0 +1,40 @@
+package uk.gov.moj.cpp.listing.common.pastdate;
+
+import static uk.gov.justice.services.messaging.JsonObjects.getString;
+
+import javax.json.JsonObject;
+
+/**
+ * Raised when courtscheduler rejects a move-hearing-to-past-date request (422/404), or when the
+ * listing side rejects the request before ever calling courtscheduler (unknown hearingId, future
+ * date on the CROWN listing-side path). Carries the upstream HTTP status and body so the
+ * {@code MoveHearingToPastDateExceptionMapper} can render an equivalent response back to the
+ * caller.
+ */
+public class MoveHearingToPastDateException extends RuntimeException {
+
+ private static final long serialVersionUID = 1L;
+
+ private final int httpStatus;
+ private final transient JsonObject responseBody;
+ private final String errorCode;
+
+ public MoveHearingToPastDateException(final int httpStatus, final JsonObject responseBody, final String message) {
+ super(message);
+ this.httpStatus = httpStatus;
+ this.responseBody = responseBody;
+ this.errorCode = responseBody == null ? null : getString(responseBody, "errorCode").orElse(null);
+ }
+
+ public int getHttpStatus() {
+ return httpStatus;
+ }
+
+ public JsonObject getResponseBody() {
+ return responseBody;
+ }
+
+ public String getErrorCode() {
+ return errorCode;
+ }
+}
diff --git a/listing-common/src/main/java/uk/gov/moj/cpp/listing/common/pastdate/MoveHearingToPastDateResult.java b/listing-common/src/main/java/uk/gov/moj/cpp/listing/common/pastdate/MoveHearingToPastDateResult.java
new file mode 100644
index 000000000..6d35209ed
--- /dev/null
+++ b/listing-common/src/main/java/uk/gov/moj/cpp/listing/common/pastdate/MoveHearingToPastDateResult.java
@@ -0,0 +1,18 @@
+package uk.gov.moj.cpp.listing.common.pastdate;
+
+import java.time.LocalDate;
+import java.util.UUID;
+
+/**
+ * Purpose-built result of a successful {@code courtscheduler.move-hearing-to-past-date} call for
+ * the MAGISTRATES path. Deliberately narrow — unlike the ccsph2n-only {@code CrownFallbackResult}
+ * this branch does not carry any crown-fallback/search-and-book concerns, only the slot details
+ * needed to enrich {@code listing.command.move-hearing-to-past-date-enriched}.
+ */
+public record MoveHearingToPastDateResult(UUID courtScheduleId,
+ String courtRoomId,
+ LocalDate sessionDate,
+ String sessionStartTime,
+ String sessionEndTime,
+ Integer durationInMinutes) {
+}
diff --git a/listing-common/src/main/java/uk/gov/moj/cpp/listing/common/service/CourtSchedulerServiceAdapter.java b/listing-common/src/main/java/uk/gov/moj/cpp/listing/common/service/CourtSchedulerServiceAdapter.java
index b493a0dab..6c82e3661 100644
--- a/listing-common/src/main/java/uk/gov/moj/cpp/listing/common/service/CourtSchedulerServiceAdapter.java
+++ b/listing-common/src/main/java/uk/gov/moj/cpp/listing/common/service/CourtSchedulerServiceAdapter.java
@@ -5,6 +5,8 @@
import uk.gov.justice.services.common.converter.JsonObjectToObjectConverter;
import uk.gov.justice.services.common.converter.ObjectToJsonObjectConverter;
+import uk.gov.moj.cpp.listing.common.pastdate.MoveHearingToPastDateException;
+import uk.gov.moj.cpp.listing.common.pastdate.MoveHearingToPastDateResult;
import uk.gov.moj.cpp.listing.domain.JudicialRole;
import uk.gov.moj.cpp.listing.domain.JudicialRoleType;
@@ -21,8 +23,10 @@
import javax.enterprise.context.ApplicationScoped;
import javax.inject.Inject;
+import javax.json.Json;
import javax.json.JsonArray;
import javax.json.JsonObject;
+import javax.json.JsonObjectBuilder;
import javax.ws.rs.core.Response;
import org.apache.commons.collections.CollectionUtils;
@@ -47,6 +51,14 @@ public class CourtSchedulerServiceAdapter {
public static final String PANEL_ADULT_YOUTH = "ADULT,YOUTH";
private static final String PANEL = "panel";
public static final String HEARING_ID = "hearingId";
+ public static final String COURT_CENTRE_ID = "courtCentreId";
+ public static final String JURISDICTION = "jurisdiction";
+ public static final String START_DATE = "startDate";
+ public static final String DURATION_IN_MINUTES = "durationInMinutes";
+ public static final String MAGISTRATES_JURISDICTION = "MAGISTRATES";
+ public static final String NO_SESSION_FOUND = "NO_SESSION_FOUND";
+ private static final String ERROR_CODE = "errorCode";
+ private static final String MESSAGE = "message";
@Inject
private HearingSlotsService hearingSlotsService;
@Inject
@@ -220,4 +232,62 @@ HearingIdsResponse getHearingIds(final Response response) {
return new HearingIdsResponse(uuids, results, pageCount);
}
+
+ /**
+ * MAGISTRATES-only. Calls courtscheduler's {@code move-hearing-to-past-date} action
+ * synchronously. CROWN moves are handled entirely listing-side and never reach this method
+ * (Baris decision D1). On any non-200 response the upstream errorCode/status is surfaced via
+ * {@link MoveHearingToPastDateException} so the caller sends no event.
+ */
+ public MoveHearingToPastDateResult moveHearingToPastDate(final UUID hearingId,
+ final UUID courtCentreId,
+ final LocalDate startDate,
+ final Integer durationInMinutes) {
+ // hearingId travels only in the URL path; courtscheduler's REST adapter injects it
+ final JsonObjectBuilder requestBuilder = Json.createObjectBuilder()
+ .add(COURT_CENTRE_ID, courtCentreId.toString())
+ .add(JURISDICTION, MAGISTRATES_JURISDICTION)
+ .add(START_DATE, startDate.toString());
+ if (durationInMinutes != null) {
+ requestBuilder.add(DURATION_IN_MINUTES, durationInMinutes);
+ }
+
+ final Response response = hearingSlotsService.moveHearingToPastDate(hearingId, requestBuilder.build());
+ final int status = response.getStatus();
+ final JsonObject body = (response.hasEntity() && response.getEntity() instanceof JsonObject)
+ ? (JsonObject) response.getEntity()
+ : Json.createObjectBuilder().build();
+
+ if (HttpStatus.SC_OK == status) {
+ return parseMoveHearingToPastDateResult(body);
+ }
+
+ LOGGER.error("moveHearingToPastDate from courtscheduler returned status {} for hearingId {}: {}",
+ status, hearingId, body);
+
+ if (HttpStatus.SC_NOT_FOUND == status) {
+ // older courtscheduler releases signal no-session as a bare 404 - normalise to the
+ // 422 NO_SESSION_FOUND contract so callers see a single failure shape
+ final JsonObject noSessionBody = Json.createObjectBuilder()
+ .add(ERROR_CODE, NO_SESSION_FOUND)
+ .add(MESSAGE, body.getString(MESSAGE,
+ "No court-schedule session found for hearingId " + hearingId + " on " + startDate))
+ .build();
+ throw new MoveHearingToPastDateException(HttpStatus.SC_UNPROCESSABLE_ENTITY, noSessionBody,
+ "moveHearingToPastDate found no session for hearingId " + hearingId);
+ }
+
+ throw new MoveHearingToPastDateException(status, body,
+ "moveHearingToPastDate returned " + status + " for hearingId " + hearingId);
+ }
+
+ private static MoveHearingToPastDateResult parseMoveHearingToPastDateResult(final JsonObject body) {
+ return new MoveHearingToPastDateResult(
+ body.containsKey("courtScheduleId") ? UUID.fromString(body.getString("courtScheduleId")) : null,
+ body.getString(COURT_ROOM_ID, null),
+ body.containsKey("sessionDate") ? LocalDate.parse(body.getString("sessionDate")) : null,
+ body.getString("sessionStartTime", null),
+ body.getString("sessionEndTime", null),
+ body.containsKey(DURATION_IN_MINUTES) ? body.getInt(DURATION_IN_MINUTES) : null);
+ }
}
diff --git a/listing-common/src/main/java/uk/gov/moj/cpp/listing/common/service/HearingSlotsService.java b/listing-common/src/main/java/uk/gov/moj/cpp/listing/common/service/HearingSlotsService.java
index 137cf7c49..4a1d1b3be 100644
--- a/listing-common/src/main/java/uk/gov/moj/cpp/listing/common/service/HearingSlotsService.java
+++ b/listing-common/src/main/java/uk/gov/moj/cpp/listing/common/service/HearingSlotsService.java
@@ -26,6 +26,7 @@
import org.apache.http.client.methods.CloseableHttpResponse;
import org.apache.http.client.methods.HttpDelete;
import org.apache.http.client.methods.HttpGet;
+import org.apache.http.client.methods.HttpPost;
import org.apache.http.client.methods.HttpPut;
import org.apache.http.client.methods.HttpRequestBase;
import org.apache.http.client.utils.URIBuilder;
@@ -35,6 +36,8 @@
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
+import javax.json.JsonObject;
+
@SuppressWarnings({"squid:S1312", "squid:S2629", "squid:S6813"})
@ApplicationScoped
public class HearingSlotsService {
@@ -53,6 +56,9 @@ public class HearingSlotsService {
private static final String COUTRT_SCHEDULER_HEARING_IDS = "application/vnd.courtscheduler.get.hearing.ids+json";
private static final String COURTSCHEDULER_SEARCH_BOOK_COURTSCHEDULES = "application/vnd.courtscheduler.search.book.hearing.slots+json";
+ private static final String HEARINGS_RESOURCE = "/hearings/";
+ private static final String COURTSCHEDULER_MOVE_TO_PAST_DATE = "application/vnd.courtscheduler.move-hearing-to-past-date+json";
+
private static final String CJS_CPP_UID = "CJSCPPUID";
@Inject
@Value(key = "courtscheduler.base.url", defaultValue = "http://localhost:8080/listingcourtscheduler-api/rest/courtscheduler")
@@ -117,6 +123,40 @@ public Response getCourtSchedulesById(final Map params) {
return query(COURTSCHEDULES_RESOURCE, COURTSCHEDULER_SEARCH_COURTSCHEDULES_BY_ID, params);
}
+ public Response moveHearingToPastDate(final UUID hearingId, final JsonObject payload) {
+ if (LOGGER.isInfoEnabled()) {
+ LOGGER.info("move-hearing-to-past-date for hearing id '{}'", hearingId);
+ }
+
+ try {
+ final HttpPost httpPost = new HttpPost(new URL(baseUri + HEARINGS_RESOURCE + hearingId).toString());
+ httpPost.addHeader(CONTENT_TYPE, COURTSCHEDULER_MOVE_TO_PAST_DATE);
+ httpPost.addHeader(CJS_CPP_UID, getUserId().toString());
+
+ final StringEntity requestEntity = new StringEntity(payload.toString());
+ httpPost.setEntity(requestEntity);
+
+ final HttpResponse httpResponse = execute(httpPost);
+ final int statusCode = httpResponse.getStatusLine().getStatusCode();
+ final String entityBodyAsString = httpResponse.getEntity() == null ? "" : EntityUtils.toString(httpResponse.getEntity());
+
+ if (LOGGER.isInfoEnabled()) {
+ LOGGER.info("move-hearing-to-past-date returned status {}", statusCode);
+ }
+
+ return Response
+ .status(statusCode)
+ .entity(entityBodyAsString.isBlank() ? null : stringToJsonObjectConverter.convert(entityBodyAsString))
+ .build();
+ } catch (IOException ex) {
+ LOGGER.error("Exception thrown on trying to move hearing to past date", ex);
+ return Response
+ .status(HttpStatus.SC_INTERNAL_SERVER_ERROR)
+ .entity(ex.getMessage())
+ .build();
+ }
+ }
+
public void delete(final UUID hearingId) {
if (LOGGER.isInfoEnabled()) {
LOGGER.info("Delete HearingSlots in CourtScheduler S & L with hearing id '{}'", hearingId);
diff --git a/listing-common/src/test/java/uk/gov/moj/cpp/listing/common/service/CourtSchedulerServiceAdapterMoveHearingToPastDateTest.java b/listing-common/src/test/java/uk/gov/moj/cpp/listing/common/service/CourtSchedulerServiceAdapterMoveHearingToPastDateTest.java
new file mode 100644
index 000000000..94f6b787e
--- /dev/null
+++ b/listing-common/src/test/java/uk/gov/moj/cpp/listing/common/service/CourtSchedulerServiceAdapterMoveHearingToPastDateTest.java
@@ -0,0 +1,162 @@
+package uk.gov.moj.cpp.listing.common.service;
+
+import static javax.json.Json.createObjectBuilder;
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.Matchers.is;
+import static org.hamcrest.Matchers.nullValue;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import uk.gov.moj.cpp.listing.common.pastdate.MoveHearingToPastDateException;
+import uk.gov.moj.cpp.listing.common.pastdate.MoveHearingToPastDateResult;
+
+import java.time.LocalDate;
+import java.util.UUID;
+
+import javax.json.JsonObject;
+import javax.ws.rs.core.Response;
+
+import org.apache.http.HttpStatus;
+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;
+
+@ExtendWith(MockitoExtension.class)
+class CourtSchedulerServiceAdapterMoveHearingToPastDateTest {
+
+ @InjectMocks
+ private CourtSchedulerServiceAdapter adapter;
+
+ @Mock
+ private HearingSlotsService hearingSlotsService;
+
+ @Mock
+ private Response response;
+
+ @Test
+ void shouldParseSlotDetailsOn200() {
+ final UUID hearingId = UUID.randomUUID();
+ final UUID courtCentreId = UUID.randomUUID();
+ final UUID courtScheduleId = UUID.randomUUID();
+ final LocalDate startDate = LocalDate.parse("2026-05-01");
+
+ final JsonObject body = createObjectBuilder()
+ .add("hearingId", hearingId.toString())
+ .add("courtScheduleId", courtScheduleId.toString())
+ .add("courtRoomId", "9d324f4f-6c3b-451f-ac1e-f459db781153")
+ .add("sessionDate", "2026-05-01")
+ .add("sessionStartTime", "2026-05-01T09:00:00Z")
+ .add("sessionEndTime", "2026-05-01T17:00:00Z")
+ .add("durationInMinutes", 30)
+ .add("source", "MOVE_TO_PAST_DATE")
+ .build();
+ when(response.getStatus()).thenReturn(HttpStatus.SC_OK);
+ when(response.hasEntity()).thenReturn(true);
+ when(response.getEntity()).thenReturn(body);
+ when(hearingSlotsService.moveHearingToPastDate(eq(hearingId), any())).thenReturn(response);
+
+ final MoveHearingToPastDateResult result = adapter.moveHearingToPastDate(hearingId, courtCentreId, startDate, 30);
+
+ assertThat(result.courtScheduleId(), is(courtScheduleId));
+ assertThat(result.courtRoomId(), is("9d324f4f-6c3b-451f-ac1e-f459db781153"));
+ assertThat(result.sessionDate(), is(startDate));
+ assertThat(result.sessionStartTime(), is("2026-05-01T09:00:00Z"));
+ assertThat(result.sessionEndTime(), is("2026-05-01T17:00:00Z"));
+ assertThat(result.durationInMinutes(), is(30));
+ }
+
+ @Test
+ void shouldOmitDurationInRequestWhenNotSupplied() {
+ final UUID hearingId = UUID.randomUUID();
+ final JsonObject body = createObjectBuilder().add("courtScheduleId", UUID.randomUUID().toString())
+ .add("sessionDate", "2026-05-01").build();
+ when(response.getStatus()).thenReturn(HttpStatus.SC_OK);
+ when(response.hasEntity()).thenReturn(true);
+ when(response.getEntity()).thenReturn(body);
+ when(hearingSlotsService.moveHearingToPastDate(eq(hearingId), any())).thenReturn(response);
+
+ final MoveHearingToPastDateResult result = adapter.moveHearingToPastDate(hearingId, UUID.randomUUID(), LocalDate.parse("2026-05-01"), null);
+
+ assertThat(result.durationInMinutes(), is(nullValue()));
+ }
+
+ @Test
+ void shouldThrowWith422AndErrorCodeWhenFutureDate() {
+ final JsonObject body = createObjectBuilder()
+ .add("errorCode", "FUTURE_DATE_NOT_ALLOWED")
+ .add("message", "must not be after today")
+ .build();
+ when(response.getStatus()).thenReturn(422);
+ when(response.hasEntity()).thenReturn(true);
+ when(response.getEntity()).thenReturn(body);
+ when(hearingSlotsService.moveHearingToPastDate(any(), any())).thenReturn(response);
+
+ final MoveHearingToPastDateException ex = assertThrows(MoveHearingToPastDateException.class,
+ () -> adapter.moveHearingToPastDate(UUID.randomUUID(), UUID.randomUUID(), LocalDate.parse("2999-01-01"), 30));
+
+ assertThat(ex.getHttpStatus(), is(422));
+ assertThat(ex.getErrorCode(), is("FUTURE_DATE_NOT_ALLOWED"));
+ }
+
+ @Test
+ void shouldThrowWith422NoSessionFoundWhenCourtschedulerReturns422() {
+ final JsonObject body = createObjectBuilder()
+ .add("errorCode", "NO_SESSION_FOUND")
+ .add("message", "No session available")
+ .build();
+ when(response.getStatus()).thenReturn(422);
+ when(response.hasEntity()).thenReturn(true);
+ when(response.getEntity()).thenReturn(body);
+ when(hearingSlotsService.moveHearingToPastDate(any(), any())).thenReturn(response);
+
+ final MoveHearingToPastDateException ex = assertThrows(MoveHearingToPastDateException.class,
+ () -> adapter.moveHearingToPastDate(UUID.randomUUID(), UUID.randomUUID(), LocalDate.parse("2026-05-01"), 30));
+
+ assertThat(ex.getHttpStatus(), is(422));
+ assertThat(ex.getErrorCode(), is("NO_SESSION_FOUND"));
+ }
+
+ @Test
+ void shouldNormaliseLegacy404ToA422NoSessionFound() {
+ final JsonObject body = createObjectBuilder().build();
+ when(response.getStatus()).thenReturn(HttpStatus.SC_NOT_FOUND);
+ when(response.hasEntity()).thenReturn(true);
+ when(response.getEntity()).thenReturn(body);
+ when(hearingSlotsService.moveHearingToPastDate(any(), any())).thenReturn(response);
+
+ final MoveHearingToPastDateException ex = assertThrows(MoveHearingToPastDateException.class,
+ () -> adapter.moveHearingToPastDate(UUID.randomUUID(), UUID.randomUUID(), LocalDate.parse("2026-05-01"), 30));
+
+ assertThat(ex.getHttpStatus(), is(HttpStatus.SC_UNPROCESSABLE_ENTITY));
+ assertThat(ex.getErrorCode(), is("NO_SESSION_FOUND"));
+ }
+
+ @Test
+ void shouldNotSendHearingIdInRequestBody() {
+ final UUID hearingId = UUID.randomUUID();
+ final UUID courtCentreId = UUID.randomUUID();
+ final JsonObject body = createObjectBuilder().add("courtScheduleId", UUID.randomUUID().toString())
+ .add("sessionDate", "2026-05-01").build();
+ when(response.getStatus()).thenReturn(HttpStatus.SC_OK);
+ when(response.hasEntity()).thenReturn(true);
+ when(response.getEntity()).thenReturn(body);
+ when(hearingSlotsService.moveHearingToPastDate(eq(hearingId), any())).thenReturn(response);
+
+ adapter.moveHearingToPastDate(hearingId, courtCentreId, LocalDate.parse("2026-05-01"), 30);
+
+ final ArgumentCaptor requestCaptor = ArgumentCaptor.forClass(JsonObject.class);
+ verify(hearingSlotsService).moveHearingToPastDate(eq(hearingId), requestCaptor.capture());
+ final JsonObject request = requestCaptor.getValue();
+ assertThat(request.containsKey("hearingId"), is(false));
+ assertThat(request.getString("courtCentreId"), is(courtCentreId.toString()));
+ assertThat(request.getString("jurisdiction"), is("MAGISTRATES"));
+ assertThat(request.getString("startDate"), is("2026-05-01"));
+ assertThat(request.getInt("durationInMinutes"), is(30));
+ }
+}
diff --git a/listing-common/src/test/java/uk/gov/moj/cpp/listing/common/service/HearingSlotsServiceTest.java b/listing-common/src/test/java/uk/gov/moj/cpp/listing/common/service/HearingSlotsServiceTest.java
index b38cebb37..7b7c3098d 100644
--- a/listing-common/src/test/java/uk/gov/moj/cpp/listing/common/service/HearingSlotsServiceTest.java
+++ b/listing-common/src/test/java/uk/gov/moj/cpp/listing/common/service/HearingSlotsServiceTest.java
@@ -23,6 +23,7 @@
import org.apache.http.client.methods.CloseableHttpResponse;
import org.apache.http.client.methods.HttpDelete;
import org.apache.http.client.methods.HttpGet;
+import org.apache.http.client.methods.HttpPost;
import org.apache.http.client.methods.HttpPut;
import org.apache.http.impl.client.CloseableHttpClient;
import org.apache.http.impl.client.HttpClientBuilder;
@@ -65,6 +66,8 @@ class HearingSlotsServiceTest {
private ArgumentCaptor httpPutCaptor;
@Captor
private ArgumentCaptor httpDeleteCaptor;
+ @Captor
+ private ArgumentCaptor httpPostCaptor;
@InjectMocks
private HearingSlotsService hearingSlotsService;
@@ -425,4 +428,78 @@ public void shouldThrowExceptionWhenSearchAndBookParamsAreNull() {
assertThat(e.getMessage(), is("Params for search application/vnd.courtscheduler.search.book.hearing.slots+json is null ...."));
}
}
+
+ @Test
+ public void shouldPostMoveHearingToPastDateSuccessfully() throws Exception {
+ // Given
+ when(systemUserProvider.getContextSystemUserId()).thenReturn(java.util.Optional.of(TEST_USER_ID));
+ final javax.json.JsonObject payload = javax.json.Json.createObjectBuilder()
+ .add("hearingId", TEST_HEARING_ID.toString())
+ .build();
+
+ try (MockedStatic mockedStatic = Mockito.mockStatic(HttpClientBuilder.class)) {
+ mockedStatic.when(HttpClientBuilder::create).thenReturn(httpClientBuilder);
+ when(httpClientBuilder.build()).thenReturn(httpClient);
+ when(httpClient.execute(any(HttpPost.class))).thenReturn(httpResponse);
+ when(httpResponse.getStatusLine()).thenReturn(statusLine);
+ when(statusLine.getStatusCode()).thenReturn(Response.Status.OK.getStatusCode());
+ when(httpResponse.getEntity()).thenReturn(null);
+
+ // When
+ final Response response = hearingSlotsService.moveHearingToPastDate(TEST_HEARING_ID, payload);
+
+ // Then
+ assertThat(response.getStatus(), is(Response.Status.OK.getStatusCode()));
+ verify(httpClient).execute(httpPostCaptor.capture());
+ final HttpPost capturedPost = httpPostCaptor.getValue();
+ assertThat(capturedPost.getURI().toString(), is(BASE_URI + "/hearings/" + TEST_HEARING_ID));
+ assertThat(capturedPost.getFirstHeader("Content-Type").getValue(),
+ is("application/vnd.courtscheduler.move-hearing-to-past-date+json"));
+ }
+ }
+
+ @Test
+ public void shouldHandleMoveHearingToPastDateErrorResponse() throws Exception {
+ // Given
+ when(systemUserProvider.getContextSystemUserId()).thenReturn(java.util.Optional.of(TEST_USER_ID));
+ final javax.json.JsonObject payload = javax.json.Json.createObjectBuilder()
+ .add("hearingId", TEST_HEARING_ID.toString())
+ .build();
+
+ try (MockedStatic mockedStatic = Mockito.mockStatic(HttpClientBuilder.class)) {
+ mockedStatic.when(HttpClientBuilder::create).thenReturn(httpClientBuilder);
+ when(httpClientBuilder.build()).thenReturn(httpClient);
+ when(httpClient.execute(any(HttpPost.class))).thenReturn(httpResponse);
+ when(httpResponse.getStatusLine()).thenReturn(statusLine);
+ when(statusLine.getStatusCode()).thenReturn(422);
+ when(httpResponse.getEntity()).thenReturn(null);
+
+ // When
+ final Response response = hearingSlotsService.moveHearingToPastDate(TEST_HEARING_ID, payload);
+
+ // Then
+ assertThat(response.getStatus(), is(422));
+ }
+ }
+
+ @Test
+ public void shouldHandleMoveHearingToPastDateIOException() throws Exception {
+ // Given
+ when(systemUserProvider.getContextSystemUserId()).thenReturn(java.util.Optional.of(TEST_USER_ID));
+ final javax.json.JsonObject payload = javax.json.Json.createObjectBuilder()
+ .add("hearingId", TEST_HEARING_ID.toString())
+ .build();
+
+ try (MockedStatic mockedStatic = Mockito.mockStatic(HttpClientBuilder.class)) {
+ mockedStatic.when(HttpClientBuilder::create).thenReturn(httpClientBuilder);
+ when(httpClientBuilder.build()).thenReturn(httpClient);
+ when(httpClient.execute(any(HttpPost.class))).thenThrow(new IOException("Test exception"));
+
+ // When
+ final Response response = hearingSlotsService.moveHearingToPastDate(TEST_HEARING_ID, payload);
+
+ // Then
+ assertThat(response.getStatus(), is(Response.Status.INTERNAL_SERVER_ERROR.getStatusCode()));
+ }
+ }
}
\ No newline at end of file
diff --git a/listing-integration-test/src/test/java/uk/gov/moj/cpp/listing/it/MoveHearingToPastDateIT.java b/listing-integration-test/src/test/java/uk/gov/moj/cpp/listing/it/MoveHearingToPastDateIT.java
new file mode 100644
index 000000000..0e504871c
--- /dev/null
+++ b/listing-integration-test/src/test/java/uk/gov/moj/cpp/listing/it/MoveHearingToPastDateIT.java
@@ -0,0 +1,203 @@
+package uk.gov.moj.cpp.listing.it;
+
+import static java.util.UUID.randomUUID;
+import static javax.ws.rs.core.Response.Status.ACCEPTED;
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.Matchers.containsString;
+import static org.hamcrest.Matchers.is;
+import static uk.gov.moj.cpp.listing.helper.SearchHearingHelper.pollUntilHearingIsPresent;
+import static uk.gov.moj.cpp.listing.steps.data.HearingsData.hearingsDataWithAllocationDataAndJudiciary;
+import static uk.gov.moj.cpp.listing.steps.data.factory.HearingsDataFactory.CROWN_JURISDICTION;
+import static uk.gov.moj.cpp.listing.steps.data.factory.HearingsDataFactory.MAGISTRATES_JURISDICTION;
+import static uk.gov.moj.cpp.listing.utils.CourtSchedulerServiceStub.stubListHearingInCourtSessions;
+import static uk.gov.moj.cpp.listing.utils.CourtSchedulerServiceStub.stubMoveHearingToPastDate;
+import static uk.gov.moj.cpp.listing.utils.CourtSchedulerServiceStub.stubMoveHearingToPastDateFailure;
+import static uk.gov.moj.cpp.listing.utils.CourtSchedulerServiceStub.stubProvisionalBookingWithCustomParams;
+import static uk.gov.moj.cpp.listing.utils.CourtSchedulerServiceStub.verifyMoveHearingToPastDateCalled;
+import static uk.gov.moj.cpp.listing.utils.CourtSchedulerServiceStub.verifyMoveHearingToPastDateNeverCalled;
+
+import uk.gov.moj.cpp.listing.it.util.ItClock;
+import uk.gov.moj.cpp.listing.steps.ListCourtHearingSteps;
+import uk.gov.moj.cpp.listing.steps.MoveHearingToPastDateSteps;
+import uk.gov.moj.cpp.listing.steps.data.HearingsData;
+
+import java.time.LocalDate;
+import java.time.ZonedDateTime;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.UUID;
+
+import javax.ws.rs.core.Response;
+
+import org.junit.jupiter.api.Test;
+
+/**
+ * Covers listing.command.move-hearing-to-past-date: MAGISTRATES wraps courtscheduler synchronously
+ * and stores the returned slot as enrichment; CROWN is listing-side-only (Baris decision D1) and
+ * never calls courtscheduler. Single-day only.
+ */
+class MoveHearingToPastDateIT extends AbstractIT {
+
+ private static final String COURT_ROOM_ID = "731816c1-27ea-4711-8d92-0a1c2f3ab7de";
+
+ /**
+ * Lists a real hearing through the full flow (command → events → viewstore projection) and only
+ * returns once it is queryable — the move command's HEARING_ID_NOT_FOUND pre-check reads the
+ * viewstore, so moving an un-listed hearing is legitimately rejected. Mirrors VacateHearingIT:
+ * MAGS listing needs the provisional-booking + list-hearing-in-court-sessions stubs; CROWN
+ * listing never calls courtscheduler pre-Phase-2.
+ */
+ private MoveHearingToPastDateSteps givenAListedHearing(final String jurisdiction) {
+ final HearingsData hearingsData = hearingsDataWithAllocationDataAndJudiciary(jurisdiction);
+ final ListCourtHearingSteps listCourtHearingSteps = new ListCourtHearingSteps(hearingsData);
+
+ if (MAGISTRATES_JURISDICTION.equals(jurisdiction)) {
+ final ZonedDateTime hearingStartTime = listCourtHearingSteps.getHearingsData().getHearingData().get(0).getHearingStartTime();
+ final UUID courtCentreId = listCourtHearingSteps.getHearingsData().getHearingData().get(0).getCourtCentreId();
+ final UUID courtroomId = listCourtHearingSteps.getHearingsData().getHearingData().get(0).getCourtRoomId();
+ final String listedCourtScheduleId = randomUUID().toString();
+
+ final Map stubParams = new HashMap<>();
+ stubParams.put("SESSION_DATE", hearingStartTime.toLocalDate().toString());
+ stubParams.put("COURT_CENTRE_ID", courtCentreId.toString());
+ stubParams.put("COURT_SCHEDULE_ID", listedCourtScheduleId);
+ stubParams.put("COURT_ROOM_ID", courtroomId.toString());
+ stubParams.put("BOOKING_ID", randomUUID().toString());
+ stubParams.put("HEARING_START_TIME", hearingStartTime.toString());
+ stubProvisionalBookingWithCustomParams(stubParams);
+ stubListHearingInCourtSessions(listCourtHearingSteps.getHearingsData().getHearingData().get(0).getId().toString(),
+ listedCourtScheduleId, hearingStartTime);
+ }
+
+ listCourtHearingSteps.whenCaseIsSubmittedForListing();
+ listCourtHearingSteps.verifyHearingListedFromAPI(ALLOCATED);
+ // verifyHearingListedFromAPI's indefinite json-path filters have no result matcher, so
+ // they match vacuously against an empty hearings list - it can return before THIS hearing
+ // is projected. Poll on the hearing id (hasSize(1)) so the move command's viewstore
+ // pre-check cannot race the hearing-listed projection and 422 with HEARING_ID_NOT_FOUND.
+ pollUntilHearingIsPresent(hearingsData.getHearingData().get(0).getCourtCentreId().toString(),
+ ALLOCATED, getLoggedInUser().toString(), hearingsData.getHearingData().get(0).getId().toString());
+
+ return new MoveHearingToPastDateSteps(hearingsData);
+ }
+
+ @Test
+ void shouldMoveMagistratesHearingToPastDateAndStoreCourtScheduleEnrichment() {
+ final MoveHearingToPastDateSteps moveSteps = givenAListedHearing(MAGISTRATES_JURISDICTION);
+
+ final LocalDate pastDate = ItClock.today().minusDays(1);
+ final String courtScheduleId = randomUUID().toString();
+ stubMoveHearingToPastDate(moveSteps.getHearingId(), courtScheduleId, COURT_ROOM_ID, pastDate, 30);
+
+ final Response response = moveSteps.whenHearingIsMovedToPastDate("MAGS", pastDate);
+
+ assertThat(response.getStatus(), is(ACCEPTED.getStatusCode()));
+ verifyMoveHearingToPastDateCalled(moveSteps.getHearingId());
+ moveSteps.verifyCourtScheduleStored(courtScheduleId);
+ }
+
+ @Test
+ void shouldReleasePriorAllocationWhenMagistratesHearingMovedAgain() {
+ final MoveHearingToPastDateSteps moveSteps = givenAListedHearing(MAGISTRATES_JURISDICTION);
+ final LocalDate pastDate = ItClock.today().minusDays(1);
+
+ final String firstCourtScheduleId = randomUUID().toString();
+ stubMoveHearingToPastDate(moveSteps.getHearingId(), firstCourtScheduleId, COURT_ROOM_ID, pastDate, 30);
+ assertThat(moveSteps.whenHearingIsMovedToPastDate("MAGS", pastDate).getStatus(), is(ACCEPTED.getStatusCode()));
+ moveSteps.verifyCourtScheduleStored(firstCourtScheduleId);
+
+ final String secondCourtScheduleId = randomUUID().toString();
+ stubMoveHearingToPastDate(moveSteps.getHearingId(), secondCourtScheduleId, COURT_ROOM_ID, pastDate, 30);
+ assertThat(moveSteps.whenHearingIsMovedToPastDate("MAGS", pastDate).getStatus(), is(ACCEPTED.getStatusCode()));
+ moveSteps.verifyCourtScheduleStored(secondCourtScheduleId);
+ }
+
+ @Test
+ void shouldRejectMagistratesMoveWith422WhenCourtschedulerReturnsFutureDateNotAllowed() {
+ final MoveHearingToPastDateSteps moveSteps = givenAListedHearing(MAGISTRATES_JURISDICTION);
+
+ stubMoveHearingToPastDateFailure(moveSteps.getHearingId(), 422, "FUTURE_DATE_NOT_ALLOWED",
+ "Hearings can only be moved to today or an earlier date");
+
+ final Response response = moveSteps.whenHearingIsMovedToPastDate("MAGS", ItClock.today().plusDays(1));
+
+ assertThat(response.getStatus(), is(422));
+ assertThat(response.readEntity(String.class), containsString("FUTURE_DATE_NOT_ALLOWED"));
+ }
+
+ @Test
+ void shouldRejectMagistratesMoveWith422WhenNoCourtScheduleSessionExists() {
+ final MoveHearingToPastDateSteps moveSteps = givenAListedHearing(MAGISTRATES_JURISDICTION);
+
+ stubMoveHearingToPastDateFailure(moveSteps.getHearingId(), 422, "NO_SESSION_FOUND",
+ "No court-schedule session found for the given date and court centre");
+
+ final Response response = moveSteps.whenHearingIsMovedToPastDate("MAGS", ItClock.today().minusDays(1));
+
+ assertThat(response.getStatus(), is(422));
+ assertThat(response.readEntity(String.class), containsString("NO_SESSION_FOUND"));
+ }
+
+ /** Older courtscheduler releases signalled no-session as a bare 404 - the listing adapter
+ * normalises that to the 422 NO_SESSION_FOUND contract. */
+ @Test
+ void shouldNormaliseLegacyCourtscheduler404ToA422NoSessionFound() {
+ final MoveHearingToPastDateSteps moveSteps = givenAListedHearing(MAGISTRATES_JURISDICTION);
+
+ stubMoveHearingToPastDateFailure(moveSteps.getHearingId(), 404, null,
+ "No court-schedule session found for the given date and court centre");
+
+ final Response response = moveSteps.whenHearingIsMovedToPastDate("MAGS", ItClock.today().minusDays(1));
+
+ assertThat(response.getStatus(), is(422));
+ assertThat(response.readEntity(String.class), containsString("NO_SESSION_FOUND"));
+ }
+
+ @Test
+ void shouldRejectMoveWith422WhenHearingIdUnknown() {
+ // A hearing that was never listed - MoveHearingToPastDateSteps still needs SOME allocated
+ // hearing to obtain a courtCentreId, but we submit against a random unknown hearingId.
+ final HearingsData hearingsData = hearingsDataWithAllocationDataAndJudiciary(MAGISTRATES_JURISDICTION);
+ final MoveHearingToPastDateSteps moveSteps = new MoveHearingToPastDateSteps(hearingsData);
+ final UUID unknownHearingId = randomUUID();
+
+ final Response response = moveSteps.whenHearingIsMovedToPastDateForHearing(unknownHearingId, ItClock.today().minusDays(1));
+
+ assertThat(response.getStatus(), is(422));
+ assertThat(response.readEntity(String.class), containsString("HEARING_ID_NOT_FOUND"));
+ verifyMoveHearingToPastDateNeverCalled(unknownHearingId.toString());
+ }
+
+ @Test
+ void shouldRejectMoveWith400WhenMandatoryFieldMissing() {
+ final HearingsData hearingsData = hearingsDataWithAllocationDataAndJudiciary(MAGISTRATES_JURISDICTION);
+ final MoveHearingToPastDateSteps moveSteps = new MoveHearingToPastDateSteps(hearingsData);
+
+ final Response response = moveSteps.whenHearingIsMovedWithMissingCourtCentre(ItClock.today().minusDays(1));
+
+ assertThat(response.getStatus(), is(400));
+ }
+
+ @Test
+ void shouldMoveCrownHearingToPastDateListingSideOnlyWithoutCallingCourtScheduler() {
+ final MoveHearingToPastDateSteps moveSteps = givenAListedHearing(CROWN_JURISDICTION);
+ final LocalDate pastDate = ItClock.today().minusDays(1);
+
+ final Response response = moveSteps.whenHearingIsMovedToPastDate("CROWN", pastDate);
+
+ assertThat(response.getStatus(), is(ACCEPTED.getStatusCode()));
+ verifyMoveHearingToPastDateNeverCalled(moveSteps.getHearingId());
+ moveSteps.verifyStartDateUpdated(pastDate);
+ }
+
+ @Test
+ void shouldRejectCrownMoveToFutureDateWithoutCallingCourtScheduler() {
+ final MoveHearingToPastDateSteps moveSteps = givenAListedHearing(CROWN_JURISDICTION);
+
+ final Response response = moveSteps.whenHearingIsMovedToPastDate("CROWN", ItClock.today().plusDays(1));
+
+ assertThat(response.getStatus(), is(422));
+ assertThat(response.readEntity(String.class), containsString("FUTURE_DATE_NOT_ALLOWED"));
+ verifyMoveHearingToPastDateNeverCalled(moveSteps.getHearingId());
+ }
+}
diff --git a/listing-integration-test/src/test/java/uk/gov/moj/cpp/listing/steps/MoveHearingToPastDateSteps.java b/listing-integration-test/src/test/java/uk/gov/moj/cpp/listing/steps/MoveHearingToPastDateSteps.java
new file mode 100644
index 000000000..4751a6baf
--- /dev/null
+++ b/listing-integration-test/src/test/java/uk/gov/moj/cpp/listing/steps/MoveHearingToPastDateSteps.java
@@ -0,0 +1,109 @@
+package uk.gov.moj.cpp.listing.steps;
+
+import static com.jayway.jsonpath.matchers.JsonPathMatchers.withJsonPath;
+import static java.text.MessageFormat.format;
+import static javax.ws.rs.core.Response.Status.OK;
+import static org.hamcrest.CoreMatchers.hasItem;
+import static org.hamcrest.CoreMatchers.is;
+import static uk.gov.justice.services.common.http.HeaderConstants.USER_ID;
+import static uk.gov.justice.services.test.utils.core.http.RequestParamsBuilder.requestParams;
+import static uk.gov.justice.services.test.utils.core.matchers.ResponsePayloadMatcher.payload;
+import static uk.gov.justice.services.test.utils.core.matchers.ResponseStatusMatcher.status;
+import static uk.gov.moj.cpp.listing.it.util.RestPollerHelper.pollWithDefaults;
+import static uk.gov.moj.cpp.listing.utils.FileUtil.getPayload;
+import static uk.gov.moj.cpp.listing.utils.PropertyUtil.getBaseUri;
+import static uk.gov.moj.cpp.listing.utils.PropertyUtil.readConfig;
+
+import uk.gov.moj.cpp.listing.it.AbstractIT;
+import uk.gov.moj.cpp.listing.steps.data.HearingData;
+import uk.gov.moj.cpp.listing.steps.data.HearingsData;
+
+import java.time.LocalDate;
+import java.util.UUID;
+
+import javax.ws.rs.core.Response;
+
+/**
+ * Steps for the listing.command.move-hearing-to-past-date wrapper endpoint. Same
+ * {@code POST /hearings/{hearingId}} resource as vacate-trial/extend-hearing, distinguished by
+ * media type {@code application/vnd.listing.command.move-hearing-to-past-date+json}.
+ */
+public class MoveHearingToPastDateSteps extends AbstractIT {
+
+ private static final String LISTING_QUERY_HEARING = "listing.search.hearing";
+ private static final String MEDIA_TYPE_SEARCH_HEARING = "application/vnd.listing.search.hearing+json";
+ private static final String LISTING_COMMAND_MOVE = "listing.command.move-hearing-to-past-date";
+ private static final String MEDIA_TYPE_MOVE = "application/vnd.listing.command.move-hearing-to-past-date+json";
+
+ private final String hearingId;
+ private final UUID courtCentreId;
+
+ public MoveHearingToPastDateSteps(final HearingsData hearingsData) {
+ final HearingData hearingData = hearingsData.getHearingData().get(0);
+ this.hearingId = hearingData.getId().toString();
+ this.courtCentreId = hearingData.getCourtCentreId();
+ givenAUserHasLoggedInAsAListingOfficer(USER_ID_VALUE);
+ }
+
+ public String getHearingId() {
+ return hearingId;
+ }
+
+ public Response whenHearingIsMovedToPastDate(final String jurisdictionDir, final LocalDate date) {
+ final String payload = getPayload("test-data/" + jurisdictionDir + "/move-to-past-date/move-hearing-to-past-date.json")
+ .replace("%%COURT_CENTRE_ID%%", courtCentreId.toString())
+ .replace("%%START_DATE%%", date.toString());
+
+ return postMove(payload);
+ }
+
+ public Response whenHearingIsMovedWithMissingCourtCentre(final LocalDate date) {
+ final String payload = "{\"startDate\":\"" + date + "\"}";
+ return postMove(hearingId, payload);
+ }
+
+ /** Submits the move against an arbitrary hearingId (e.g. one that was never listed), reusing this
+ * steps' own courtCentreId so only the hearingId lookup is exercised. The target hearing is
+ * identified purely by the URL path - hearingId is not part of the body. */
+ public Response whenHearingIsMovedToPastDateForHearing(final UUID otherHearingId, final LocalDate date) {
+ final String payload = "{\"courtCentreId\":\"" + courtCentreId + "\",\"startDate\":\"" + date + "\"}";
+ return postMove(otherHearingId.toString(), payload);
+ }
+
+ private Response postMove(final String payload) {
+ return postMove(hearingId, payload);
+ }
+
+ private Response postMove(final String targetHearingId, final String payload) {
+ final String url = String.format("%s/%s", getBaseUri(),
+ format(readConfig().getProperty(LISTING_COMMAND_MOVE), targetHearingId));
+ return restClient.postCommand(url, MEDIA_TYPE_MOVE, payload, getLoggedInHeader());
+ }
+
+ public void verifyCourtScheduleStored(final String expectedCourtScheduleId) {
+ final String searchHearingUrl = String.format("%s/%s", getBaseUri(),
+ format(readConfig().getProperty(LISTING_QUERY_HEARING), hearingId));
+
+ pollWithDefaults(requestParams(searchHearingUrl, MEDIA_TYPE_SEARCH_HEARING).withHeader(USER_ID, getLoggedInUser()).build())
+ .until(
+ status().is(OK),
+ payload().isJson(org.hamcrest.CoreMatchers.allOf(
+ withJsonPath("$.id", is(hearingId)),
+ withJsonPath("$.hearingDays[*].courtScheduleId", hasItem(expectedCourtScheduleId))
+ )));
+ }
+
+ public void verifyStartDateUpdated(final LocalDate expectedStartDate) {
+ final String searchHearingUrl = String.format("%s/%s", getBaseUri(),
+ format(readConfig().getProperty(LISTING_QUERY_HEARING), hearingId));
+
+ pollWithDefaults(requestParams(searchHearingUrl, MEDIA_TYPE_SEARCH_HEARING).withHeader(USER_ID, getLoggedInUser()).build())
+ .until(
+ status().is(OK),
+ payload().isJson(org.hamcrest.CoreMatchers.allOf(
+ withJsonPath("$.id", is(hearingId)),
+ withJsonPath("$.startDate", is(expectedStartDate.toString())),
+ withJsonPath("$.hearingDays[0].hearingDate", is(expectedStartDate.toString()))
+ )));
+ }
+}
diff --git a/listing-integration-test/src/test/java/uk/gov/moj/cpp/listing/utils/CourtSchedulerServiceStub.java b/listing-integration-test/src/test/java/uk/gov/moj/cpp/listing/utils/CourtSchedulerServiceStub.java
index bfca77eb2..d2b51a0a3 100644
--- a/listing-integration-test/src/test/java/uk/gov/moj/cpp/listing/utils/CourtSchedulerServiceStub.java
+++ b/listing-integration-test/src/test/java/uk/gov/moj/cpp/listing/utils/CourtSchedulerServiceStub.java
@@ -57,6 +57,7 @@ public class CourtSchedulerServiceStub {
private static final String SEARCH_COURT_SCHEDULES_BY_ID = "/courtschedule/search.court-schedules-by-id";
private static final String CROWN_FALLBACK_SEARCH_BOOK = "/crownfallbacksearchandbook/hearingslots";
private static final String CROWN_FALLBACK_SEARCH_BOOK_TYPE = "application/vnd.courtscheduler.crown.fallback.search.book.hearing.slots+json";
+ private static final String MOVE_HEARING_TO_PAST_DATE_TYPE = "application/vnd.courtscheduler.move-hearing-to-past-date+json";
private static final String COURTSCHEDULER_GET_HEARING_SLOTS_TYPE = "application/vnd.courtscheduler.get.hearing.slots+json";
private static final String COURTSCHEDULER_VALIDATE_SESSION_AVAILABILITY_TYPE = "application/vnd.courtscheduler.validate.session.availability+json";
public static final String COURTSCHEDULER_GET_PROVISIONAL_BOOKING_TYPE = "application/vnd.courtscheduler.get.provisional.booking+json";
@@ -1436,4 +1437,66 @@ private static void stubCourtSchedulesByIdResponse(final String body) {
.withHeader(CONTENT_TYPE, APPLICATION_JSON)
));
}
+
+ // --- move-hearing-to-past-date stubs (MAGISTRATES-only: CROWN never reaches courtscheduler, Baris decision D1) ---
+
+ /** Stub a successful POST /hearings/{hearingId} move-hearing-to-past-date response. */
+ public static void stubMoveHearingToPastDate(final String hearingId,
+ final String courtScheduleId,
+ final String courtRoomId,
+ final LocalDate sessionDate,
+ final int durationInMinutes) {
+ final String startTime = sessionDate + "T09:00:00Z";
+ final String endTime = sessionDate + "T17:00:00Z";
+ final String body = format(
+ "{\"hearingId\":\"%s\",\"courtScheduleId\":\"%s\",\"courtRoomId\":\"%s\"," +
+ "\"sessionDate\":\"%s\",\"sessionStartTime\":\"%s\",\"sessionEndTime\":\"%s\"," +
+ "\"durationInMinutes\":%s}",
+ hearingId, courtScheduleId, courtRoomId, sessionDate, startTime, endTime, durationInMinutes);
+
+ stubFor(post(urlPathMatching(format("%s", COURT_SCHEDULER_ENDPOINT + "/hearings/" + hearingId)))
+ .withHeader(CONTENT_TYPE, containing(MOVE_HEARING_TO_PAST_DATE_TYPE))
+ .willReturn(aResponse().withStatus(OK.getStatusCode())
+ .withBody(body)
+ .withHeader(CONTENT_TYPE, APPLICATION_JSON)));
+ }
+
+ /** Stub a courtscheduler rejection (e.g. 422 FUTURE_DATE_NOT_ALLOWED / NO_SESSION_FOUND, or a
+ * legacy 404 no-session) for move-hearing-to-past-date. */
+ public static void stubMoveHearingToPastDateFailure(final String hearingId,
+ final int statusCode,
+ final String errorCode,
+ final String message) {
+ final StringBuilder body = new StringBuilder("{");
+ if (errorCode != null) {
+ body.append("\"errorCode\":\"").append(errorCode).append("\",");
+ }
+ body.append("\"message\":\"").append(message).append("\"}");
+
+ stubFor(post(urlPathMatching(format("%s", COURT_SCHEDULER_ENDPOINT + "/hearings/" + hearingId)))
+ .withHeader(CONTENT_TYPE, containing(MOVE_HEARING_TO_PAST_DATE_TYPE))
+ .willReturn(aResponse().withStatus(statusCode)
+ .withBody(body.toString())
+ .withHeader(CONTENT_TYPE, APPLICATION_JSON)));
+ }
+
+ /** Verify courtscheduler's move-hearing-to-past-date endpoint was called for the given hearing
+ * (matched on the URL path - hearingId no longer travels in the request body). */
+ public static void verifyMoveHearingToPastDateCalled(final String hearingId) {
+ Awaitility.await().atMost(15, SECONDS).pollInterval(POLL_INTERVAL).until(() -> {
+ try {
+ WireMock.verify(WireMock.postRequestedFor(urlPathMatching(
+ COURT_SCHEDULER_ENDPOINT + "/hearings/" + hearingId)));
+ return true;
+ } catch (VerificationException e) {
+ return false;
+ }
+ });
+ }
+
+ /** Regression guard for the CROWN listing-side-only path: courtscheduler must never be called. */
+ public static void verifyMoveHearingToPastDateNeverCalled(final String hearingId) {
+ WireMock.verify(0, WireMock.postRequestedFor(urlPathMatching(
+ COURT_SCHEDULER_ENDPOINT + "/hearings/" + hearingId)));
+ }
}
diff --git a/listing-integration-test/src/test/resources/endpoint.properties b/listing-integration-test/src/test/resources/endpoint.properties
index 8d9ef3764..f462e3dc0 100644
--- a/listing-integration-test/src/test/resources/endpoint.properties
+++ b/listing-integration-test/src/test/resources/endpoint.properties
@@ -45,6 +45,7 @@ listing.publishedcourtlist=listing-service/query/view/rest/listing/publishedcour
listing.command.publish-court-lists-for-crown-courts=listing-service/command/api/rest/listing/publishCourtListsForCrownCourts
listing.command.extend-hearing-for-hearing=listing-service/command/api/rest/listing/hearings/{0}
listing.command.hearing-vacate-trial=listing-service/command/api/rest/listing/hearings/{0}
+listing.command.move-hearing-to-past-date=listing-service/command/api/rest/listing/hearings/{0}
listing.command.create-listing-note=listing-service/command/api/rest/listing/listing-note
listing.command.edit-listing-note=listing-service/command/api/rest/listing/listing-notes/{0}
listing.search.hearings.by.allocated.court-room-id.search-date=listing-service/query/api/rest/listing/hearings/?allocated={0}&courtRoomId={1}&searchDate={2}
diff --git a/listing-integration-test/src/test/resources/test-data/CROWN/move-to-past-date/move-hearing-to-past-date.json b/listing-integration-test/src/test/resources/test-data/CROWN/move-to-past-date/move-hearing-to-past-date.json
new file mode 100644
index 000000000..ab58d1d4f
--- /dev/null
+++ b/listing-integration-test/src/test/resources/test-data/CROWN/move-to-past-date/move-hearing-to-past-date.json
@@ -0,0 +1,4 @@
+{
+ "courtCentreId": "%%COURT_CENTRE_ID%%",
+ "startDate": "%%START_DATE%%"
+}
diff --git a/listing-integration-test/src/test/resources/test-data/MAGS/move-to-past-date/move-hearing-to-past-date.json b/listing-integration-test/src/test/resources/test-data/MAGS/move-to-past-date/move-hearing-to-past-date.json
new file mode 100644
index 000000000..ab58d1d4f
--- /dev/null
+++ b/listing-integration-test/src/test/resources/test-data/MAGS/move-to-past-date/move-hearing-to-past-date.json
@@ -0,0 +1,4 @@
+{
+ "courtCentreId": "%%COURT_CENTRE_ID%%",
+ "startDate": "%%START_DATE%%"
+}