diff --git a/build.gradle b/build.gradle
index 59866a62..0d541fcb 100644
--- a/build.gradle
+++ b/build.gradle
@@ -201,7 +201,7 @@ dependencies {
implementation group: 'com.azure.spring', name: 'spring-cloud-azure-starter-storage-blob', version: '7.3.0'
-
+ implementation group: 'tools.jackson.dataformat', name: 'jackson-dataformat-csv'
implementation group: 'tools.jackson.datatype', name: 'jackson-datatype-hibernate7'
implementation group: 'com.github.hmcts.java-logging', name: 'logging', version: '8.0.0'
diff --git a/infrastructure/storage-account.tf b/infrastructure/storage-account.tf
index 036d0e7d..b3192ca1 100644
--- a/infrastructure/storage-account.tf
+++ b/infrastructure/storage-account.tf
@@ -16,6 +16,10 @@ module "storage_account" {
{
name = "photos",
access_type = "container"
+ },
+ {
+ name = "csv",
+ access_type = "container"
}
]
diff --git a/src/functionalTest/java/uk/gov/hmcts/reform/fact/functional/controllers/CsvControllerFunctionalTest.java b/src/functionalTest/java/uk/gov/hmcts/reform/fact/functional/controllers/CsvControllerFunctionalTest.java
new file mode 100644
index 00000000..c0123c6e
--- /dev/null
+++ b/src/functionalTest/java/uk/gov/hmcts/reform/fact/functional/controllers/CsvControllerFunctionalTest.java
@@ -0,0 +1,35 @@
+package uk.gov.hmcts.reform.fact.functional.controllers;
+
+import io.qameta.allure.Feature;
+import io.restassured.response.Response;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import uk.gov.hmcts.reform.fact.functional.http.HttpClient;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.springframework.http.HttpStatus.UNAUTHORIZED;
+
+@Feature("CSV Controller")
+@DisplayName("CSV Controller")
+class CsvControllerFunctionalTest {
+
+ private static final String CSV_ENDPOINT = "/csv/";
+ private static final String INVALID_TOKEN = "invalid-token";
+ private static final HttpClient http = new HttpClient();
+
+ @Test
+ @DisplayName("POST /csv/ without bearer token returns 401")
+ void shouldReturnUnauthorizedWithoutToken() {
+ final Response response = http.doPost(CSV_ENDPOINT, null, "");
+
+ assertThat(response.statusCode()).isEqualTo(UNAUTHORIZED.value());
+ }
+
+ @Test
+ @DisplayName("POST /csv/ with invalid bearer token returns 401")
+ void shouldReturnUnauthorizedWithInvalidToken() {
+ final Response response = http.doPost(CSV_ENDPOINT, null, INVALID_TOKEN);
+
+ assertThat(response.statusCode()).isEqualTo(UNAUTHORIZED.value());
+ }
+}
diff --git a/src/integrationTest/java/uk/gov/hmcts/reform/fact/data/api/config/AzureBlobConfigurationTestConfiguration.java b/src/integrationTest/java/uk/gov/hmcts/reform/fact/data/api/config/AzureBlobConfigurationTestConfiguration.java
index 95eec118..6f74f929 100644
--- a/src/integrationTest/java/uk/gov/hmcts/reform/fact/data/api/config/AzureBlobConfigurationTestConfiguration.java
+++ b/src/integrationTest/java/uk/gov/hmcts/reform/fact/data/api/config/AzureBlobConfigurationTestConfiguration.java
@@ -9,6 +9,8 @@
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Profile;
+import uk.gov.hmcts.reform.fact.data.api.services.AzureBlobService;
+
@Configuration
@Profile("test")
public class AzureBlobConfigurationTestConfiguration {
@@ -17,7 +19,10 @@ public class AzureBlobConfigurationTestConfiguration {
private BlobServiceClient blobServiceClient;
@Mock
- private BlobContainerClient containerClient;
+ private BlobContainerClient photoContainerClient;
+
+ @Mock
+ private BlobContainerClient csvContainerClient;
@Mock
private BlobClient blobClient;
@@ -27,8 +32,13 @@ public AzureBlobConfigurationTestConfiguration() {
}
@Bean
- public BlobContainerClient blobContainerClient() {
- return containerClient;
+ public BlobContainerClient photoBlobContainerClient() {
+ return photoContainerClient;
+ }
+
+ @Bean
+ public BlobContainerClient csvBlobContainerClient() {
+ return csvContainerClient;
}
@Bean
@@ -40,4 +50,14 @@ public BlobClient blobClient() {
public BlobServiceClient blobServiceClient() {
return blobServiceClient;
}
+
+ @Bean
+ public AzureBlobService photoAzureBlobService() {
+ return new AzureBlobService(photoContainerClient);
+ }
+
+ @Bean
+ public AzureBlobService csvAzureBlobService() {
+ return new AzureBlobService(csvContainerClient);
+ }
}
diff --git a/src/integrationTest/java/uk/gov/hmcts/reform/fact/data/api/controllers/CsvControllerTest.java b/src/integrationTest/java/uk/gov/hmcts/reform/fact/data/api/controllers/CsvControllerTest.java
new file mode 100644
index 00000000..643b0e4c
--- /dev/null
+++ b/src/integrationTest/java/uk/gov/hmcts/reform/fact/data/api/controllers/CsvControllerTest.java
@@ -0,0 +1,82 @@
+package uk.gov.hmcts.reform.fact.data.api.controllers;
+
+import io.qameta.allure.Feature;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.test.context.ActiveProfiles;
+import org.springframework.test.context.bean.override.mockito.MockitoBean;
+import org.springframework.test.web.servlet.MockMvc;
+import uk.gov.hmcts.reform.fact.data.api.clients.SlackClient;
+import uk.gov.hmcts.reform.fact.data.api.services.AzureBlobService;
+import uk.gov.hmcts.reform.fact.data.api.services.CourtDetailsViewService;
+import uk.gov.hmcts.reform.fact.data.api.services.CourtService;
+
+import java.util.Collections;
+
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
+@Feature("CSV Controller")
+@DisplayName("CSV Controller")
+@SpringBootTest
+@AutoConfigureMockMvc(addFilters = false)
+@ActiveProfiles("test")
+class CsvControllerTest {
+
+ @Autowired
+ private MockMvc mvc;
+
+ @MockitoBean(name = "csvAzureBlobService")
+ private AzureBlobService azureBlobService;
+
+ @MockitoBean
+ private CourtService courtService;
+
+ @MockitoBean
+ private CourtDetailsViewService courtDetailsViewService;
+
+ @MockitoBean
+ private SlackClient slackClient;
+
+ @Test
+ @DisplayName("POST /csv/ returns 200 when CSV is created and uploaded successfully")
+ void createAndUploadCsvReturns200() throws Exception {
+ when(courtService.getAllCourtDetails()).thenReturn(Collections.emptyList());
+ when(azureBlobService.uploadFile(anyString(), any())).thenReturn("http://example.com/blob");
+
+ mvc.perform(post("/csv/"))
+ .andExpect(status().isOk());
+
+ verify(azureBlobService, times(1)).uploadFile(anyString(), any());
+ }
+
+ @Test
+ @DisplayName("POST /csv/ returns 502 when Azure upload fails (e.g. container missing)")
+ void createAndUploadCsvReturns502OnAzureUploadException() throws Exception {
+ when(courtService.getAllCourtDetails()).thenReturn(Collections.emptyList());
+ when(azureBlobService.uploadFile(anyString(), any())).thenThrow(new RuntimeException("Container not found"));
+
+ mvc.perform(post("/csv/"))
+ .andExpect(status().isBadGateway())
+ .andExpect(jsonPath("$.message").value("Failed to upload CSV file to Azure Blob Storage"));
+ }
+
+ @Test
+ @DisplayName("POST /csv/ returns 500 when CSV creation fails")
+ void createAndUploadCsvReturns500OnCsvCreationException() throws Exception {
+ when(courtService.getAllCourtDetails()).thenThrow(new RuntimeException("DB error"));
+
+ mvc.perform(post("/csv/"))
+ .andExpect(status().isInternalServerError())
+ .andExpect(jsonPath("$.message").value("Failed to create CSV file"));
+ }
+}
diff --git a/src/main/java/uk/gov/hmcts/reform/fact/data/api/config/AzureBlobConfiguration.java b/src/main/java/uk/gov/hmcts/reform/fact/data/api/config/AzureBlobConfiguration.java
index b56c05ce..9ab90fff 100644
--- a/src/main/java/uk/gov/hmcts/reform/fact/data/api/config/AzureBlobConfiguration.java
+++ b/src/main/java/uk/gov/hmcts/reform/fact/data/api/config/AzureBlobConfiguration.java
@@ -7,14 +7,36 @@
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Profile;
+import uk.gov.hmcts.reform.fact.data.api.services.AzureBlobService;
+import org.springframework.beans.factory.annotation.Qualifier;
+
@Configuration
@Profile("!test")
public class AzureBlobConfiguration {
@Bean
- public BlobContainerClient blobContainerClient(
+ public BlobContainerClient photoBlobContainerClient(
+ BlobServiceClient blobServiceClient,
+ @Value("${spring.cloud.azure.storage.blob.photo-container-name}") String containerName) {
+ return blobServiceClient.getBlobContainerClient(containerName);
+ }
+
+ @Bean
+ public BlobContainerClient csvBlobContainerClient(
BlobServiceClient blobServiceClient,
- @Value("${spring.cloud.azure.storage.blob.container-name}") String containerName) {
+ @Value("${spring.cloud.azure.storage.blob.csv-container-name}") String containerName) {
return blobServiceClient.getBlobContainerClient(containerName);
}
+
+ @Bean
+ public AzureBlobService photoAzureBlobService(
+ @Qualifier("photoBlobContainerClient") BlobContainerClient photoBlobContainerClient) {
+ return new AzureBlobService(photoBlobContainerClient);
+ }
+
+ @Bean
+ public AzureBlobService csvAzureBlobService(
+ @Qualifier("csvBlobContainerClient") BlobContainerClient csvBlobContainerClient) {
+ return new AzureBlobService(csvBlobContainerClient);
+ }
}
diff --git a/src/main/java/uk/gov/hmcts/reform/fact/data/api/controllers/CsvController.java b/src/main/java/uk/gov/hmcts/reform/fact/data/api/controllers/CsvController.java
new file mode 100644
index 00000000..f9d606d5
--- /dev/null
+++ b/src/main/java/uk/gov/hmcts/reform/fact/data/api/controllers/CsvController.java
@@ -0,0 +1,37 @@
+package uk.gov.hmcts.reform.fact.data.api.controllers;
+
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.responses.ApiResponse;
+import io.swagger.v3.oas.annotations.responses.ApiResponses;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+import uk.gov.hmcts.reform.fact.data.api.security.SecuredFactRestController;
+import uk.gov.hmcts.reform.fact.data.api.services.CsvService;
+
+@SecuredFactRestController(
+ name = "CSV",
+ description = "Operations related to CSV file handling"
+)
+@RequestMapping("/csv")
+public class CsvController {
+
+ private final CsvService csvService;
+
+ public CsvController(CsvService csvService) {
+ this.csvService = csvService;
+ }
+
+ @PostMapping("/")
+ @Operation(
+ summary = "Create and upload CSV file",
+ description = "Generates a CSV file with court data and uploads it to the configured storage service."
+ )
+ @ApiResponses(value = {
+ @ApiResponse(responseCode = "200", description = "CSV file created and uploaded successfully"),
+ @ApiResponse(responseCode = "500", description = "Failed to create CSV file"),
+ @ApiResponse(responseCode = "502", description = "Failed to upload CSV file to storage")
+ })
+ public void createAndUploadCsv() {
+ csvService.createAndUploadCsv();
+ }
+}
diff --git a/src/main/java/uk/gov/hmcts/reform/fact/data/api/errorhandling/GlobalExceptionHandler.java b/src/main/java/uk/gov/hmcts/reform/fact/data/api/errorhandling/GlobalExceptionHandler.java
index f336672d..31c1601f 100644
--- a/src/main/java/uk/gov/hmcts/reform/fact/data/api/errorhandling/GlobalExceptionHandler.java
+++ b/src/main/java/uk/gov/hmcts/reform/fact/data/api/errorhandling/GlobalExceptionHandler.java
@@ -1,13 +1,17 @@
package uk.gov.hmcts.reform.fact.data.api.errorhandling;
+import uk.gov.hmcts.reform.fact.data.api.errorhandling.exceptions.AzureUploadException;
import uk.gov.hmcts.reform.fact.data.api.errorhandling.exceptions.CourtResourceNotFoundException;
+import uk.gov.hmcts.reform.fact.data.api.errorhandling.exceptions.CsvCreationException;
import uk.gov.hmcts.reform.fact.data.api.errorhandling.exceptions.DuplicatedListItemException;
+import uk.gov.hmcts.reform.fact.data.api.errorhandling.exceptions.JsonConvertException;
import uk.gov.hmcts.reform.fact.data.api.errorhandling.exceptions.InvalidAreaOfLawException;
import uk.gov.hmcts.reform.fact.data.api.errorhandling.exceptions.InvalidDateRangeException;
import uk.gov.hmcts.reform.fact.data.api.errorhandling.exceptions.InvalidFileException;
import uk.gov.hmcts.reform.fact.data.api.errorhandling.exceptions.InvalidParameterCombinationException;
import uk.gov.hmcts.reform.fact.data.api.errorhandling.exceptions.InvalidPostcodeException;
import uk.gov.hmcts.reform.fact.data.api.errorhandling.exceptions.NotFoundException;
+
import uk.gov.hmcts.reform.fact.data.api.validation.annotations.ValidUUID;
import java.time.LocalDateTime;
@@ -206,6 +210,27 @@ public ExceptionResponse handle(InvalidAreaOfLawException ex) {
return generateExceptionResponse(ex.getMessage());
}
+ @ExceptionHandler(JsonConvertException.class)
+ @ResponseStatus(HttpStatus.BAD_REQUEST)
+ public ExceptionResponse handle(JsonConvertException ex) {
+ log.error("400, JSON conversion error. Details: {}", ex.getMessage());
+ return generateExceptionResponse(ex.getMessage());
+ }
+
+ @ExceptionHandler(AzureUploadException.class)
+ @ResponseStatus(HttpStatus.BAD_GATEWAY)
+ public ExceptionResponse handle(AzureUploadException ex) {
+ log.error("502, error while uploading CSV to Azure. Details: {}", ex.getMessage());
+ return generateExceptionResponse(ex.getMessage());
+ }
+
+ @ExceptionHandler(CsvCreationException.class)
+ @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
+ public ExceptionResponse handle(CsvCreationException ex) {
+ log.error("500, error while creating CSV file. Details: {}", ex.getMessage());
+ return generateExceptionResponse(ex.getMessage());
+ }
+
private ExceptionResponse generateExceptionResponse(String message) {
ExceptionResponse response = new ExceptionResponse();
response.setMessage(message);
diff --git a/src/main/java/uk/gov/hmcts/reform/fact/data/api/errorhandling/exceptions/CsvCreationException.java b/src/main/java/uk/gov/hmcts/reform/fact/data/api/errorhandling/exceptions/CsvCreationException.java
new file mode 100644
index 00000000..741052ef
--- /dev/null
+++ b/src/main/java/uk/gov/hmcts/reform/fact/data/api/errorhandling/exceptions/CsvCreationException.java
@@ -0,0 +1,7 @@
+package uk.gov.hmcts.reform.fact.data.api.errorhandling.exceptions;
+
+import lombok.experimental.StandardException;
+
+@StandardException
+public class CsvCreationException extends RuntimeException {
+}
diff --git a/src/main/java/uk/gov/hmcts/reform/fact/data/api/errorhandling/exceptions/JsonConvertException.java b/src/main/java/uk/gov/hmcts/reform/fact/data/api/errorhandling/exceptions/JsonConvertException.java
new file mode 100644
index 00000000..656c6bf1
--- /dev/null
+++ b/src/main/java/uk/gov/hmcts/reform/fact/data/api/errorhandling/exceptions/JsonConvertException.java
@@ -0,0 +1,7 @@
+package uk.gov.hmcts.reform.fact.data.api.errorhandling.exceptions;
+
+import lombok.experimental.StandardException;
+
+@StandardException
+public class JsonConvertException extends RuntimeException {
+}
diff --git a/src/main/java/uk/gov/hmcts/reform/fact/data/api/models/StringMultipartFile.java b/src/main/java/uk/gov/hmcts/reform/fact/data/api/models/StringMultipartFile.java
new file mode 100644
index 00000000..d27db0f7
--- /dev/null
+++ b/src/main/java/uk/gov/hmcts/reform/fact/data/api/models/StringMultipartFile.java
@@ -0,0 +1,57 @@
+package uk.gov.hmcts.reform.fact.data.api.models;
+
+import lombok.Getter;
+import org.springframework.web.multipart.MultipartFile;
+import java.io.ByteArrayInputStream;
+import java.io.InputStream;
+
+/**
+ * An implementation of the {@link MultipartFile} interface that wraps a {@link String} content.
+ *
+ *
This class is used to represent a file created from a string, which can be then treated as a standard
+ * multipart file for operations like uploading to cloud storage.
+ */
+@Getter
+public class StringMultipartFile implements MultipartFile {
+
+ private final String name;
+ private final String originalFilename;
+ private final String contentType;
+ private final byte[] bytes;
+
+ /**
+ * Constructs a new StringMultipartFile.
+ *
+ * @param name the name of the file
+ * @param originalFilename the original filename
+ * @param contentType the content type of the file
+ * @param content the string content of the file
+ */
+ public StringMultipartFile(String name, String originalFilename, String contentType, String content) {
+ this.name = name;
+ this.originalFilename = originalFilename;
+ this.contentType = contentType;
+ this.bytes = content.getBytes();
+ }
+
+ @Override
+ public boolean isEmpty() {
+ return bytes.length == 0;
+ }
+
+ @Override
+ public long getSize() {
+ return bytes.length;
+ }
+
+ @Override
+ public InputStream getInputStream() {
+ return new ByteArrayInputStream(bytes);
+ }
+
+ @Override
+ public void transferTo(java.io.File dest) throws IllegalStateException {
+ // This implementation is currently empty as it's not required for current use cases,
+ // but the override is necessary for the interface.
+ }
+}
diff --git a/src/main/java/uk/gov/hmcts/reform/fact/data/api/services/AzureBlobService.java b/src/main/java/uk/gov/hmcts/reform/fact/data/api/services/AzureBlobService.java
index 238fd82a..6da2026f 100644
--- a/src/main/java/uk/gov/hmcts/reform/fact/data/api/services/AzureBlobService.java
+++ b/src/main/java/uk/gov/hmcts/reform/fact/data/api/services/AzureBlobService.java
@@ -3,13 +3,13 @@
import com.azure.storage.blob.BlobClient;
import com.azure.storage.blob.BlobContainerClient;
import com.azure.storage.blob.models.BlobHttpHeaders;
-import org.springframework.stereotype.Service;
+import lombok.extern.slf4j.Slf4j;
import org.springframework.web.multipart.MultipartFile;
import uk.gov.hmcts.reform.fact.data.api.errorhandling.exceptions.AzureUploadException;
import java.io.IOException;
-@Service
+@Slf4j
public class AzureBlobService {
private final BlobContainerClient blobContainerClient;
@@ -19,37 +19,41 @@ public AzureBlobService(BlobContainerClient blobContainerClient) {
}
/**
- * Uploads the image in the Azure blob service.
+ * Uploads a file to an Azure blob container.
+ * If containerName is provided, it uploads to that container and creates it if it does not exist.
*
- * @param imageId The identifier of the image.
- * @param file The file to upload.
- * @return The id linked to the uploaded image.
+ * @param blobName The name of the blob to create.
+ * @param file The file to upload.
+ * @return The URL of the uploaded blob.
*/
- public String uploadFile(String imageId, MultipartFile file) {
- BlobClient blobClient = blobContainerClient.getBlobClient(imageId);
+ public String uploadFile(String blobName, MultipartFile file) {
+ BlobClient blobClient = blobContainerClient.getBlobClient(blobName);
+ uploadToBlob(blobClient, file);
+
+ log.info("Uploaded file {} to {}", file.getOriginalFilename(), blobClient.getBlobUrl());
+
+ return blobClient.getBlobUrl();
+ }
+
+ private void uploadToBlob(BlobClient blobClient, MultipartFile file) {
try {
blobClient.upload(file.getInputStream(), file.getSize(), true);
-
BlobHttpHeaders headers = new BlobHttpHeaders()
.setContentType(file.getContentType());
-
blobClient.setHttpHeaders(headers);
-
} catch (IOException e) {
throw new AzureUploadException("Could not upload provided file to Azure");
}
-
- return blobClient.getBlobUrl();
}
/**
- * Delete a blob from the blob store by the imageId.
+ * Delete a blob from the blob store by the blob name.
*
- * @param imageId The identifier of the image.
+ * @param blobName The name of the blob to delete.
*/
- public void deleteBlob(String imageId) {
- BlobClient blobClient = blobContainerClient.getBlobClient(imageId);
+ public void deleteBlob(String blobName) {
+ BlobClient blobClient = blobContainerClient.getBlobClient(blobName);
blobClient.delete();
}
diff --git a/src/main/java/uk/gov/hmcts/reform/fact/data/api/services/CourtPhotoService.java b/src/main/java/uk/gov/hmcts/reform/fact/data/api/services/CourtPhotoService.java
index 39ac4cfd..4c2062e0 100644
--- a/src/main/java/uk/gov/hmcts/reform/fact/data/api/services/CourtPhotoService.java
+++ b/src/main/java/uk/gov/hmcts/reform/fact/data/api/services/CourtPhotoService.java
@@ -7,14 +7,13 @@
import java.util.UUID;
-import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;
@Slf4j
@Service
-@RequiredArgsConstructor
public class CourtPhotoService {
private final CourtPhotoRepository courtPhotoRepository;
@@ -22,6 +21,16 @@ public class CourtPhotoService {
private final AzureBlobService azureBlobService;
private final AuditUserContext auditUserContext;
+ public CourtPhotoService(CourtPhotoRepository courtPhotoRepository,
+ CourtService courtService,
+ @Qualifier("photoAzureBlobService") AzureBlobService azureBlobService,
+ AuditUserContext auditUserContext) {
+ this.courtPhotoRepository = courtPhotoRepository;
+ this.courtService = courtService;
+ this.azureBlobService = azureBlobService;
+ this.auditUserContext = auditUserContext;
+ }
+
/**
* Get a court photo by court id.
*
diff --git a/src/main/java/uk/gov/hmcts/reform/fact/data/api/services/CsvService.java b/src/main/java/uk/gov/hmcts/reform/fact/data/api/services/CsvService.java
new file mode 100644
index 00000000..b0a96441
--- /dev/null
+++ b/src/main/java/uk/gov/hmcts/reform/fact/data/api/services/CsvService.java
@@ -0,0 +1,108 @@
+package uk.gov.hmcts.reform.fact.data.api.services;
+
+import tools.jackson.databind.ObjectMapper;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+
+import uk.gov.hmcts.reform.fact.data.api.clients.SlackClient;
+import uk.gov.hmcts.reform.fact.data.api.errorhandling.exceptions.AzureUploadException;
+import uk.gov.hmcts.reform.fact.data.api.errorhandling.exceptions.CsvCreationException;
+import uk.gov.hmcts.reform.fact.data.api.models.StringMultipartFile;
+import uk.gov.hmcts.reform.fact.data.api.utils.CsvUtil;
+
+import java.time.ZoneId;
+import java.time.ZonedDateTime;
+import java.time.format.DateTimeFormatter;
+import java.util.ArrayList;
+import java.util.List;
+
+import org.springframework.beans.factory.annotation.Qualifier;
+
+/**
+ * Service for handling CSV file operations.
+ */
+@Slf4j
+@Service
+public class CsvService {
+
+ private static final String CSV_FILE_NAME = "courts-and-tribunals-data.csv";
+ private static final String CSV_CONTENT_TYPE = "text/csv";
+
+ private final CourtService courtService;
+ private final CourtDetailsViewService courtDetailsViewService;
+ private final AzureBlobService azureBlobService;
+ private final ObjectMapper objectMapper;
+ private final SlackClient slackClient;
+
+ public CsvService(CourtService courtService,
+ CourtDetailsViewService courtDetailsViewService,
+ @Qualifier("csvAzureBlobService") AzureBlobService azureBlobService,
+ ObjectMapper objectMapper,
+ SlackClient slackClient) {
+ this.courtService = courtService;
+ this.courtDetailsViewService = courtDetailsViewService;
+ this.azureBlobService = azureBlobService;
+ this.objectMapper = objectMapper;
+ this.slackClient = slackClient;
+ }
+
+ public void createAndUploadCsv() {
+ List actions = new ArrayList<>();
+ try {
+ StringMultipartFile csvFile = createCsvFile(actions);
+ uploadCsvToAzureBlob(actions, csvFile);
+ } finally {
+ sendSlackSummary(actions);
+ }
+ }
+
+ public void uploadCsvToAzureBlob(List actions, StringMultipartFile stringMultipartFile) {
+ try {
+ azureBlobService
+ .uploadFile(CSV_FILE_NAME, stringMultipartFile);
+ } catch (Exception e) {
+ log.error("Error while uploading CSV", e);
+ actions.add("Failed to upload CSV file to Azure Blob Storage. Check App insights.");
+ throw new AzureUploadException("Failed to upload CSV file to Azure Blob Storage", e);
+ }
+ }
+
+ public StringMultipartFile createCsvFile(List actions) {
+ try {
+ return new StringMultipartFile(
+ CSV_FILE_NAME,
+ CSV_FILE_NAME,
+ CSV_CONTENT_TYPE,
+ new CsvUtil()
+ .convertJsonToCsv(
+ objectMapper.valueToTree(
+ courtService.getAllCourtDetails().stream().map(
+ courtDetailsViewService::prepareDetailsView)
+ .toList()))
+ );
+ } catch (Exception e) {
+ log.error("Error while creating CSV file", e);
+ actions.add("Failed to create CSV file. Check App insights.");
+ throw new CsvCreationException("Failed to create CSV file", e);
+ }
+ }
+
+ /**
+ * Sends a summary of the CSV creation and upload check to Slack.
+ * @param actions List of action descriptions to include in the summary
+ */
+ private void sendSlackSummary(List actions) {
+ ZonedDateTime nowUk = ZonedDateTime.now(ZoneId.of("Europe/London"));
+ String timestamp = nowUk.format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm"));
+ StringBuilder sb = new StringBuilder(
+ String.format("*:mag: CSV creation and upload check (%s)*\n", timestamp)
+ );
+ if (!actions.isEmpty()) {
+ sb.append("> ❗ Issue found:\n");
+ for (String action : actions) {
+ sb.append("• ").append(action).append("\n");
+ }
+ slackClient.sendSlackMessage(sb.toString());
+ }
+ }
+}
diff --git a/src/main/java/uk/gov/hmcts/reform/fact/data/api/utils/CsvUtil.java b/src/main/java/uk/gov/hmcts/reform/fact/data/api/utils/CsvUtil.java
new file mode 100644
index 00000000..47e9ee3f
--- /dev/null
+++ b/src/main/java/uk/gov/hmcts/reform/fact/data/api/utils/CsvUtil.java
@@ -0,0 +1,421 @@
+package uk.gov.hmcts.reform.fact.data.api.utils;
+
+import tools.jackson.core.JacksonException;
+import tools.jackson.databind.JsonNode;
+import tools.jackson.databind.SerializationFeature;
+import tools.jackson.dataformat.csv.CsvMapper;
+import tools.jackson.dataformat.csv.CsvSchema;
+import uk.gov.hmcts.reform.fact.data.api.errorhandling.exceptions.JsonConvertException;
+
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * Utility class for converting court-related JSON data into a flat CSV format.
+ *
+ * This class uses the Jackson CSV and JSON libraries to parse and flatten complex, nested JSON structures
+ * representing court details into a simplified CSV format. It includes specific logic to handle various fields
+ * such as court names, geolocation, types, addresses, and areas of law, transforming them into string
+ * representations suitable for CSV output
.
+ *
+ * Key functionality includes:
+ * - Flattening nested JSON nodes (like addresses and areas of law) into single string fields.
+ * - Building a consistent CSV schema for output.
+ * - Safely handling missing or null fields to ensure robust CSV generation
.
+ *
+ * Intended for use in services or tools that need to export structured court data from JSON APIs into a
+ * human-readable and spreadsheet-friendly CSV format
.
+ */
+public class CsvUtil {
+ private static final String NAME = "name";
+ private static final String LAT = "lat";
+ private static final String LON = "lon";
+ private static final String SLUG = "slug";
+ private static final String TYPES = "types";
+ private static final String CCI_CODE = "cci_code";
+ private static final String MAGISTRATE_CODE = "magistrate_code";
+ private static final String AREAS_OF_LAW = "areas_of_law";
+ private static final String AREAS_OF_LAW_PATH = "areasOfLaw";
+ private static final String ADDRESSES = "addresses";
+ private static final String COURT_ADDRESSES = "courtAddresses";
+ private static final String COURT_TYPES = "courtTypes";
+ private static final String DX_NUMBER = "dx_number";
+ private static final String NOT_AVAILABLE = "N/A";
+ private static final String PIPE_SEPARATOR = " | ";
+ private static final String NO_AREAS_OF_LAW_AVAILABLE = "No areas of law available";
+ private static final String NO_ADDRESS_AVAILABLE = "No address available";
+ private static final String AREAS_OF_LAW_LABEL = "Areas of Law";
+ private static final String COURTS_LABEL = "Courts";
+
+ private final CsvMapper csvMapper;
+
+ protected CsvUtil(CsvMapper csvMapper) {
+ this.csvMapper = csvMapper;
+ }
+
+ public CsvUtil() {
+ this.csvMapper = CsvMapper.builder()
+ .enable(SerializationFeature.INDENT_OUTPUT)
+ .build();
+ }
+
+ public String convertJsonToCsv(JsonNode jsonArrayNode) {
+ CsvSchema schema = buildCsvSchema();
+ List