From cede983f3a8e2e8dc22a6b34b1fbd18076a5961d Mon Sep 17 00:00:00 2001 From: aykutdanisman Date: Thu, 4 Jun 2026 20:23:04 +0100 Subject: [PATCH 1/2] CHD-2556 - filter out offence based on proceedingsConcluded flag --- .gitignore | 1 - CLAUDE.md | 110 ++++++++++++++++++ .../query/view/service/HearingService.java | 62 +++++++++- .../view/service/HearingServiceTest.java | 78 +++++++++++++ pom.xml | 4 +- 5 files changed, 249 insertions(+), 6 deletions(-) create mode 100644 CLAUDE.md diff --git a/.gitignore b/.gitignore index 92aa3fe39f..eb78fefc74 100644 --- a/.gitignore +++ b/.gitignore @@ -59,4 +59,3 @@ buildContexts.sh lightning-jenkins.properties **/.DS_Store -/CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000000..59f5c92a94 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,110 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Commands + +### Build +```bash +mvn clean install -nsu +``` + +### Run unit tests +```bash +# All unit tests +mvn test + +# Single test class +mvn test -Dtest=ClassName + +# Single test method +mvn test -Dtest=ClassName#methodName +``` + +### Run integration tests +```bash +cd hearing-integration-test +mvn verify -Phearing-integration-test + +# Single integration test class +mvn verify -Phearing-integration-test -Dit.test=TestClassName +``` + +### Build with Sonar analysis +```bash +mvn -C -U verify sonar:sonar -Dsonar.analysis.mode=preview -Dsonar.issuesReport.html.enable=true -Dsonar.exclusions=target/generated-sources/** +``` + +## Architecture + +This is an **event-sourced CQRS microservice** for the Ministry of Justice's Common Platform Project (CPP). It manages hearing lifecycle operations: applications, cases, defendants, counsel, witnesses, and scheduling. + +### CQRS + Event Sourcing Pattern + +The service strictly separates commands (writes) from queries (reads): + +- **Commands** mutate state by appending domain events to an event store +- **Event processors** consume events and build projections into the view store +- **Queries** read directly from the view store (denormalized read model) + +### Module Structure + +``` +hearing-command/ + hearing-command-api/ # JAX-RS REST endpoints that accept commands + hearing-command-handler/ # ~30 command handlers (business logic + event publishing) + +hearing-query/ + hearing-query-api/ # JAX-RS REST endpoints that return query results + hearing-query-view/ # Query result formatting/projection + +hearing-domain/ + hearing-domain-aggregate/ # Domain aggregates (event-sourced state reconstruction) + hearing-domain-common/ # Shared value objects and domain types + hearing-domain-event/ # Domain event definitions (what happened) + hearing-domain-xhibit/ # Xhibit integration domain types + +hearing-event/ + hearing-event-listener/ # Subscribes to the event stream + hearing-event-processor/ # Projects events into the view store + +hearing-viewstore/ + hearing-viewstore-persistence/ # JPA entities + repositories for the read model + +hearing-event-sources/ # Infrastructure: event store connection +hearing-common/ # Cross-cutting utilities +hearing-healthchecks/ # Health check endpoints +hearing-json/ # JSON serialization helpers +hearing-service/ # Assembles all modules into a deployable WAR (WildFly) + +hearing-integration-test/ # Integration tests (Failsafe, WildFly embedded, Rest-assured) +test-utilities/ # Shared test matchers and fixtures +pojo-plugin/ # Maven plugin for POJO code generation +``` + +### Data Flow + +1. Client sends HTTP request → **Command API** (hearing-command-api) +2. Command handler loads aggregate from event store, validates, publishes domain events → **Command Handler** (hearing-command-handler) +3. Event listener receives events → **Event Processor** builds view store projection +4. Client queries → **Query API** reads from **View Store** + +### Key Technologies + +- **Runtime**: Java 17, WildFly application server, Docker +- **Database**: PostgreSQL, schema managed by Liquibase in `hearing-viewstore` +- **Framework**: `uk.gov.moj.cpp.common:service-parent-pom` (HMCTS CPP internal framework for event sourcing and CQRS) +- **Testing**: JUnit, Rest-assured (HTTP), Awaitility (async), Wiremock (stubbing), Testcontainers +- **CI/CD**: Azure DevOps (`azure-pipelines.yaml`) — runs Sonar on PRs, full integration tests on `main` and `team/*` branches + +### Adding New Command Handlers + +Command handlers live in `hearing-command/hearing-command-handler/src/main/java`. Each handler implements the framework's command handler interface, loads the aggregate, applies business logic, and publishes events. The corresponding domain events are defined in `hearing-domain/hearing-domain-event`. + +### Database Migrations + +Liquibase changesets are in `hearing-viewstore/hearing-viewstore-persistence/src/main/resources`. These run automatically on deployment via the `hearing-viewstore-liquibase` module bundled into the WAR. + +### Integration Tests + +Integration tests in `hearing-integration-test/` use a profile `hearing-integration-test` and follow the `*IT.java` naming convention. They spin up a WildFly embedded container and test the full HTTP → command → event → view store flow. diff --git a/hearing-query/hearing-query-view/src/main/java/uk/gov/moj/cpp/hearing/query/view/service/HearingService.java b/hearing-query/hearing-query-view/src/main/java/uk/gov/moj/cpp/hearing/query/view/service/HearingService.java index e47afb782f..f4a7bd365c 100644 --- a/hearing-query/hearing-query-view/src/main/java/uk/gov/moj/cpp/hearing/query/view/service/HearingService.java +++ b/hearing-query/hearing-query-view/src/main/java/uk/gov/moj/cpp/hearing/query/view/service/HearingService.java @@ -5,6 +5,7 @@ import static java.lang.String.format; import static java.time.format.DateTimeFormatter.ofPattern; import static java.util.Collections.emptyList; +import static java.util.Collections.emptySet; import static java.util.Comparator.comparing; import static java.util.Objects.isNull; import static java.util.Objects.nonNull; @@ -13,8 +14,10 @@ import static java.util.Optional.ofNullable; import static java.util.UUID.fromString; import static java.util.stream.Collectors.toList; +import static java.util.stream.Collectors.toSet; import static org.apache.commons.collections.CollectionUtils.isEmpty; import static org.apache.commons.collections.CollectionUtils.isNotEmpty; +import static org.apache.commons.lang3.BooleanUtils.isTrue; import static uk.gov.justice.core.courts.ApplicationStatus.EJECTED; import static uk.gov.justice.core.courts.JurisdictionType.CROWN; import static uk.gov.justice.services.core.annotation.Component.QUERY_API; @@ -128,7 +131,6 @@ import com.fasterxml.jackson.databind.JsonNode; import com.google.common.collect.Sets; -import org.apache.commons.collections.CollectionUtils; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -621,7 +623,7 @@ public HearingDetailsResponse getHearingDetailsResponseById(final JsonEnvelope e if (hearing.getCourtApplications() != null) { - Set uniqueApplications = hearing.getCourtApplications().stream().map(CourtApplication::getId).collect(Collectors.toSet()); + Set uniqueApplications = hearing.getCourtApplications().stream().map(CourtApplication::getId).collect(toSet()); relatedApplicationId = hearing.getCourtApplications().get(0).getId(); final List parentCourtApplications = hearing.getCourtApplications().stream() @@ -1135,7 +1137,12 @@ public HearingDetailsResponse filterOutProsecutionCases(final HearingDetailsResp return payload; } - payload.setHearing(buildHearingWithoutProsecutionCases(payload.getHearing())); + //if all selected offenses inactive(proceedingsConcluded=true) then this is an application hearing + if (areAllCourtApplicationOffencesConcluded(payload.getHearing())) { + payload.setHearing(buildHearingWithoutProsecutionCases(payload.getHearing())); + } else { //otherwise this is a case hearing + filterProsecutionCaseOffencesAndDeduplicateFromApplications(payload.getHearing()); + } return payload; } @@ -1147,6 +1154,55 @@ private uk.gov.justice.core.courts.Hearing buildHearingWithoutProsecutionCases(f .build(); } + private boolean areAllCourtApplicationOffencesConcluded(final uk.gov.justice.core.courts.Hearing hearing) { + return hearing.getCourtApplications().stream() + .filter(app -> nonNull(app.getCourtApplicationCases())) + .flatMap(app -> app.getCourtApplicationCases().stream()) + .filter(courtCase -> isNotEmpty(courtCase.getOffences())) + .flatMap(courtCase -> courtCase.getOffences().stream()) + .allMatch(offence -> isTrue(offence.getProceedingsConcluded())); + } + + private void filterProsecutionCaseOffencesAndDeduplicateFromApplications(final uk.gov.justice.core.courts.Hearing hearing) { + final Set courtAppOffenceIds = hearing.getCourtApplications().stream() + .filter(app -> nonNull(app.getCourtApplicationCases())) + .flatMap(app -> app.getCourtApplicationCases().stream()) + .filter(courtCase -> nonNull(courtCase.getOffences())) + .flatMap(courtCase -> courtCase.getOffences().stream()) + .map(uk.gov.justice.core.courts.Offence::getId) + .collect(toSet()); + + if (isNotEmpty(hearing.getProsecutionCases())) { + hearing.getProsecutionCases().stream() + .filter(pc -> nonNull(pc.getDefendants())) + .flatMap(pc -> pc.getDefendants().stream()) + .filter(defendant -> nonNull(defendant.getOffences())) + .forEach(defendant -> defendant.getOffences() + .removeIf(offence -> !courtAppOffenceIds.contains(offence.getId()))); + } + + final Set keptOffenceIds = isNull(hearing.getProsecutionCases()) + ? emptySet() + : hearing.getProsecutionCases().stream() + .filter(pc -> nonNull(pc.getDefendants())) + .flatMap(pc -> pc.getDefendants().stream()) + .filter(defendant -> nonNull(defendant.getOffences())) + .flatMap(defendant -> defendant.getOffences().stream()) + .map(uk.gov.justice.core.courts.Offence::getId) + .collect(toSet()); + + hearing.getCourtApplications().stream() + .filter(app -> nonNull(app.getCourtApplicationCases())) + .flatMap(app -> app.getCourtApplicationCases().stream()) + .filter(courtCase -> nonNull(courtCase.getOffences())) + .forEach(courtCase -> { + courtCase.getOffences().removeIf(offence -> keptOffenceIds.contains(offence.getId())); + if (courtCase.getOffences().isEmpty()) { + courtCase.setOffences(null); + } + }); + } + private boolean isApplicationHasNoOffences(final uk.gov.justice.core.courts.Hearing hearing) { if (isNull(hearing.getCourtApplications())) { return true; diff --git a/hearing-query/hearing-query-view/src/test/java/uk/gov/moj/cpp/hearing/query/view/service/HearingServiceTest.java b/hearing-query/hearing-query-view/src/test/java/uk/gov/moj/cpp/hearing/query/view/service/HearingServiceTest.java index f93df4da28..2edc7741ec 100644 --- a/hearing-query/hearing-query-view/src/test/java/uk/gov/moj/cpp/hearing/query/view/service/HearingServiceTest.java +++ b/hearing-query/hearing-query-view/src/test/java/uk/gov/moj/cpp/hearing/query/view/service/HearingServiceTest.java @@ -21,6 +21,7 @@ import static org.hamcrest.Matchers.instanceOf; import static org.hamcrest.Matchers.is; import static org.hamcrest.Matchers.iterableWithSize; +import static org.hamcrest.Matchers.empty; import static org.hamcrest.Matchers.nullValue; import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; import static org.junit.jupiter.api.Assertions.assertFalse; @@ -2283,6 +2284,7 @@ public void shouldFilterOutProsecutionCasesWhenApplicationHasOffences() { .withCourtApplicationCases(singletonList(CourtApplicationCase.courtApplicationCase() .withOffences(singletonList(Offence.offence() .withId(randomUUID()) + .withProceedingsConcluded(true) .build())) .build())) .build())) @@ -2331,4 +2333,80 @@ public void shouldKeepProsecutionCasesWhenApplicationHasNoOffences() { assertThat(result.getHearing().getProsecutionCases(), hasSize(1)); assertThat(result.getHearing().getProsecutionCases().get(0).getDefendants().get(0).getOffences().size(), is(1)); } + + @Test + public void shouldKeepFilteredProsecutionCasesWhenApplicationOffencesNotConcluded() { + final UUID sharedOffenceId = randomUUID(); + final UUID extraOffenceId = randomUUID(); + + final uk.gov.justice.core.courts.Hearing hearing = hearing() + .withCourtApplications(singletonList(CourtApplication.courtApplication() + .withCourtApplicationCases(singletonList(CourtApplicationCase.courtApplicationCase() + .withOffences(new ArrayList<>(singletonList(Offence.offence() + .withId(sharedOffenceId) + .withProceedingsConcluded(false) + .build()))) + .build())) + .build())) + .withProsecutionCases(singletonList(uk.gov.justice.core.courts.ProsecutionCase.prosecutionCase() + .withDefendants(singletonList(uk.gov.justice.core.courts.Defendant.defendant() + .withId(randomUUID()) + .withOffences(new ArrayList<>(Arrays.asList( + Offence.offence().withId(sharedOffenceId).build(), + Offence.offence().withId(extraOffenceId).build()))) + .build())) + .build())) + .build(); + + final HearingDetailsResponse payload = new HearingDetailsResponse(); + payload.setHearing(hearing); + + final HearingDetailsResponse result = hearingService.filterOutProsecutionCases(payload); + + assertThat(result.getHearing().getProsecutionCases(), hasSize(1)); + final List remainingDefendantOffences = + result.getHearing().getProsecutionCases().get(0).getDefendants().get(0).getOffences(); + assertThat(remainingDefendantOffences, hasSize(1)); + assertThat(remainingDefendantOffences.get(0).getId(), is(sharedOffenceId)); + + final List remainingAppOffences = result.getHearing().getCourtApplications() + .get(0).getCourtApplicationCases().get(0).getOffences(); + assertThat(remainingAppOffences, nullValue()); + } + + @Test + public void shouldLeaveEmptyDefendantOffencesWhenNoneMatchCourtApplicationOffences() { + final UUID appOffenceId = randomUUID(); + final UUID defOffenceId = randomUUID(); + + final uk.gov.justice.core.courts.Hearing hearing = hearing() + .withCourtApplications(singletonList(CourtApplication.courtApplication() + .withCourtApplicationCases(singletonList(CourtApplicationCase.courtApplicationCase() + .withOffences(new ArrayList<>(singletonList(Offence.offence() + .withId(appOffenceId) + .withProceedingsConcluded(false) + .build()))) + .build())) + .build())) + .withProsecutionCases(singletonList(uk.gov.justice.core.courts.ProsecutionCase.prosecutionCase() + .withDefendants(singletonList(uk.gov.justice.core.courts.Defendant.defendant() + .withId(randomUUID()) + .withOffences(new ArrayList<>(singletonList( + Offence.offence().withId(defOffenceId).build()))) + .build())) + .build())) + .build(); + + final HearingDetailsResponse payload = new HearingDetailsResponse(); + payload.setHearing(hearing); + + final HearingDetailsResponse result = hearingService.filterOutProsecutionCases(payload); + + assertThat(result.getHearing().getProsecutionCases(), hasSize(1)); + assertThat(result.getHearing().getProsecutionCases().get(0) + .getDefendants().get(0).getOffences(), empty()); + + assertThat(result.getHearing().getCourtApplications() + .get(0).getCourtApplicationCases().get(0).getOffences(), hasSize(1)); + } } diff --git a/pom.xml b/pom.xml index 82233dc922..2a8225bacb 100644 --- a/pom.xml +++ b/pom.xml @@ -30,8 +30,8 @@ 17.0.75 17.103.95 17.0.1 - 17.103.77 - 17.0.252 + 17.104.81 + 17.0.266 2.2.11 2.6.3 5.7 From 6c5fc7a1735e6782a6da3d1e89e2947605031c6d Mon Sep 17 00:00:00 2001 From: aykutdanisman Date: Thu, 4 Jun 2026 20:33:40 +0100 Subject: [PATCH 2/2] added some comments --- .../moj/cpp/hearing/query/view/service/HearingService.java | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/hearing-query/hearing-query-view/src/main/java/uk/gov/moj/cpp/hearing/query/view/service/HearingService.java b/hearing-query/hearing-query-view/src/main/java/uk/gov/moj/cpp/hearing/query/view/service/HearingService.java index f4a7bd365c..a1b8692d87 100644 --- a/hearing-query/hearing-query-view/src/main/java/uk/gov/moj/cpp/hearing/query/view/service/HearingService.java +++ b/hearing-query/hearing-query-view/src/main/java/uk/gov/moj/cpp/hearing/query/view/service/HearingService.java @@ -1164,6 +1164,8 @@ private boolean areAllCourtApplicationOffencesConcluded(final uk.gov.justice.cor } private void filterProsecutionCaseOffencesAndDeduplicateFromApplications(final uk.gov.justice.core.courts.Hearing hearing) { + + // Collect all offence IDs referenced by CourtApplicationCases final Set courtAppOffenceIds = hearing.getCourtApplications().stream() .filter(app -> nonNull(app.getCourtApplicationCases())) .flatMap(app -> app.getCourtApplicationCases().stream()) @@ -1172,6 +1174,7 @@ private void filterProsecutionCaseOffencesAndDeduplicateFromApplications(final u .map(uk.gov.justice.core.courts.Offence::getId) .collect(toSet()); + // Step 1: keep only offences those appear in the CourtApplicationCases if (isNotEmpty(hearing.getProsecutionCases())) { hearing.getProsecutionCases().stream() .filter(pc -> nonNull(pc.getDefendants())) @@ -1181,6 +1184,7 @@ private void filterProsecutionCaseOffencesAndDeduplicateFromApplications(final u .removeIf(offence -> !courtAppOffenceIds.contains(offence.getId()))); } + // Collect offence IDs that survived in step 1 final Set keptOffenceIds = isNull(hearing.getProsecutionCases()) ? emptySet() : hearing.getProsecutionCases().stream() @@ -1191,6 +1195,8 @@ private void filterProsecutionCaseOffencesAndDeduplicateFromApplications(final u .map(uk.gov.justice.core.courts.Offence::getId) .collect(toSet()); + // Step 2: remove from CourtApplicationCases any offence already present under prosecution cases; + // set offences to null (rather than empty list) so the field is omitted from the JSON response. hearing.getCourtApplications().stream() .filter(app -> nonNull(app.getCourtApplicationCases())) .flatMap(app -> app.getCourtApplicationCases().stream())