diff --git a/src/main/java/uk/gov/hmcts/sptribs/caseworker/util/EventConstants.java b/src/main/java/uk/gov/hmcts/sptribs/caseworker/util/EventConstants.java index 64bbe3620f..e388025f79 100644 --- a/src/main/java/uk/gov/hmcts/sptribs/caseworker/util/EventConstants.java +++ b/src/main/java/uk/gov/hmcts/sptribs/caseworker/util/EventConstants.java @@ -49,6 +49,7 @@ public final class EventConstants { public static final String CASEWORKER_EDIT_PANEL_COMPOSITION = "caseworker-edit-panel-composition"; public static final String CASEWORKER_UPDATE_CASE_DATA = "update-case-data"; public static final String SUPERUSER_REINDEX_CASES = "superuser-reindex-cases"; + public static final String SUPERUSER_VIEW_OPEN_CASES_STATISTICS = "superuser-view-open-cases-statistics"; public static final String CHANGE_SECURITY_CLASS = "change-security-class"; public static final String CASEWORKER_REFER_TO_JUDGE = "refer-to-judge"; public static final String CREATE_BUNDLE = "createBundle"; diff --git a/src/main/java/uk/gov/hmcts/sptribs/ciccase/model/CaseData.java b/src/main/java/uk/gov/hmcts/sptribs/ciccase/model/CaseData.java index 174e7cc0c5..f967272a20 100644 --- a/src/main/java/uk/gov/hmcts/sptribs/ciccase/model/CaseData.java +++ b/src/main/java/uk/gov/hmcts/sptribs/ciccase/model/CaseData.java @@ -548,6 +548,15 @@ public class CaseData { ) private String deleteFieldName; + @CCD( + label = "Case statistics (copy/paste)", + hint = "Copy and paste this into Excel (tab-separated values).", + typeOverride = TextArea, + access = {SuperUserOnlyAccess.class} + ) + @External + private String openCasesStatisticsTsv; + @CCD( label = "Reindex cases modified since", access = {SuperUserOnlyAccess.class} diff --git a/src/main/java/uk/gov/hmcts/sptribs/common/event/ViewOpenCasesStatisticsEvent.java b/src/main/java/uk/gov/hmcts/sptribs/common/event/ViewOpenCasesStatisticsEvent.java new file mode 100644 index 0000000000..13edadb312 --- /dev/null +++ b/src/main/java/uk/gov/hmcts/sptribs/common/event/ViewOpenCasesStatisticsEvent.java @@ -0,0 +1,90 @@ +package uk.gov.hmcts.sptribs.common.event; + +import lombok.RequiredArgsConstructor; +import lombok.SneakyThrows; +import org.postgresql.PGConnection; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Component; +import uk.gov.hmcts.ccd.sdk.api.CCDConfig; +import uk.gov.hmcts.ccd.sdk.api.CaseDetails; +import uk.gov.hmcts.ccd.sdk.api.ConfigBuilder; +import uk.gov.hmcts.ccd.sdk.api.callback.AboutToStartOrSubmitResponse; +import uk.gov.hmcts.sptribs.ciccase.model.CaseData; +import uk.gov.hmcts.sptribs.ciccase.model.State; +import uk.gov.hmcts.sptribs.ciccase.model.UserRole; +import uk.gov.hmcts.sptribs.common.ccd.PageBuilder; + +import java.io.StringWriter; +import java.sql.Connection; + +import static uk.gov.hmcts.sptribs.caseworker.util.EventConstants.SUPERUSER_VIEW_OPEN_CASES_STATISTICS; +import static uk.gov.hmcts.sptribs.ciccase.model.UserRole.SUPER_USER; +import static uk.gov.hmcts.sptribs.ciccase.model.access.Permissions.CREATE_READ_UPDATE_DELETE; + +@Component +@RequiredArgsConstructor +public class ViewOpenCasesStatisticsEvent implements CCDConfig { + + private final JdbcTemplate jdbcTemplate; + + @Override + public void configure(final ConfigBuilder configBuilder) { + PageBuilder pageBuilder = new PageBuilder(configBuilder + .event(SUPERUSER_VIEW_OPEN_CASES_STATISTICS) + .forAllStates() + .name("View case statistics") + .description("Shows a tab-separated report for copy/paste into Excel (includes closure reasons for CaseClosed)") + .aboutToStartCallback(this::aboutToStart) + .grant(CREATE_READ_UPDATE_DELETE, SUPER_USER)); + + pageBuilder.page("viewOpenCasesStatistics") + .pageLabel("View case statistics") + .label( + "viewOpenCasesStatisticsLabel", + "Copy the report below into Excel (Ctrl/Cmd+A then Ctrl/Cmd+C). Closed cases include closure columns." + ) + .optional(CaseData::getOpenCasesStatisticsTsv) + .done(); + } + + public AboutToStartOrSubmitResponse aboutToStart( + CaseDetails details + ) { + CaseData data = details.getData(); + data.setOpenCasesStatisticsTsv(generateCasesStatisticsTsv()); + + return AboutToStartOrSubmitResponse.builder() + .data(data) + .build(); + } + + @SneakyThrows + private String generateCasesStatisticsTsv() { + String copySql = """ + COPY ( + select + reference::text as "Case reference", + to_char(created_date, 'YYYY-MM-DD HH24:MI') as "Created (UTC)", + state as "State", + to_char(coalesce(last_state_modified_date, created_date), 'YYYY-MM-DD HH24:MI') + as "State since (UTC)", + coalesce(data ->> 'closeCloseCaseReason', '') as "Closure reason", + concat_ws(' - ', + nullif(data ->> 'closeRejectionReason', ''), + nullif(data ->> 'closeStrikeOutReason', ''), + nullif(data ->> 'caseIssueFinalDecisionDecisionTemplate', '') + ) as "Closure details" + from ccd.case_data + where case_type_id = 'CriminalInjuriesCompensation' + limit 50000 + ) TO STDOUT WITH (FORMAT csv, DELIMITER E'\\t', HEADER TRUE, NULL ''); + """; + + try (Connection connection = jdbcTemplate.getDataSource().getConnection()) { + StringWriter writer = new StringWriter(256 * 1024); + PGConnection pgConnection = connection.unwrap(PGConnection.class); + pgConnection.getCopyAPI().copyOut(copySql, writer); + return writer.toString(); + } + } +} diff --git a/src/test/java/uk/gov/hmcts/sptribs/common/event/ViewOpenCasesStatisticsEventTest.java b/src/test/java/uk/gov/hmcts/sptribs/common/event/ViewOpenCasesStatisticsEventTest.java new file mode 100644 index 0000000000..366e0036cc --- /dev/null +++ b/src/test/java/uk/gov/hmcts/sptribs/common/event/ViewOpenCasesStatisticsEventTest.java @@ -0,0 +1,86 @@ +package uk.gov.hmcts.sptribs.common.event; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.postgresql.PGConnection; +import org.postgresql.copy.CopyManager; +import org.springframework.jdbc.core.JdbcTemplate; +import uk.gov.hmcts.ccd.sdk.ConfigBuilderImpl; +import uk.gov.hmcts.ccd.sdk.api.CaseDetails; +import uk.gov.hmcts.ccd.sdk.api.Event; +import uk.gov.hmcts.ccd.sdk.api.callback.AboutToStartOrSubmitResponse; +import uk.gov.hmcts.sptribs.ciccase.model.CaseData; +import uk.gov.hmcts.sptribs.ciccase.model.State; +import uk.gov.hmcts.sptribs.ciccase.model.UserRole; + +import java.io.Writer; +import java.sql.Connection; +import javax.sql.DataSource; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.when; +import static uk.gov.hmcts.sptribs.caseworker.util.EventConstants.SUPERUSER_VIEW_OPEN_CASES_STATISTICS; +import static uk.gov.hmcts.sptribs.testutil.ConfigTestUtil.createCaseDataConfigBuilder; +import static uk.gov.hmcts.sptribs.testutil.ConfigTestUtil.getEventsFrom; + +@ExtendWith(MockitoExtension.class) +class ViewOpenCasesStatisticsEventTest { + + @Mock + private JdbcTemplate jdbcTemplate; + + @InjectMocks + private ViewOpenCasesStatisticsEvent event; + + @Test + void shouldAddConfigurationToConfigBuilder() { + final ConfigBuilderImpl configBuilder = createCaseDataConfigBuilder(); + + event.configure(configBuilder); + + assertThat(getEventsFrom(configBuilder).values()) + .extracting(Event::getId) + .contains(SUPERUSER_VIEW_OPEN_CASES_STATISTICS); + } + + @Test + void shouldPopulateTsvOnAboutToStart() throws Exception { + final CaseDetails caseDetails = new CaseDetails<>(); + final CaseData caseData = new CaseData(); + caseDetails.setData(caseData); + + DataSource dataSource = org.mockito.Mockito.mock(DataSource.class); + Connection connection = org.mockito.Mockito.mock(Connection.class); + PGConnection pgConnection = org.mockito.Mockito.mock(PGConnection.class); + CopyManager copyManager = org.mockito.Mockito.mock(CopyManager.class); + + when(jdbcTemplate.getDataSource()).thenReturn(dataSource); + when(dataSource.getConnection()).thenReturn(connection); + when(connection.unwrap(PGConnection.class)).thenReturn(pgConnection); + when(pgConnection.getCopyAPI()).thenReturn(copyManager); + + doAnswer(invocation -> { + Writer writer = invocation.getArgument(1); + writer.write( + "Case reference\tCreated (UTC)\tState\tState since (UTC)\tClosure reason\tClosure details\n" + + "123\t2025-12-18 10:00\tCaseClosed\t2025-12-17 10:00\tcaseRejected\t" + + "deadlineMissed\n" + ); + return 1L; + }).when(copyManager).copyOut(anyString(), any(Writer.class)); + + AboutToStartOrSubmitResponse response = event.aboutToStart(caseDetails); + + String tsv = response.getData().getOpenCasesStatisticsTsv(); + assertThat(tsv).contains("Case reference\tCreated (UTC)\tState\tState since (UTC)"); + assertThat(tsv).contains("123\t"); + assertThat(tsv).contains("\tcaseRejected\t"); + assertThat(tsv).contains("deadlineMissed"); + } +}