Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -59,4 +59,3 @@ buildContexts.sh

lightning-jenkins.properties
**/.DS_Store
/CLAUDE.md
110 changes: 110 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -621,7 +623,7 @@ public HearingDetailsResponse getHearingDetailsResponseById(final JsonEnvelope e

if (hearing.getCourtApplications() != null) {

Set<UUID> uniqueApplications = hearing.getCourtApplications().stream().map(CourtApplication::getId).collect(Collectors.toSet());
Set<UUID> uniqueApplications = hearing.getCourtApplications().stream().map(CourtApplication::getId).collect(toSet());
relatedApplicationId = hearing.getCourtApplications().get(0).getId();

final List<CourtApplication> parentCourtApplications = hearing.getCourtApplications().stream()
Expand Down Expand Up @@ -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;
}
Expand All @@ -1147,6 +1154,61 @@ 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) {

// Collect all offence IDs referenced by CourtApplicationCases
final Set<UUID> 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());

// Step 1: keep only offences those appear in the CourtApplicationCases
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())));
}

// Collect offence IDs that survived in step 1
final Set<UUID> 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());

// 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())
.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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -2283,6 +2284,7 @@ public void shouldFilterOutProsecutionCasesWhenApplicationHasOffences() {
.withCourtApplicationCases(singletonList(CourtApplicationCase.courtApplicationCase()
.withOffences(singletonList(Offence.offence()
.withId(randomUUID())
.withProceedingsConcluded(true)
.build()))
.build()))
.build()))
Expand Down Expand Up @@ -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<Offence> remainingDefendantOffences =
result.getHearing().getProsecutionCases().get(0).getDefendants().get(0).getOffences();
assertThat(remainingDefendantOffences, hasSize(1));
assertThat(remainingDefendantOffences.get(0).getId(), is(sharedOffenceId));

final List<Offence> 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));
}
}
4 changes: 2 additions & 2 deletions pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,8 @@
<material.version>17.0.75</material.version>
<results.version>17.103.95</results.version>
<notification.notify.version>17.0.1</notification.notify.version>
<stagingenforcement.version>17.103.77</stagingenforcement.version>
<progression.version>17.0.252</progression.version>
<stagingenforcement.version>17.104.81</stagingenforcement.version>
<progression.version>17.0.266</progression.version>
<jaxb.version>2.2.11</jaxb.version>
<org.xmlunit.version>2.6.3</org.xmlunit.version>
<sardine.version>5.7</sardine.version>
Expand Down
Loading